WIP
This commit is contained in:
@@ -9,13 +9,14 @@ 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."
|
||||
- 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*.
|
||||
- `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
|
||||
|
||||
@@ -9,7 +9,7 @@ An arrow `(x, y)` has a length: how far it reaches. Pythagoras:
|
||||
|
||||
### Normalize
|
||||
|
||||
Often you want *just the direction* of an arrow, with length exactly 1 (a "unit
|
||||
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)`.
|
||||
|
||||
@@ -20,14 +20,15 @@ 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.
|
||||
> `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.
|
||||
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
|
||||
|
||||
|
||||
@@ -16,8 +16,8 @@ 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
|
||||
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
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
## 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.
|
||||
**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:
|
||||
|
||||
@@ -18,7 +19,7 @@ So the box spans `x .. x+w` horizontally and `y .. y+h` vertically.
|
||||
|
||||
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:
|
||||
intersect only if _both_ pairs of shadows intersect:
|
||||
|
||||
```
|
||||
overlapX: a.x < b.x + b.w AND b.x < a.x + a.w
|
||||
@@ -32,10 +33,10 @@ 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
|
||||
`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*,
|
||||
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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 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
|
||||
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
|
||||
@@ -9,26 +9,28 @@ 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),
|
||||
`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`.
|
||||
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
|
||||
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*.
|
||||
- `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
|
||||
@@ -37,7 +39,7 @@ If `v` is **negative** (moving left), the point hits `max` *before* `min`, so
|
||||
|
||||
### The `v == 0` edge case
|
||||
|
||||
If the point isn't moving (`v == 0`), it never *crosses* an edge — dividing by
|
||||
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:
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@ entry/exit time of one sweep") finally fuse. A **moving point vs a static box**.
|
||||
|
||||
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*.
|
||||
**box** only while it's inside the x-interval **and** the y-interval _at the
|
||||
same time_.
|
||||
|
||||
So run `sweepInterval` twice:
|
||||
|
||||
@@ -18,7 +18,7 @@ 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
|
||||
_that one axis's_ strip. You're inside the box during the **overlap of the two
|
||||
windows**:
|
||||
|
||||
```
|
||||
@@ -28,7 +28,7 @@ exit = min(spanX.exit, spanY.exit) // out of the box once you leave the FIR
|
||||
|
||||
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
|
||||
- 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`.
|
||||
@@ -36,28 +36,29 @@ Read those two lines until they feel obvious — they're the whole algorithm:
|
||||
### 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.
|
||||
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.
|
||||
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.
|
||||
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 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:
|
||||
- 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 }`.
|
||||
- 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.)
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
# 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.
|
||||
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?
|
||||
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
|
||||
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
|
||||
@@ -22,14 +22,14 @@ 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:
|
||||
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
|
||||
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`.
|
||||
|
||||
@@ -45,18 +45,18 @@ inflated = {
|
||||
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.
|
||||
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.
|
||||
> 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.
|
||||
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
|
||||
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
# Step 08 — Resolve (move *to* the wall, not through it)
|
||||
# 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.**
|
||||
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:
|
||||
`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)
|
||||
@@ -22,21 +22,22 @@ That's it — you already have `add` and `scale`; this is them, gated on the hit
|
||||
|
||||
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.
|
||||
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.
|
||||
> _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`.
|
||||
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
|
||||
|
||||
@@ -1,28 +1,31 @@
|
||||
# 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.
|
||||
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
|
||||
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.
|
||||
- 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:
|
||||
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)
|
||||
@@ -30,8 +33,8 @@ 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.)
|
||||
share a direction. Subtract the shared-with-the-normal part, and nothing
|
||||
pointing into the wall survives.)
|
||||
|
||||
### Feel it with numbers
|
||||
|
||||
|
||||
@@ -6,14 +6,15 @@ 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.
|
||||
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.
|
||||
(hit a wall, slide, hit the perpendicular wall, slide, stop) without ever
|
||||
risking an infinite loop.
|
||||
|
||||
## The algorithm
|
||||
|
||||
@@ -46,29 +47,32 @@ 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.
|
||||
- **`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`.
|
||||
- **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.
|
||||
> **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`.
|
||||
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
|
||||
|
||||
@@ -19,27 +19,29 @@ bunx serve workshop/steps/11-capstone
|
||||
```
|
||||
|
||||
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.
|
||||
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).
|
||||
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.
|
||||
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
|
||||
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.
|
||||
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.
|
||||
|
||||
@@ -1,61 +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>
|
||||
<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>
|
||||
</div>
|
||||
<script src="./game.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -2,21 +2,21 @@
|
||||
|
||||
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
|
||||
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
|
||||
> `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
|
||||
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:
|
||||
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.
|
||||
|
||||
@@ -38,33 +38,34 @@ 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
|
||||
_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*?
|
||||
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?
|
||||
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,
|
||||
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.
|
||||
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 —
|
||||
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:
|
||||
|
||||
```
|
||||
@@ -79,9 +80,9 @@ 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
|
||||
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
|
||||
`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
|
||||
@@ -92,7 +93,7 @@ Two functions in `overlap.ts`:
|
||||
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
|
||||
(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.
|
||||
|
||||
@@ -105,4 +106,4 @@ bun test workshop/steps/12-overlap
|
||||
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?
|
||||
about: does your _real_ engine survive the same experiment?
|
||||
|
||||
@@ -3,17 +3,17 @@
|
||||
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*.
|
||||
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
|
||||
> 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
|
||||
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.
|
||||
@@ -28,7 +28,7 @@ Two experiments in a scratch file, **predicting each outcome before running**
|
||||
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*?
|
||||
`x` bounce between, and _why will it never stop_?
|
||||
|
||||
## The negotiation, and when it honestly fails
|
||||
|
||||
@@ -36,11 +36,11 @@ The fix for experiment 1 is patience: don't do one pass — **repeat whole passe
|
||||
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
|
||||
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.
|
||||
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
|
||||
@@ -48,9 +48,9 @@ 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
|
||||
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
|
||||
@@ -67,8 +67,8 @@ Two functions in `crush.ts`:
|
||||
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.)
|
||||
"bonus" question last session — it's still open, and one of the tests refuses to
|
||||
look away.)
|
||||
|
||||
```sh
|
||||
bun test workshop/steps/13-crush
|
||||
@@ -76,13 +76,13 @@ 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.
|
||||
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
|
||||
|
||||
@@ -93,4 +93,4 @@ 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.
|
||||
the winner change? That flip _is_ the watermelon seed.
|
||||
|
||||
Reference in New Issue
Block a user