This commit is contained in:
Schluffe
2026-07-30 22:06:28 +02:00
parent 7262752e16
commit ec9f2e2a2d
13 changed files with 873 additions and 68 deletions
+96
View File
@@ -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.
+98
View File
@@ -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);
});
+41
View File
@@ -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");
}
+132
View File
@@ -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;
}