ehm
This commit is contained in:
@@ -133,6 +133,51 @@ function drawBox(b, fill, stroke) {
|
||||
ctx.strokeRect(b.x + 0.5, b.y + 0.5, b.w - 1, b.h - 1);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* How to push `a` OUT of `b`, by the smallest possible single-axis move.
|
||||
*
|
||||
* Returns the push as a vector to ADD to a's position, or null if the boxes
|
||||
* don't strictly overlap (merely touching edges is not overlapping).
|
||||
*
|
||||
* There are four ways out (left, right, up, down) — measure all four
|
||||
* distances and return the shortest one. See the README.
|
||||
*/
|
||||
function penetrationVector(a, b) {
|
||||
const outLeft = a.x + a.w - b.x;
|
||||
const outRight = b.x + b.w - a.x;
|
||||
const outUp = a.y + a.h - b.y;
|
||||
const outDown = b.y + b.h - a.y;
|
||||
|
||||
const minX = Math.min(outLeft, outRight);
|
||||
const minY = Math.min(outUp, outDown);
|
||||
|
||||
if (minX > 0 && minY > 0) {
|
||||
return minX > minY
|
||||
? { x: 0, y: outDown > outUp ? -minY : minY }
|
||||
: { x: outLeft > outRight ? minX : -minX, y: 0 };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* moveAndSlide, but immune to the overlap trap: first push `box` out of any
|
||||
* wall it is already inside (plus an EPSILON of slack, same idea as the
|
||||
* backoff in the loop), THEN run the normal sweep loop from the safe spot.
|
||||
*
|
||||
* Everything you need is penetrationVector above and given.ts.
|
||||
*/
|
||||
function safeMoveAndSlide(box, v, walls) {
|
||||
for (const wall of walls) {
|
||||
const pv = penetrationVector(box, wall);
|
||||
if (pv) {
|
||||
box.x += pv.x > 0 ? pv.x + EPSILON : pv.x - EPSILON;
|
||||
box.y += pv.y > 0 ? pv.y + EPSILON : pv.y - EPSILON;
|
||||
}
|
||||
}
|
||||
|
||||
return moveAndSlide(box, v, walls);
|
||||
}
|
||||
|
||||
let then = performance.now();
|
||||
function frame(now) {
|
||||
@@ -156,7 +201,7 @@ function frame(now) {
|
||||
|
||||
// full-frame displacement is velocity * delta — exactly what moveAndSlide expects
|
||||
const displacement = scale(velocity, delta);
|
||||
const next = moveAndSlide(player, displacement, walls);
|
||||
const next = safeMoveAndSlide(player, displacement, walls);
|
||||
player.x = next.x;
|
||||
player.y = next.y;
|
||||
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
# Step 12 — The overlap trap (when the sweep has no answer)
|
||||
|
||||
You found this one yourself, in the capstone: start a box **inside** another
|
||||
collider, press a key, and the position turns into `NaN`. That's not a typo in
|
||||
your code from steps 01–10 — the kernel is *correct* and still does this. It's a
|
||||
**blind spot in the whole approach**, and every real engine has to patch it.
|
||||
|
||||
> This is a genuine hole in the finished kernel, shared by the capstone's
|
||||
> `game.js`. It is a *third* thing, separate from the two bugs you're hunting in
|
||||
> `engine/system/physics.ts` — no spoilers here.
|
||||
|
||||
## Why the sweep can't see it
|
||||
|
||||
Everything since step 05 answers one question: *"when, during this frame, will I
|
||||
**enter** the box?"* The whole ladder quietly assumes the answer lies in the
|
||||
future — that you start the frame **outside**.
|
||||
|
||||
Start inside, and "when will I enter?" has no sane answer. The math doesn't
|
||||
refuse — it cheerfully reports that you entered *in the past*. Remember step 05:
|
||||
what sign does `entry` have when `p` is already between `min` and `max`? Every
|
||||
function above `sweepInterval` trusts that number without checking it.
|
||||
|
||||
## The autopsy (do this before reading on)
|
||||
|
||||
The `NaN` isn't born where you see it — it's the end of a chain, and each link
|
||||
is one line you wrote. Reproduce it in a scratch file and trace it:
|
||||
|
||||
```ts
|
||||
import { moveAndSlide, sweptAABB } from "./given.ts";
|
||||
|
||||
const box = { x: 10, y: 10, w: 2, h: 2 };
|
||||
const wall = { x: 0, y: 0, w: 32, h: 32 };
|
||||
console.log(sweptAABB(box, { x: 5, y: 0 }, wall));
|
||||
console.log(moveAndSlide(box, { x: 5, y: 0 }, [wall]));
|
||||
```
|
||||
|
||||
Work through these **in order, predicting each answer before checking** (add
|
||||
`console.log`s inside your step-10 loop — it's your code, instrument it):
|
||||
|
||||
1. What `time` does `sweptAABB` report? Now flip the velocity so the box moves
|
||||
*away* from the wall — why do you *still* get a hit? (This is why you can't
|
||||
even walk out of a wall you're stuck in.)
|
||||
2. Follow that `time` into the `else` branch of `moveAndSlide`. Three lines use
|
||||
it. Which line is saved by the `Math.max(0, …)`? What happens to `vel` when
|
||||
you `slide` against that normal? And what does `timeLeft = timeLeft * (1 - time)`
|
||||
do when `time` is negative — shrink, or *grow*?
|
||||
3. Next iteration: `vel` is now `(0, 0)` but the loop keeps going. What does
|
||||
`sweepInterval` return for `v = 0` while inside the interval (look at the
|
||||
first branch — you wrote it in step 05)? So what is `entry` now, and what
|
||||
does `timeLeft` become after multiplying by `(1 - entry)`?
|
||||
4. Last link. In JavaScript, what is `0 * Infinity`? That's `scale(vel, timeLeft)`
|
||||
on iteration three. And once one `NaN` exists, every comparison against it is
|
||||
`false` — so which branch of the loop does the poisoned move fall into, and
|
||||
what does `pos = add(pos, move)` do then?
|
||||
|
||||
Four links: **overlap → a hit in the past → dead velocity + growing time debt →
|
||||
`0 × ∞`**. When you can retell that chain from memory, you own it.
|
||||
|
||||
## The fix: measure the overlap, push out
|
||||
|
||||
The sweep is *continuous* detection — it prevents overlap but can't recover from
|
||||
it. So real engines pair it with a *discrete* partner: if you're already inside,
|
||||
don't ask "when do I enter?" — ask **"how deep am I, and what's the shortest way
|
||||
out?"**, then teleport that far and *only then* sweep.
|
||||
|
||||
That shortest-way-out is the **penetration vector** (the famous *minimum
|
||||
translation vector*). For two overlapping AABBs there are exactly four escapes —
|
||||
push `a` left, right, up, or down until the boxes just separate:
|
||||
|
||||
```
|
||||
outLeft = (a.x + a.w) - b.x // slide a left this far -> separated
|
||||
outRight = (b.x + b.w) - a.x // slide a right this far -> separated
|
||||
outUp = (a.y + a.h) - b.y
|
||||
outDown = (b.y + b.h) - a.y
|
||||
```
|
||||
|
||||
Two things to convince yourself of (don't skip — the tests check both):
|
||||
|
||||
- The boxes strictly overlap **iff all four distances are positive**. (What is
|
||||
`outLeft` when `a` sits fully to the right of `b`? When they merely touch?)
|
||||
- The answer is the **smallest** of the four, as a vector, with the sign that
|
||||
moves `a` *away*. Smallest, because depenetration is a teleport the player can
|
||||
see — one pixel of pop beats being flung across the room. Note this handles
|
||||
`a` fully *swallowed* by `b` too, where "the overlap of the intervals" would
|
||||
lie to you — one of the tests is exactly that case.
|
||||
|
||||
## Task
|
||||
|
||||
Two functions in `overlap.ts`:
|
||||
|
||||
1. `penetrationVector(a, b)` — the smallest single-axis push that separates the
|
||||
boxes, or `null` if they don't strictly overlap.
|
||||
2. `safeMoveAndSlide(box, v, walls)` — check every wall; if the box is inside
|
||||
one, apply the push **plus an `EPSILON` of slack in the push direction**
|
||||
(same idea as the backoff in the loop: land *flush* on the wall and next
|
||||
frame's sweep starts half-trapped again). Then run the given `moveAndSlide`
|
||||
from the safe position.
|
||||
|
||||
```sh
|
||||
bun test workshop/steps/12-overlap
|
||||
```
|
||||
|
||||
## Make the capstone unbreakable (optional)
|
||||
|
||||
Port both functions into `11-capstone/game.js`, swap the `moveAndSlide` call for
|
||||
`safeMoveAndSlide`, and set the player's spawn inside the pillar. It should pop
|
||||
out and play on like nothing happened. Then the question you actually care
|
||||
about: does your *real* engine survive the same experiment?
|
||||
@@ -0,0 +1,113 @@
|
||||
// The whole kernel you've built, steps 01-10, 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;
|
||||
|
||||
/** Step 04, finished. Strict overlap — merely touching edges is false. */
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
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)));
|
||||
}
|
||||
|
||||
/** Step 10, finished — your loop, exactly as you wrote it. */
|
||||
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,102 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { type AABB, aabbOverlap, vec } from "./given.ts";
|
||||
import { penetrationVector, safeMoveAndSlide } from "./overlap.ts";
|
||||
|
||||
// --- penetrationVector -------------------------------------------------------
|
||||
|
||||
test("separated boxes -> null", () => {
|
||||
const a: AABB = { x: 0, y: 0, w: 2, h: 2 };
|
||||
const b: AABB = { x: 5, y: 5, w: 2, h: 2 };
|
||||
expect(penetrationVector(a, b)).toBeNull();
|
||||
});
|
||||
|
||||
test("touching edges (no strict overlap) -> null", () => {
|
||||
const a: AABB = { x: 0, y: 0, w: 2, h: 2 };
|
||||
const b: AABB = { x: 2, y: 0, w: 2, h: 2 };
|
||||
expect(penetrationVector(a, b)).toBeNull();
|
||||
});
|
||||
|
||||
test("shallow on x, deep on y -> pushes left (the short way out)", () => {
|
||||
// a pokes 1 unit into b's left side; getting out via y would cost 6
|
||||
const a: AABB = { x: 0, y: 0, w: 4, h: 4 };
|
||||
const b: AABB = { x: 3, y: -2, w: 4, h: 8 };
|
||||
expect(penetrationVector(a, b)).toEqual(vec(-1, 0));
|
||||
});
|
||||
|
||||
test("mirrored -> pushes right", () => {
|
||||
const a: AABB = { x: 4, y: 0, w: 4, h: 4 };
|
||||
const b: AABB = { x: 1, y: -2, w: 4, h: 8 };
|
||||
expect(penetrationVector(a, b)).toEqual(vec(1, 0));
|
||||
});
|
||||
|
||||
test("shallow on y -> pushes up", () => {
|
||||
const a: AABB = { x: 0, y: 0, w: 4, h: 4 };
|
||||
const b: AABB = { x: -2, y: 3, w: 8, h: 4 };
|
||||
expect(penetrationVector(a, b)).toEqual(vec(0, -1));
|
||||
});
|
||||
|
||||
test("fully contained -> still gets out (all the way past the nearest edge)", () => {
|
||||
// a is swallowed by b: the push must clear the whole distance to an edge,
|
||||
// not just the 2-unit "overlap" of the intervals
|
||||
const a: AABB = { x: 10, y: 10, w: 2, h: 2 };
|
||||
const b: AABB = { x: 0, y: 0, w: 32, h: 32 };
|
||||
const p = penetrationVector(a, b);
|
||||
expect(p).not.toBeNull();
|
||||
if (p !== null) {
|
||||
const moved: AABB = { ...a, x: a.x + p.x, y: a.y + p.y };
|
||||
expect(aabbOverlap(moved, b)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
// --- safeMoveAndSlide --------------------------------------------------------
|
||||
|
||||
const player: AABB = { x: 0, y: 0, w: 2, h: 2 };
|
||||
|
||||
test("no overlap at start -> behaves exactly like moveAndSlide", () => {
|
||||
// step 10's diagonal test: x blocked at 3, y keeps going
|
||||
const walls: AABB[] = [{ x: 5, y: -10, w: 2, h: 40 }];
|
||||
const p = safeMoveAndSlide(player, vec(10, 10), walls);
|
||||
expect(p.x).toBeCloseTo(3);
|
||||
expect(p.y).toBeCloseTo(10);
|
||||
});
|
||||
|
||||
test("starting inside a wall -> position stays a number", () => {
|
||||
const inside: AABB = { x: 10, y: 10, w: 2, h: 2 };
|
||||
const wall: AABB = { x: 0, y: 0, w: 32, h: 32 };
|
||||
const p = safeMoveAndSlide(inside, vec(5, 0), [wall]);
|
||||
expect(Number.isFinite(p.x)).toBe(true);
|
||||
expect(Number.isFinite(p.y)).toBe(true);
|
||||
});
|
||||
|
||||
test("starting inside a wall -> ends free of it", () => {
|
||||
const inside: AABB = { x: 10, y: 10, w: 2, h: 2 };
|
||||
const wall: AABB = { x: 0, y: 0, w: 32, h: 32 };
|
||||
const p = safeMoveAndSlide(inside, vec(5, 0), [wall]);
|
||||
expect(aabbOverlap({ ...inside, x: p.x, y: p.y }, wall)).toBe(false);
|
||||
});
|
||||
|
||||
test("overlapping and walking AWAY -> actually gets away", () => {
|
||||
// pokes 1 into the wall's left side; pushed out to x≈8, then walks -5
|
||||
const stuck: AABB = { x: 9, y: 0, w: 2, h: 2 };
|
||||
const wall: AABB = { x: 10, y: -5, w: 4, h: 10 };
|
||||
const p = safeMoveAndSlide(stuck, vec(-5, 0), [wall]);
|
||||
expect(p.x).toBeCloseTo(3);
|
||||
expect(p.y).toBeCloseTo(0);
|
||||
});
|
||||
|
||||
test("overlapping and pushing IN -> freed, then held at the wall", () => {
|
||||
// pushed out to x≈8 first; the sweep then stops the rightward move there
|
||||
const stuck: AABB = { x: 9, y: 0, w: 2, h: 2 };
|
||||
const wall: AABB = { x: 10, y: -5, w: 4, h: 10 };
|
||||
const p = safeMoveAndSlide(stuck, vec(5, 0), [wall]);
|
||||
expect(p.x).toBeCloseTo(8);
|
||||
expect(p.y).toBeCloseTo(0);
|
||||
});
|
||||
|
||||
test("overlapping and pushing IN (rtl) -> freed, then held at the wall", () => {
|
||||
const stuck: AABB = { x: 9, y: 0, w: 2, h: 2 };
|
||||
const wall: AABB = { x: 10, y: -5, w: 4, h: 10 };
|
||||
const p = safeMoveAndSlide(stuck, vec(-5, 0), [wall]);
|
||||
expect(p.x).toBeCloseTo(3);
|
||||
expect(p.y).toBeCloseTo(0);
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { type AABB, EPSILON, moveAndSlide, type Vec } from "./given.ts";
|
||||
|
||||
/**
|
||||
* How to push `a` OUT of `b`, by the smallest possible single-axis move.
|
||||
*
|
||||
* Returns the push as a vector to ADD to a's position, or null if the boxes
|
||||
* don't strictly overlap (merely touching edges is not overlapping).
|
||||
*
|
||||
* There are four ways out (left, right, up, down) — measure all four
|
||||
* distances and return the shortest one. See the README.
|
||||
*/
|
||||
export function penetrationVector(a: AABB, b: AABB): Vec | null {
|
||||
const outLeft = a.x + a.w - b.x;
|
||||
const outRight = b.x + b.w - a.x;
|
||||
const outUp = a.y + a.h - b.y;
|
||||
const outDown = b.y + b.h - a.y;
|
||||
|
||||
const minX = Math.min(outLeft, outRight);
|
||||
const minY = Math.min(outUp, outDown);
|
||||
|
||||
if (minX > 0 && minY > 0) {
|
||||
return minX > minY
|
||||
? { x: 0, y: outDown > outUp ? -minY : minY }
|
||||
: { x: outLeft > outRight ? minX : -minX, y: 0 };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* moveAndSlide, but immune to the overlap trap: first push `box` out of any
|
||||
* wall it is already inside (plus an EPSILON of slack, same idea as the
|
||||
* backoff in the loop), THEN run the normal sweep loop from the safe spot.
|
||||
*
|
||||
* Everything you need is penetrationVector above and given.ts.
|
||||
*/
|
||||
export function safeMoveAndSlide(box: AABB, v: Vec, walls: AABB[]): Vec {
|
||||
for (const wall of walls) {
|
||||
const pv = penetrationVector(box, wall);
|
||||
if (pv) {
|
||||
box.x += pv.x > 0 ? pv.x + EPSILON : pv.x - EPSILON;
|
||||
box.y += pv.y > 0 ? pv.y + EPSILON : pv.y - EPSILON;
|
||||
}
|
||||
}
|
||||
|
||||
return moveAndSlide(box, v, walls);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
# Step 13 — The crush (when there is no way out)
|
||||
|
||||
You found this one yourself too, chasing the capstone's ledge: the hero gets
|
||||
pushed out of the moving ledge, lands **inside the pillar**, and the `NaN` you
|
||||
buried in step 12 climbs right back out of its grave. Your autopsy chain from
|
||||
last time is unchanged — the only new thing is *how a box you just freed ends
|
||||
up inside a wall again in the very same frame*.
|
||||
|
||||
> Still a *fourth* thing, separate from the two bugs you're hunting in
|
||||
> `engine/system/physics.ts` — no spoilers there.
|
||||
|
||||
## Why one pass isn't enough
|
||||
|
||||
Step 12's `safeMoveAndSlide` walks the walls **once, in array order**, fixing
|
||||
each overlap it meets. For one wall that's airtight. But a depenetration push is
|
||||
a *teleport* — and a teleport can land you inside a wall the loop already
|
||||
checked and cleared, or one it hasn't reached yet (in which case it works, by
|
||||
luck of the ordering). A resolver whose correctness depends on the order of the
|
||||
wall array isn't a resolver — it's a coin flip.
|
||||
|
||||
Two experiments in a scratch file, **predicting each outcome before running**
|
||||
(use your step-12 code as-is):
|
||||
|
||||
1. Hero `{x: 9, y: 1, w: 2, h: 2}`, walls `A = {x: 10, y: 0, w: 4, h: 4}` and
|
||||
`B = {x: 4, y: 0, w: 4.5, h: 1.4}`. The hero overlaps only `A`. Run one pass
|
||||
with the array `[A, B]`, then with `[B, A]`. Where does the hero end up in
|
||||
each case, and is it free? Explain the difference before moving on.
|
||||
2. Walls `{x: 10, y: -5, w: 4, h: 10}` and `{x: 6.5, y: -5, w: 2, h: 10}` — a
|
||||
gap 1.5 wide. Hero (2 wide) at `{x: 9, y: 0}`. Apply `penetrationVector`
|
||||
pushes in a loop and log `x` each time. Does it converge? What number does
|
||||
`x` bounce between, and *why will it never stop*?
|
||||
|
||||
## The negotiation, and when it honestly fails
|
||||
|
||||
The fix for experiment 1 is patience: don't do one pass — **repeat whole passes
|
||||
until a full pass finds nothing to fix**. That clean pass is your proof of
|
||||
freedom. Each pass is cheap, and in sane geometry it settles in one or two.
|
||||
|
||||
But experiment 2 shows the negotiation can be *unwinnable*: when the gap is
|
||||
narrower than the box, **no overlap-free position exists**. No amount of math
|
||||
fixes that, because it isn't a math problem — it's a game-design question, and
|
||||
every game answers it differently. Mario between a Thwomp and the floor:
|
||||
crushed = death. Some engines let the wall shove you *through* its partner.
|
||||
Zelda-flavored games mostly refuse the situation: solid wins, the hero holds
|
||||
still until the gap opens. We take that one — it's the smallest honest answer:
|
||||
**cap the passes, and if the cap fires, report it** (`settled: false`) instead
|
||||
of pretending. You already believe in caps; your step-10 loop carries one for
|
||||
exactly the same reason.
|
||||
|
||||
Last session you proposed armoring `sweepInterval` against the `Infinity`
|
||||
directly. You can — see the optional section — but notice what that answer
|
||||
skips over: even with the `NaN` gone, *what should a crushed hero do?* The
|
||||
kernel can't know; it only measures. Deciding is the resolver's job. Keeping
|
||||
**detection** and **policy** separate is the actual lesson of this step.
|
||||
|
||||
## Task
|
||||
|
||||
Two functions in `crush.ts`:
|
||||
|
||||
1. `resolveOverlaps(box, walls)` — the negotiation: passes until clean or
|
||||
capped, returning `{x, y, settled}`.
|
||||
2. `safeMoveAndSlide(box, v, walls)` — step 12's version rebuilt on top of it.
|
||||
Settled → sweep as usual. Crushed → **don't feed the sweep an overlapping
|
||||
box** (you know its opinion of those); the box stays where the resolver left
|
||||
it and waits.
|
||||
|
||||
One warning on the `EPSILON` slack: apply it **only along the axis you actually
|
||||
pushed**. Before you port your step-12 slack code verbatim, play computer with
|
||||
`pv = { x: -3, y: 0 }` and watch what your two lines do to `y`. (That was my
|
||||
"bonus" question last session — it's still open, and one of the tests refuses
|
||||
to look away.)
|
||||
|
||||
```sh
|
||||
bun test workshop/steps/13-crush
|
||||
```
|
||||
|
||||
## Optional 1 — the airbag
|
||||
|
||||
Defense in depth: even if some future caller hands `moveAndSlide` an
|
||||
overlapping box directly, it should return finite numbers — wrong-ish, maybe,
|
||||
but *finite*. You traced in step 12 exactly which value poisons the well. The
|
||||
loop already clamps it once (`Math.max(0, …)`) — find the **other** line that
|
||||
trusts `nearest.time` to be non-negative. The fix is almost nothing. Then write
|
||||
the test step 12 should have had: `moveAndSlide` (not `safe…`) with an
|
||||
overlapping start returns finite coordinates.
|
||||
|
||||
## Optional 2 — the capstone payoff
|
||||
|
||||
Port `resolveOverlaps` into `11-capstone/game.js` and rebuild its
|
||||
`safeMoveAndSlide` on it. Now let the ledge squeeze the hero against the pillar,
|
||||
and against the border. Watch closely: instead of vanishing, the hero should
|
||||
squirt around the ledge like a watermelon seed pinched between two fingers.
|
||||
|
||||
Then earn the effect: the ledge is 16 tall and the hero is 12. As the ledge digs
|
||||
deeper, which of `penetrationVector`'s four escapes wins, and at what depth does
|
||||
the winner change? That flip *is* the watermelon seed.
|
||||
@@ -0,0 +1,98 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { resolveOverlaps, safeMoveAndSlide } from "./crush.ts";
|
||||
import { type AABB, aabbOverlap, vec } from "./given.ts";
|
||||
|
||||
// --- resolveOverlaps ---------------------------------------------------------
|
||||
|
||||
test("free box -> untouched, settled", () => {
|
||||
const r = resolveOverlaps({ x: 0, y: 0, w: 2, h: 2 }, [
|
||||
{ x: 5, y: 5, w: 2, h: 2 },
|
||||
]);
|
||||
expect(r.settled).toBe(true);
|
||||
expect(r.x).toBe(0);
|
||||
expect(r.y).toBe(0);
|
||||
});
|
||||
|
||||
test("single overlap -> pushed out, settled", () => {
|
||||
const r = resolveOverlaps({ x: 9, y: 0, w: 2, h: 2 }, [
|
||||
{ x: 10, y: -5, w: 4, h: 10 },
|
||||
]);
|
||||
expect(r.settled).toBe(true);
|
||||
expect(r.x).toBeCloseTo(8);
|
||||
// the push was x-only: the axis that needed no fixing must come back
|
||||
// EXACTLY untouched — not "close to" untouched
|
||||
expect(r.y).toBe(0);
|
||||
});
|
||||
|
||||
// pushed out of A (left) -> lands inside B -> a second push (downward, B is
|
||||
// shallow) frees it. One pass can't see this coming; the negotiation can.
|
||||
const chainA: AABB = { x: 10, y: 0, w: 4, h: 4 };
|
||||
const chainB: AABB = { x: 4, y: 0, w: 4.5, h: 1.4 };
|
||||
|
||||
test("a push that lands you in the NEXT wall still resolves", () => {
|
||||
const hero: AABB = { x: 9, y: 1, w: 2, h: 2 };
|
||||
const r = resolveOverlaps(hero, [chainA, chainB]);
|
||||
expect(r.settled).toBe(true);
|
||||
const moved = { ...hero, x: r.x, y: r.y };
|
||||
expect(aabbOverlap(moved, chainA)).toBe(false);
|
||||
expect(aabbOverlap(moved, chainB)).toBe(false);
|
||||
});
|
||||
|
||||
test("...and the answer must not depend on wall order", () => {
|
||||
const hero: AABB = { x: 9, y: 1, w: 2, h: 2 };
|
||||
const r = resolveOverlaps(hero, [chainB, chainA]);
|
||||
expect(r.settled).toBe(true);
|
||||
const moved = { ...hero, x: r.x, y: r.y };
|
||||
expect(aabbOverlap(moved, chainA)).toBe(false);
|
||||
expect(aabbOverlap(moved, chainB)).toBe(false);
|
||||
});
|
||||
|
||||
// the gap between these two walls is 1.5 wide; the hero is 2 wide.
|
||||
// there is NO overlap-free position — the pushes ping-pong forever.
|
||||
const vice: AABB[] = [
|
||||
{ x: 10, y: -5, w: 4, h: 10 },
|
||||
{ x: 6.5, y: -5, w: 2, h: 10 },
|
||||
];
|
||||
|
||||
test("gap narrower than the box -> reports the crush, stays finite", () => {
|
||||
const r = resolveOverlaps({ x: 9, y: 0, w: 2, h: 2 }, vice);
|
||||
expect(r.settled).toBe(false);
|
||||
expect(Number.isFinite(r.x)).toBe(true);
|
||||
expect(Number.isFinite(r.y)).toBe(true);
|
||||
});
|
||||
|
||||
// --- safeMoveAndSlide --------------------------------------------------------
|
||||
|
||||
test("no overlap at start -> behaves exactly like moveAndSlide", () => {
|
||||
// step 10's diagonal test: x blocked at 3, y keeps going
|
||||
const walls: AABB[] = [{ x: 5, y: -10, w: 2, h: 40 }];
|
||||
const p = safeMoveAndSlide({ x: 0, y: 0, w: 2, h: 2 }, vec(10, 10), walls);
|
||||
expect(p.x).toBeCloseTo(3);
|
||||
expect(p.y).toBeCloseTo(10);
|
||||
});
|
||||
|
||||
test("resolvable overlap, then walking away -> freed and gone", () => {
|
||||
const p = safeMoveAndSlide({ x: 9, y: 0, w: 2, h: 2 }, vec(-5, 0), [
|
||||
{ x: 10, y: -5, w: 4, h: 10 },
|
||||
]);
|
||||
expect(p.x).toBeCloseTo(3);
|
||||
expect(p.y).toBeCloseTo(0);
|
||||
});
|
||||
|
||||
test("crushed with zero velocity -> a number, not a ghost", () => {
|
||||
// the capstone bug: ledge pushes hero into the pillar, hero holds still
|
||||
const p = safeMoveAndSlide({ x: 9, y: 0, w: 2, h: 2 }, vec(0, 0), vice);
|
||||
expect(Number.isFinite(p.x)).toBe(true);
|
||||
expect(Number.isFinite(p.y)).toBe(true);
|
||||
// still pinched between the walls — not flung across the room
|
||||
expect(p.x).toBeGreaterThan(7);
|
||||
expect(p.x).toBeLessThan(9.5);
|
||||
});
|
||||
|
||||
test("crushed and still pushing -> the walls win", () => {
|
||||
const p = safeMoveAndSlide({ x: 9, y: 0, w: 2, h: 2 }, vec(5, 0), vice);
|
||||
expect(Number.isFinite(p.x)).toBe(true);
|
||||
expect(Number.isFinite(p.y)).toBe(true);
|
||||
expect(p.x).toBeGreaterThan(7);
|
||||
expect(p.x).toBeLessThan(9.5);
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import {
|
||||
type AABB,
|
||||
moveAndSlide,
|
||||
penetrationVector,
|
||||
type Vec,
|
||||
} from "./given.ts";
|
||||
|
||||
export type Resolution = { x: number; y: number; settled: boolean };
|
||||
|
||||
/**
|
||||
* Push `box` out of EVERY wall — even when one push shoves it into another.
|
||||
*
|
||||
* One pass over the walls is not enough: a push is a teleport, and a teleport
|
||||
* can land you inside a wall the loop already cleared. So: run whole passes
|
||||
* over the wall list, applying pushes (with the EPSILON slack — on the pushed
|
||||
* axis only!), until a full pass finds nothing to fix. That pass proves you're
|
||||
* settled.
|
||||
*
|
||||
* But some arrangements have NO free spot (a gap narrower than the box), and
|
||||
* the passes would ping-pong forever. Cap them — 8 is plenty — and if the cap
|
||||
* fires, report `settled: false`. You already believe in caps: your step-10
|
||||
* loop has one for exactly the same reason.
|
||||
*
|
||||
* Returns where the box ended up and whether it truly got free.
|
||||
*/
|
||||
export function resolveOverlaps(box: AABB, walls: AABB[]): Resolution {
|
||||
throw new Error("not implemented");
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 12's safeMoveAndSlide, rebuilt on resolveOverlaps.
|
||||
*
|
||||
* Settled -> sweep from the safe spot, business as usual.
|
||||
* Crushed (not settled) -> the sweep would be handed an overlapping box and
|
||||
* you know exactly what it does with one of those. Don't feed it. Our crush
|
||||
* policy: the walls win — the box stays where the resolver left it, finite,
|
||||
* and waits for the gap to open.
|
||||
*/
|
||||
export function safeMoveAndSlide(box: AABB, v: Vec, walls: AABB[]): Vec {
|
||||
throw new Error("not implemented");
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
// The whole kernel you've built, steps 01-10, 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;
|
||||
|
||||
/** Step 04, finished. Strict overlap — merely touching edges is false. */
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
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)));
|
||||
}
|
||||
|
||||
/** Step 10, finished — your loop, exactly as you wrote it. */
|
||||
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;
|
||||
}
|
||||
|
||||
/** Step 12, finished — your penetrationVector, exactly as you wrote it. */
|
||||
export function penetrationVector(a: AABB, b: AABB): Vec | null {
|
||||
const outLeft = a.x + a.w - b.x;
|
||||
const outRight = b.x + b.w - a.x;
|
||||
const outUp = a.y + a.h - b.y;
|
||||
const outDown = b.y + b.h - a.y;
|
||||
|
||||
const minX = Math.min(outLeft, outRight);
|
||||
const minY = Math.min(outUp, outDown);
|
||||
|
||||
if (minX > 0 && minY > 0) {
|
||||
return minX > minY
|
||||
? { x: 0, y: outDown > outUp ? -minY : minY }
|
||||
: { x: outLeft > outRight ? minX : -minX, y: 0 };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user