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
+31
View File
@@ -0,0 +1,31 @@
# Step 01 — Vectors
> You know this cold. The point of this step is to make the red→green loop
> automatic, not to teach you arithmetic. Blast through it.
## Concept
A **vector** here is nothing mystical: a pair of numbers `(x, y)`. We use the
same value to mean two different things depending on context:
- a **position** — a point in the world.
- a **displacement / velocity** — an arrow: "move this much in x, this much in y."
That's it. All of 2D physics is built on adding, subtracting, and scaling these
pairs.
- `add(a, b)` → `(a.x + b.x, a.y + b.y)` — apply an arrow to a point.
- `sub(a, b)` → `(a.x - b.x, a.y - b.y)` — the arrow that points *from b to a*.
- `scale(a, s)` → `(a.x * s, a.y * s)` — make an arrow longer/shorter.
> Note: we return **new** objects (pure functions) here for clarity. Your real
> engine mutates vectors in place to avoid garbage collection — that's a speed
> optimization we'll deliberately ignore until the very end.
## Task
Implement `vec`, `add`, `sub`, and `scale` in `vector.ts`.
```sh
bun test workshop/steps/01-vectors
```
+25
View File
@@ -0,0 +1,25 @@
import { expect, test } from "bun:test";
import { add, scale, sub, vec } from "./vector.ts";
test("vec builds a vector", () => {
expect(vec(2, 3)).toEqual({ x: 2, y: 3 });
});
test("add is component-wise", () => {
expect(add(vec(1, 2), vec(3, 4))).toEqual({ x: 4, y: 6 });
});
test("sub is component-wise", () => {
expect(sub(vec(5, 5), vec(1, 2))).toEqual({ x: 4, y: 3 });
});
test("scale multiplies both components", () => {
expect(scale(vec(2, 3), 2)).toEqual({ x: 4, y: 6 });
expect(scale(vec(2, 3), 0)).toEqual({ x: 0, y: 0 });
});
test("inputs are not mutated (pure functions)", () => {
const a = vec(1, 2);
add(a, vec(3, 4));
expect(a).toEqual({ x: 1, y: 2 });
});
+30
View File
@@ -0,0 +1,30 @@
export type Vec = { x: number; y: number };
/** Build a vector from two numbers. */
export function vec(x: number, y: number): Vec {
return { x, y };
}
/** a + b (component-wise) */
export function add(a: Vec, b: Vec): Vec {
return {
x: a.x + b.x,
y: a.y + b.y,
};
}
/** a - b (component-wise) */
export function sub(a: Vec, b: Vec): Vec {
return {
x: a.x - b.x,
y: a.y - b.y,
};
}
/** a scaled by the scalar s */
export function scale(a: Vec, s: number): Vec {
return {
x: a.x * s,
y: a.y * s,
};
}
@@ -0,0 +1,39 @@
# Step 02 — Length, Normalize, Dot
## Concept
### Length (magnitude)
An arrow `(x, y)` has a length: how far it reaches. Pythagoras:
`length = sqrt(x*x + y*y)`. A velocity's length is its **speed**.
### Normalize
Often you want *just the direction* of an arrow, with length exactly 1 (a "unit
vector"). You get it by dividing the arrow by its own length:
`(x / len, y / len)`.
This is how your engine turns "the player pressed up+left" into a clean diagonal
direction before multiplying by a speed — so diagonal movement isn't faster than
straight movement.
> ⚠️ **The zero-vector trap.** What is the length of `(0, 0)`? Zero. What is
> `0 / 0`? `NaN`. If you normalize a zero vector naively, you poison it with
> `NaN`, and `NaN` spreads through every later calculation silently. A correct
> `normalize` must check for zero length and return `(0, 0)` instead of dividing.
> Remember this trap — it is exactly the kind of bug that hides in a real engine.
### Dot product
`dot(a, b) = a.x*b.x + a.y*b.y`. One number out of two vectors. For now just
implement it; in step 09 you'll learn that it answers "how much of arrow A points
along arrow B?" — the key to sliding along a wall.
## Task
Implement `length`, `normalize` (zero-safe!), and `dot` in `mathv.ts`.
`vec`/`add`/`sub`/`scale` are already provided in `given.ts`.
```sh
bun test workshop/steps/02-vectors-length
```
@@ -0,0 +1,6 @@
// Finished in step 01 — provided so you only implement the new ideas here.
export type Vec = { x: number; y: number };
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 });
@@ -0,0 +1,27 @@
import { expect, test } from "bun:test";
import { vec } from "./given.ts";
import { dot, length, normalize } from "./mathv.ts";
test("length uses Pythagoras", () => {
expect(length(vec(3, 4))).toBe(5);
expect(length(vec(0, 0))).toBe(0);
});
test("normalize gives a unit vector in the same direction", () => {
const n = normalize(vec(3, 4));
expect(n.x).toBeCloseTo(0.6);
expect(n.y).toBeCloseTo(0.8);
expect(length(n)).toBeCloseTo(1);
});
test("normalize of the zero vector is (0,0), not NaN", () => {
const n = normalize(vec(0, 0));
expect(n).toEqual({ x: 0, y: 0 });
expect(Number.isNaN(n.x)).toBe(false);
expect(Number.isNaN(n.y)).toBe(false);
});
test("dot product", () => {
expect(dot(vec(1, 0), vec(0, 1))).toBe(0); // perpendicular
expect(dot(vec(2, 3), vec(4, 5))).toBe(23); // 8 + 15
});
+23
View File
@@ -0,0 +1,23 @@
import type { Vec } from "./given.ts";
/** The length (magnitude) of the arrow. */
export function length(a: Vec): number {
return Math.sqrt(a.x * a.x + a.y * a.y);
}
/**
* A unit-length vector pointing the same way as `a`.
* MUST return (0, 0) when `a` is the zero vector — do not divide by zero.
*/
export function normalize(a: Vec): Vec {
const len = length(a);
const x = a.x === 0 ? 0 : a.x / len;
const y = a.y === 0 ? 0 : a.y / len;
return { x, y };
}
/** The dot product a·b. */
export function dot(a: Vec, b: Vec): number {
return a.x * b.x + a.y * b.y;
}
+35
View File
@@ -0,0 +1,35 @@
# Step 03 — Integration (moving over time)
## Concept
"Integration" sounds like calculus, but the version we need is one line. An
object has a **position** and a **velocity**. Each frame we advance the position
by the velocity, scaled by how much time passed:
```
newPosition = position + velocity * delta
```
That's `add(pos, scale(vel, delta))`. This single step is the beating heart of
every game's update loop.
### Why `delta`?
`delta` is the number of **milliseconds since the last frame**. Frames are not
evenly spaced — a busy frame takes longer. If you moved a fixed amount *per
frame* instead of *per millisecond*, your game would run faster on a fast
computer and slower on a slow one.
By storing velocity as **units-per-millisecond** and multiplying by `delta`, the
distance travelled over a given stretch of real time is the same no matter how
the frames are chopped up. That property is called **framerate independence**,
and one of the tests below proves it: moving once with `delta = 16` lands in the
same place as moving sixteen times with `delta = 1`.
## Task
Implement `integrate(pos, vel, delta)` in `motion.ts`.
```sh
bun test workshop/steps/03-integration
```
+5
View File
@@ -0,0 +1,5 @@
// Finished in earlier steps.
export type Vec = { x: number; y: number };
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 scale = (a: Vec, s: number): Vec => ({ x: a.x * s, y: a.y * s });
@@ -0,0 +1,27 @@
import { expect, test } from "bun:test";
import { vec } from "./given.ts";
import { integrate } from "./motion.ts";
test("integrate advances position by velocity * delta", () => {
// velocity 0.1 units/ms, 100ms => move 10 units
expect(integrate(vec(0, 0), vec(0.1, 0), 100)).toEqual({ x: 10, y: 0 });
});
test("zero delta does not move", () => {
expect(integrate(vec(5, 5), vec(0.1, 0.1), 0)).toEqual({ x: 5, y: 5 });
});
test("framerate independence: one big step == many small steps", () => {
const start = vec(0, 0);
const vel = vec(0.1, -0.05);
const oneBigStep = integrate(start, vel, 16);
let manySmall = start;
for (let i = 0; i < 16; i++) {
manySmall = integrate(manySmall, vel, 1);
}
expect(manySmall.x).toBeCloseTo(oneBigStep.x);
expect(manySmall.y).toBeCloseTo(oneBigStep.y);
});
+9
View File
@@ -0,0 +1,9 @@
import { add, scale, type Vec } from "./given.ts";
/**
* Advance a position by a velocity over `delta` milliseconds.
* newPosition = position + velocity * delta
*/
export function integrate(pos: Vec, vel: Vec, delta: number): Vec {
return add(pos, scale(vel, delta));
}
+47
View File
@@ -0,0 +1,47 @@
# Step 04 — AABB & the discrete overlap test
## Concept
**AABB** = **A**xis-**A**ligned **B**ounding **B**ox: a rectangle whose sides are
parallel to the x and y axes (never rotated). They're cheap to test, which is why
almost every 2D engine — including yours — uses them as the base collision shape.
We represent one as a corner plus a size:
```
type AABB = { x, y, w, h } // (x,y) = top-left corner, w = width, h = height
```
So the box spans `x .. x+w` horizontally and `y .. y+h` vertically.
### Two boxes overlap when they overlap on BOTH axes
This is the key insight you'll reuse for the rest of the workshop. Think of each
box as a **shadow on the x-axis** and a **shadow on the y-axis**. Two boxes
intersect only if *both* pairs of shadows intersect:
```
overlapX: a.x < b.x + b.w AND b.x < a.x + a.w
overlapY: a.y < b.y + b.h AND b.y < a.y + a.h
overlap = overlapX AND overlapY
```
Hold onto "collision = the AND of two 1D tests." In a few steps you'll do the
exact same thing, but with **time** instead of space, and that's the whole trick
behind swept collision.
### The discrete trap (why this test alone isn't enough)
`aabbOverlap` only answers "are they overlapping *right now*?" If a fast object
jumps from one side of a thin wall to the other in a single frame, it never
overlaps the wall at any sampled instant — so this test says "no collision" and
the object tunnels straight through. Steps 05+ fix that by testing the *path*,
not the endpoints. Feel the gap here first; it's why everything after exists.
## Task
Implement `pointInAABB` and `aabbOverlap` in `aabb.ts`.
```sh
bun test workshop/steps/04-aabb
```
+28
View File
@@ -0,0 +1,28 @@
import { expect, test } from "bun:test";
import { type AABB, aabbOverlap, pointInAABB } from "./aabb.ts";
const box: AABB = { x: 10, y: 10, w: 20, h: 20 }; // spans 10..30 in both axes
test("point inside the box", () => {
expect(pointInAABB({ x: 15, y: 15 }, box)).toBe(true);
});
test("point outside the box", () => {
expect(pointInAABB({ x: 5, y: 15 }, box)).toBe(false);
expect(pointInAABB({ x: 15, y: 35 }, box)).toBe(false);
});
test("overlapping boxes", () => {
const other: AABB = { x: 20, y: 20, w: 20, h: 20 };
expect(aabbOverlap(box, other)).toBe(true);
});
test("separated on the x axis only -> no overlap", () => {
const other: AABB = { x: 40, y: 10, w: 5, h: 20 };
expect(aabbOverlap(box, other)).toBe(false);
});
test("separated on the y axis only -> no overlap", () => {
const other: AABB = { x: 10, y: 40, w: 20, h: 5 };
expect(aabbOverlap(box, other)).toBe(false);
});
+18
View File
@@ -0,0 +1,18 @@
export type Vec = { x: number; y: number };
/** Axis-aligned box: (x,y) is the top-left corner, w/h are width/height. */
export type AABB = { x: number; y: number; w: number; h: number };
/** Is the point inside (or on the edge of) the box? */
export function pointInAABB(p: Vec, box: AABB): boolean {
return (
p.x >= box.x && p.x <= box.x + box.w && p.y >= box.y && p.y <= box.y + box.h
);
}
/** Do the two boxes overlap right now? (discrete test) */
export function aabbOverlap(a: AABB, b: AABB): boolean {
return (
a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h
);
}
+59
View File
@@ -0,0 +1,59 @@
# Step 05 — Sweeping in 1D (entry & exit time)
This is the seed of the whole engine. Get this one *in your bones* and the scary
2D `sweptAABB` becomes "do this twice and combine."
## Concept
Forget 2D. Forget boxes. We have:
- a **point** sitting at position `p` on a number line,
- moving with velocity `v` — meaning over this one frame it travels a total of
`v` units (so at fraction `t` of the frame, it's at `p + v*t`, for `t` from 0 to 1),
- and a static **interval** `[min, max]` on that same line.
Question: **during this frame, for which `t` is the point inside `[min, max]`?**
### The slab math
The point reaches `min` when `p + v*t = min`, i.e. `t = (min - p) / v`.
Likewise it reaches `max` at `t = (max - p) / v`.
```
t1 = (min - p) / v
t2 = (max - p) / v
```
If `v` is **negative** (moving left), the point hits `max` *before* `min`, so
`t1 > t2`. We always want `entry` to be the smaller and `exit` the larger, so
**swap them if they're out of order**. Then:
- `entry` = the time the point *enters* the interval,
- `exit` = the time it *leaves*.
> These can be negative or greater than 1 — that just means the crossing happens
> before this frame started or after it ends. Don't clamp here; the caller (step
> 06/08) decides whether `entry` falls within `[0, 1]`. Keeping the raw numbers
> is what lets us combine axes later.
### The `v == 0` edge case
If the point isn't moving (`v == 0`), it never *crosses* an edge — dividing by
zero is meaningless. Instead: it's either already inside the interval for the
whole frame, or never. So:
- if `min <= p <= max`: it's inside the entire time → `entry = -Infinity`,
`exit = +Infinity`.
- otherwise: it never overlaps → return `null`.
(Those infinities are deliberate: in 2D they let a non-moving axis say "I'm not
the axis that limits the collision," without breaking the `max`/`min` combine
step. You'll see why in step 06.)
## Task
Implement `sweepInterval(p, v, min, max)` in `sweep1d.ts`. Return
`{ entry, exit }`, or `null` only in the not-moving-and-outside case.
```sh
bun test workshop/steps/05-sweep-1d
```
@@ -0,0 +1,29 @@
import { expect, test } from "bun:test";
import { sweepInterval } from "./sweep1d.ts";
test("moving right into the interval", () => {
// point at 0, moves +10 this frame, interval [5, 8]
// enters at (5-0)/10 = 0.5, exits at (8-0)/10 = 0.8
const s = sweepInterval(0, 10, 5, 8)!;
expect(s.entry).toBeCloseTo(0.5);
expect(s.exit).toBeCloseTo(0.8);
});
test("moving left: entry/exit are swapped into order", () => {
// point at 10, moves -10, interval [5, 8]
// crosses 8 at t=0.2, crosses 5 at t=0.5 -> entry 0.2, exit 0.5
const s = sweepInterval(10, -10, 5, 8)!;
expect(s.entry).toBeCloseTo(0.2);
expect(s.exit).toBeCloseTo(0.5);
expect(s.entry).toBeLessThanOrEqual(s.exit);
});
test("not moving but already inside -> infinite span", () => {
const s = sweepInterval(6, 0, 5, 8)!;
expect(s.entry).toBe(-Infinity);
expect(s.exit).toBe(Infinity);
});
test("not moving and outside -> null", () => {
expect(sweepInterval(2, 0, 5, 8)).toBeNull();
});
+42
View File
@@ -0,0 +1,42 @@
export type Span = { entry: number; exit: number };
/**
* When is a point at `p`, moving by `v` over the frame, inside [min, max]?
*
* Returns the entry/exit times (t, where the position is p + v*t).
* `entry` is always <= `exit`. Values may be < 0 or > 1.
*
* Special case: if v === 0, return {entry:-Infinity, exit:Infinity} when the
* point is already inside [min, max], otherwise return null.
*/
export function sweepInterval(
p: number,
v: number,
min: number,
max: number,
): Span | null {
if (v === 0) {
return isInBetween(p, min, 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 isInBetween(p: number, min: number, max: number): boolean {
return min <= p && p <= max;
}
+73
View File
@@ -0,0 +1,73 @@
# Step 06 — Ray vs AABB (two sweeps become one hit)
This is where step 04 ("collision = the AND of two 1D tests") and step 05 ("the
entry/exit time of one sweep") finally fuse. A **moving point vs a static box**.
## Concept
A point at `p` moves by `v` over the frame. A static box has a left/right edge
(its x-interval) and a top/bottom edge (its y-interval). The point is inside the
**box** only while it's inside the x-interval **and** the y-interval *at the same
time*.
So run `sweepInterval` twice:
```
spanX = sweepInterval(p.x, v.x, box.x, box.x + box.w) // the x-edges
spanY = sweepInterval(p.y, v.y, box.y, box.y + box.h) // the y-edges
```
Each gives you a time-window `[entry, exit]` during which the point is inside
*that one axis's* strip. You're inside the box during the **overlap of the two
windows**:
```
entry = max(spanX.entry, spanY.entry) // inside the box once you're inside the LAST axis
exit = min(spanX.exit, spanY.exit) // out of the box once you leave the FIRST axis
```
Read those two lines until they feel obvious — they're the whole algorithm:
- You're only truly *inside the box* once you've entered **both** strips, so the
real entry is the **later** of the two entries → `max`.
- You **leave** the box the instant you exit **either** strip → the **earlier**
exit → `min`.
### When is there NO hit?
1. **A span is `null`** — on some axis the point isn't moving and is already
outside that strip. It can never be inside the box. Return `null` immediately.
2. **`entry > exit`** — the two windows never overlap. The point is inside one
strip, then the other, but never both at once. That's the classic "flies past
the corner" miss.
3. **`entry >= 1` or `exit <= 0`** — the windows overlap, but not *during this
frame* (it's entirely in the future, or entirely in the past). Not our problem
this frame.
### The normal (which wall did we hit?)
When you do collide, you also want to know **which face** you hit, so the response
later can push you back the right way. That's the `normal` — a unit vector
pointing out of the surface you struck.
The trick: **the axis you entered *last* is the axis you actually hit.** Compare
the two entry times — whichever is larger is the blocking axis:
- if `spanX.entry > spanY.entry` → you hit a **vertical** wall (left/right face).
The normal is horizontal, pointing back against your x-motion:
`normal = { x: v.x > 0 ? -1 : 1, y: 0 }`.
- otherwise → you hit a **horizontal** wall (top/bottom). The normal is vertical:
`normal = { x: 0, y: v.y > 0 ? -1 : 1 }`.
(Moving right and hitting something → the surface pushes you left → normal `-1`.
That sign rule is all there is to it.)
## Task
Implement `rayVsAABB(p, v, box)` in `ray.ts`. Return `{ time, normal }` for the
entry, or `null` for any of the three no-hit cases. `sweepInterval` is provided
in `given.ts` — **reuse it**, don't re-derive it.
```sh
bun test workshop/steps/06-ray-vs-aabb
```
+28
View File
@@ -0,0 +1,28 @@
// Finished in earlier steps — reuse, don't rewrite.
export type Vec = { x: number; y: number };
export const vec = (x: number, y: number): Vec => ({ x, y });
export type AABB = { x: number; y: number; w: number; h: number };
export type Span = { entry: number; exit: number };
/** Step 05, finished. */
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 };
}
+37
View File
@@ -0,0 +1,37 @@
import { expect, test } from "bun:test";
import { type AABB, vec } from "./given.ts";
import { rayVsAABB } from "./ray.ts";
test("direct hit moving right -> normal points left", () => {
// point at (0,5) moving +10 in x; box spans x:[5,8], y:[0,10]
const box: AABB = { x: 5, y: 0, w: 3, h: 10 };
const hit = rayVsAABB(vec(0, 5), vec(10, 0), box)!;
expect(hit.time).toBeCloseTo(0.5); // reaches x=5 at half the frame
expect(hit.normal).toEqual({ x: -1, y: 0 });
});
test("passes above the box (not moving in y, outside in y) -> null", () => {
const box: AABB = { x: 5, y: 0, w: 3, h: 10 };
expect(rayVsAABB(vec(0, 20), vec(10, 0), box)).toBeNull();
});
test("diagonal hit where y is the blocking axis -> vertical normal", () => {
// box x:[5,15], y:[8,18]; you enter x at t=0.5 but y only at t=0.8,
// so the real entry is 0.8 and the wall you hit is horizontal (top).
const box: AABB = { x: 5, y: 8, w: 10, h: 10 };
const hit = rayVsAABB(vec(0, 0), vec(10, 10), box)!;
expect(hit.time).toBeCloseTo(0.8);
expect(hit.normal).toEqual({ x: 0, y: -1 });
});
test("flies past the corner (windows never overlap) -> null", () => {
// inside x during [0.5,0.8], inside y during [0,0.1] — never both at once
const box: AABB = { x: 5, y: 0, w: 3, h: 1 };
expect(rayVsAABB(vec(0, 0), vec(10, 10), box)).toBeNull();
});
test("collision is real but lands after this frame -> null", () => {
// too slow: reaches the box at t=5, outside [0,1)
const box: AABB = { x: 5, y: 0, w: 2, h: 10 };
expect(rayVsAABB(vec(0, 0), vec(1, 0), box)).toBeNull();
});
+52
View File
@@ -0,0 +1,52 @@
import { type AABB, type Span, sweepInterval, type Vec } from "./given.ts";
/** A collision: the fraction of the frame at impact, and the surface normal. */
export type Hit = { time: number; normal: Vec };
/**
* A point at `p` moving by `v` over this frame, against a static box.
* Returns the entry Hit, or null if there is no collision this frame.
*
* Reuse sweepInterval twice (x and y), then combine:
* entry = max of the two entries, exit = min of the two exits.
*/
export function rayVsAABB(p: Vec, v: Vec, box: AABB): Hit | null {
const spanX = sweepInterval(p.x, v.x, box.x, box.x + box.w);
if (spanX === null) {
return null;
}
const spanY = sweepInterval(p.y, v.y, box.y, box.y + box.h);
if (spanY === null) {
return null;
}
const span: Span = {
entry: Math.max(spanX.entry, spanY.entry),
exit: Math.min(spanX.exit, spanY.exit),
};
if (span.entry > span.exit) {
return null;
}
if (span.entry >= 1 || span.exit <= 0) {
return null;
}
const time = span.entry;
const normal = {
x: 0,
y: 0,
};
if (spanX.entry > spanY.entry) {
normal.x = v.x > 0 ? -1 : 1;
} else {
normal.y = v.y > 0 ? -1 : 1;
}
return {
time,
normal,
};
}
+68
View File
@@ -0,0 +1,68 @@
# Step 07 — Swept AABB (the Minkowski trick)
Step 06 handled a moving **point** vs a box. But in a real game the thing that
moves is a **box** (the player), not a point. This step turns "moving box vs box"
into "moving point vs box" so you can reuse step 06 *unchanged*. That conversion
is the single cleverest idea in the whole engine.
## The problem
Box A (the player) sits at corner `(a.x, a.y)` with size `a.w × a.h`, and moves by
`v` this frame. Box B (a wall) is static. When do they touch?
It's fiddly because *both* shapes have size. You'd have to track four edges of A
against four edges of B. Ugh.
## The trick: grow B, shrink A to a point
Watch what "just touching" means on the x-axis. A spans `[a.x, a.x + a.w]`, B
spans `[b.x, b.x + b.w]`. They overlap when:
```
a.x < b.x + b.w AND b.x < a.x + a.w
```
Rearrange the second one (`b.x - a.w < a.x`) and you get a statement purely about
**`a.x`**, the corner of A:
```
b.x - a.w < a.x < b.x + b.w
```
Read that: A's *corner* `a.x` behaves exactly like a **point** sliding inside a
**wider interval** — one that starts `a.w` earlier and is `a.w` longer than B.
The same happens on y with `a.h`.
So: **dump all of A's size onto B, and A collapses to just its corner point.**
```
inflated = {
x: b.x - a.w, // push the left edge out by A's width
y: b.y - a.h, // push the top edge out by A's height
w: b.w + a.w, // grow width by A's width
h: b.h + a.h, // grow height by A's height
}
point = { x: a.x, y: a.y } // A is now just its corner
```
This grown box is the **Minkowski sum** of B with A. And "does this point, moving
by `v`, hit `inflated`?" is *exactly* `rayVsAABB` from step 06. You're done in
three lines.
> Sanity picture: player box 2 wide with its right edge at x=2, wall left edge at
> x=5 → real gap is 3. Inflate: `inflated.x = 5 - 2 = 3`, and the player's corner
> sits at x=0, so the corner-to-inflated-edge gap is also 3. Same answer, simpler
> shape. The inflation *bakes A's size into the wall* so the corner can pretend to
> be a point.
This is the heart of your real engine's `sweptAABB` — the `inflAABB` it builds is
this very inflated box, and `(ax, ay)` is this corner point.
## Task
Implement `sweptAABB(a, v, b)` in `swept.ts`: build the inflated box, then call
the provided `rayVsAABB` (finished, in `given.ts`). Return its `Hit | null`.
```sh
bun test workshop/steps/07-swept-aabb
```
+54
View File
@@ -0,0 +1,54 @@
// Finished in earlier steps — reuse, don't rewrite.
export type Vec = { x: number; y: number };
export const vec = (x: number, y: number): Vec => ({ x, y });
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 };
/** Step 05, finished. */
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 };
}
/** Step 06, finished. A moving point vs a static box. */
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) {
return null;
}
if (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 };
}
@@ -0,0 +1,35 @@
import { expect, test } from "bun:test";
import { type AABB, vec } from "./given.ts";
import { sweptAABB } from "./swept.ts";
test("box moving right into a wall -> hits at the real gap", () => {
// A right edge at x=2, B left edge at x=5 -> gap 3, speed 10 -> t=0.3
const a: AABB = { x: 0, y: 0, w: 2, h: 2 };
const b: AABB = { x: 5, y: 0, w: 2, h: 2 };
const hit = sweptAABB(a, vec(10, 0), b)!;
expect(hit.time).toBeCloseTo(0.3);
expect(hit.normal).toEqual({ x: -1, y: 0 });
});
test("box passes below the wall -> null", () => {
const a: AABB = { x: 0, y: 10, w: 2, h: 2 };
const b: AABB = { x: 5, y: 0, w: 2, h: 2 };
expect(sweptAABB(a, vec(10, 0), b)).toBeNull();
});
test("box moving left into a wall -> normal points right", () => {
// A left edge at x=10, B right edge at x=2 -> gap 8, speed 10 -> t=0.8
const a: AABB = { x: 10, y: 0, w: 2, h: 2 };
const b: AABB = { x: 0, y: 0, w: 2, h: 2 };
const hit = sweptAABB(a, vec(-10, 0), b)!;
expect(hit.time).toBeCloseTo(0.8);
expect(hit.normal).toEqual({ x: 1, y: 0 });
});
test("diagonal where y blocks first -> horizontal normal", () => {
const a: AABB = { x: 0, y: 0, w: 2, h: 2 };
const b: AABB = { x: 5, y: 8, w: 2, h: 2 };
const hit = sweptAABB(a, vec(10, 10), b)!;
expect(hit.time).toBeCloseTo(0.6);
expect(hit.normal).toEqual({ x: 0, y: -1 });
});
+20
View File
@@ -0,0 +1,20 @@
import { type AABB, type Hit, rayVsAABB, type Vec } from "./given.ts";
/**
* A moving box `a` (velocity `v` over this frame) vs a static box `b`.
*
* Inflate `b` by `a`'s size (Minkowski sum), collapse `a` to its corner point,
* then reuse rayVsAABB. Return its Hit, or null for no collision this frame.
*/
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,
};
const point: Vec = { x: a.x, y: a.y };
return rayVsAABB(point, v, inflated);
}
+43
View File
@@ -0,0 +1,43 @@
# Step 08 — Resolve (move *to* the wall, not through it)
Detection is done. Now for **response** — actually reacting to the hit. This first
half is almost embarrassingly small, but it introduces the idea the whole loop
(step 10) is built on: **the frame isn't all-or-nothing.**
## Concept
`sweptAABB` hands you a `Hit` with a `time` between 0 and 1 — the fraction of the
frame at which you'd collide. So instead of moving the full displacement `v`
(which would bury you inside the wall), you move only the part of it that happens
*before* impact:
```
no hit → newPos = pos + v (nothing in the way: take the whole move)
hit at t → newPos = pos + v * t (stop exactly at the contact point)
```
That's it — you already have `add` and `scale`; this is them, gated on the hit.
### The leftover that matters
Here's the seed for step 10: if you hit at `t = 0.3`, you only used **30%** of
this frame. The other **70%** is still owed to the player — they should keep
moving for the rest of the frame, just not *into* the wall. That leftover time is
exactly why sliding (step 09) and the loop (step 10) exist. A collision doesn't
end the frame; it **interrupts** it.
> Real-engine footnote: production code usually moves to `t - EPSILON` (a hair
> *short* of contact) so floating-point error can't leave the box a sliver inside
> the wall, where the next frame's sweep would start already-overlapping. Your
> `nage` does this with `Math.max(0, time - EPSILON)`. We keep the kata exact so
> the numbers stay clean — just know that tiny backoff is there for a real reason.
## Task
Implement `resolve(pos, v, hit)` in `resolve.ts`: return the full move when `hit`
is `null`, otherwise the position at contact. `vec/add/scale` and the `Hit` type
are in `given.ts`.
```sh
bun test workshop/steps/08-resolve
```
+8
View File
@@ -0,0 +1,8 @@
// Finished in earlier steps.
export type Vec = { x: number; y: number };
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 scale = (a: Vec, s: number): Vec => ({ x: a.x * s, y: a.y * s });
/** From step 06/07. */
export type Hit = { time: number; normal: Vec };
+19
View File
@@ -0,0 +1,19 @@
import { expect, test } from "bun:test";
import { vec } from "./given.ts";
import { resolve } from "./resolve.ts";
test("no collision -> full move", () => {
expect(resolve(vec(0, 0), vec(10, 5), null)).toEqual({ x: 10, y: 5 });
});
test("collision at t=0.3 -> stop at contact", () => {
const hit = { time: 0.3, normal: vec(-1, 0) };
const p = resolve(vec(0, 0), vec(10, 0), hit);
expect(p.x).toBeCloseTo(3);
expect(p.y).toBeCloseTo(0);
});
test("collision at t=0 -> don't move at all", () => {
const hit = { time: 0, normal: vec(-1, 0) };
expect(resolve(vec(5, 5), vec(10, 10), hit)).toEqual({ x: 5, y: 5 });
});
+11
View File
@@ -0,0 +1,11 @@
import { add, type Hit, scale, type Vec } from "./given.ts";
/**
* The new position after this frame's move, stopping at a collision if there is
* one.
* hit === null -> pos + v (take the whole move)
* hit -> pos + v * time (stop at the contact point)
*/
export function resolve(pos: Vec, v: Vec, hit: Hit | null): Vec {
return hit ? add(pos, scale(v, hit.time)) : add(pos, v);
}
+58
View File
@@ -0,0 +1,58 @@
# Step 09 — Slide (this is what `dot` was for)
Back in step 02 you implemented `dot` and I said "you'll see later." This is
later. Sliding is the difference between a game that feels good and one where you
stick to every wall like glue.
## The problem
You're moving with velocity `v` and you hit a wall whose outward normal is `n`.
If you just *stop* (velocity → 0), the player jams against the wall — press
into a wall diagonally and all motion dies, even the part that was parallel to
the wall and perfectly fine. What we actually want: **cancel only the part of `v`
that pushes *into* the wall, and keep the part that runs *along* it.** That's a
slide.
## The math (projection)
Any velocity `v` can be split into two pieces relative to the wall:
- the part **along the normal** (into/out of the wall) — this is what the wall forbids,
- the part **along the wall surface** (perpendicular to the normal) — this is fine.
Because `n` is a **unit vector**, the amount of `v` pointing along `n` is exactly
`dot(v, n)`. That single number is "how much of `v` goes straight into the wall."
The vector piece pointing into the wall is `n * dot(v, n)`. Subtract it off:
```
vSlide = v - n * dot(v, n)
```
What's left has **zero** component along the normal — it lies flat against the
wall. (That's the geometric meaning of `dot`: it measures how much two vectors
share a direction. Subtract the shared-with-the-normal part, and nothing pointing
into the wall survives.)
### Feel it with numbers
Move `v = (10, 5)` into a wall whose normal is `n = (-1, 0)` (a vertical wall on
your right, pushing you left):
- `dot(v, n) = 10*(-1) + 5*0 = -10`
- `n * dot = (-1,0) * -10 = (10, 0)`
- `vSlide = (10,5) - (10,0) = (0, 5)`
The rightward motion (10) is gone — that was driving you into the wall — but the
downward motion (5) survives untouched. You slide down the wall. Exactly right.
This is the very last line of your engine's `moveAndSlide`:
`velocity -= normal * (velocity · normal)`.
## Task
Implement `slide(v, normal)` in `slide.ts` using `dot`, `scale`, and `sub` (all
in `given.ts`). Return the velocity with its into-the-wall component removed.
```sh
bun test workshop/steps/09-slide
```
+6
View File
@@ -0,0 +1,6 @@
// Finished in earlier steps.
export type Vec = { x: number; y: number };
export const vec = (x: number, y: number): Vec => ({ x, 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;
+24
View File
@@ -0,0 +1,24 @@
import { expect, test } from "bun:test";
import { dot, vec } from "./given.ts";
import { slide } from "./slide.ts";
test("vertical wall kills horizontal motion, keeps vertical", () => {
// moving (10,5) into a wall with normal (-1,0)
const s = slide(vec(10, 5), vec(-1, 0));
expect(s.x).toBeCloseTo(0);
expect(s.y).toBeCloseTo(5);
});
test("horizontal wall kills vertical motion, keeps horizontal", () => {
const s = slide(vec(10, 5), vec(0, -1));
expect(s.x).toBeCloseTo(10);
expect(s.y).toBeCloseTo(0);
});
test("result always runs along the wall (perpendicular to the normal)", () => {
// a diagonal (unit) normal: 0.36 + 0.64 = 1
const normal = vec(-0.6, -0.8);
const s = slide(vec(10, 0), normal);
// the slid velocity has no component into the wall -> dot with normal is 0
expect(dot(s, normal)).toBeCloseTo(0);
});
+11
View File
@@ -0,0 +1,11 @@
import { dot, scale, sub, type Vec } from "./given.ts";
/**
* Remove the component of `v` that points into a wall with the given (unit)
* normal, leaving only the motion that runs along the wall.
*
* vSlide = v - normal * dot(v, normal)
*/
export function slide(v: Vec, normal: Vec): Vec {
return sub(v, scale(normal, dot(v, normal)));
}
@@ -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;
}
+45
View File
@@ -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.
+179
View File
@@ -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);
+61
View File
@@ -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>