Files
nage/workshop/steps/11-capstone/game.js
T
2026-07-11 17:48:28 +02:00

180 lines
4.8 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 };
// 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: 150, y: 30, w: 16, h: 90 }, // 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);
}
}
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 = moveAndSlide(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);