time for a snapshot

This commit is contained in:
Schluffe
2026-07-11 17:48:28 +02:00
parent 135b7c3042
commit 7262752e16
47 changed files with 1712 additions and 61 deletions
@@ -0,0 +1,75 @@
# Step 10 — moveAndSlide (the whole engine, in one loop)
This is it. Every function you've written since step 01 gets tied together here
into the exact loop your real `nage` engine runs. It's the hardest step, so I'll
give you the skeleton — you write the code.
## The idea
A single collision doesn't end the frame (step 08's "leftover that matters"). You
hit a wall at `t = 0.3`, slide, and **70% of the frame is still owed** — during
which you might hit *another* wall, slide again, and so on. So moving is a small
**loop**: sweep → stop at the nearest hit → slide → repeat with the leftover time.
We loop a **maximum of 4 times** (your engine's cap) — enough to handle a corner
(hit a wall, slide, hit the perpendicular wall, slide, stop) without ever risking
an infinite loop.
## The algorithm
You're given the moving `box`, its full-frame displacement `v`, and a list of
static `walls`. Track a running `pos`, a running `vel`, and `timeLeft` (fraction
of the frame remaining, starts at `1`).
```
pos = { box.x, box.y }
vel = { v.x, v.y }
timeLeft = 1
repeat up to 4 times, while timeLeft > 0:
move = vel * timeLeft // what's left to travel this frame
find the NEAREST hit: for each wall, sweptAABB(box-at-pos, move, wall);
keep the hit with the smallest .time
if no hit:
pos = pos + move // clear path: take the rest of the move
stop
else:
pos = pos + move * max(0, hit.time - EPSILON) // advance to just before contact
vel = slide(vel, hit.normal) // redirect along the wall
timeLeft = timeLeft * (1 - hit.time) // consume the used fraction
return pos
```
Two things worth understanding, not just copying:
- **`move = vel * timeLeft`.** `vel` is a *full-frame* displacement (how far you'd
go in a whole frame at this velocity). You only have `timeLeft` of the frame
left, so the actual travel is `vel * timeLeft`. `sweptAABB`'s returned `time` is
then a fraction *of that sub-move*, which is why `pos + move * time` is correct.
- **The `EPSILON` backoff** (`max(0, hit.time - EPSILON)`). Stop a hair *short* of
the wall. If you land exactly on it, floating-point error can leave you a sliver
inside — and next iteration's sweep would start already-overlapping, reporting a
garbage negative-time "collision" that makes you stick or jitter. That tiny gap
is exactly the `Math.max(0, time - EPSILON)` in your real `moveAndSlide`. Now you
know *why* it's there. `EPSILON` is provided in `given.ts`.
> **Moving-vs-moving (why your real engine has `velocity - otherVel`).** Here the
> walls are static, so we sweep with plain `vel`. When the *other* body also moves,
> you sweep in its frame of reference by using the **relative** velocity
> `vel - otherVel` — then the exact same loop works, because from the other body's
> point of view it's standing still. That's the only difference between this kata
> and the full engine. The loop itself doesn't change.
## Task
Implement `moveAndSlide(box, v, walls)` in `moveAndSlide.ts`. Everything you need —
`sweptAABB`, `slide`, the vector ops, `EPSILON` — is finished in `given.ts`.
```sh
bun test workshop/steps/10-move-and-slide
```
+65
View File
@@ -0,0 +1,65 @@
// The whole kernel you've built, steps 01-09, finished. Reuse it.
export type Vec = { x: number; y: number };
export type AABB = { x: number; y: number; w: number; h: number };
export type Span = { entry: number; exit: number };
export type Hit = { time: number; normal: Vec };
/** A hair of slack so we stop just short of a wall instead of inside it. */
export const EPSILON = 1e-4;
export const vec = (x: number, y: number): Vec => ({ x, y });
export const add = (a: Vec, b: Vec): Vec => ({ x: a.x + b.x, y: a.y + b.y });
export const sub = (a: Vec, b: Vec): Vec => ({ x: a.x - b.x, y: a.y - b.y });
export const scale = (a: Vec, s: number): Vec => ({ x: a.x * s, y: a.y * s });
export const dot = (a: Vec, b: Vec): number => a.x * b.x + a.y * b.y;
export function sweepInterval(
p: number,
v: number,
min: number,
max: number,
): Span | null {
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 };
}
export function rayVsAABB(p: Vec, v: Vec, box: AABB): Hit | null {
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 };
}
export function sweptAABB(a: AABB, v: Vec, b: AABB): Hit | null {
const inflated: AABB = {
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);
}
export function slide(v: Vec, normal: Vec): Vec {
return sub(v, scale(normal, dot(v, normal)));
}
@@ -0,0 +1,37 @@
import { expect, test } from "bun:test";
import { type AABB, vec } from "./given.ts";
import { moveAndSlide } from "./moveAndSlide.ts";
const player: AABB = { x: 0, y: 0, w: 2, h: 2 };
test("no walls -> full move", () => {
const p = moveAndSlide(player, vec(10, 5), []);
expect(p.x).toBeCloseTo(10);
expect(p.y).toBeCloseTo(5);
});
test("head-on into the NEAREST of two walls -> stops there", () => {
const walls: AABB[] = [
{ x: 5, y: 0, w: 2, h: 2 }, // nearer
{ x: 8, y: 0, w: 2, h: 2 }, // farther
];
const p = moveAndSlide(player, vec(10, 0), walls);
expect(p.x).toBeCloseTo(3); // right edge (2) meets wall left edge (5)
expect(p.y).toBeCloseTo(0);
});
test("diagonal into a vertical wall -> slides down it", () => {
// x is blocked at 3, but the downward motion keeps going for the whole frame
const walls: AABB[] = [{ x: 5, y: -10, w: 2, h: 40 }];
const p = moveAndSlide(player, vec(10, 10), walls);
expect(p.x).toBeCloseTo(3);
expect(p.y).toBeCloseTo(10);
});
test("diagonal onto a floor -> slides along it", () => {
// falls until its bottom meets the floor top (y=8 -> corner y=6), keeps moving in x
const walls: AABB[] = [{ x: 0, y: 8, w: 20, h: 2 }];
const p = moveAndSlide(player, vec(5, 10), walls);
expect(p.x).toBeCloseTo(5);
expect(p.y).toBeCloseTo(6);
});
@@ -0,0 +1,59 @@
import {
type AABB,
add,
EPSILON,
type Hit,
scale,
slide,
sweptAABB,
type Vec,
} from "./given.ts";
/**
* Move `box` by the full-frame displacement `v`, sliding along any `walls` it
* hits, up to 4 iterations. Returns the final corner position.
*
* See the README for the algorithm. You already have everything you need in
* given.ts: sweptAABB, slide, add, scale, EPSILON.
*/
export function moveAndSlide(box: AABB, v: Vec, walls: AABB[]): Vec {
let pos: Vec = { x: box.x, y: box.y };
let vel: Vec = { x: v.x, y: v.y };
let timeLeft = 1;
let i = 0;
while (i++ < 4 && timeLeft > 0) {
const move = scale(vel, timeLeft);
let nearest: Hit = {
normal: { x: 0, y: 0 },
time: 2,
};
for (const wall of walls) {
const hit = sweptAABB(
{
x: pos.x,
y: pos.y,
w: box.w,
h: box.h,
},
move,
wall,
);
if (hit !== null) {
nearest = hit.time < nearest.time ? hit : nearest;
}
}
if (nearest.time === 2) {
pos = add(pos, move);
break;
} else {
pos = add(pos, scale(move, Math.max(0, nearest.time - EPSILON)));
vel = slide(vel, nearest.normal);
timeLeft = timeLeft * (1 - nearest.time);
}
}
return pos;
}