259 lines
6.4 KiB
JavaScript
259 lines
6.4 KiB
JavaScript
// ── The physics kernel you built across steps 01–10, assembled in one file ──
|
||
// (Plain JS so the demo runs from a file:// URL with zero build step. Compare it
|
||
// to your own step-10 code — it's the same engine you wrote.)
|
||
const add = (a, b) => ({ x: a.x + b.x, y: a.y + b.y });
|
||
const sub = (a, b) => ({ x: a.x - b.x, y: a.y - b.y });
|
||
const scale = (a, s) => ({ x: a.x * s, y: a.y * s });
|
||
const dot = (a, b) => a.x * b.x + a.y * b.y;
|
||
const length = (a) => Math.sqrt(a.x * a.x + a.y * a.y);
|
||
function normalize(a) {
|
||
const l = length(a);
|
||
return l === 0 ? { x: 0, y: 0 } : { x: a.x / l, y: a.y / l };
|
||
}
|
||
|
||
const EPSILON = 1e-4;
|
||
|
||
function sweepInterval(p, v, min, max) {
|
||
if (v === 0) {
|
||
return min <= p && p <= max ? { entry: -Infinity, exit: Infinity } : null;
|
||
}
|
||
|
||
let t1 = (min - p) / v;
|
||
let t2 = (max - p) / v;
|
||
if (t1 > t2) {
|
||
[t1, t2] = [t2, t1];
|
||
}
|
||
|
||
return { entry: t1, exit: t2 };
|
||
}
|
||
|
||
function rayVsAABB(p, v, box) {
|
||
const spanX = sweepInterval(p.x, v.x, box.x, box.x + box.w);
|
||
const spanY = sweepInterval(p.y, v.y, box.y, box.y + box.h);
|
||
if (spanX === null || spanY === null) return null;
|
||
const entry = Math.max(spanX.entry, spanY.entry);
|
||
const exit = Math.min(spanX.exit, spanY.exit);
|
||
if (entry > exit || entry >= 1 || exit <= 0) return null;
|
||
const normal =
|
||
spanX.entry > spanY.entry
|
||
? { x: v.x > 0 ? -1 : 1, y: 0 }
|
||
: { x: 0, y: v.y > 0 ? -1 : 1 };
|
||
return { time: entry, normal };
|
||
}
|
||
|
||
function sweptAABB(a, v, b) {
|
||
const inflated = { x: b.x - a.w, y: b.y - a.h, w: b.w + a.w, h: b.h + a.h };
|
||
return rayVsAABB({ x: a.x, y: a.y }, v, inflated);
|
||
}
|
||
|
||
const slide = (v, n) => sub(v, scale(n, dot(v, n)));
|
||
|
||
function moveAndSlide(box, v, walls) {
|
||
let pos = { x: box.x, y: box.y };
|
||
let vel = { x: v.x, y: v.y };
|
||
let timeLeft = 1;
|
||
|
||
for (let i = 0; i < 4 && timeLeft > 0; i++) {
|
||
const move = scale(vel, timeLeft);
|
||
let nearest = null;
|
||
for (const wall of walls) {
|
||
const hit = sweptAABB(
|
||
{ x: pos.x, y: pos.y, w: box.w, h: box.h },
|
||
move,
|
||
wall,
|
||
);
|
||
if (hit && (nearest === null || hit.time < nearest.time)) {
|
||
nearest = hit;
|
||
}
|
||
}
|
||
if (nearest === null) {
|
||
pos = add(pos, move);
|
||
break;
|
||
}
|
||
pos = add(pos, scale(move, Math.max(0, nearest.time - EPSILON)));
|
||
vel = slide(vel, nearest.normal);
|
||
timeLeft *= 1 - nearest.time;
|
||
}
|
||
return pos;
|
||
}
|
||
|
||
// ─────────────────────────── the playable demo ───────────────────────────
|
||
|
||
const W = 240;
|
||
const H = 160;
|
||
const SPEED = 0.07; // units per millisecond
|
||
|
||
const canvas = document.getElementById("view");
|
||
const ctx = canvas.getContext("2d");
|
||
ctx.imageSmoothingEnabled = false;
|
||
|
||
// player is an AABB (top-left corner + size)
|
||
const player = { x: 40, y: 40, w: 12, h: 12 };
|
||
window.player = player;
|
||
|
||
// walls: a border plus a few interior blocks to slide against
|
||
const T = 8; // border thickness
|
||
|
||
const ledge = { x: 70, y: 80, w: 40, h: 16 }; // a ledge
|
||
let ledgeDir = 1;
|
||
|
||
const walls = [
|
||
{ x: 0, y: 0, w: W, h: T }, // top
|
||
{ x: 0, y: H - T, w: W, h: T }, // bottom
|
||
{ x: 0, y: 0, w: T, h: H }, // left
|
||
{ x: W - T, y: 0, w: T, h: H }, // right
|
||
{ x: 144, y: 30, w: 12, h: 82 }, // a pillar
|
||
{ x: 96, y: 96, w: 60, h: 16 }, // a bar
|
||
ledge,
|
||
];
|
||
|
||
const keys = {
|
||
ArrowUp: false,
|
||
ArrowDown: false,
|
||
ArrowLeft: false,
|
||
ArrowRight: false,
|
||
};
|
||
addEventListener("keydown", (e) => {
|
||
if (e.key in keys) {
|
||
keys[e.key] = true;
|
||
e.preventDefault();
|
||
}
|
||
});
|
||
addEventListener("keyup", (e) => {
|
||
if (e.key in keys) {
|
||
keys[e.key] = false;
|
||
e.preventDefault();
|
||
}
|
||
});
|
||
|
||
function drawBox(b, fill, stroke) {
|
||
ctx.fillStyle = fill;
|
||
ctx.fillRect(b.x, b.y, b.w, b.h);
|
||
if (stroke) {
|
||
ctx.strokeStyle = stroke;
|
||
ctx.strokeRect(b.x + 0.5, b.y + 0.5, b.w - 1, b.h - 1);
|
||
}
|
||
}
|
||
/**
|
||
* How to push `a` OUT of `b`, by the smallest possible single-axis move.
|
||
*
|
||
* Returns the push as a vector to ADD to a's position, or null if the boxes
|
||
* don't strictly overlap (merely touching edges is not overlapping).
|
||
*
|
||
* There are four ways out (left, right, up, down) — measure all four
|
||
* distances and return the shortest one. See the README.
|
||
*/
|
||
function penetrationVector(a, b) {
|
||
const outLeft = a.x + a.w - b.x;
|
||
const outRight = b.x + b.w - a.x;
|
||
const outUp = a.y + a.h - b.y;
|
||
const outDown = b.y + b.h - a.y;
|
||
|
||
const minX = Math.min(outLeft, outRight);
|
||
const minY = Math.min(outUp, outDown);
|
||
|
||
if (minX > 0 && minY > 0) {
|
||
return minX > minY
|
||
? { x: 0, y: outDown > outUp ? -minY : minY }
|
||
: { x: outLeft > outRight ? minX : -minX, y: 0 };
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function resolveOverlaps(box, walls) {
|
||
const res = {
|
||
x: box.x,
|
||
y: box.y,
|
||
settled: true,
|
||
};
|
||
|
||
const count = walls.length;
|
||
let index = 0;
|
||
let tries = 0;
|
||
|
||
while (index < count) {
|
||
const pv = penetrationVector(box, walls[index++]);
|
||
|
||
if (pv) {
|
||
if (pv.x !== 0) {
|
||
box.x += pv.x > 0 ? pv.x + EPSILON : pv.x - EPSILON;
|
||
}
|
||
if (pv.y !== 0) {
|
||
box.y += pv.y > 0 ? pv.y + EPSILON : pv.y - EPSILON;
|
||
}
|
||
|
||
res.x = box.x;
|
||
res.y = box.y;
|
||
res.settled = false;
|
||
|
||
index = 0;
|
||
tries += 1;
|
||
} else {
|
||
res.settled = true;
|
||
}
|
||
|
||
if (tries > 3) {
|
||
break;
|
||
}
|
||
}
|
||
|
||
return res;
|
||
}
|
||
|
||
function safeMoveAndSlide(box, v, walls) {
|
||
const res = resolveOverlaps(box, walls);
|
||
if (res.settled) {
|
||
return moveAndSlide(box, v, walls);
|
||
}
|
||
|
||
return {
|
||
x: box.x,
|
||
y: box.y,
|
||
};
|
||
}
|
||
|
||
let then = performance.now();
|
||
function frame(now) {
|
||
const delta = Math.min(now - then, 50); // clamp huge deltas (tab was hidden)
|
||
then = now;
|
||
|
||
// input -> direction -> velocity (units/ms), normalized so diagonals aren't faster
|
||
const dir = normalize({
|
||
x: (keys.ArrowRight ? 1 : 0) - (keys.ArrowLeft ? 1 : 0),
|
||
y: (keys.ArrowDown ? 1 : 0) - (keys.ArrowUp ? 1 : 0),
|
||
});
|
||
|
||
if (ledge.x + ledge.w >= W) {
|
||
ledgeDir = -1;
|
||
} else if (ledge.x <= 0) {
|
||
ledgeDir = 1;
|
||
}
|
||
ledge.x += SPEED * ledgeDir * delta;
|
||
|
||
const velocity = scale(dir, SPEED);
|
||
|
||
// full-frame displacement is velocity * delta — exactly what moveAndSlide expects
|
||
const displacement = scale(velocity, delta);
|
||
const next = safeMoveAndSlide(player, displacement, walls);
|
||
player.x = next.x;
|
||
player.y = next.y;
|
||
|
||
// draw
|
||
ctx.fillStyle = "#12141c";
|
||
ctx.fillRect(0, 0, W, H);
|
||
for (let gx = 0; gx <= W; gx += 16) {
|
||
ctx.fillStyle = "#191c26";
|
||
ctx.fillRect(gx, 0, 1, H);
|
||
}
|
||
for (let gy = 0; gy <= H; gy += 16) {
|
||
ctx.fillStyle = "#191c26";
|
||
ctx.fillRect(0, gy, W, 1);
|
||
}
|
||
for (const wall of walls) drawBox(wall, "#2e3550", "#3d4670");
|
||
drawBox(player, "#ee459e", "#ffa8d6");
|
||
|
||
requestAnimationFrame(frame);
|
||
}
|
||
requestAnimationFrame(frame);
|