Files
nage/workshop/steps/11-capstone/game.js
T
2026-08-08 14:00:36 +02:00

259 lines
6.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ── 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);