time for a snapshot
This commit is contained in:
@@ -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
|
||||
```
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
```
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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
|
||||
```
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
```
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
```
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
```
|
||||
@@ -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 };
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
```
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
```
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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