// ── 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: 40, 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 > 7) { 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);