time for a snapshot
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
# Step 11 — Capstone: watch your kernel run
|
||||
|
||||
No test to make green here. This is the payoff. `game.js` contains the **exact
|
||||
engine you built** across steps 01–10 — the same `sweptAABB`, `slide`, and
|
||||
`moveAndSlide`, assembled into one file — wired to a canvas and the arrow keys.
|
||||
|
||||
## Run it
|
||||
|
||||
It's a plain HTML file with no build step. Any of these work:
|
||||
|
||||
```sh
|
||||
# simplest: just open the file in a browser
|
||||
xdg-open workshop/steps/11-capstone/index.html # linux
|
||||
# or drag index.html into a browser window
|
||||
|
||||
# or serve it (nicer, avoids any file:// quirks):
|
||||
bunx serve workshop/steps/11-capstone
|
||||
# then open the printed http://localhost:... URL
|
||||
```
|
||||
|
||||
Arrow keys move the pink box. Run it into the border, the ledge, the pillar, the
|
||||
bar. Push diagonally into a wall and watch it **slide** along instead of sticking.
|
||||
That sliding is your step-09 `dot`-product projection. The fact that it stops
|
||||
*at* the wall instead of tunneling through, even at speed, is your step-07 swept
|
||||
detection. The clean corners are your step-10 loop running twice in one frame.
|
||||
|
||||
## Make it yours (optional)
|
||||
|
||||
- Open `game.js`. The top half is your kernel — read it and confirm it matches
|
||||
what you wrote. Swap in your own `moveAndSlide` from step 10 and check it feels
|
||||
identical (it will).
|
||||
- Add a wall to the `walls` array. Change `SPEED`. Make the player bigger.
|
||||
- Try **deleting the `EPSILON` backoff** (`Math.max(0, nearest.time - EPSILON)`
|
||||
→ `nearest.time`) and push into a wall. Watch it stick and jitter. Then put it
|
||||
back. Now you've *felt* why that line exists in your real engine.
|
||||
|
||||
## You're back
|
||||
|
||||
That's the whole climb: pairs of numbers → sweeping a point → sweeping a box via
|
||||
Minkowski → detecting the hit → stopping and sliding → the full loop → a thing you
|
||||
can play. Every rung is a function that exists, by name, inside your real
|
||||
`engine/system/physics.ts`.
|
||||
|
||||
Now go open the real `sweptAABB` with fresh eyes. You know exactly what every line
|
||||
is *supposed* to do — so the two lines that don't should stand out. Happy hunting.
|
||||
@@ -0,0 +1,179 @@
|
||||
// ── 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);
|
||||
@@ -0,0 +1,61 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>nage physics kernel — capstone</title>
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
background: #0b0c10;
|
||||
color: #c8cde0;
|
||||
font: 14px/1.5 ui-monospace, "SF Mono", Menlo, monospace;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.wrap {
|
||||
text-align: center;
|
||||
}
|
||||
canvas {
|
||||
width: 720px;
|
||||
max-width: 96vw;
|
||||
height: auto;
|
||||
image-rendering: pixelated;
|
||||
border: 1px solid #2e3550;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 10px 40px #0008;
|
||||
}
|
||||
h1 {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
color: #ee459e;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
p {
|
||||
margin: 12px 0 0;
|
||||
color: #7d84a0;
|
||||
}
|
||||
kbd {
|
||||
background: #1b1f2b;
|
||||
border: 1px solid #2e3550;
|
||||
border-radius: 3px;
|
||||
padding: 1px 6px;
|
||||
color: #c8cde0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<h1>your swept-AABB kernel, live</h1>
|
||||
<canvas id="view" width="240" height="160"></canvas>
|
||||
<p>
|
||||
<kbd>↑</kbd> <kbd>↓</kbd> <kbd>←</kbd> <kbd>→</kbd> to move — run into
|
||||
the walls and feel it slide
|
||||
</p>
|
||||
</div>
|
||||
<script src="./game.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user