Files
nage/workshop/steps/12-overlap/README.md
T
2026-09-04 13:57:52 +02:00

110 lines
5.2 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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?