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
+108
View File
@@ -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?