diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..8b68537
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
+node_modules/
+dist/
+shots/
+.DS_Store
diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 0000000..30cf57e
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,10 @@
+# Default ignored files
+/shelf/
+/workspace.xml
+# Editor-based HTTP Client requests
+/httpRequests/
+# Ignored default folder with query files
+/queries/
+# Datasource local storage ignored files
+/dataSources/
+/dataSources.local.xml
diff --git a/.idea/modules.xml b/.idea/modules.xml
new file mode 100644
index 0000000..e8334fc
--- /dev/null
+++ b/.idea/modules.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/tilt.iml b/.idea/tilt.iml
new file mode 100644
index 0000000..c956989
--- /dev/null
+++ b/.idea/tilt.iml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/README.md b/README.md
index 2afb72e..e0fa5eb 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,501 @@
# tilt
-Sports Sim starting with Hockey in Three.js and Box3D physics
\ No newline at end of file
+Physics-driven hockey. Three.js for rendering, Box3D (wasm) for physics, with
+the character stack lifted from [Ludus](../ludus).
+
+## Where it's at
+
+Two teams of three chase a puck around a rink, under AI or on an Xbox pad,
+hitting each other and each other's sticks. No nets, no goalies, no rules.
+
+- **Spike 1** — skating, the rink, physical presence, collision response.
+- **Spike 1.5** — a controller, so the feel can be judged by hand.
+- **Spike 2** — body checks: limb-level impacts, skeleton impulses, knockdowns
+ and getting back up.
+- **Spike 3** — the Xbox layer (Skill Stick, analog triggers, rumble) and the
+ puck: stick, possession, shooting, passing, poke checks.
+- **Spike 4** — the stick socketed to the hand, and animations for everything
+ the controls can do: carry, hustle, wind-up, shot, pass, poke.
+- **Spike 5 (MVP)** — nets, a goalie, and a working **shootout**: alternating
+ attempts, goal detection, a scoreboard, and bots that shoot.
+
+```bash
+npm install
+npm run dev # http://localhost:5174
+npm test # headless: sim, rink, AI, pose, input, physics, hits
+npm run capture # boots the app headless and screenshots it into shots/
+```
+
+In the browser:
+
+| Xbox | keyboard | |
+|------|----------|-----|
+| — | **P** / Tab | take control of the skater the camera is on, or hand back |
+| **left stick** | **WASD** | skate (relative to the camera, not to the skater) |
+| **RT** | Shift | hustle — analog, so half-throttle is a real thing |
+| **LT** | Space | hockey stop |
+| **right stick** | arrows | Skill Stick: stickhandle, and pull back + push to shoot |
+| **A** | J | pass to the nearest teammate |
+| **X** | K | shoot |
+| **B** | L | poke check |
+| — | **C** | cycle broadcast → follow each of the six skaters → broadcast |
+| — | **R** | faceoff: reset everyone and the puck |
+| — | **[** **]** | tune puck magnetism live (see below) |
+| drag / wheel | | orbit / zoom |
+
+Whichever input was touched last wins, so a pad can be picked up mid-game. The
+pad also rumbles on any hit you are part of, harder when you are the one
+taking it.
+
+`window.tilt` exposes the match, camera, input and physics world for poking at
+from the console.
+
+`npm run capture` writes `lineup`, `broadcast`, `follow`, `closeup` and `side`
+into `shots/`, and fails on any console error — which makes it the quickest
+check that a change did not break the render path.
+
+### What it does
+
+- NHL-dimension rink (200 × 85 ft, 28 ft corners) with the boards as static
+ Box3D bodies and markings baked into one canvas texture.
+- Six skaters — two teams of three, starting in a faceoff lineup in their own
+ halves — built from Ludus's 23-bone skeleton, procedural lofted body meshes
+ and distance-field skinning, each with the full 18-capsule / 17-joint ragdoll
+ attached and kinematically driven.
+- Skating locomotion with real momentum: you carve, you glide, and you cannot
+ turn on a rail at full speed.
+- Waypoint AI with separation and board avoidance.
+- Board contact and skater-on-skater contact solved by Box3D and fed back into
+ the sim as momentum.
+- Body checks that vary with the pose: shoulder, hip, body, arm or leg, graded
+ from a bump through a stagger to a knockdown, with the victim's ragdoll going
+ dynamic, taking the impulse, hitting the ice and getting back up.
+- A regulation puck (76 mm, 170 g) as a continuous-collision cylinder, sticks
+ with kinematic blade colliders, and a possession model on a runtime dial.
+- Shooting with wind-up power off the Skill Stick, passing, poke checks, and
+ contact knocking the puck off whoever is carrying it.
+
+Roster size is a parameter, not a constant: `createMatch({ perTeam, teams })`,
+and the lineup, collision layers and tests all follow from it. 5-on-5 works
+today — it just has nothing to play for yet.
+
+## The one idea worth knowing
+
+Everything else follows from how skating is modelled. A runner's velocity
+points where they push, so friction is isotropic and stopping is nearly
+instant. A skate glides almost freely along its own length and bites hard
+across it. So movement is two separate things:
+
+1. **The carve.** Each step, the momentum vector is rotated toward the blade
+ line at `edgeGrip`. It is a *rotation*, not lateral friction, so turning
+ redirects speed rather than destroying it — which is why a skater leans into
+ a turn and comes out of it somewhere they were not pointing. A hard carve
+ costs a little (`carveScrub`); a lazy one is nearly free.
+2. **Speed along that line**, which the stride adds to and a small drag
+ removes.
+
+That is `shared/skaterSim.js`. It is pure numbers with no three.js import, and
+it is where the feel lives. Current tuning: 7.4 m/s flat out, ~13% of speed
+lost per second of glide, a hockey stop inside a second from full speed.
+
+## Layout
+
+```
+shared/ pure sim — no three.js, node-testable, server-ready
+ rink.js rink geometry, containment, the board outline
+ skaterSim.js intent → velocity. the carve, the stride, the drag
+ ai.js waypoint brains and steering
+ scalar.js angle and scalar helpers
+src/
+ core/ math + seeded rng (from Ludus, unchanged)
+ character/
+ skeleton.js 23 bones (from Ludus, unchanged)
+ body.js procedural lofted body (from Ludus, unchanged)
+ skinning.js distance-field weights (from Ludus, unchanged)
+ skater.js assembly: mesh + kit + ragdoll + proxy + stick + animator
+ skaterGear.js helmet, pads, jersey, pants, socks, skates, gloves
+ stick.js stick mesh and the kinematic blade collider
+ goalie.js assembly: mesh + gear + pad/body colliders + animator
+ goalieGear.js pads, mask, trapper, blocker, chest, paddle
+ gearMesh.js loft / carved-shell / swept-bar builders for gear
+ physics/
+ bridge.js three ↔ Box3D types, collision layers
+ ragdoll.js 18 capsules, 17 joints (from Ludus, filters retargeted)
+ world.js rink world: ice + boards
+ bodyProxy.js the one dynamic capsule per skater
+ puck.js the one body that genuinely needs continuous collision
+ anim/
+ skateAnimator.js pose buffer, state crossfade, two-bone leg IK
+ poses/skate.js the numbers: stance, lean, arm carry, hockey stop
+ goalieAnimator.js stance selection, leg IK onto the ice, paddle grip
+ poses/goalie.js ready, butterfly, shuffle, reach
+ studio/
+ img2mesh.js character studio: pose presets, fixed views, capture API
+ render/ rink, materials, camera
+ game/
+ match.js the loop
+ input.js Xbox pad + keyboard, Skill Stick, camera-relative stick
+ hits.js severity, limb resolution, impulses
+ possession.js who has the puck, and the magnetism dial
+tools/
+ capture.mjs headless boot, viewport assertions, screenshots
+ hitprobe.mjs fire skaters at each other and print what lands
+ img2mesh.mjs shot sheet of player + goalie for gear iteration
+```
+
+### Two deliberate departures from Ludus
+
+**The animator does not own movement.** In Ludus the animator integrates the
+fighter's position. Here the sim and the Box3D proxy own it and the animator is
+*told* where the body ended up. Anything else has the pose fighting the
+collision response.
+
+**The feet are not planted in world space.** A walking foot is stationary while
+it bears weight; a skate is gliding the entire time, including through the
+push. Foot targets are authored in mover-local space and scaled by stride
+amplitude, so a glide collapses them to a pair of blades sitting under the hips
+with no separate "glide" pose to keep in sync. Planting them is exactly what
+would have made this read as running on ice.
+
+## Why there is a proxy capsule
+
+The ragdoll is kinematic while a skater is on their feet, and kinematic bodies
+do not respond to each other — two rigs driven through one another generate
+contacts and resolve none of them. So physical presence lives in one dynamic
+capsule per skater, and the ragdoll rides on top as the visible, hittable
+skeleton.
+
+The loop per substep is read → step → write: pull position and velocity out of
+Box3D, let the skating sim edit that velocity, write it back, solve. Reading
+velocity back rather than only writing it is the point — a board hit or a
+shoulder arrives as a change to `vx/vz` that the sim carries forward as
+momentum.
+
+The sim runs *inside* the substep loop rather than once per frame, because
+momentum only survives a collision if the thing that resolved it and the thing
+that integrates motion agree about the timestep.
+
+**When a skater goes down, the two swap jobs.** The ragdoll goes dynamic and
+becomes the body, and the proxy is *disabled* — not merely ignored, because a
+body left enabled still occupies space and would leave an invisible upright
+bollard on the ice where the skater used to be. Getting up reverses it: read
+where the pelvis actually ended up, put the proxy there, move the sim to match,
+hand the skeleton back to the animator and crossfade out of the collapsed pose.
+That round trip is the seam with nowhere to hide, so most of `test/hits.mjs` is
+about it.
+
+### Getting up without teleporting
+
+The reverse handoff is the fiddly half, and the naive version has a specific,
+very visible failure: the skater flies out by however far they slid, then snaps
+back over the crossfade.
+
+The cause is that while limp the ragdoll writes the body's displacement into the
+**root bone**, because the mover has been parked where they fell for the whole
+knockdown. The world pose is `moverAtFallPosition x bigRootOffset`. Teleporting
+the mover onto the pelvis without touching that offset applies the displacement
+a second time, and the crossfade then drags it back as the root decays to its
+skating value.
+
+So `getUp` re-expresses the root in the new mover frame — `inverse(newMover) x
+oldRootWorld` — making the world pose across the handoff bit-for-bit identical.
+The crossfade then has no position to undo and only interpolates lying to
+skating. Measured across a 4.25 m slide: every bone moves **1-3 mm** at the
+handoff, and the pelvis drifts **under 10 cm** over the entire get-up.
+
+Three smaller things fall out of the same fix. Facing is taken from the
+pelvis-to-chest line flattened onto the ice, because the pelvis' own forward
+axis points at the floor on someone lying face-down. The foot IK targets are
+re-read from where the blades actually are, or the legs drag across the rink to
+catch up with a stale target. And intent is suppressed while rising, so they
+stand up where they lay instead of skating off mid-animation.
+
+## How a hit knows what kind of hit it is
+
+Two questions, and conflating them is what makes hits feel like one canned
+event.
+
+***Did* a hit land** is a physics question, answered by the proxy capsules —
+they are what actually collide. Closing speed and mass give severity.
+
+***What kind* of hit was it** is a pose question, and the proxy cannot answer
+it: a capsule contact tells you two bodies met at roughly hip height, not that
+a shoulder went through a chest. So on the frame of impact we go back to the
+two 18-capsule ragdolls, which *are* posed, and find the closest pair of limbs.
+That pair is the hit — `spine3 → pelvis` is a shoulder into the body, `pelvis →
+thighL` is a hip check, and a shoulder arriving at a head is the one that should
+draw a penalty. 324 segment-segment tests, only on the frame something lands.
+
+This comes out genuinely varied because it follows the skating pose rather than
+a dice roll: running down a stationary skater leads with the shoulder, while a
+head-on between two skaters both crouched low at speed is a hip check.
+
+Two things that had to be got right, both found by looking at the output:
+
+- **Nobody checks with their head.** A skater at speed is pitched ~30° forward,
+ which makes the head the leading part of the body *geometrically*, so an
+ unrestricted search credited almost every hit to a headbutt. Only shoulders,
+ chest, hips and thighs can deliver. The victim side stays unrestricted, so
+ head shots still register.
+- **A check drives you down and back, not over the hitter.** Putting the whole
+ impulse at the contact point — which sits well above the centre of mass —
+ is mostly torque, and cartwheeled the victim over the attacker's head. Most
+ of it now goes through the pelvis centre, with a third at the contact point
+ to shape the fall.
+
+## Possession is a dial, not a decision
+
+This is the one genuinely open design question in the game, so it is built as a
+dial rather than as an answer. `magnetism` runs 0..1 between the two models
+every hockey game has to choose between:
+
+- **0 — pure physics.** The puck is always a free rigid body and the only thing
+ that moves it is the blade collider pushing it. Authentic, and skittery to the
+ point of being unplayable.
+- **1 — hard attach.** The puck is placed at the carry point every frame.
+ Totally controllable, looks glued, and kills the scrambles that are the reason
+ to build a physics-driven hockey game at all.
+
+In between, the puck's velocity is blended toward whatever would carry it to the
+stick, so it mostly follows but can be jostled off the blade. It sits at **0.72**
+today, which is a guess, not a finding — press `[` and `]` while playing and
+find the real answer by hand.
+
+### Two things the puck taught us immediately
+
+**A carrier with nothing to fear is untouchable.** The first minute of 3-on-3
+with a puck produced *one* possession change and *one* hit: a skater picked it
+up and kept it for the entire minute while five others followed them around.
+Possession only becomes a contest once losing it is possible, so contact now
+knocks the puck loose (a stagger is enough — it does not need a knockdown) and
+there is a poke check that both the player and the bots use.
+
+**Everyone chasing looks like a bug.** With all six converging on the puck the
+hit system fired constantly — 28 hits a minute — but the game was one moving
+scrum with nobody anywhere else on the ice. Only the nearest skater per side
+chases now; the rest find space. Contact drops to a believable handful a minute
+and the mean separation goes from a huddle to 7.7 m.
+
+| | everyone chases | nearest chases |
+|---|---|---|
+| hits / min | 28 | 4 |
+| possession changes / min | 35 | 13 |
+| mean separation | huddle | 7.7 m |
+
+## The stick is held, and the puck follows it
+
+The first pass hung the stick off the mover and positioned it so the blade sat
+wherever the puck was being carried. That put the blade in the right place and
+the hands nowhere near it — the stick floated.
+
+It is now parented to a socket on the right hand, authored in *grip space*: the
+origin is the top hand, the shaft runs down −Y, the blade is at the far end. The
+hands carry the stick, which is the correct dependency order.
+
+**That inverts the puck relationship.** `possession` no longer picks a carry
+point and drags the stick to it; it reads where the blade actually is and
+carries the puck there. Stickhandling became an arm pose plus a blade target,
+which is what it is in real life, and the puck can no longer be somewhere the
+stick is not.
+
+### Aimed, not bolted
+
+The obvious authoring — a fixed socket rotation per stance — does not survive an
+animated arm. That rotation composes with the hand's own world rotation, so a
+grip tuned to put the blade on the ice for one arm pose swings it into the air
+in another, and every stride is a different arm pose. Measured before the fix:
+the blade sat between **0.55 m and 0.97 m** off the ice depending on gait.
+
+So a stance is a blade *target* plus a roll, and the stick aims itself:
+
+- **Height is solved exactly**, direction is aimed. Pointing straight at the
+ target and hoping the length works out puts the blade short of an on-ice
+ target, which means *above* it. Solving `dy` from the height difference makes
+ blade height exact for any arm pose and any stick length.
+- **The aimed axis is grip-to-blade, not the shaft's −Y.** The blade sits
+ forward of the shaft end by the toe offset, ~6° off axis; aiming −Y left the
+ blade 10 cm above where the height solve said it would be.
+
+Blade height is now 0.03 m across every skating stance, and 0.62 m drawn back on
+a wind-up. There is a pose test for exactly that.
+
+### Animations
+
+`poses/stickwork.js` authors carry, wind-up, shot, pass and poke as *override
+layers*, not states — you keep skating while you shoot, and a shot that stopped
+the legs would read as a cutscene. Arms are replaced; the spine is *multiplied*,
+because it is already carrying the skating lean and the bank, and overwriting it
+stood everybody upright the moment they picked up a stick.
+
+Hustle is a continuous parameter rather than an action: as the throttle goes
+down the stick eases out in front and the left hand comes off it, so half a
+trigger is half a dangle. A wind-up is *held* for as long as the Skill Stick is
+pulled back; shot, pass and poke run once and blend out.
+
+## The shootout
+
+The MVP: one shooter, one goalie, one puck, and a result. Attempts alternate, so
+it is two players trading chances rather than a drill. **P** takes the shooter
+(control follows whoever is up), **R** restarts.
+
+The puck starts on the dot at centre ice and the shooter a few metres back, so
+picking it up is part of the attempt — that is the only moment the carry model
+has to prove it can *gain* possession rather than keep it, and starting glued to
+the puck skipped it. Losing the handle mid-attempt does not end anything either;
+in a one-on-one the puck getting away from you is part of the rush. Only a goal,
+the goalie covering it, the puck leaving the picture, or the clock finishes an
+attempt.
+
+A goalie is deliberately *not* a skater. The skating sim is a carve model —
+momentum dragged onto a blade line — and a goalie almost never carves. Reusing
+it would mean fighting the locomotion for every metre. So it is a purpose-built
+entity that plays the angle: stand on the line between puck and net, a set depth
+out, with a lateral speed limit and a reaction lag. The lag is what makes them
+beatable; a goalie always exactly on the angle is a wall, not a goalie.
+
+**Saves are physics, not a dice roll.** The pads and body are kinematic
+colliders and the puck is a bullet. A shot either hits a pad or it does not.
+There is no save percentage anywhere.
+
+### Four bugs it took to get the first goal
+
+The first build produced **0 goals from 30 attempts**, and each fix revealed the
+next. Worth recording because every one of them looked like a goalie problem:
+
+1. **The net was backwards.** For the +X end the back panel was placed at
+ `line − depth`, a metre *in front* of the goal line — a solid wall across the
+ mouth. Every shot in the game bounced off it before it could cross.
+2. **Both clamps in `goalieSpot` were inverted.** Between them they teleported
+ the goalie onto the puck and then pinned them to the goal line, throwing away
+ all the angle the depth was there to buy.
+3. **A redundant "fumble" check stripped the puck off every shooter.** It was a
+ function of stiffness and magnetism, and after stiffness went up it fired
+ *tighter* than the break radius it was backing up — twenty of twenty-four
+ attempts ended with nobody ever shooting.
+4. **Shots were hitting the shooter's own stick.** The puck sits exactly on the
+ blade — that is what carrying means — and the follow-through then swept that
+ kinematic collider through the same point. Shots stopped six metres short or
+ flew twelve wide. The puck is now stepped clear of the blade on release.
+
+Plus two tuning errors worth naming: bots aimed at the *centre* of the net,
+which is where the goalie stands by construction; and shot spread was 0.22 rad
+at 8 m — ±1.76 m of scatter against a net 1.83 m wide.
+
+Currently around **15 goals per 29 attempts**. That is a number to tune, not a
+finding — real NHL shootouts convert about a third. It jumped from 7-in-31 the
+moment shooters started skating onto the puck instead of spawning on it, because
+they now carry real speed into the shot.
+
+### Bots can shoot now
+
+`handleShooting` used to sit behind `if (control)`, so only a human could ever
+shoot — a bot picked the puck up and carried it until somebody took it away, and
+a minute of play produced zero shots. They now pick a corner, alternate sides,
+and their accuracy falls off with range.
+
+## Performance
+
+Simulation, physics and animation, excluding rendering:
+
+| roster | skaters | ms/frame |
+|--------|---------|----------|
+| 3-on-3 | 6 | 0.24 |
+| 5-on-5 | 10 | 0.38 |
+
+A full 5-on-5 plus goalies is well inside a 60 Hz budget with the render cost
+still to come. (`npm run capture` reports 20–30 fps, but that is SwiftShader
+software rasterisation in a headless browser, not a real GPU.)
+
+## Tests
+
+1171 checks, all headless, `npm test`:
+
+- **skaterSim** — top speed, acceleration curve, glide decay, braking, the
+ carve preserving momentum, resistance to instant reversal, determinism, and
+ the `applyIntent` seam surviving garbage input.
+- **rink** — containment maths including the corner arcs, which a plain
+ rectangle test gets wrong.
+- **ai** — a 3-on-3 for a simulated minute: nobody leaves the ice, nobody
+ stands inside anybody, waypoints actually get reached, and the lineup puts
+ each team in its own half with index order matching team order. Also a
+ 5-on-5, as the cheapest check that steering does not fall over with a full
+ side on the ice.
+- **pose** — the animator, driven headlessly: no NaN, blades on the ice, torso
+ angle, arm carry, elbow bend, bank into a turn, the stop pose. This is the
+ only way "the skater looks wrong" gets caught by anything but a human
+ squinting at a screenshot.
+- **input** — camera-relative steering. Its own file because the failure is
+ silent and infuriating: a sign flip means the stick works from one camera
+ angle and inverts from another, which reads as a physics bug. Checks that
+ forward is always away from the camera, that the four directions stay square,
+ that "right" is the camera's right and not its left, and end to end that
+ holding forward from any camera angle and any starting facing puts the skater
+ where the stick pointed.
+- **physics** — the Box3D claims: boards hold at full speed including in the
+ corner seams, contact costs speed, two skaters cannot occupy the same ice, a
+ bump transfers momentum, the ragdoll follows the skeleton, and a six-body
+ pile-up at centre ice resolves without anyone escaping or interpenetrating.
+
+- **hits** — the handoff, mostly. A knockdown must disable the proxy, leave the
+ frozen sim position where it is rather than skating a disabled capsule around
+ the rink, move the sim to wherever the body actually slid to on the way up,
+ and restore the collision filter. Plus the things that were wrong when first
+ looked at: nobody delivers a check with their head, a knockdown never lifts
+ the hips above standing height, the victim carries on down the ice rather
+ than bouncing back, a longer run-up hits harder, and more than one kind of
+ check is reachable.
+
+The render path is covered separately by `npm run capture`, which boots the app
+headless **at DPR 2** and asserts the canvas fills the window at two sizes. That
+check exists because it didn't: running captures at DPR 1 hid a canvas-sizing
+bug that made the element twice the window on any retina display.
+
+## Not built yet
+
+Full roadmap, ordered by difficulty: **[ROADMAP.md](ROADMAP.md)**.
+
+Nets, goalies, scoring, offside/icing, penalties, faceoffs, gear textures,
+netplay.
+
+## Where the next spike plugs in
+
+**Nets and scoring.** It is the shortest path from "physics demo you can play"
+to "game you can win". Two static goal frames with a trigger volume behind the
+line, a whistle, and a faceoff reset — `match.reset()` already puts the puck at
+centre ice and stands everybody up. Everything needed to detect a goal exists;
+the puck is a real body with a real position.
+
+Then, roughly in order of how much they would improve the thing:
+
+- **Goalies** — a seventh skater per side with a different brain and a bigger
+ collider. No new systems.
+- **Arm IK onto the stick.** The stick is positioned from the carry point and
+ the arms do not yet reach for it. `solveGrabArm` in the Ludus animator is
+ exactly this problem, already solved, and can be ported.
+- **Penalties.** Hits already carry `headshot`, `blindside`, the delivering part
+ and the struck region, so boarding, charging and elbowing have the data they
+ need without any new detection.
+- **Positional AI.** The brains know four states — carrying, chasing,
+ supporting, defending — and pick between them off one nearest-to-puck test.
+ Real forechecking and zone coverage is the next big behavioural step.
+- **Netplay.** `shared/` is still pure, deterministic and three.js-free, and
+ `applyIntent` is a clamped entry point that never trusts what it is given.
+
+### Three soft spots worth knowing
+
+**The lower hand does not quite reach the shaft.** The left arm is 0.55 m and
+the natural two-handed grip point is ~1 m from the left shoulder on this
+skeleton, so the IK grips the nearest *reachable* point and still ends about
+0.25 m short. It reads as reaching for the stick rather than holding it. Fixing
+it properly means either a longer reach from a shoulder/spine contribution or
+accepting a higher grip; both are pose work, not architecture.
+
+**Staggers are still visually unverified.** Knockdowns were tested hard — peak
+hip height, direction of travel, the full proxy/ragdoll handoff, and that
+nothing jumps on the way back up. The stagger path (physics deflecting the pose
+while animation shows through) is only asserted to *enter* the right state. It
+is far more common in play than a knockdown.
+
+**Possession changes may be too frequent.** Around sixteen a minute in a 3-on-3
+with no zones, no goalies and no reason to hold position is plausible but
+untuned. It will want revisiting once there is a net to protect.
diff --git a/ROADMAP.md b/ROADMAP.md
new file mode 100644
index 0000000..4699867
--- /dev/null
+++ b/ROADMAP.md
@@ -0,0 +1,190 @@
+# Roadmap
+
+Where tilt is, and what parity with NHL 26 would actually take.
+
+Worth stating the scale honestly up front: NHL 26 is ~30 years of iteration by a
+studio of hundreds, plus licensing. Full parity isn't a backlog, it's a company.
+This document is the real shape of the gap, ordered by difficulty, with notes on
+where the current architecture helps and where it will fight us.
+
+---
+
+## Built
+
+- **Skating** — carve/glide momentum model. Blade line, edge grip, stride,
+ hockey stop. 7.4 m/s flat out.
+- **Rink** — NHL dimensions, boards as static bodies, markings, corner arcs.
+- **Bodies** — 23-bone skeleton, procedural lofted meshes, distance-field
+ skinning, 18-capsule / 17-joint ragdoll, dynamic proxy capsule per skater.
+- **Hits** — limb-level resolution from the posed ragdolls (shoulder / hip /
+ body / arm / leg), graded bump → stagger → knockdown, skeleton impulses,
+ knockdown and a seamless get-up.
+- **Puck** — regulation cylinder, continuous collision, boards and corners hold
+ at 55 m/s.
+- **Stick** — socketed to the hand, aimed per stance, kinematic blade collider.
+- **Possession** — magnetism dial (runtime tunable), capture, carry, shot, pass,
+ poke check, contact knocking the puck loose.
+- **Controls** — Xbox pad with Skill Stick (pull back / push to shoot), analog
+ triggers, rumble; keyboard fallback.
+- **AI** — puck chasing, designated chaser per side, support positioning, bots
+ that shoot and pass.
+- **Shootout** — nets, goalie, alternating attempts, goal detection, scoreboard.
+
+**1171 headless checks.** `npm test`, `npm run capture`.
+
+---
+
+## Tier 1 — the genuinely hard ones
+
+### Goalies (beyond the shootout MVP)
+The shootout goalie plays the angle with a reaction lag and stops pucks with
+kinematic colliders. That is enough for a shootout and nowhere near enough for
+a game.
+
+A real goalie is a separate locomotion model (shuffles, t-pushes, butterfly,
+RVH, post integration), a separate animation set (glove, blocker, pad stacks,
+desperation saves), save *selection* that has to feel fair rather than optimal,
+and rebound control that decides on its own whether the game is fun.
+
+**Have:** angle positioning, lag, physics saves.
+**Why hard:** the system EA still gets criticised for annually.
+
+### Animation volume and quality
+The largest body of work in the project by an order of magnitude.
+
+NHL runs thousands of mocap clips through motion matching. We run procedural
+poses, which got further than expected — the stride, the carve lean, the
+limb-level hits all read correctly — but it has a ceiling and broadcast hockey
+is above it. Parity means mocap plus a clip/motion-matching system, or a hybrid
+where procedural drives locomotion and clips drive everything contextual.
+
+**Have:** pose-buffer + crossfade + IK + override-layer architecture a clip
+system can plug into.
+**Why hard:** content volume. Cannot be engineered around.
+
+### AI that plays hockey
+Forechecking systems, D-zone coverage, breakouts, cycling, gap control, reading
+the play, line changes. Genuine multi-agent planning under adversarial pressure.
+
+**Have:** four states (carry / chase / support / defend) off one nearest-to-puck
+test, and a clean intent seam anything smarter writes into.
+
+### Netcode
+12 skaters, 2 goalies, a puck, sticks, plus lag compensation for hits and shots.
+
+**Have:** better odds than most — `shared/` is pure, deterministic and
+three.js-free by design, and `applyIntent` is a clamped entry point that never
+trusts input.
+**Why hard:** Box3D and the ragdoll layer are *not* obviously deterministic
+across machines. **Prove that before anything else** — it gates netcode and
+replays, and finding out late would be very expensive.
+
+### Possession feel
+The `magnetism` dial is at 0.72, which is a guess. Not analytically solvable —
+only iteration with real players answers it. It is what NHL retunes every year
+and still gets complaints about.
+
+### Full-fidelity interaction at scale
+Stick-on-stick, stick lifts, puck off skates and shin pads and glass, board
+battles with three bodies pinned to the wall, all stable at 60 Hz.
+
+**Have:** one blade collider and skater proxies. The 0.53 m proxy-equilibrium
+noted in the README gets worse with pile-ups.
+
+### Presentation
+Broadcast camera direction, replays, commentary, crowd, arena atmosphere,
+celebrations, likenesses. Nearly all production, not engineering.
+
+### Modes: Franchise, Be A Pro, HUT, World of Chel
+By content volume, arguably most of an NHL release. Contracts, scouting, drafts,
+trades, progression, card economy, matchmaking, live service.
+
+### Licensing
+NHL/NHLPA teams, players, arenas, logos, music. Not engineering, but parity is
+impossible without it.
+
+---
+
+## Tier 2 — medium
+
+| | Notes |
+|---|---|
+| Full game mode | Periods, clock, faceoffs after whistles, line changes. |
+| Rules: offside, icing | Detection is straightforward; edge cases are the work. |
+| Penalties | Hits already carry `headshot`, `blindside`, delivering part and struck region — boarding/charging/elbowing have the data. |
+| Faceoffs | Mechanic + animation. The lineup spawn already exists. |
+| Line changes / bench | Roster is already a parameter. |
+| Full 5-on-5 | Runs today at 0.38 ms/frame; needs positional AI to mean anything. |
+| Shot variety | Wrist, snap, slap, backhand, one-timers, deflections, tips. |
+| Dekes, dangles, toe drags | Skill Stick gesture layer exists; these are new gestures + poses. |
+| Board play / puck battles | Likely needs possession *reworked*, not extended. |
+| Stick-on-stick, stick lifts | New collider pairs and filter work. |
+| **Fighting** | Self-contained minigame — and **Ludus already has a full one**. Highest value-per-effort item on this list. |
+| Injuries / fatigue / momentum | Ragdoll and hit severity already feed it. |
+| Puck physics polish | Deflections, glass, puck on edge, knuckling. |
+| Camera systems | Several presets; we have two. |
+| Audio | Skate cuts, puck-on-stick, boards, crowd, goal horn. Currently **silent**. |
+| Replays | Cheap given a deterministic sim — record inputs, re-simulate. |
+| Difficulty sliders | Needs AI worth tuning first. |
+| Menus, profiles, settings, save | Conventional. |
+
+---
+
+## Tier 3 — small
+
+Hours to days each.
+
+### Owed / immediate
+- **Pad parity.** `P` (take shooter), `R` (restart) and `C` (camera) are
+ keyboard-only, so you cannot play a shootout with a controller alone.
+ `LB` (switch), `Y` (dump) and `START` are read and discarded. ~20 lines.
+- **Goalie animation depth.** Goalie is now a skinned skeleton with pads,
+ trapper, blocker, mask and paddle, plus ready / shuffle / butterfly / reach
+ stances. Still owed: RVH, post-integration, glove *saves* as events, and
+ rebound control that is more than the pad restitution.
+- **Goal feedback.** A goal is a line of HUD text. No horn, no camera cut, no
+ celebration. Cheap, disproportionate effect.
+- **Tune the shootout conversion rate.** 15-in-29 (~52%) against a real ~33%.
+ Dials: `GOALIE.depth`, `GOALIE.lag`, `GOALIE.speed`, `SHOT_RANGE` and shot
+ spread. Needs hands on a pad, not test output.
+
+### Controls / possession
+- `LT` puck protection — the analog value is computed and discarded.
+- `RB` deke modifier.
+- Bot stickhandling (`possession.handling` is human-only; bots leave it at 0).
+- Forehand / backhand blade roll.
+- Contested capture instead of nearest-index-wins.
+
+### Polish
+- Ice spray, skate trails, snow.
+- Jersey numbers and names.
+- Scoreboard, clock, period structure.
+- Basic stat tracking.
+
+---
+
+## Known soft spots
+
+- **Lower hand does not quite reach the shaft** (~0.25 m short). The left arm is
+ 0.55 m and the natural grip point is ~1 m from the left shoulder on this
+ skeleton, so the IK grips the nearest reachable point. Pose work, not
+ architecture.
+- **Staggers are visually unverified.** Knockdowns were tested hard; the stagger
+ path is only asserted to *enter* the right state, and it is far more common in
+ play.
+- **Proxy interpenetration** under sustained pressure — two skaters at full
+ sprint settle 0.53 m apart against 0.72 m of capsule. Documented in the README;
+ gets worse in pile-ups.
+- **Shootout conversion is untuned** at ~52%.
+
+---
+
+## Suggested order
+
+1. **Play the shootout and tune the conversion rate.** Cannot be done from here.
+2. Pad parity — small, owed, and it is what makes the MVP couch-playable.
+3. Goalie animation and goal feedback — biggest read-improvement per hour.
+4. **Prove Box3D determinism.** Cheap now, gates netcode and replays, expensive
+ to discover late.
+5. Port the Ludus fighting system — highest value-per-effort in Tier 2, and the
+ code already exists.
diff --git a/character.html b/character.html
new file mode 100644
index 0000000..eef707b
--- /dev/null
+++ b/character.html
@@ -0,0 +1,76 @@
+
+
+
+
+
+tilt — img2mesh
+
+
+
+
+
+
+
+
IMG2MESH
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ drag orbit · wheel zoom
+ 1 player 2 goalie 3 both
+ [] pose · ,. view
+ g gear bones · b bones
+
+
+
+
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..76e7dd8
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,1259 @@
+{
+ "name": "tilt",
+ "version": "0.0.1",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "tilt",
+ "version": "0.0.1",
+ "dependencies": {
+ "box3d.js": "^0.0.2",
+ "three": "^0.185.1"
+ },
+ "devDependencies": {
+ "puppeteer-core": "^25.4.0",
+ "vite": "^8.1.5"
+ }
+ },
+ "node_modules/@emnapi/core": {
+ "version": "2.0.0-alpha.3",
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-2.0.0-alpha.3.tgz",
+ "integrity": "sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/wasi-threads": "2.0.1",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "2.0.0-alpha.3",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-2.0.0-alpha.3.tgz",
+ "integrity": "sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/wasi-threads": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-2.0.1.tgz",
+ "integrity": "sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@napi-rs/wasm-runtime": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz",
+ "integrity": "sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@tybys/wasm-util": "^0.10.3"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=23.5.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ },
+ "peerDependencies": {
+ "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.3",
+ "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3"
+ }
+ },
+ "node_modules/@oxc-project/types": {
+ "version": "0.142.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.142.0.tgz",
+ "integrity": "sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ }
+ },
+ "node_modules/@puppeteer/browsers": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-3.0.6.tgz",
+ "integrity": "sha512-B/gKoqlFkzhvzsI6jo9K1cZz9o5ypviVv/xu8CwA4grZzyVwN+XfkT+tu8T1zrauuEXv6VhS2oGX+6NL95WcKA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "modern-tar": "^0.7.6",
+ "yargs": "^18.0.0"
+ },
+ "bin": {
+ "browsers": "lib/main-cli.js"
+ },
+ "engines": {
+ "node": ">=22.12.0"
+ },
+ "peerDependencies": {
+ "proxy-agent": ">=8.0.1",
+ "yauzl": "^2.10.0 || ^3.4.0"
+ },
+ "peerDependenciesMeta": {
+ "proxy-agent": {
+ "optional": true
+ },
+ "yauzl": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@rolldown/binding-android-arm64": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.1.tgz",
+ "integrity": "sha512-02hOeOSryYxVrOIphmLAsqnCJWxwlzFk+pEt/N/i6OgT3lShHO7xGCU5cpgchRDHboAEbSjzgGh+O/u1GswQmA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-arm64": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.1.tgz",
+ "integrity": "sha512-fMsTOnN0OjFm3CyppWPitKnc8UlliVARUULW6cfU6AIqjdtgmSFWSk9vecHzZduv/yMWIHDlRhM1e8Iff9uAfA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-x64": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.1.tgz",
+ "integrity": "sha512-1wjKdz/XLGKHaTNHjQveQ/B23TKx4ItAqm1JbyVuvNPc4Ze0Fb48s49TAd/2zcplPl8okE/UbTgmlVfwT7eFeQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-freebsd-x64": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.1.tgz",
+ "integrity": "sha512-Fa0jHR07E7YBN4vOEsbVf2briYNsuOowfLJaXULZM0ldMlaCaj2LJgLMbMe4iacRyZmvR8efFhgR9wKuGclQUg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.1.tgz",
+ "integrity": "sha512-pzkgu1SSHGgRRyRZ4fbmSgmajbVt+epaLP99NDjFft69v/ypfTi6swBMiVdh2EkQ0OSnHE1lZDM7DRGkyAzUpA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.1.tgz",
+ "integrity": "sha512-QI5SEDY8cbiYWHx0VO4vIc3UlS6a32vXHjU8Qy/17adEmZIPuByJg13UEvo9c/UCiUkdcVWY83C+b+JrwnNyUg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.1.tgz",
+ "integrity": "sha512-Sm41FyCeXqmYcERoYOCbGIL5hNfd8w9LQ7Y61Bev48HkcjaJqV/iiVOaiDxjVTRMS+QKrZmD8cfPt4uMVnvM+A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.1.tgz",
+ "integrity": "sha512-2x+WhXTGl9yJYPbltW/BSEPTVz9OIWQyER4N+gJEDWkkn904eRcBzELqh/Hf7K0w/ubGbKNMv0ZC+94QK/IFEg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.1.tgz",
+ "integrity": "sha512-eEjmQpuRQayHPWWnywaWHkFT3ToPbP3RYy42VVd/B9aBGDA+Ol25EIWHxKQST3IiWJjikCWUF7KtbfqwZrzVwQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.1.tgz",
+ "integrity": "sha512-/Orga1fZYkLc/56jBICcHrKchl8Z2UKdDSr3LG9ToWO1lQ6a4Livk9Xz+9WN91zsz5QR3XQz2NNoSDEvP6qadw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-musl": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.1.tgz",
+ "integrity": "sha512-xxBJRL+0q0Kce7orznGWLuylHDY65vuARXZRpX+hPdv+DqK2c3NlCsVA98tlWzWNEE7yPqA/1NQ5nnCrj49Y5A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-openharmony-arm64": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.1.tgz",
+ "integrity": "sha512-M6AdXIXw3s+/8XpKMzdGDEXGS1S7kwUsy+rcTIUIOx5Ge4nXKCtAFHFV9YKkXvGcC5WMoTjAteLzlsQROVI0Yw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-wasm32-wasi": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.2.1.tgz",
+ "integrity": "sha512-/TX0SoRGojHzSAHpfVBbavRVSazg5U3h3Y3VXfcc0cdugq6kxdqw8LPGFiPr+/7gE/60zRcsOY2Vi9b9eT0jww==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "2.0.0-alpha.3",
+ "@emnapi/runtime": "2.0.0-alpha.3",
+ "@napi-rs/wasm-runtime": "^1.2.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=23.5.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.1.tgz",
+ "integrity": "sha512-EvRrivJieyHG+AO9lleZWgq+g0+S7oV2C51yuqlcyU/R9net+sI4Pj0F+lUoP2bEr6TWX3SqFaaS0SzfLxSzkw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.1.tgz",
+ "integrity": "sha512-Z4eCmn5QJ/5+azF9knpLWKfVd9aidn0mAe9TpJgvBLId9Ax3t0+JVxBmT25Bv7NBbVW1TZyKjQjQReouMeH5UQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@tybys/wasm-util": {
+ "version": "0.10.3",
+ "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
+ "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/ansi-regex": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
+ "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-regex?sponsor=1"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "6.2.3",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
+ "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/box3d.js": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/box3d.js/-/box3d.js-0.0.2.tgz",
+ "integrity": "sha512-ziC6IqMbMAYns1aJ7E1czhBEE2Kj+/QK9L16vMXOz7UaXKUj9gX7Za5ut+Dg3euHK6I/1brFSHOpmOwCI6FhYQ==",
+ "license": "MIT"
+ },
+ "node_modules/chromium-bidi": {
+ "version": "17.0.2",
+ "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-17.0.2.tgz",
+ "integrity": "sha512-5v9GQFhTktFvotn/OFNJBmKLKRAb6n9r0bVCwf7sHgWc3/JryK0bj1nn93L3pHFrfgcsu6Be6EWsDi+1XHTGDg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "mitt": "^3.0.1",
+ "zod": "^3.24.1"
+ },
+ "engines": {
+ "node": ">=20.19.0 <22.0.0 || >=22.12.0"
+ },
+ "peerDependencies": {
+ "devtools-protocol": "*"
+ }
+ },
+ "node_modules/cliui": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz",
+ "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^7.2.0",
+ "strip-ansi": "^7.1.0",
+ "wrap-ansi": "^9.0.0"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/cliui/node_modules/string-width": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
+ "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^10.3.0",
+ "get-east-asian-width": "^1.0.0",
+ "strip-ansi": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/devtools-protocol": {
+ "version": "0.0.1653615",
+ "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1653615.tgz",
+ "integrity": "sha512-pGVkY3T/qXxAp2nFPodwYqOevk6ncNMSmvL8QfRCx5ZWGd6Vor7AFNmyaA8Zs6uJyP1QAfjuLandCgvSix1BNA==",
+ "dev": true,
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/emoji-regex": {
+ "version": "10.6.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
+ "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/get-caller-file": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
+ "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": "6.* || 8.* || >= 10.*"
+ }
+ },
+ "node_modules/get-east-asian-width": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz",
+ "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
+ "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.33.0",
+ "lightningcss-darwin-arm64": "1.33.0",
+ "lightningcss-darwin-x64": "1.33.0",
+ "lightningcss-freebsd-x64": "1.33.0",
+ "lightningcss-linux-arm-gnueabihf": "1.33.0",
+ "lightningcss-linux-arm64-gnu": "1.33.0",
+ "lightningcss-linux-arm64-musl": "1.33.0",
+ "lightningcss-linux-x64-gnu": "1.33.0",
+ "lightningcss-linux-x64-musl": "1.33.0",
+ "lightningcss-win32-arm64-msvc": "1.33.0",
+ "lightningcss-win32-x64-msvc": "1.33.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
+ "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
+ "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
+ "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
+ "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
+ "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
+ "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
+ "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
+ "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
+ "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
+ "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
+ "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/mitt": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
+ "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/modern-tar": {
+ "version": "0.7.7",
+ "resolved": "https://registry.npmjs.org/modern-tar/-/modern-tar-0.7.7.tgz",
+ "integrity": "sha512-t9VmxaqrmANnEOBhpSDI6HD192Ge48k8vmWqQQL7hSFEqHEYwZbbsu49+aKLWZeRvFs3j1pMhXOqqF4kPlvjkQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.16",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
+ "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
+ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.25",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz",
+ "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.16",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/puppeteer-core": {
+ "version": "25.4.0",
+ "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-25.4.0.tgz",
+ "integrity": "sha512-K1plkLOdeoUnGeT1OvdqF3qxl33v+Ra/uH5VyPEhXdMcpvGiEskHzxxEU3fgpccJpJLIipB/rPUsvkZRWeKqOA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@puppeteer/browsers": "3.0.6",
+ "chromium-bidi": "17.0.2",
+ "devtools-protocol": "0.0.1653615",
+ "typed-query-selector": "^2.12.2",
+ "webdriver-bidi-protocol": "0.4.2",
+ "ws": "^8.21.1"
+ },
+ "engines": {
+ "node": ">=22.12.0"
+ }
+ },
+ "node_modules/rolldown": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.1.tgz",
+ "integrity": "sha512-4FKJhg8d3OiyQOA6Q1Q0hoFFpW9/OoX+VsHzpECsdsIZoOArrAK90gl59YK/Z+gnDel45bgJZK03ozH/9bCqEw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@oxc-project/types": "=0.142.0",
+ "@rolldown/pluginutils": "^1.0.0"
+ },
+ "bin": {
+ "rolldown": "bin/cli.mjs"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "optionalDependencies": {
+ "@rolldown/binding-android-arm64": "1.2.1",
+ "@rolldown/binding-darwin-arm64": "1.2.1",
+ "@rolldown/binding-darwin-x64": "1.2.1",
+ "@rolldown/binding-freebsd-x64": "1.2.1",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.2.1",
+ "@rolldown/binding-linux-arm64-gnu": "1.2.1",
+ "@rolldown/binding-linux-arm64-musl": "1.2.1",
+ "@rolldown/binding-linux-ppc64-gnu": "1.2.1",
+ "@rolldown/binding-linux-s390x-gnu": "1.2.1",
+ "@rolldown/binding-linux-x64-gnu": "1.2.1",
+ "@rolldown/binding-linux-x64-musl": "1.2.1",
+ "@rolldown/binding-openharmony-arm64": "1.2.1",
+ "@rolldown/binding-wasm32-wasi": "1.2.1",
+ "@rolldown/binding-win32-arm64-msvc": "1.2.1",
+ "@rolldown/binding-win32-x64-msvc": "1.2.1"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/string-width": {
+ "version": "8.2.2",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz",
+ "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "get-east-asian-width": "^1.5.0",
+ "strip-ansi": "^7.1.2"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/strip-ansi": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
+ "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^6.2.2"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/strip-ansi?sponsor=1"
+ }
+ },
+ "node_modules/three": {
+ "version": "0.185.1",
+ "resolved": "https://registry.npmjs.org/three/-/three-0.185.1.tgz",
+ "integrity": "sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg==",
+ "license": "MIT"
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "dev": true,
+ "license": "0BSD",
+ "optional": true
+ },
+ "node_modules/typed-query-selector": {
+ "version": "2.12.2",
+ "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.2.tgz",
+ "integrity": "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/vite": {
+ "version": "8.2.0",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz",
+ "integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "lightningcss": "^1.33.0",
+ "picomatch": "^4.0.5",
+ "postcss": "^8.5.23",
+ "rolldown": "~1.2.0",
+ "tinyglobby": "^0.2.17"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "@vitejs/devtools": "^0.4.0",
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "@vitejs/devtools": {
+ "optional": true
+ },
+ "esbuild": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/webdriver-bidi-protocol": {
+ "version": "0.4.2",
+ "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.2.tgz",
+ "integrity": "sha512-VSV+fzfChirL3e7jay2yUC7B4HQCGtEWEg/MSSQbK+qWbqeGlRLlXTzPpYr3XGUvbpDHumWZBJxgesg4N7dbtA==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/wrap-ansi": {
+ "version": "9.0.2",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
+ "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^6.2.1",
+ "string-width": "^7.0.0",
+ "strip-ansi": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/wrap-ansi/node_modules/string-width": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
+ "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^10.3.0",
+ "get-east-asian-width": "^1.0.0",
+ "strip-ansi": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/ws": {
+ "version": "8.21.1",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
+ "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/y18n": {
+ "version": "5.0.8",
+ "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
+ "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/yargs": {
+ "version": "18.1.0",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.1.0.tgz",
+ "integrity": "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cliui": "^9.0.1",
+ "escalade": "^3.1.1",
+ "get-caller-file": "^2.0.5",
+ "string-width": "^8.2.1",
+ "y18n": "^5.0.5",
+ "yargs-parser": "^22.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=23"
+ }
+ },
+ "node_modules/yargs-parser": {
+ "version": "22.0.0",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz",
+ "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=23"
+ }
+ },
+ "node_modules/zod": {
+ "version": "3.25.76",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
+ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..e47f23d
--- /dev/null
+++ b/package.json
@@ -0,0 +1,25 @@
+{
+ "name": "tilt",
+ "version": "0.0.1",
+ "private": true,
+ "type": "module",
+ "description": "Physics-driven hockey. Spike 1: three agents skating on ice.",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview",
+ "test": "node test/skaterSim.mjs && node test/rink.mjs && node test/ai.mjs && node test/pose.mjs && node test/input.mjs && node test/physics.mjs && node test/hits.mjs && node test/puck.mjs && node test/goalie.mjs && node test/shootout.mjs",
+ "capture": "node tools/capture.mjs",
+ "img2mesh": "node tools/img2mesh.mjs",
+ "img2mesh:player": "node tools/img2mesh.mjs --subject player",
+ "img2mesh:goalie": "node tools/img2mesh.mjs --subject goalie"
+ },
+ "dependencies": {
+ "box3d.js": "^0.0.2",
+ "three": "^0.185.1"
+ },
+ "devDependencies": {
+ "puppeteer-core": "^25.4.0",
+ "vite": "^8.1.5"
+ }
+}
diff --git a/shared/ai.js b/shared/ai.js
new file mode 100644
index 0000000..f7703c3
--- /dev/null
+++ b/shared/ai.js
@@ -0,0 +1,199 @@
+import { RINK, randomIcePoint, rinkPenetration } from './rink.js';
+import { SKATE, speedOf } from './skaterSim.js';
+import { clamp } from './scalar.js';
+
+/**
+ * Waypoint AI.
+ *
+ * Deliberately dumb: pick a spot, steer at it, get out of everyone's way.
+ * It exists to exercise the locomotion — the interesting question for this
+ * spike is whether the *skating* reads, and a bot that just holds a direction
+ * answers that better than one making tactical decisions.
+ *
+ * Everything is produced as a steering *intent*, never as a position fix, so
+ * when a real controller or a puck-chasing brain replaces this the movement
+ * layer underneath does not change at all.
+ */
+
+const AI = {
+ /** Considered arrived inside this radius. */
+ arriveR: 2.6,
+ /** New waypoints are at least this far away, so bots commit to a line. */
+ minTravel: 9,
+ /** Repath anyway after this long, in case a waypoint became unreachable. */
+ patience: 9,
+ /** Start avoiding another skater inside this range. */
+ personalSpace: 3.4,
+ /** Weight of the avoidance push relative to the waypoint pull. */
+ avoidGain: 1.5,
+ /** Much weaker while racing for a puck — you contest it, you don't yield. */
+ avoidChasing: 0.25,
+ /** Seconds of velocity looked ahead when checking for boards. */
+ boardLookahead: 0.9,
+ /** Steer away once the lookahead point is within this of the boards. */
+ boardMargin: 2.2,
+ boardGain: 2.2,
+ /** Slow down for the last stretch so they don't overshoot every waypoint. */
+ easeR: 6,
+};
+
+export function createBrain(rand, opts = {}) {
+ return {
+ rand,
+ target: null,
+ age: 0,
+ /** Personality: some bots cruise, some chase every waypoint flat out. */
+ eagerness: opts.eagerness ?? (0.35 + rand() * 0.6),
+ /** Small heading wobble so three bots on the same errand don't lockstep. */
+ wobblePhase: rand() * Math.PI * 2,
+ wobbleRate: 0.5 + rand() * 0.7,
+ };
+}
+
+function pickWaypoint(brain, from) {
+ for (let i = 0; i < 12; i++) {
+ const p = randomIcePoint(brain.rand, 3.5);
+ if (Math.hypot(p.x - from.x, p.z - from.z) >= AI.minTravel) return p;
+ }
+ return randomIcePoint(brain.rand, 3.5);
+}
+
+/**
+ * Produce this frame's intent for one skater.
+ *
+ * @param {object} brain from createBrain
+ * @param {object} s skater state (mutated: ix, iz, sprint)
+ * @param {object[]} others other skater states to keep clear of
+ * @param {number} dt
+ */
+export function steer(brain, s, others, dt, play = null) {
+ brain.age += dt;
+
+ // ---- what am I doing --------------------------------------------------
+ // With a puck on the ice there is something to want. Without one this falls
+ // back to wandering, which is what spike 1 did and is still what happens
+ // between whistles.
+ if (play?.puck) {
+ const mine = play.carrier === s.id;
+ const teammateHas = play.carrier != null && play.carrierTeam === s.team && !mine;
+ // Only the closest skater on each side actually goes for it. Letting all
+ // six chase does produce contact, but it also produces a single moving
+ // scrum with nobody anywhere else on the ice, which reads as a bug rather
+ // than as hockey.
+ const chaser = play.chaser?.[s.team] === s.id;
+ if (mine) {
+ // Carrying: head for open ice up the attacking end.
+ const attackX = s.team === 0 ? RINK.halfX * 0.7 : -RINK.halfX * 0.7;
+ brain.target = { x: attackX, z: clamp(play.puck.z * 0.6, -RINK.halfZ * 0.6, RINK.halfZ * 0.6) };
+ brain.age = 0;
+ } else if (chaser) {
+ // Go and get it. This is what makes contact happen on its own.
+ brain.target = { x: play.puck.x, z: play.puck.z };
+ brain.age = 0;
+ } else {
+ // Everyone else finds space: ahead of the puck when their side has it,
+ // between the puck and their own end when it does not.
+ const dir = s.team === 0 ? 1 : -1;
+ const depth = teammateHas ? RINK.halfX * 0.42 : -RINK.halfX * 0.18;
+ const side = s.id % 2 === 0 ? 1 : -1;
+ brain.target = {
+ x: clamp(play.puck.x + dir * depth, -RINK.halfX * 0.85, RINK.halfX * 0.85),
+ z: clamp(play.puck.z + side * RINK.halfZ * 0.55, -RINK.halfZ * 0.8, RINK.halfZ * 0.8),
+ };
+ brain.age = 0;
+ }
+ }
+
+ const reached = brain.target
+ && Math.hypot(brain.target.x - s.x, brain.target.z - s.z) < AI.arriveR;
+ if (!brain.target || (reached && !play?.puck) || brain.age > AI.patience) {
+ brain.target = pickWaypoint(brain, s);
+ brain.age = 0;
+ }
+
+ // ---- pull toward the waypoint ------------------------------------------
+ let dx = brain.target.x - s.x;
+ let dz = brain.target.z - s.z;
+ const dist = Math.hypot(dx, dz) || 1e-6;
+ dx /= dist;
+ dz /= dist;
+
+ // ---- push away from other skaters --------------------------------------
+ // Box3D resolves the actual bump; this only stops the bots from queueing up
+ // to walk through each other, which looks like a bug even when it isn't.
+ //
+ // Chasing a loose puck is the exception, and an important one: at full
+ // avoidance six skaters converging on the same puck politely peel off before
+ // they ever touch, and the entire hit system never fires in normal play.
+ // Competing for a puck means being willing to skate into somebody.
+ const avoid = play?.puck && play.chaser?.[s.team] === s.id ? AI.avoidChasing : AI.avoidGain;
+ for (const o of others) {
+ if (o === s) continue;
+ const ox = s.x - o.x;
+ const oz = s.z - o.z;
+ const d = Math.hypot(ox, oz);
+ if (d >= AI.personalSpace || d < 1e-4) continue;
+ const w = (1 - d / AI.personalSpace) * avoid;
+ dx += (ox / d) * w;
+ dz += (oz / d) * w;
+ }
+
+ // ---- push away from the boards -----------------------------------------
+ // Checked against where they will be, not where they are: on ice, noticing
+ // the boards at arm's length is already too late.
+ const ahead = AI.boardLookahead;
+ const pen = rinkPenetration(s.x + s.vx * ahead, s.z + s.vz * ahead, SKATE.radius);
+ if (pen.dist > -AI.boardMargin) {
+ const w = clamp((pen.dist + AI.boardMargin) / AI.boardMargin, 0, 1) * AI.boardGain;
+ dx += pen.nx * w;
+ dz += pen.nz * w;
+ }
+
+ // ---- wobble + normalise -------------------------------------------------
+ brain.wobblePhase += brain.wobbleRate * dt;
+ const wob = Math.sin(brain.wobblePhase) * 0.12;
+ const len = Math.hypot(dx, dz) || 1e-6;
+ const yaw = Math.atan2(dx / len, dz / len) + wob;
+ s.ix = Math.sin(yaw);
+ s.iz = Math.cos(yaw);
+
+ // Ease off approaching the waypoint, and only sprint on the long straights.
+ // Chasing a puck is the exception: you do not coast in on a loose puck, you
+ // get there first, so the ease and the arrival brake are both dropped.
+ const chasing = !!play?.puck && play.chaser?.[s.team] === s.id;
+ const ease = chasing ? 1 : clamp(dist / AI.easeR, 0.25, 1);
+ s.ix *= ease;
+ s.iz *= ease;
+ s.sprint = chasing ? dist > 2 : (dist > AI.easeR * 1.5 && brain.eagerness > 0.5);
+ // Hard stop rather than a lazy drift when arriving hot.
+ s.brake = !chasing && dist < AI.arriveR * 1.4 && speedOf(s) > 4.5;
+}
+
+/**
+ * Starting lineup: each team in its own half, everyone facing centre ice.
+ *
+ * Laid out like a faceoff rather than scattered at random, because that is the
+ * arrangement every later spike starts from — drop a puck at centre and this is
+ * already the right picture. Ordered team by team, so index `i` belongs to
+ * team `Math.floor(i / perTeam)` and the returned `team` field agrees.
+ *
+ * The depth pattern is one skater up, the rest spread behind: with three a side
+ * that reads as a forward and two defenders without encoding any positional
+ * rules the game does not have yet.
+ */
+export function spawnLineup(perTeam = 3, teams = 2) {
+ const out = [];
+ for (let t = 0; t < teams; t++) {
+ // Team 0 defends the -X end, team 1 the +X end.
+ const sign = t === 0 ? -1 : 1;
+ for (let i = 0; i < perTeam; i++) {
+ const lead = i === 0;
+ const x = sign * (lead ? RINK.halfX * 0.18 : RINK.halfX * 0.42);
+ // Fan the back line across the width; a single one sits on the centre.
+ const back = perTeam > 1 ? (i - 1) / Math.max(1, perTeam - 2) : 0.5;
+ const z = lead ? 0 : (back * 2 - 1) * RINK.halfZ * 0.55;
+ out.push({ x, z, yaw: Math.atan2(-x, -z), team: t });
+ }
+ }
+ return out;
+}
diff --git a/shared/bodyStyle.js b/shared/bodyStyle.js
new file mode 100644
index 0000000..49577d0
--- /dev/null
+++ b/shared/bodyStyle.js
@@ -0,0 +1,165 @@
+/**
+ * Body style — three appearance sliders inspired by dreamfall simhuman
+ * global morphs (mass / muscle / fat).
+ *
+ * Plain numbers only: Party, C path, and client mesh build all share this.
+ * Ranges match vibe-human MODELING_CONTROLS for body.global.*:
+ * mass −1 lean … +1 heavy
+ * muscle −1 soft … +1 muscular
+ * fat 0 base … +1 fat (no negative target in the reference)
+ */
+
+export const BODY_STYLE_DEFAULTS = Object.freeze({
+ mass: 0,
+ muscle: 0,
+ fat: 0,
+});
+
+/** Slider metadata for the kit chest UI. */
+export const BODY_STYLE_SLIDERS = Object.freeze([
+ {
+ id: 'mass',
+ label: 'Mass',
+ min: -1,
+ max: 1,
+ step: 0.01,
+ left: 'Lean',
+ right: 'Heavy',
+ },
+ {
+ id: 'muscle',
+ label: 'Muscle',
+ min: -1,
+ max: 1,
+ step: 0.01,
+ left: 'Soft',
+ right: 'Muscular',
+ },
+ {
+ id: 'fat',
+ label: 'Fat',
+ min: 0,
+ max: 1,
+ step: 0.01,
+ left: 'Base',
+ right: 'Heavy',
+ },
+]);
+
+function clamp(v, lo, hi) {
+ const n = Number(v);
+ if (!Number.isFinite(n)) return lo;
+ return Math.max(lo, Math.min(hi, n));
+}
+
+/**
+ * Normalize a partial body bag. Missing keys become 0 (average build).
+ * @param {unknown} raw
+ * @returns {{ mass: number, muscle: number, fat: number }}
+ */
+export function normalizeBodyStyle(raw) {
+ if (!raw || typeof raw !== 'object') {
+ return { ...BODY_STYLE_DEFAULTS };
+ }
+ return {
+ mass: clamp(raw.mass, -1, 1),
+ muscle: clamp(raw.muscle, -1, 1),
+ fat: clamp(raw.fat, 0, 1),
+ };
+}
+
+/** True when every channel is at the neutral default. */
+export function isDefaultBodyStyle(style) {
+ const s = normalizeBodyStyle(style);
+ return s.mass === 0 && s.muscle === 0 && s.fat === 0;
+}
+
+/**
+ * Compact wire form for full-roster snapshots (two decimals).
+ * @returns {{ m: number, u: number, f: number }}
+ */
+export function packBodyStyle(style) {
+ const s = normalizeBodyStyle(style);
+ return {
+ m: Math.round(s.mass * 100) / 100,
+ u: Math.round(s.muscle * 100) / 100,
+ f: Math.round(s.fat * 100) / 100,
+ };
+}
+
+/** Inverse of packBodyStyle — also accepts full { mass, muscle, fat }. */
+export function unpackBodyStyle(raw) {
+ if (!raw || typeof raw !== 'object') return normalizeBodyStyle(null);
+ if ('mass' in raw || 'muscle' in raw || 'fat' in raw) {
+ return normalizeBodyStyle(raw);
+ }
+ return normalizeBodyStyle({
+ mass: raw.m,
+ muscle: raw.u,
+ fat: raw.f,
+ });
+}
+
+/**
+ * Ease unit strength so mid-slider stays mild and the last third punches
+ * harder into caricature (leaner / bulkier / more cut / fatter).
+ * @param {number} t 0..1
+ * @returns {number} 0..1
+ */
+function easeExtreme(t) {
+ const a = clamp(t, 0, 1);
+ // ~0.35 at half travel, 1.0 at the end — more of the gain sits near the stop.
+ return a * 0.35 + a * a * 0.25 + a * a * a * 0.4;
+}
+
+/** Signed ease: preserves direction, applies easeExtreme on |v|. */
+function shapedSigned(v) {
+ if (v === 0) return 0;
+ return Math.sign(v) * easeExtreme(Math.abs(v));
+}
+
+/**
+ * Map mass/muscle/fat onto the loft physique scalars used by buildBodyGeometry.
+ *
+ * Ends of each slider push hard (ease-in + large peak gains). Mid values stay
+ * readable so average builds do not jump. Seeded rng still adds a small natural
+ * variation so two fighters with the same sliders are not voxel-identical.
+ *
+ * @param {{ mass: number, muscle: number, fat: number }} style
+ * @param {{ range: (a: number, b: number) => number }} rng
+ */
+export function physiqueFromBodyStyle(style, rng) {
+ const s = normalizeBodyStyle(style);
+ // Shaped channels: mild near 0, extreme at ±1 / 1.
+ const mass = shapedSigned(s.mass);
+ const muscle = shapedSigned(s.muscle);
+ const fat = easeExtreme(s.fat);
+
+ // Peak gains (at shaped = ±1 / 1) — roughly 2× the first-pass response so
+ // full lean / tank / soft / cut / fat reads clearly under armor.
+ // Overall girth: mass + fat dominate; muscle adds a little.
+ const bulkBase = 1 + mass * 0.32 + fat * 0.26 + muscle * 0.1;
+ // Waist: fat fills hard, muscle nips, mass thickens.
+ const waistBase = 1 + fat * 0.48 + mass * 0.2 - muscle * 0.18;
+ // Shoulders / chest: muscle primary.
+ const shoulderBase = 1 + muscle * 0.42 + mass * 0.16 + fat * 0.1;
+ // Arms: muscle, with a little mass/fat.
+ const armBase = 1 + muscle * 0.48 + mass * 0.14 + fat * 0.12;
+ // Legs: fat + mass, muscle secondary.
+ const legBase = 1 + fat * 0.36 + mass * 0.2 + muscle * 0.16;
+ // Head: slight only — keeps helm fit.
+ const headBase = 1 + mass * 0.06 + fat * 0.05;
+
+ const jitter = (base, lo = 0.97, hi = 1.03) => base * (rng?.range?.(lo, hi) ?? 1);
+
+ return {
+ bulk: jitter(bulkBase),
+ // Lower floors so full lean/soft can actually go thin.
+ waistF: Math.max(0.52, jitter(waistBase, 0.98, 1.02)),
+ shoulderF: Math.max(0.62, jitter(shoulderBase, 0.98, 1.02)),
+ armF: Math.max(0.58, jitter(armBase, 0.97, 1.03)),
+ legF: Math.max(0.6, jitter(legBase, 0.97, 1.03)),
+ headF: Math.max(0.85, jitter(headBase, 0.98, 1.02)),
+ style: s,
+ };
+}
diff --git a/shared/net.js b/shared/net.js
new file mode 100644
index 0000000..c327c89
--- /dev/null
+++ b/shared/net.js
@@ -0,0 +1,102 @@
+import { MARKINGS, RINK } from './rink.js';
+
+/**
+ * The net, and what counts as a goal.
+ *
+ * Pure numbers and pure predicates, so the shootout logic and the tests can
+ * agree about scoring without a physics world in the room.
+ */
+
+/** Regulation: 6ft wide, 4ft tall, 44in deep. */
+export const NET = Object.freeze({
+ width: 1.83,
+ height: 1.22,
+ depth: 1.12,
+ postRadius: 0.048,
+ /** Crease: 6ft radius arc off the goal line. */
+ creaseRadius: 1.83,
+});
+
+/**
+ * Goal line X for an end. `end` is +1 for the +X end, −1 for −X.
+ * The net's mouth sits *on* this line, opening back toward centre ice.
+ */
+export const goalLineX = (end) => end * MARKINGS.goalLine;
+
+/**
+ * Has the puck fully crossed the line, between the posts and under the bar?
+ *
+ * "Fully" is the rule and it matters: a puck resting on the line is not a goal.
+ * The whole puck has to be past, so the test is against the puck's leading edge
+ * — its centre plus its radius.
+ */
+export function isGoal(puck, end, puckRadius = 0.0381) {
+ const line = goalLineX(end);
+ // Leading edge past the line, travelling into the net.
+ const past = end > 0 ? puck.x - puckRadius > line : puck.x + puckRadius < line;
+ if (!past) return false;
+ // ...but not out the back of it.
+ if (Math.abs(puck.x - line) > NET.depth) return false;
+ if (Math.abs(puck.z) > NET.width / 2 - puckRadius) return false;
+ return (puck.y ?? 0) < NET.height;
+}
+
+/** True once the puck is behind the goal line but wide or high — a miss. */
+export function isWide(puck, end, puckRadius = 0.0381) {
+ const line = goalLineX(end);
+ const past = end > 0 ? puck.x - puckRadius > line : puck.x + puckRadius < line;
+ return past && !isGoal(puck, end, puckRadius);
+}
+
+/**
+ * Where a goalie should stand, given where the puck is.
+ *
+ * Angle play, which is the whole of goaltending positioning: stand on the line
+ * between the puck and the middle of the net, `depth` metres out from the goal
+ * line. Cover the angle and the shooter has nothing to shoot at; the rest is
+ * reflexes.
+ *
+ * Returns a point, clamped so the goalie never wanders past the posts by more
+ * than a pad's width — a goalie who chases the puck to the corner has left an
+ * open net, which reads as broken rather than as aggressive.
+ */
+export function goalieSpot(puck, end, depth = 0.55, out = { x: 0, z: 0 }) {
+ const line = goalLineX(end);
+ // Aim from the middle of the goal mouth toward the puck.
+ const dx = puck.x - line;
+ const dz = puck.z - 0;
+ const len = Math.hypot(dx, dz);
+ if (len < 1e-4) {
+ out.x = line - end * 0.1;
+ out.z = 0;
+ return out;
+ }
+ out.x = line + (dx / len) * depth;
+ out.z = (dz / len) * depth;
+
+ // Two clamps, and *both* were originally the wrong way round — between them
+ // they teleported the goalie onto the puck and then pinned them to the goal
+ // line, which threw away every bit of angle the depth was there to buy.
+ //
+ // "Out" means toward centre ice, which is decreasing x at the +X end. So
+ // coming out past the puck is `out.x < puck.x` there, and going behind the
+ // line is `out.x > line`.
+ if (end > 0 ? out.x < puck.x : out.x > puck.x) out.x = puck.x;
+ if (end > 0 ? out.x > line : out.x < line) out.x = line;
+ // Stay within the posts, plus a little for a pad sticking out.
+ const limit = NET.width / 2 + 0.22;
+ out.z = Math.max(-limit, Math.min(limit, out.z));
+ return out;
+}
+
+/** Centre ice, facing the end being shot at — where a shootout attempt starts. */
+export function shootoutStart(end) {
+ return { x: 0, z: 0, yaw: end > 0 ? Math.PI / 2 : -Math.PI / 2 };
+}
+
+/** Is the puck still in a sensible place for a live attempt? */
+export function attemptLive(puck, end) {
+ if (Math.abs(puck.z) > RINK.halfZ) return false;
+ // Past the goal line at that end, one way or another, ends it.
+ return end > 0 ? puck.x < goalLineX(end) + NET.depth : puck.x > goalLineX(end) - NET.depth;
+}
diff --git a/shared/rink.js b/shared/rink.js
new file mode 100644
index 0000000..886b77a
--- /dev/null
+++ b/shared/rink.js
@@ -0,0 +1,159 @@
+/**
+ * Rink geometry.
+ *
+ * NHL dimensions in metres, kept as plain numbers with no three.js import so
+ * the sim, the tests and (later) a server can all agree on where the boards
+ * are without pulling in a renderer.
+ *
+ * The surface is a rounded rectangle: a `halfX` by `halfZ` box with the four
+ * corners replaced by quarter circles of radius `cornerR`. Every containment
+ * query in the game reduces to "how far outside that shape are you", so it
+ * lives here once as `rinkPenetration`.
+ */
+
+/** 200ft x 85ft, 28ft corner radius, 42in boards. */
+export const RINK = Object.freeze({
+ halfX: 30.48, // length/2 — the long axis runs along X
+ halfZ: 12.95, // width/2
+ cornerR: 8.53,
+ boardHeight: 1.07,
+ /** Glass above the boards is visual only in this spike. */
+ glassHeight: 1.8,
+});
+
+/** Blue lines / centre line, as distances from centre ice along X. */
+export const MARKINGS = Object.freeze({
+ blueLine: 7.77,
+ goalLine: 25.6,
+ faceoffCircleR: 4.57,
+ centreCircleR: 4.57,
+ faceoffDotX: 6.7,
+ faceoffDotZ: 6.7,
+ zoneDotX: 20.2,
+});
+
+/**
+ * Centre of the corner arc nearest (x, z), and the sign of the quadrant.
+ * Points outside the straight sections belong to exactly one corner.
+ */
+function cornerCentre(x, z, out) {
+ const sx = x >= 0 ? 1 : -1;
+ const sz = z >= 0 ? 1 : -1;
+ out.x = sx * (RINK.halfX - RINK.cornerR);
+ out.z = sz * (RINK.halfZ - RINK.cornerR);
+ return out;
+}
+
+const _c = { x: 0, z: 0 };
+
+/**
+ * Signed distance from the rink's inner surface, plus the inward normal.
+ *
+ * Positive `dist` means the point is outside the playing surface by that much;
+ * `nx`/`nz` point back toward the ice. Returns the same object every call, so
+ * copy anything you need to keep.
+ */
+const _pen = { dist: 0, nx: 0, nz: 0 };
+export function rinkPenetration(x, z, radius = 0) {
+ const ax = Math.abs(x);
+ const az = Math.abs(z);
+ const straightX = RINK.halfX - RINK.cornerR;
+ const straightZ = RINK.halfZ - RINK.cornerR;
+
+ if (ax <= straightX || az <= straightZ) {
+ // Straight section: whichever wall is closer wins. A point can only be
+ // outside one of them here, since the corners are handled below.
+ const overX = ax + radius - RINK.halfX;
+ const overZ = az + radius - RINK.halfZ;
+ if (overX >= overZ) {
+ _pen.dist = overX;
+ _pen.nx = x >= 0 ? -1 : 1;
+ _pen.nz = 0;
+ } else {
+ _pen.dist = overZ;
+ _pen.nx = 0;
+ _pen.nz = z >= 0 ? -1 : 1;
+ }
+ return _pen;
+ }
+
+ cornerCentre(x, z, _c);
+ const dx = x - _c.x;
+ const dz = z - _c.z;
+ const d = Math.hypot(dx, dz) || 1e-6;
+ _pen.dist = d + radius - RINK.cornerR;
+ _pen.nx = -dx / d;
+ _pen.nz = -dz / d;
+ return _pen;
+}
+
+/** True when a circle of `radius` at (x, z) is fully on the ice. */
+export function insideRink(x, z, radius = 0) {
+ return rinkPenetration(x, z, radius).dist <= 0;
+}
+
+/**
+ * Push a body back inside the boards and kill the velocity going into them.
+ *
+ * Box3D owns board contact for anything with a proxy capsule; this is the
+ * headless fallback (tests, and any future server tick without a physics
+ * world) and a cheap safety net against a body escaping the world.
+ *
+ * `restitution` 0 is a dead thud, 1 a perfect bounce. Boards eat most of it.
+ */
+export function clampToRink(state, radius = 0.36, restitution = 0.18) {
+ const pen = rinkPenetration(state.x, state.z, radius);
+ if (pen.dist <= 0) return false;
+ state.x += pen.nx * pen.dist;
+ state.z += pen.nz * pen.dist;
+ const into = state.vx * pen.nx + state.vz * pen.nz;
+ if (into < 0) {
+ // Remove the inward-normal component, then add back a fraction reversed.
+ state.vx -= into * pen.nx * (1 + restitution);
+ state.vz -= into * pen.nz * (1 + restitution);
+ }
+ return true;
+}
+
+/**
+ * The board line as a closed polyline, counter-clockwise from the +X end.
+ *
+ * The physics boards and the rendered boards are both built from this, so the
+ * wall a skater bounces off is the wall they can see. `cornerSteps` is the
+ * number of segments each of the four corner arcs is cut into.
+ */
+export function rinkOutline(cornerSteps = 8) {
+ const sx = RINK.halfX - RINK.cornerR;
+ const sz = RINK.halfZ - RINK.cornerR;
+ const pts = [];
+ // Four corners, each an arc swept from its own quadrant, with the straight
+ // sections falling out as the gaps between consecutive arcs.
+ const corners = [
+ { cx: sx, cz: sz, a0: 0 }, // +X +Z
+ { cx: -sx, cz: sz, a0: Math.PI / 2 }, // -X +Z
+ { cx: -sx, cz: -sz, a0: Math.PI }, // -X -Z
+ { cx: sx, cz: -sz, a0: -Math.PI / 2 }, // +X -Z
+ ];
+ for (const c of corners) {
+ for (let i = 0; i <= cornerSteps; i++) {
+ const a = c.a0 + (i / cornerSteps) * (Math.PI / 2);
+ pts.push({ x: c.cx + Math.cos(a) * RINK.cornerR, z: c.cz + Math.sin(a) * RINK.cornerR });
+ }
+ }
+ return pts;
+}
+
+/**
+ * A random point on the ice, inset from the boards.
+ * `rand` is any () => [0,1) so callers can keep it seeded.
+ */
+export function randomIcePoint(rand, inset = 3) {
+ for (let i = 0; i < 24; i++) {
+ const x = (rand() * 2 - 1) * (RINK.halfX - inset);
+ const z = (rand() * 2 - 1) * (RINK.halfZ - inset);
+ if (insideRink(x, z, inset)) return { x, z };
+ }
+ // Rejection sampling in a rounded rect basically never fails, but never
+ // hand back an off-ice waypoint if it does.
+ return { x: 0, z: 0 };
+}
diff --git a/shared/scalar.js b/shared/scalar.js
new file mode 100644
index 0000000..34fa7cc
--- /dev/null
+++ b/shared/scalar.js
@@ -0,0 +1,29 @@
+/** Scalar helpers shared by the sim and the renderer. No three.js here. */
+
+export const clamp = (x, a, b) => (x < a ? a : x > b ? b : x);
+export const lerp = (a, b, t) => a + (b - a) * t;
+export const smooth = (t) => t * t * (3 - 2 * t);
+
+/** Wrap to (-PI, PI]. */
+export function wrapAngle(a) {
+ let x = a;
+ while (x > Math.PI) x -= Math.PI * 2;
+ while (x <= -Math.PI) x += Math.PI * 2;
+ return x;
+}
+
+/** Shortest-arc interpolation between two headings. */
+export function lerpAngle(a, b, t) {
+ return a + wrapAngle(b - a) * t;
+}
+
+/** Move `from` toward `to` by at most `step`, without overshooting. */
+export function approach(from, to, step) {
+ return Math.abs(to - from) <= step ? to : from + Math.sign(to - from) * step;
+}
+
+/** Same, on the circle. */
+export function approachAngle(from, to, step) {
+ const d = wrapAngle(to - from);
+ return Math.abs(d) <= step ? wrapAngle(to) : wrapAngle(from + Math.sign(d) * step);
+}
diff --git a/shared/skaterSim.js b/shared/skaterSim.js
new file mode 100644
index 0000000..2c30638
--- /dev/null
+++ b/shared/skaterSim.js
@@ -0,0 +1,215 @@
+import { clamp, lerpAngle, wrapAngle } from './scalar.js';
+import { clampToRink } from './rink.js';
+
+/**
+ * Skating locomotion.
+ *
+ * This is Ludus's `fighterSim` pattern — intent in, velocity out, integrated on
+ * a fixed step so client and (eventual) server cannot drift — with the walking
+ * model swapped for a blade.
+ *
+ * The difference that matters: a runner's velocity points where they are
+ * pushing, so friction is isotropic and stopping is instant-ish. A skate glides
+ * almost freely along its own length and bites hard across it. Movement is
+ * therefore modelled as two separate things:
+ *
+ * 1. the *blade line*, which the momentum vector is continuously dragged onto
+ * (`edgeGrip`) — this is the carve, and it is why a hockey player leans
+ * into a turn and arrives somewhere they were not pointing a moment ago;
+ * 2. speed *along* that line, which a stride adds to and a very small drag
+ * removes.
+ *
+ * Modelling the carve as a rotation of the velocity vector rather than as
+ * lateral friction is what keeps momentum: turning redirects speed instead of
+ * destroying it, so a hard change of direction costs a little (`carveScrub`)
+ * and coasts out wide, which is the whole feel we are after.
+ */
+
+export const SKATE = Object.freeze({
+ /** Flat-out forward speed, m/s. ~8 m/s is a fast NHL skater. */
+ sprintSpeed: 8.4,
+ /** Speed the stride settles at without pushing hard. */
+ cruiseSpeed: 5.6,
+ /** Stride acceleration from a standstill, m/s². */
+ accel: 7.2,
+ /** Extra push while sprinting. */
+ sprintAccel: 9.0,
+ /** Constant scrub from blade friction, m/s². Small — this is ice. */
+ glideDrag: 0.42,
+ /** Quadratic term so top speed is reached asymptotically, per (m/s)². */
+ dragQuad: 0.011,
+ /** Rate the momentum vector swings onto the blade line, 1/s. */
+ edgeGrip: 6.2,
+ /** Speed lost per radian of redirect. A hard carve costs; a lazy one doesn't. */
+ carveScrub: 0.55,
+ /** Snowplow / hockey stop deceleration, m/s². */
+ brakeDecel: 12.5,
+ /** Body yaw rate at a standstill, rad/s. */
+ turnRate: 5.2,
+ /** Body yaw rate at top speed — you cannot pivot on a rail. */
+ turnRateFast: 1.9,
+ /** Proxy capsule radius, also used for board clamping. */
+ radius: 0.36,
+ /** Never let a collision fling anyone faster than this. */
+ speedCeiling: 11,
+});
+
+export function createSkaterState(id, spawn = {}, opts = {}) {
+ return {
+ id,
+ name: opts.name ?? `Skater ${id}`,
+ /** Seed for appearance; the sim itself is deterministic without it. */
+ seed: opts.seed ?? 1337,
+ team: opts.team ?? 0,
+
+ x: spawn.x ?? 0,
+ y: 0,
+ z: spawn.z ?? 0,
+ vx: 0,
+ vz: 0,
+ yaw: spawn.yaw ?? 0,
+
+ /** Intent: world-space XZ direction, length 0..1 (a left stick). */
+ ix: 0,
+ iz: 0,
+ sprint: false,
+ /** Held brake — a hockey stop, independent of which way the stick points. */
+ brake: false,
+
+ // ---- read-only outputs the animator and the AI read -------------------
+ /** Signed speed along the blade. Negative means gliding backwards. */
+ bladeSpeed: 0,
+ /** Last frame's redirect, rad. Sign tells the animator which edge is loaded. */
+ carve: 0,
+ /** How hard the skater is pushing, 0..1. Drives stride amplitude. */
+ effort: 0,
+ };
+}
+
+/** Clamp an input bag into something the sim can trust. */
+export function applyIntent(s, msg) {
+ const n = (v) => (Number.isFinite(v) ? v : 0);
+ let ix = clamp(n(msg.ix), -1, 1);
+ let iz = clamp(n(msg.iz), -1, 1);
+ const len = Math.hypot(ix, iz);
+ if (len > 1) {
+ ix /= len;
+ iz /= len;
+ }
+ s.ix = ix;
+ s.iz = iz;
+ s.sprint = !!msg.sprint;
+ s.brake = !!msg.brake;
+}
+
+/** Unit forward for a yaw, in three.js's convention (+Z is forward at yaw 0). */
+export const forwardX = (yaw) => Math.sin(yaw);
+export const forwardZ = (yaw) => Math.cos(yaw);
+
+/**
+ * Advance one skater by `dt`.
+ *
+ * `clampBoards` is on for the headless path. In the browser the Box3D proxy
+ * capsule owns board contact — running both would double the push-out.
+ */
+export function stepSkater(s, dt, { clampBoards = true } = {}) {
+ const intentLen = Math.min(1, Math.hypot(s.ix, s.iz));
+ const speed0 = Math.hypot(s.vx, s.vz);
+
+ // ---- 1. body yaw --------------------------------------------------------
+ // The skater turns their body toward the stick. How fast falls off with
+ // speed: at a standstill you can spin on the spot, at full flight you have
+ // to carve the turn.
+ if (intentLen > 0.05) {
+ const intentYaw = Math.atan2(s.ix, s.iz);
+ const t = clamp(speed0 / SKATE.sprintSpeed, 0, 1);
+ const rate = SKATE.turnRate + (SKATE.turnRateFast - SKATE.turnRate) * t;
+ s.yaw = lerpAngle(s.yaw, intentYaw, Math.min(1, rate * dt));
+ }
+ s.yaw = wrapAngle(s.yaw);
+
+ // ---- 2. the carve -------------------------------------------------------
+ // Drag the momentum vector onto the blade line. The blade is a line, not a
+ // ray, so a skater gliding backwards keeps gliding backwards instead of
+ // being snapped through 180°.
+ s.carve = 0;
+ if (speed0 > 1e-4) {
+ const velYaw = Math.atan2(s.vx, s.vz);
+ const off = wrapAngle(s.yaw - velYaw);
+ const bladeOff = Math.abs(off) > Math.PI / 2 ? wrapAngle(off - Math.sign(off) * Math.PI) : off;
+ const grip = 1 - Math.exp(-SKATE.edgeGrip * dt);
+ const turnBy = bladeOff * grip;
+ const newVelYaw = velYaw + turnBy;
+ // Redirect preserves magnitude; the scrub below is the only speed cost, so
+ // a wide turn is nearly free and a hard one bleeds.
+ const kept = 1 - clamp(SKATE.carveScrub * Math.abs(turnBy), 0, 0.6);
+ const speed = speed0 * kept;
+ s.vx = Math.sin(newVelYaw) * speed;
+ s.vz = Math.cos(newVelYaw) * speed;
+ s.carve = turnBy;
+ }
+
+ // ---- 3. speed along the blade -------------------------------------------
+ const fx = forwardX(s.yaw);
+ const fz = forwardZ(s.yaw);
+ let vf = s.vx * fx + s.vz * fz;
+ const rx = Math.cos(s.yaw);
+ const rz = -Math.sin(s.yaw);
+ let vr = s.vx * rx + s.vz * rz;
+
+ const maxSpeed = s.sprint ? SKATE.sprintSpeed : SKATE.cruiseSpeed;
+ // How much of the stick points where the skater is facing. A stick pulled
+ // behind them is a request to turn (handled above) and, until the body comes
+ // round, a request to stop.
+ const align = intentLen > 0.05 ? (s.ix * fx + s.iz * fz) / intentLen : 0;
+
+ let effort = 0;
+ if (s.brake || (intentLen > 0.05 && align < -0.35 && vf > 0.4)) {
+ // Hockey stop: both blades across the momentum.
+ const decel = SKATE.brakeDecel * dt;
+ vf = Math.abs(vf) <= decel ? 0 : vf - Math.sign(vf) * decel;
+ effort = 1;
+ } else if (intentLen > 0.05 && align > 0.15) {
+ // Stride. The push weakens as the blade approaches the speed the legs can
+ // deliver, so top speed is a property of the stride rather than a clamp.
+ const base = s.sprint ? SKATE.sprintAccel : SKATE.accel;
+ const headroom = clamp(1 - vf / maxSpeed, 0, 1);
+ vf += base * align * intentLen * headroom * dt;
+ // Effort is leg work, not acceleration. A skater holding top speed is
+ // still throwing full strides — they just stop gaining from them — so
+ // folding `headroom` in here would quietly freeze the legs of anyone at
+ // cruise, which is most of the time.
+ effort = clamp(align * intentLen, 0, 1);
+ }
+
+ // Glide drag: tiny linear term plus a quadratic one that sets the ceiling.
+ if (Math.abs(vf) > 1e-4) {
+ const drag = (SKATE.glideDrag + SKATE.dragQuad * vf * vf) * dt;
+ vf = Math.abs(vf) <= drag ? 0 : vf - Math.sign(vf) * drag;
+ }
+ // Whatever lateral slip survived the carve dies here — it is only ever a
+ // rounding remnant, but leaving it in lets a skater drift sideways forever.
+ vr *= Math.exp(-SKATE.edgeGrip * 2 * dt);
+
+ s.vx = fx * vf + rx * vr;
+ s.vz = fz * vf + rz * vr;
+ s.bladeSpeed = vf;
+ s.effort = effort;
+
+ // A board hit or a body check can hand back more speed than a skater can
+ // generate; cap it so nothing launches.
+ const speed = Math.hypot(s.vx, s.vz);
+ if (speed > SKATE.speedCeiling) {
+ const k = SKATE.speedCeiling / speed;
+ s.vx *= k;
+ s.vz *= k;
+ }
+
+ // ---- 4. integrate -------------------------------------------------------
+ s.x += s.vx * dt;
+ s.z += s.vz * dt;
+ if (clampBoards) clampToRink(s, SKATE.radius);
+}
+
+/** Planar speed, m/s. */
+export const speedOf = (s) => Math.hypot(s.vx, s.vz);
diff --git a/src/anim/goalieAnimator.js b/src/anim/goalieAnimator.js
new file mode 100644
index 0000000..7dc4d80
--- /dev/null
+++ b/src/anim/goalieAnimator.js
@@ -0,0 +1,292 @@
+import * as THREE from 'three';
+import { E, clamp, smooth } from '../core/math.js';
+import {
+ FOOT_Y,
+ GOALIE_BONES,
+ GOALIE_LEGS,
+ GOALIE_UPPER,
+ poseButterfly,
+ poseReach,
+ poseReady,
+ poseShuffle,
+} from './poses/goalie.js';
+
+/**
+ * Goalie animator.
+ *
+ * Upper body is pose-authored; legs are two-bone IK onto mover-local foot
+ * targets so the pads stay on the ice. The paddle stick keeps its authored
+ * grip rotation (re-aiming it every frame is what made it thrash).
+ */
+
+export function buildGoalieAnimator(skelData, mover) {
+ const B = skelData.bones;
+ const LEN = {
+ thigh: B.shinL.position.length(),
+ shin: B.footL.position.length(),
+ };
+ const restThighDir = {
+ L: B.shinL.position.clone().normalize(),
+ R: B.shinR.position.clone().normalize(),
+ };
+ const restShinDir = {
+ L: B.footL.position.clone().normalize(),
+ R: B.footR.position.clone().normalize(),
+ };
+
+ function newPose() {
+ const p = {
+ q: {},
+ rootOffset: new THREE.Vector3(),
+ rootQuat: new THREE.Quaternion(),
+ feet: {
+ L: { x: 0.28, z: 0.05, yaw: 0.15 },
+ R: { x: -0.28, z: 0.05, yaw: -0.15 },
+ },
+ };
+ for (const n of GOALIE_BONES) p.q[n] = new THREE.Quaternion();
+ return p;
+ }
+
+ const cur = newPose();
+ const frozen = newPose();
+
+ const anim = {
+ state: 'ready',
+ blend: 1,
+ BLEND_TIME: 0.16,
+ transitionTime: 0.16,
+ time: 0,
+ stateTime: 0,
+ speed: 1,
+
+ origin: new THREE.Vector3(),
+ originYaw: 0,
+
+ moveSpeed: 0,
+ lateralVel: 0,
+ puckHeight: 0.05,
+ puckDist: 8,
+ threatened: 0,
+
+ /** Goalie paddle group, parented to handR. Grip is adjusted per stance. */
+ stick: null,
+ };
+
+ // Hand-local stick grips, tuned against the equipment reference:
+ // ready = paddle on ice in the five-hole, shaft up into the blocker hand;
+ // butterfly = same idea, flatter, so it does not spear the surface.
+ // Searched: nearly down-forward puts the paddle on the ice in the five-hole
+ // (minY ≈ 0.02–0.05) without spearing through.
+ const STICK_READY_E = new THREE.Euler(1.55, 0.3, 0.05, 'XYZ');
+ const STICK_FLY_E = new THREE.Euler(1.65, 0.2, 0.0, 'XYZ');
+ const STICK_READY_POS = new THREE.Vector3(0.04, -0.02, 0.04);
+ const STICK_FLY_POS = new THREE.Vector3(0.05, 0.02, 0.05);
+ const _stickEuler = new THREE.Euler();
+ const _stickPos = new THREE.Vector3();
+
+ function applyMover() {
+ mover.position.copy(anim.origin);
+ mover.rotation.set(0, anim.originYaw, 0);
+ }
+
+ anim.setTransform = function setTransform(position, yaw) {
+ anim.origin.copy(position);
+ anim.originYaw = yaw;
+ };
+
+ function snapshot() {
+ for (const n of GOALIE_BONES) frozen.q[n].copy(B[n].quaternion);
+ frozen.rootOffset.copy(B.root.position);
+ frozen.rootQuat.copy(B.root.quaternion);
+ if (cur.feet) {
+ frozen.feet.L = { ...cur.feet.L };
+ frozen.feet.R = { ...cur.feet.R };
+ }
+ }
+
+ anim.setState = function setState(name, blendTime = null) {
+ if (name === anim.state) return;
+ snapshot();
+ anim.state = name;
+ anim.stateTime = 0;
+ anim.blend = 0;
+ anim.transitionTime = blendTime ?? anim.BLEND_TIME;
+ };
+
+ function chooseState() {
+ const low = anim.puckHeight < 0.38;
+ const high = anim.puckHeight > 0.75;
+ const close = anim.puckDist < 8;
+ const veryClose = anim.puckDist < 3.5;
+ const sliding = Math.abs(anim.lateralVel) > 1.2 || anim.moveSpeed > 1.6;
+
+ if (low && (veryClose || (close && anim.threatened > 0.3))) return 'butterfly';
+ if (high && close && anim.threatened > 0.25) return 'reach';
+ if (sliding) return 'shuffle';
+ return 'ready';
+ }
+
+ // ---- two-bone leg IK (same pattern as the skater) ------------------------
+ const _H = new THREE.Vector3();
+ const _d = new THREE.Vector3();
+ const _pole = new THREE.Vector3();
+ const _e2 = new THREE.Vector3();
+ const _knee = new THREE.Vector3();
+ const _dir = new THREE.Vector3();
+ const _f = new THREE.Vector3();
+ const _r = new THREE.Vector3();
+ const _qP = new THREE.Quaternion();
+ const _q1 = new THREE.Quaternion();
+ const _q2 = new THREE.Quaternion();
+ const _qF = new THREE.Quaternion();
+ const _qInv = new THREE.Quaternion();
+ const _worldFoot = new THREE.Vector3();
+
+ const fwdOf = (yaw, out) => out.set(Math.sin(yaw), 0, Math.cos(yaw));
+ const rightOf = (yaw, out) => out.set(Math.cos(yaw), 0, -Math.sin(yaw));
+
+ function solveLeg(side, localX, localZ, toeYaw) {
+ const thigh = B['thigh' + side];
+ const shin = B['shin' + side];
+ const foot = B['foot' + side];
+
+ // Local foot → world via the mover (already at originYaw).
+ _worldFoot.set(localX, FOOT_Y, localZ).applyMatrix4(mover.matrixWorld);
+ _worldFoot.y = FOOT_Y;
+
+ thigh.getWorldPosition(_H);
+ _d.subVectors(_worldFoot, _H);
+ let d = _d.length();
+ const a = LEN.thigh;
+ const b = LEN.shin;
+ d = clamp(d, 0.12, a + b - 0.003);
+ _d.normalize();
+ const cosA = clamp((a * a + d * d - b * b) / (2 * a * d), -1, 1);
+ const sinA = Math.sqrt(Math.max(0, 1 - cosA * cosA));
+
+ fwdOf(anim.originYaw, _f);
+ rightOf(anim.originYaw, _r);
+ // Knee pole: forward and outward so butterfly pads open, not knock-knees.
+ _pole.copy(_f).addScaledVector(_r, side === 'L' ? 0.55 : -0.55);
+ _pole.y -= 0.15;
+ _e2.copy(_pole).addScaledVector(_d, -_pole.dot(_d));
+ if (_e2.lengthSq() < 1e-8) _e2.copy(_f);
+ _e2.normalize();
+ _knee.copy(_H).addScaledVector(_d, a * cosA).addScaledVector(_e2, a * sinA);
+
+ _dir.subVectors(_knee, _H).normalize();
+ _q1.setFromUnitVectors(restThighDir[side], _dir);
+ thigh.parent.getWorldQuaternion(_qP);
+ _qInv.copy(_qP).invert();
+ thigh.quaternion.copy(_qInv).multiply(_q1);
+
+ _dir.subVectors(_worldFoot, _knee).normalize();
+ _q2.setFromUnitVectors(restShinDir[side], _dir);
+ _qInv.copy(_q1).invert();
+ shin.quaternion.copy(_qInv).multiply(_q2);
+
+ const worldYaw = anim.originYaw + toeYaw;
+ E(_qF, 0, worldYaw, 0, 'YXZ');
+ _qInv.copy(_q2).invert();
+ foot.quaternion.copy(_qInv).multiply(_qF);
+ B['toe' + side].quaternion.identity();
+ }
+
+ anim.update = function update(dt) {
+ dt *= anim.speed;
+ anim.time += dt;
+ anim.stateTime += dt;
+ anim.blend = Math.min(1, anim.blend + dt / anim.transitionTime);
+
+ anim.setState(chooseState());
+ applyMover();
+ mover.updateMatrixWorld(true);
+
+ const lean = clamp(anim.lateralVel / 3.5, -1, 1);
+ const t = anim.time;
+
+ for (const n of GOALIE_BONES) cur.q[n].identity();
+ cur.rootOffset.set(0, 0, 0);
+ cur.rootQuat.identity();
+
+ if (anim.state === 'butterfly') {
+ poseButterfly(cur, { lean, t });
+ } else if (anim.state === 'shuffle') {
+ poseShuffle(cur, {
+ dir: anim.lateralVel >= 0 ? 1 : -1,
+ effort: clamp(anim.moveSpeed / 3.5, 0.3, 1),
+ t,
+ });
+ } else if (anim.state === 'reach') {
+ const side = lean > 0.25 ? 1 : -1;
+ poseReach(cur, {
+ side,
+ up: clamp((anim.puckHeight - 0.6) / 0.8, 0.4, 1),
+ lean,
+ t,
+ });
+ } else {
+ poseReady(cur, { lean: lean * 0.5, t });
+ }
+
+ const w = smooth(anim.blend);
+ for (const n of GOALIE_UPPER) {
+ B[n].quaternion.slerpQuaternions(frozen.q[n], cur.q[n], w);
+ }
+ // Legs identity mid-blend then IK — slerping free leg eulers fights the IK.
+ for (const n of GOALIE_LEGS) B[n].quaternion.identity();
+ B.root.position.lerpVectors(frozen.rootOffset, cur.rootOffset, w);
+ B.root.quaternion.slerpQuaternions(frozen.rootQuat, cur.rootQuat, w);
+
+ mover.updateMatrixWorld(true);
+
+ // Blend foot targets in mover-local space, then IK.
+ const fL = {
+ x: lerp(frozen.feet.L.x, cur.feet.L.x, w),
+ z: lerp(frozen.feet.L.z, cur.feet.L.z, w),
+ yaw: lerp(frozen.feet.L.yaw, cur.feet.L.yaw, w),
+ };
+ const fR = {
+ x: lerp(frozen.feet.R.x, cur.feet.R.x, w),
+ z: lerp(frozen.feet.R.z, cur.feet.R.z, w),
+ yaw: lerp(frozen.feet.R.yaw, cur.feet.R.yaw, w),
+ };
+ solveLeg('L', fL.x, fL.z, fL.yaw);
+ solveLeg('R', fR.x, fR.z, fR.yaw);
+
+ // Stick grip: blend ready → butterfly so the paddle stays near the ice.
+ if (anim.stick) {
+ const k = anim.state === 'butterfly' ? Math.min(1, anim.stateTime / 0.14) : 0;
+ _stickEuler.set(
+ STICK_READY_E.x + (STICK_FLY_E.x - STICK_READY_E.x) * k,
+ STICK_READY_E.y + (STICK_FLY_E.y - STICK_READY_E.y) * k,
+ STICK_READY_E.z + (STICK_FLY_E.z - STICK_READY_E.z) * k,
+ 'XYZ',
+ );
+ anim.stick.quaternion.setFromEuler(_stickEuler);
+ _stickPos.lerpVectors(STICK_READY_POS, STICK_FLY_POS, k);
+ anim.stick.position.copy(_stickPos);
+ }
+
+ mover.updateMatrixWorld(true);
+ };
+
+ function lerp(a, b, t) {
+ return a + (b - a) * t;
+ }
+
+ // Seed frozen from a ready pose so the first frame has real foot targets.
+ poseReady(frozen, { lean: 0, t: 0 });
+ poseReady(cur, { lean: 0, t: 0 });
+ for (const n of GOALIE_UPPER) B[n].quaternion.copy(frozen.q[n]);
+ for (const n of GOALIE_LEGS) B[n].quaternion.identity();
+ B.root.position.copy(frozen.rootOffset);
+ B.root.quaternion.copy(frozen.rootQuat);
+ applyMover();
+ mover.updateMatrixWorld(true);
+ solveLeg('L', frozen.feet.L.x, frozen.feet.L.z, frozen.feet.L.yaw);
+ solveLeg('R', frozen.feet.R.x, frozen.feet.R.z, frozen.feet.R.yaw);
+
+ return anim;
+}
diff --git a/src/anim/poses/goalie.js b/src/anim/poses/goalie.js
new file mode 100644
index 0000000..f39d51c
--- /dev/null
+++ b/src/anim/poses/goalie.js
@@ -0,0 +1,173 @@
+import { E } from '../../core/math.js';
+import { clamp } from '../../../shared/scalar.js';
+
+/**
+ * Goalie pose authoring.
+ *
+ * Upper body + root only. Feet are world targets the animator solves with the
+ * same two-bone IK the skater uses — free eulers on the legs put the pads in
+ * the air or through the ice the moment the root drops. Measured rest feet sit
+ * at y ≈ 0.07; every stance keeps them there.
+ */
+
+/** Bones written by the pose layer (legs are IK'd after). */
+export const GOALIE_UPPER = [
+ 'pelvis', 'spine1', 'spine2', 'spine3', 'neck', 'head',
+ 'clavicleL', 'upperArmL', 'forearmL', 'handL',
+ 'clavicleR', 'upperArmR', 'forearmR', 'handR',
+];
+
+export const GOALIE_LEGS = [
+ 'thighL', 'shinL', 'footL', 'toeL',
+ 'thighR', 'shinR', 'footR', 'toeR',
+];
+
+export const GOALIE_BONES = GOALIE_UPPER.concat(GOALIE_LEGS);
+
+/** Foot sole height, metres. */
+export const FOOT_Y = 0.085;
+
+/**
+ * Ready stance foot targets in mover-local space.
+ * Open base like the equipment ref (half-butterfly ready), not a narrow crouch.
+ */
+export const FEET_READY = {
+ L: { x: 0.42, z: 0.02, yaw: 0.35 },
+ R: { x: -0.42, z: 0.02, yaw: -0.35 },
+};
+
+/**
+ * Butterfly foot targets: pads flared, feet out to the sides, still on ice.
+ * Width ~1.2 m so the pad faces cover the five-hole like the ref.
+ */
+export const FEET_BUTTERFLY = {
+ L: { x: 0.62, z: -0.06, yaw: 0.65 },
+ R: { x: -0.62, z: -0.06, yaw: -0.65 },
+};
+
+/**
+ * Ready: deep knee bend, chest up enough to track the puck, trapper open at
+ * the side, blocker + paddle down over the five-hole.
+ */
+export function poseReady(P, { lean = 0, t = 0 } = {}) {
+ const breath = Math.sin(t * 1.5) * 0.01;
+ const s = clamp(lean, -1, 1);
+
+ // Soft forward crouch; head counters so eyes stay on the play.
+ E(P.q.pelvis, 0.16 + breath, s * 0.06, -s * 0.1);
+ E(P.q.spine1, 0.14, -s * 0.05, -s * 0.06);
+ E(P.q.spine2, 0.1, -s * 0.04, -s * 0.05);
+ E(P.q.spine3, 0.06, -s * 0.03, -s * 0.03);
+ E(P.q.neck, -0.18, s * 0.08, 0);
+ E(P.q.head, -0.12, s * 0.1, 0);
+
+ // Trapper: out beside the hip, pocket toward the shooter (ref photo).
+ E(P.q.clavicleL, 0.06, 0.14, -0.12);
+ E(P.q.upperArmL, -0.35, 0.85, -0.55);
+ E(P.q.forearmL, -1.0, -0.1, 0.3);
+ E(P.q.handL, -0.1, 0.4, 0.55);
+
+ // Blocker + stick: low over the five-hole so the paddle can sit on the ice.
+ E(P.q.clavicleR, 0.04, -0.1, 0.1);
+ E(P.q.upperArmR, -0.95, -0.45, 0.45);
+ E(P.q.forearmR, -0.55, 0.15, 0.1);
+ E(P.q.handR, -0.2, 0.05, -0.2);
+
+ // Seed legs (IK overwrites thighs/shins/feet).
+ for (const n of GOALIE_LEGS) P.q[n].identity();
+
+ // Hips low enough that the pad faces fill the lower net (ref ready).
+ P.rootOffset.set(s * 0.03, -0.28 + breath * 0.25, 0.02);
+ E(P.rootQuat, 0.06, 0, -s * 0.08);
+
+ P.feet = {
+ L: { ...FEET_READY.L, x: FEET_READY.L.x + s * 0.04 },
+ R: { ...FEET_READY.R, x: FEET_READY.R.x + s * 0.04 },
+ };
+}
+
+/**
+ * Butterfly: torso stays tracking; feet flare wide on the ice via IK.
+ */
+export function poseButterfly(P, { lean = 0, t = 0 } = {}) {
+ const s = clamp(lean, -1, 1);
+
+ E(P.q.pelvis, 0.1, s * 0.08, -s * 0.14);
+ E(P.q.spine1, 0.22, -s * 0.06, -s * 0.08);
+ E(P.q.spine2, 0.16, -s * 0.05, -s * 0.06);
+ E(P.q.spine3, 0.1, -s * 0.04, -s * 0.04);
+ E(P.q.neck, -0.22, s * 0.1, 0);
+ E(P.q.head, -0.14, s * 0.12, 0);
+
+ // Arms stay active above the pads.
+ E(P.q.clavicleL, 0.08, 0.12, -0.1);
+ E(P.q.upperArmL, -0.25, 0.75, -0.75);
+ E(P.q.forearmL, -0.95, -0.1, 0.3);
+ E(P.q.handL, -0.1, 0.4, 0.5);
+
+ E(P.q.clavicleR, 0.06, -0.1, 0.08);
+ E(P.q.upperArmR, -0.35, -0.55, 0.55);
+ E(P.q.forearmR, -0.85, 0.15, 0.12);
+ E(P.q.handR, -0.12, 0.12, -0.18);
+
+ for (const n of GOALIE_LEGS) P.q[n].identity();
+
+ // Drop the hips so the pad faces can meet the ice when feet are wide.
+ P.rootOffset.set(s * 0.04, -0.48, 0.0);
+ E(P.rootQuat, 0.04, 0, -s * 0.1);
+
+ P.feet = {
+ L: { ...FEET_BUTTERFLY.L, x: FEET_BUTTERFLY.L.x + s * 0.05 },
+ R: { ...FEET_BUTTERFLY.R, x: FEET_BUTTERFLY.R.x + s * 0.05 },
+ };
+}
+
+/**
+ * Lateral shuffle: ready upper body, feet shift toward the push side.
+ */
+export function poseShuffle(P, { dir = 1, effort = 0.6, t = 0 } = {}) {
+ const d = dir >= 0 ? 1 : -1;
+ const e = clamp(effort, 0, 1);
+ poseReady(P, { lean: d * 0.45 * e, t });
+
+ E(P.q.pelvis, 0.14, d * 0.12 * e, -d * 0.18 * e);
+ E(P.q.spine1, 0.12, -d * 0.08 * e, -d * 0.1 * e);
+
+ // Lead foot steps out; trail foot loads under the hip.
+ const lead = d > 0 ? 'R' : 'L'; // dir +1 = toward −X = right foot leads
+ const trail = lead === 'L' ? 'R' : 'L';
+ P.feet = {
+ L: { ...FEET_READY.L },
+ R: { ...FEET_READY.R },
+ };
+ P.feet[lead].x += d > 0 ? -0.12 * e : 0.12 * e;
+ P.feet[lead].z += 0.04 * e;
+ P.feet[trail].x += d > 0 ? 0.06 * e : -0.06 * e;
+
+ P.rootOffset.set(d * 0.06 * e, -0.22, 0.03);
+ E(P.rootQuat, 0.08, 0, -d * 0.12 * e);
+}
+
+/**
+ * High save reach. Feet stay in ready; one arm drives up.
+ */
+export function poseReach(P, { side = -1, up = 0.7, lean = 0, t = 0 } = {}) {
+ poseReady(P, { lean, t });
+ const u = clamp(up, 0, 1);
+
+ if (side < 0) {
+ E(P.q.clavicleL, -0.1 * u, 0.18 * u, -0.14 * u);
+ E(P.q.upperArmL, -0.4 + 1.15 * u, 0.65 + 0.25 * u, -0.65 - 0.35 * u);
+ E(P.q.forearmL, -1.05 + 0.65 * u, -0.15, 0.25);
+ E(P.q.handL, -0.1, 0.4, 0.5);
+ E(P.q.spine2, 0.1 - 0.06 * u, 0.1 * u, 0.05 * u);
+ } else {
+ E(P.q.clavicleR, -0.1 * u, -0.18 * u, 0.14 * u);
+ E(P.q.upperArmR, -0.85 + 1.25 * u, -0.3 - 0.3 * u, 0.4 + 0.3 * u);
+ E(P.q.forearmR, -0.7 + 0.5 * u, 0.12, 0.08);
+ E(P.q.handR, -0.15, 0.12, -0.12);
+ E(P.q.spine2, 0.1 - 0.06 * u, -0.1 * u, -0.05 * u);
+ }
+
+ P.rootOffset.y = -0.22 - 0.03 * u;
+}
diff --git a/src/anim/poses/skate.js b/src/anim/poses/skate.js
new file mode 100644
index 0000000..646ef67
--- /dev/null
+++ b/src/anim/poses/skate.js
@@ -0,0 +1,135 @@
+import { E } from '../../core/math.js';
+import { clamp, lerp } from '../../../shared/scalar.js';
+
+/**
+ * Upper-body authoring for skating.
+ *
+ * Split out from the animator for the same reason Ludus splits its stance
+ * poses: the runtime concerns (foot path, IK, blending) are fiddly and stable,
+ * while these numbers are pure feel and get tuned constantly.
+ *
+ * Everything keys off four scalars the sim already produces:
+ * gait 0..1 how much of a stride is being thrown (from effort + speed)
+ * speed m/s planar
+ * bank rad lean into the current turn, signed (+ = turning right)
+ * phase 0..1 stride cycle position
+ */
+
+export const SKATE_POSE = {
+ /** Knee bend at a standstill and at a full stride, in metres of root drop. */
+ crouchIdle: 0.1,
+ crouchStride: 0.26,
+ /**
+ * Forward pitch, radians, at a standstill and at speed.
+ *
+ * This is the *root* pitch; the spine adds roughly another half of it on top
+ * as it stacks up the chain, so the finished torso angle is around 1.5x these
+ * numbers. Authoring the final angle here instead would mean re-tuning every
+ * time a spine joint changed.
+ */
+ leanIdle: 0.08,
+ leanFast: 0.3,
+ /** How much of the bank the torso takes; the rest is absorbed by the legs. */
+ bankTorso: 0.7,
+ /** Head stays closer to level than the body — a skater looks up the ice. */
+ bankHeadCounter: 0.55,
+ /** Arm swing amplitude, radians, at a full stride. */
+ armSwing: 0.72,
+ /** Elbow bend: skaters carry their hands, they don't run with straight arms. */
+ elbow: -0.62,
+ /** Roll that pulls the arms in from the skeleton's rest A-pose. */
+ armTuck: 0.34,
+ /** Hip / shoulder counter-rotation with the stride. */
+ hipTwist: 0.2,
+ shoulderTwist: 0.26,
+};
+
+/**
+ * The moving pose: crouched, pitched forward, twisting against the stride.
+ *
+ * `P` is the animator's pose buffer — quaternions per bone plus a root offset.
+ * Foot targets are not written here; they are world-space and belong to the
+ * stepper.
+ */
+export function poseSkate(P, { gait, speed, bank, phase, t }) {
+ const K = SKATE_POSE;
+ const fast = clamp(speed / 7, 0, 1);
+ const s1 = Math.sin(phase * Math.PI * 2);
+ const s2 = Math.sin(phase * Math.PI * 4);
+ // Idle breathing, so a stopped skater is not a statue.
+ const idle = (1 - gait) * Math.sin(t * 1.6) * 0.02;
+
+ const crouch = lerp(K.crouchIdle, K.crouchStride, gait) + idle;
+ const pitch = lerp(K.leanIdle, K.leanFast, fast);
+ const twist = K.hipTwist * gait;
+
+ // Pelvis rocks with the push — the hip on the pushing side drops and rotates
+ // open, which is most of what makes a stride read as a stride and not a run.
+ E(P.q.pelvis, pitch * 0.18, twist * s1, -bank * 0.25 + gait * 0.05 * s1);
+ E(P.q.spine1, pitch * 0.3, -twist * 0.35 * s1, -bank * K.bankTorso * 0.3);
+ E(P.q.spine2, pitch * 0.3, -twist * 0.45 * s1, -bank * K.bankTorso * 0.35);
+ E(P.q.spine3, pitch * 0.22 + idle, -K.shoulderTwist * gait * s1, -bank * K.bankTorso * 0.25);
+ // Neck and head pull back up: the torso is folded forward, the eyes are not.
+ E(P.q.neck, -pitch * 0.5, 0, bank * K.bankHeadCounter * 0.4);
+ E(P.q.head, -pitch * 0.42, K.shoulderTwist * 0.3 * gait * s1, bank * K.bankHeadCounter * 0.6);
+
+ // Arms swing opposite the legs and slightly across the chest. Amplitude is
+ // pure gait: a gliding skater's hands barely move.
+ // The rest skeleton is an A-pose, so the arms already sit ~30° off the body.
+ // Roll about local Z is what brings them in, and its sign is mirrored: the
+ // left arm tucks on negative Z, the right on positive. Getting that backwards
+ // is what turns a skater into a scarecrow, so it is written as `-m` once here
+ // rather than as a per-side constant.
+ const swing = K.armSwing * gait;
+ for (const side of ['L', 'R']) {
+ const m = side === 'L' ? 1 : -1;
+ const armPhase = side === 'L' ? s1 : -s1;
+ E(P.q[`clavicle${side}`], 0.04, 0, -m * (0.04 + 0.05 * gait));
+ E(
+ P.q[`upperArm${side}`],
+ // Shoulders sit forward of the ribs at speed, hands ahead of the chest.
+ -0.45 - 0.35 * fast + swing * armPhase,
+ m * (0.1 + 0.12 * gait),
+ -m * K.armTuck,
+ );
+ E(P.q[`forearm${side}`], K.elbow - 0.25 * gait - Math.abs(armPhase) * 0.12 * gait, 0, -m * 0.1);
+ E(P.q[`hand${side}`], -0.1, 0, -m * 0.06);
+ }
+
+ // Vertical bob is small and at twice the stride rate: the body rises over
+ // each push, not once per cycle.
+ P.rootOffset.set(-bank * 0.06, -crouch + gait * 0.018 * s2, gait * 0.02);
+ E(P.rootQuat, pitch, 0, -bank);
+}
+
+/**
+ * Hockey stop: both blades thrown across the direction of travel, weight
+ * dropped hard onto them, shoulders squared back up the ice.
+ *
+ * `dir` is +1 or -1 for which shoulder leads, so a stop has a side to it.
+ */
+export function poseStop(P, { speed, dir, t }) {
+ const bite = clamp(speed / 6, 0.25, 1);
+ const shake = Math.sin(t * 22) * 0.012 * bite;
+
+ E(P.q.pelvis, 0.12, dir * 0.55 * bite, dir * 0.18 * bite);
+ E(P.q.spine1, 0.16 + shake, -dir * 0.18 * bite, -dir * 0.12 * bite);
+ E(P.q.spine2, 0.16 + shake, -dir * 0.2 * bite, -dir * 0.14 * bite);
+ E(P.q.spine3, 0.1, -dir * 0.16 * bite, -dir * 0.1 * bite);
+ E(P.q.neck, -0.24, -dir * 0.2 * bite, 0);
+ E(P.q.head, -0.18, -dir * 0.24 * bite, 0);
+
+ for (const side of ['L', 'R']) {
+ const m = side === 'L' ? 1 : -1;
+ // Hands come out for balance against the deceleration — the one pose where
+ // the arms should leave the body, so the tuck roll relaxes toward zero.
+ E(P.q[`clavicle${side}`], 0, 0, -m * 0.04);
+ E(P.q[`upperArm${side}`], -0.72 * bite, m * 0.16, -m * (0.3 - 0.28 * bite));
+ E(P.q[`forearm${side}`], -0.5 - 0.3 * bite, 0, -m * 0.12);
+ E(P.q[`hand${side}`], -0.12, 0, 0);
+ }
+
+ // Deep sit into the stop, hips back over the heels.
+ P.rootOffset.set(dir * 0.05 * bite, -(0.2 + 0.12 * bite), -0.06 * bite);
+ E(P.rootQuat, 0.12, 0, dir * 0.28 * bite);
+}
diff --git a/src/anim/poses/stickwork.js b/src/anim/poses/stickwork.js
new file mode 100644
index 0000000..0bf9f49
--- /dev/null
+++ b/src/anim/poses/stickwork.js
@@ -0,0 +1,223 @@
+import { E } from '../../core/math.js';
+import { clamp, lerp } from '../../../shared/scalar.js';
+
+/**
+ * Upper-body authoring for everything done with the stick.
+ *
+ * These are *override* poses, not whole-body states. They write the arms and
+ * some spine, and the animator blends them over the skating pose by a weight —
+ * because you keep skating while you shoot, and a shot that stopped the legs
+ * would read as a cutscene.
+ *
+ * Each one is a function of a single phase 0..1 so the animator can drive it
+ * from a timer, hold it (wind-up), or run it once and blend out (shoot, pass,
+ * poke). The right arm carries the stick; the left joins it for two-handed
+ * work and is pinned onto the shaft by IK afterwards, so what is authored here
+ * for the left side is only a starting guess that the IK refines.
+ */
+
+/**
+ * Bones the stickwork layer *replaces*. The arms belong to the stick whenever
+ * it is being used — there is no meaningful blend between "swinging with the
+ * stride" and "holding a stick", they are different arms.
+ */
+export const STICK_ARMS = [
+ 'clavicleR', 'upperArmR', 'forearmR', 'handR',
+ 'clavicleL', 'upperArmL', 'forearmL', 'handL',
+];
+
+/**
+ * Bones the layer *adds to*. The spine is already carrying the skating lean and
+ * the bank; a shot's coil is a twist on top of that, not instead of it.
+ * Replacing these was what flattened the forward lean the moment a stick
+ * appeared, and stood everybody up.
+ */
+export const STICK_SPINE = ['spine1', 'spine2', 'spine3', 'neck', 'head'];
+
+export const STICK_BONES = STICK_ARMS.concat(STICK_SPINE);
+
+/**
+ * The neutral carry, and the hustle variant.
+ *
+ * `hustle` 0..1 slides between two-hands-ready and the one-handed dangle a
+ * skater falls into when they are just trying to move: the stick goes out in
+ * front, the left arm leaves it and swings with the stride.
+ *
+ * Carry is authored from the motion-reference sheet (ready stance / puck carry):
+ * both hands in front of the torso, shaft angled down to the ice, blade a
+ * little to the forehand side — not parked out on the hip with the off-hand
+ * floating. The right arm has to sit close enough that the left can actually
+ * reach the shaft: the arm is only ~0.54 m long, so a top hand 40 cm off
+ * centre puts the stick out of reach no matter what the IK does.
+ */
+export function poseCarry(P, { hustle = 0, reach = 0, lateral = 0 }) {
+ const h = clamp(hustle, 0, 1);
+ // Skill Stick +X is "push right"; bone +X is the skater's left. Negate so
+ // the arms lean the same way the blade goes.
+ const side = -lateral;
+
+ // Right arm: top hand. Across the body and out in front at about waist /
+ // lower-chest height. Hustle extends it forward and frees the left side.
+ // Roll signs are mirrored: right arm tucks on *positive* Z, left on negative.
+ E(P.q.clavicleR, 0.03, lerp(-0.04, -0.1, h), 0.06);
+ E(
+ P.q.upperArmR,
+ lerp(-0.42, -0.7, h) + reach * 0.2,
+ lerp(0.42, 0.05, h) + side * 0.28,
+ lerp(0.68, 0.38, h),
+ );
+ E(
+ P.q.forearmR,
+ lerp(-1.35, -0.75, h) - reach * 0.15,
+ lerp(0.02, 0.12, h),
+ lerp(0.32, 0.16, h),
+ );
+ E(P.q.handR, lerp(-0.12, -0.08, h), lerp(0.12, 0.04, h), lerp(0.04, -0.12, h));
+
+ // Left arm: lower hand on the shaft when settled. Seeded near the stick so
+ // the IK only has to finish the last few centimetres, not haul it across the
+ // body. At full hustle it leaves the stick and opens for the stride swing.
+ E(P.q.clavicleL, 0.03, lerp(0.06, 0.02, h), lerp(-0.06, -0.02, h));
+ E(
+ P.q.upperArmL,
+ lerp(-0.38, -0.66, h) + reach * 0.12,
+ lerp(0.1, 0.16, h) + side * 0.18,
+ lerp(-0.48, -0.2, h),
+ );
+ E(
+ P.q.forearmL,
+ lerp(-1.32, -0.72, h),
+ lerp(-0.08, 0, h),
+ lerp(0.18, 0.08, h),
+ );
+ E(P.q.handL, -0.1, 0, 0.08 * (1 - h));
+
+ // Soft coil over the stick when both hands are on it; opens up when hustling.
+ E(P.q.spine1, 0.02 * h, lerp(-0.04, 0.02, h) + side * 0.05, 0);
+ E(P.q.spine2, 0.02 * h, lerp(-0.05, 0.02, h) + side * 0.05, 0);
+ E(P.q.spine3, 0.02, lerp(-0.04, 0, h), 0);
+}
+
+/**
+ * Wind-up. `phase` 0..1 is how loaded the shot is, and it is *held* — the
+ * animator parks here for as long as the Skill Stick is pulled back.
+ *
+ * Hands high and back, stick raised behind the head — not hanging blade-down
+ * from waist height. The torso coils open so the follow-through has something
+ * to spend.
+ */
+export function poseWindup(P, { phase = 0, aim = 0 }) {
+ const w = clamp(phase, 0, 1);
+ // Aim on the Skill Stick is "push right"; bone +Y twist toward the skater's
+ // left is the opposite sign.
+ const side = -aim;
+
+ // Torso coils open, loading the shot side.
+ E(P.q.spine1, -0.06 - 0.08 * w, -0.18 - 0.42 * w + side * 0.08, -0.05 * w);
+ E(P.q.spine2, -0.07 - 0.1 * w, -0.22 - 0.48 * w + side * 0.1, -0.06 * w);
+ E(P.q.spine3, -0.04 - 0.07 * w, -0.18 - 0.38 * w + side * 0.08, -0.04 * w);
+ // Eyes stay on the target while the body turns away from it.
+ E(P.q.neck, 0.04, 0.28 + 0.42 * w - side * 0.2, 0);
+ E(P.q.head, 0.04, 0.22 + 0.32 * w - side * 0.25, 0);
+
+ // Top hand: high and back, roughly shoulder/head height, so the aimed stick
+ // can sit up behind the head instead of dangling at the hip.
+ E(P.q.clavicleR, -0.1 * w, -0.22 * w, -0.1);
+ E(P.q.upperArmR, 0.15 + 0.65 * w, -0.55 - 0.35 * w + side * 0.15, -0.55 - 0.35 * w);
+ E(P.q.forearmR, -0.45 - 0.25 * w, 0.22, -0.12);
+ E(P.q.handR, -0.05, 0.2, 0.22);
+
+ // Lower hand comes up with it; IK pins it to the shaft.
+ E(P.q.clavicleL, 0.04, 0.12 * w, 0.08);
+ E(P.q.upperArmL, -0.35 - 0.1 * w, 0.45 + 0.2 * w, 0.4 + 0.15 * w);
+ E(P.q.forearmL, -0.85 - 0.15 * w, -0.18, -0.12);
+ E(P.q.handL, -0.08, 0, -0.1);
+}
+
+/**
+ * Follow-through. `phase` 0..1 runs once, fast.
+ *
+ * The coil released: the torso whips through the shot, the stick sweeps across
+ * and finishes high. Front-loaded easing, so the contact reads at the start of
+ * the animation rather than in the middle of it.
+ */
+export function poseShot(P, { phase = 0, power = 1, aim = 0 }) {
+ const t = clamp(phase, 0, 1);
+ // Fast out of the coil, then settle.
+ const s = 1 - (1 - t) * (1 - t);
+ const p = clamp(power, 0.2, 1);
+
+ const twist = lerp(-0.42 * p, 0.44 * p, s);
+ E(P.q.spine1, -0.06 + 0.12 * s, twist * 0.9, 0.04 * s);
+ E(P.q.spine2, -0.07 + 0.14 * s, twist, 0.05 * s);
+ E(P.q.spine3, -0.05 + 0.1 * s, twist * 0.8, 0.03 * s);
+ E(P.q.neck, 0.02, -twist * 0.5 + aim * 0.2, 0);
+ E(P.q.head, 0.02, -twist * 0.4 + aim * 0.25, 0);
+
+ // Top hand drives through and finishes high across the body.
+ E(P.q.clavicleR, lerp(-0.05, 0.04, s), lerp(-0.14, 0.1, s), -0.06);
+ E(P.q.upperArmR, lerp(0.3, -1.05 * p, s), lerp(-0.94, 0.3, s), lerp(-0.72, -0.1, s));
+ E(P.q.forearmR, lerp(-1.12, -0.42, s), 0.16, -0.1);
+ E(P.q.handR, -0.1, 0.1, 0.16);
+
+ E(P.q.clavicleL, 0.03, lerp(0.1, -0.04, s), 0.06);
+ E(P.q.upperArmL, lerp(-0.86, -0.3, s), lerp(0.66, 0.12, s), lerp(0.4, 0.5, s));
+ E(P.q.forearmL, lerp(-1.36, -0.6, s), -0.28, -0.2);
+ E(P.q.handL, -0.08, 0, -0.14);
+}
+
+/**
+ * Pass: a flat sweep, no coil and no lift. Shorter and lower than a shot,
+ * because a pass that looks like a shot makes the two impossible to read apart
+ * at a glance — which matters more for a teammate watching than for the passer.
+ */
+export function posePass(P, { phase = 0, aim = 0 }) {
+ const t = clamp(phase, 0, 1);
+ const s = Math.sin(t * Math.PI); // out and back
+ const sweep = lerp(-0.18, 0.34, 1 - (1 - t) * (1 - t));
+
+ E(P.q.spine1, 0.03 * s, sweep * 0.6, 0);
+ E(P.q.spine2, 0.04 * s, sweep * 0.7, 0);
+ E(P.q.spine3, 0.03 * s, sweep * 0.5, 0);
+ E(P.q.neck, 0, -sweep * 0.4 + aim * 0.2, 0);
+ E(P.q.head, 0, -sweep * 0.3 + aim * 0.2, 0);
+
+ E(P.q.clavicleR, 0.02, -0.04, -0.05);
+ E(P.q.upperArmR, -0.34 - 0.3 * s, -0.34 + sweep * 0.5, -0.4 - 0.12 * s);
+ E(P.q.forearmR, -0.72 - 0.24 * s, 0.12, -0.12);
+ E(P.q.handR, -0.12, 0.06, 0.18);
+
+ E(P.q.clavicleL, 0.02, 0.05, 0.05);
+ E(P.q.upperArmL, -0.58 - 0.18 * s, 0.36 + sweep * 0.3, 0.3);
+ E(P.q.forearmL, -1.06 - 0.16 * s, -0.24, -0.18);
+ E(P.q.handL, -0.1, 0, -0.12);
+}
+
+/**
+ * Poke check: a stab. One hand, the whole arm extending forward with the body
+ * reaching after it, back almost as fast as it went out.
+ */
+export function posePoke(P, { phase = 0 }) {
+ const t = clamp(phase, 0, 1);
+ // Out fast, back slower.
+ const s = t < 0.35 ? t / 0.35 : 1 - (t - 0.35) / 0.65;
+ const jab = clamp(s, 0, 1);
+
+ E(P.q.spine1, 0.06 * jab, -0.14 * jab, 0);
+ E(P.q.spine2, 0.07 * jab, -0.18 * jab, 0);
+ E(P.q.spine3, 0.05 * jab, -0.14 * jab, 0);
+ E(P.q.neck, -0.04 * jab, 0.1 * jab, 0);
+ E(P.q.head, -0.04 * jab, 0.1 * jab, 0);
+
+ // Right arm thrusts out and down toward the ice.
+ E(P.q.clavicleR, 0.02 + 0.06 * jab, -0.06 - 0.16 * jab, -0.05);
+ E(P.q.upperArmR, -0.34 - 0.5 * jab, -0.28 - 0.12 * jab, -0.36 + 0.14 * jab);
+ E(P.q.forearmR, -0.78 + 0.66 * jab, 0.1, -0.12);
+ E(P.q.handR, -0.12, 0.06, 0.18);
+
+ // Left arm comes off the stick and back for balance.
+ E(P.q.clavicleL, 0.02, 0.04, 0.04);
+ E(P.q.upperArmL, -0.5 + 0.2 * jab, 0.28 - 0.2 * jab, 0.34 + 0.16 * jab);
+ E(P.q.forearmL, -0.9 + 0.3 * jab, -0.16, -0.14);
+ E(P.q.handL, -0.1, 0, -0.1);
+}
diff --git a/src/anim/skateAnimator.js b/src/anim/skateAnimator.js
new file mode 100644
index 0000000..cc78a43
--- /dev/null
+++ b/src/anim/skateAnimator.js
@@ -0,0 +1,674 @@
+import * as THREE from 'three';
+import { E, clamp, segDist, smooth } from '../core/math.js';
+import { lerp, lerpAngle } from '../../shared/scalar.js';
+import { poseSkate, poseStop } from './poses/skate.js';
+import {
+ STICK_ARMS, STICK_BONES, STICK_SPINE,
+ poseCarry, posePass, posePoke, poseShot, poseWindup,
+} from './poses/stickwork.js';
+import { STICK } from '../character/stick.js';
+
+/**
+ * Skating animator.
+ *
+ * Same architecture as the Ludus fighter animator — a pose buffer that states
+ * write into, crossfaded on state changes, with two-bone analytic leg IK
+ * resolving world-space foot targets — with two deliberate differences.
+ *
+ * 1. It does not integrate movement. In Ludus the animator owned the fighter's
+ * position; here the sim plus the Box3D proxy own it, and the animator is
+ * told where the body ended up (`setTransform`). Anything else would have
+ * the pose fighting the collision response.
+ *
+ * 2. The feet are authored in *mover-local* space rather than planted in world
+ * space. That is not a shortcut: a walking foot is stationary while it bears
+ * weight, but a skate is gliding the entire time, including through the
+ * push. Planting it would be the thing that made this read as running on
+ * ice, which is exactly the failure mode we are trying to avoid.
+ *
+ * States exist so the next spike can add `shoot` / `stickhandle` and get the
+ * crossfade for free. Today there are two: `skate` and `stop`.
+ */
+
+/** How far the Skill Stick can push the blade around the carrier, metres. */
+const STICK_REACH = { side: 0.5, fwd: 0.34 };
+
+/** Foot joint height above the ice — boot plus blade. */
+const FOOT_SOLE = 0.085;
+
+const STRIDE = {
+ /** Fraction of the cycle the leg spends pushing rather than recovering. */
+ pushFrac: 0.55,
+ /** Half the width of a neutral glide stance, metres. */
+ narrow: 0.105,
+ /** How far out to the side a full push extends the blade. */
+ reachSide: 0.3,
+ /** Fore/aft travel of the blade through a push. */
+ reachFwd: 0.16,
+ reachAft: 0.26,
+ /** Blade clearance on the recovery. Skates barely leave the ice. */
+ lift: 0.07,
+ /** Toe flare — the V a skater's blades make as the leg extends. */
+ toeOut: 0.55,
+ toeGlide: 0.12,
+ /** Stride cycle at a standstill and at top speed, seconds. */
+ cycleSlow: 1.15,
+ cycleFast: 0.6,
+};
+
+export function buildAnimator(skelData, mover) {
+ const B = skelData.bones;
+ const LEN = { thigh: B.shinL.position.length(), shin: B.footL.position.length() };
+ const restThighDir = { L: B.shinL.position.clone().normalize(), R: B.shinR.position.clone().normalize() };
+ const restShinDir = { L: B.footL.position.clone().normalize(), R: B.footR.position.clone().normalize() };
+
+ const UPPER = [
+ 'pelvis', 'spine1', 'spine2', 'spine3', 'neck', 'head',
+ 'clavicleL', 'upperArmL', 'forearmL', 'handL',
+ 'clavicleR', 'upperArmR', 'forearmR', 'handR',
+ ];
+ const LEGS = ['thighL', 'shinL', 'footL', 'toeL', 'thighR', 'shinR', 'footR', 'toeR'];
+
+ function newPose() {
+ const p = {
+ q: {},
+ rootOffset: new THREE.Vector3(),
+ rootQuat: new THREE.Quaternion(),
+ foot: { L: { pos: new THREE.Vector3(), yaw: 0 }, R: { pos: new THREE.Vector3(), yaw: 0 } },
+ };
+ for (const n of UPPER.concat(LEGS)) p.q[n] = new THREE.Quaternion();
+ return p;
+ }
+ const cur = newPose();
+ const frozen = newPose();
+
+ const anim = {
+ state: 'skate',
+ blend: 1,
+ BLEND_TIME: 0.22,
+ transitionTime: 0.22,
+ time: 0,
+ stateTime: 0,
+ /** Playback rate, for slow motion later. */
+ speed: 1,
+
+ // ---- written by the rig each frame, read by the poses -----------------
+ origin: new THREE.Vector3(),
+ originYaw: 0,
+ /** Planar speed, m/s. */
+ moveSpeed: 0,
+ /** Signed speed along the blade — negative means gliding backwards. */
+ bladeSpeed: 0,
+ /** How hard the skater is pushing, 0..1, straight off the sim. */
+ effort: 0,
+ /** Rate the velocity vector is turning, rad/s. Drives the bank. */
+ yawRate: 0,
+ braking: false,
+
+ // ---- derived, smoothed --------------------------------------------------
+ /** Stride amplitude, 0 (pure glide) .. 1 (digging in). */
+ gait: 0,
+ /** Lean into the turn, radians. Signed: positive is turning right. */
+ bank: 0,
+ stridePhase: 0,
+ /** Which shoulder leads a hockey stop; latched when the stop starts. */
+ stopDir: 1,
+ /** Called on each blade bite, for ice spray and audio later. */
+ onStride: null,
+
+ // ---- stickwork ---------------------------------------------------------
+ /** True while this skater has the puck. Decides the resting grip. */
+ hasPuck: false,
+ /** Skill Stick, -1..1. Moves the hands, which moves the blade. */
+ handling: { x: 0, y: 0 },
+ /** Held wind-up charge from the Skill Stick, 0..1. */
+ charge: 0,
+ /** The stick, so the animator can drive its socket and IK onto its shaft. */
+ stick: null,
+ /**
+ * Current stick action: null, 'windup', 'shoot', 'pass' or 'poke'.
+ * Wind-up is held; the other three run once and blend out.
+ */
+ action: null,
+ actionTime: 0,
+ actionPower: 1,
+ actionAim: 0,
+ /** Eased 0..1 between the settled grip and the one-handed dangle. */
+ hustleGrip: 0,
+ };
+
+ /** How long each one-shot action runs, seconds. */
+ const ACTION_TIME = { shoot: 0.42, pass: 0.3, poke: 0.34 };
+ /** Seconds to blend the override in and out over the skating pose. */
+ const ACTION_BLEND = 0.09;
+
+ /** Scratch pose the action layer writes into before being blended over. */
+ const overlay = newPose();
+ const _actionSpine = new THREE.Quaternion();
+
+ const _localFoot = new THREE.Vector3();
+
+ function applyMover() {
+ mover.position.copy(anim.origin);
+ mover.rotation.set(0, anim.originYaw, 0);
+ }
+
+ /** Place the skater. Position and yaw come from the sim, never from here. */
+ anim.setTransform = function setTransform(position, yaw) {
+ anim.origin.copy(position);
+ anim.originYaw = yaw;
+ };
+
+ /**
+ * Mover-local foot target for one leg at cycle position `p`.
+ *
+ * The path is a flattened loop: out and back through the push, then in and
+ * forward through the recovery. Scaling the whole thing by `amp` means a
+ * glide collapses it to a pair of feet sitting under the hips, with no
+ * separate "glide" authoring to keep in sync.
+ */
+ function strideLocal(side, p, amp, out) {
+ const sign = side === 'L' ? 1 : -1;
+ const S = STRIDE;
+ let x;
+ let z;
+ let y;
+ let toe;
+ if (p < S.pushFrac) {
+ const u = smooth(p / S.pushFrac);
+ x = sign * (S.narrow + S.reachSide * amp * u);
+ z = lerp(S.reachFwd * amp, -S.reachAft * amp, u);
+ y = 0;
+ toe = sign * (S.toeGlide + S.toeOut * amp * u);
+ } else {
+ const u = smooth((p - S.pushFrac) / (1 - S.pushFrac));
+ x = sign * lerp(S.narrow + S.reachSide * amp, S.narrow * 0.8, u);
+ z = lerp(-S.reachAft * amp, S.reachFwd * amp, u);
+ y = S.lift * amp * Math.sin(Math.PI * u);
+ toe = sign * lerp(S.toeGlide + S.toeOut * amp, S.toeGlide, u);
+ }
+ out.set(x, FOOT_SOLE + y, z);
+ return toe;
+ }
+
+ /** Local foot placement for a hockey stop: blades thrown across the travel. */
+ function stopLocal(side, dir, bite, out) {
+ const lead = side === 'L' ? 1 : -1;
+ out.set(
+ dir * (0.06 + 0.12 * bite) * (side === 'L' ? 1 : 0.4),
+ FOOT_SOLE,
+ lead * (0.19 + 0.06 * bite),
+ );
+ return dir * (0.3 + 0.9 * bite);
+ }
+
+ const _worldFoot = new THREE.Vector3();
+ /** Write a local foot target into the pose buffer as a world-space target. */
+ function writeFoot(P, side, local, toeYaw) {
+ _worldFoot.copy(local).applyMatrix4(mover.matrixWorld);
+ // The ice is flat, so the sole height authored locally is the world height;
+ // re-pin anyway so a future heightfield only has to change this line.
+ _worldFoot.y = local.y;
+ P.foot[side].pos.copy(_worldFoot);
+ P.foot[side].yaw = anim.originYaw + toeYaw;
+ }
+
+ /**
+ * Advance the derived, smoothed values every state shares.
+ *
+ * Smoothing lives here rather than in the sim because these are presentation
+ * quantities: the sim's `effort` is allowed to change instantly when the AI
+ * changes its mind, but a skater's legs cannot.
+ */
+ function advanceCommon(dt) {
+ // Gait chases effort quickly on the way up (a push starts now) and decays
+ // slowly (the leg finishes its stroke).
+ const target = clamp(anim.effort, 0, 1);
+ const rate = target > anim.gait ? 5.5 : 2.2;
+ anim.gait = lerp(anim.gait, target, Math.min(1, rate * dt));
+
+ // Bank: the lean that balances the centripetal force of the current turn.
+ // atan(v·ω / g) is the real thing, and it behaves correctly at low speed —
+ // spinning on the spot produces no lean, which is what you want.
+ const bankTarget = clamp(
+ Math.atan2(anim.moveSpeed * anim.yawRate, 9.81),
+ -0.45,
+ 0.45,
+ );
+ anim.bank = lerp(anim.bank, bankTarget, Math.min(1, 6 * dt));
+
+ // Stride rate rises with speed; a standing skater shuffles slowly.
+ const fast = clamp(anim.moveSpeed / 7.5, 0, 1);
+ const cycle = lerp(STRIDE.cycleSlow, STRIDE.cycleFast, fast);
+ const before = anim.stridePhase;
+ // Only advance while there is a stride to throw, so a long glide holds the
+ // legs where the last push left them instead of pedalling in mid-air.
+ anim.stridePhase = (anim.stridePhase + (dt / cycle) * Math.max(anim.gait, 0.06)) % 1;
+ // Blade bite: each leg starts its push half a cycle apart.
+ if (anim.onStride) {
+ if (before > anim.stridePhase) anim.onStride('L', anim.moveSpeed);
+ else if (before < 0.5 && anim.stridePhase >= 0.5) anim.onStride('R', anim.moveSpeed);
+ }
+ }
+
+ /**
+ * Fire a one-shot stick action. Wind-up is started and stopped explicitly
+ * instead, because it is held for as long as the stick is pulled back.
+ */
+ anim.playAction = function playAction(name, { power = 1, aim = 0 } = {}) {
+ anim.action = name;
+ anim.actionTime = 0;
+ anim.actionPower = power;
+ anim.actionAim = aim;
+ };
+
+ /**
+ * Advance the stick action clock and write the override pose.
+ *
+ * Returns the blend weight, 0 when nothing is happening. Kept separate from
+ * the states because these are *layers*: a skater keeps striding through a
+ * shot, so the action owns the arms and some spine and nothing else.
+ */
+ function advanceAction(dt) {
+ if (!anim.action) return 0;
+ anim.actionTime += dt;
+
+ if (anim.action === 'windup') {
+ // Held. Blends in over ACTION_BLEND and then stays until released.
+ const w = Math.min(1, anim.actionTime / ACTION_BLEND);
+ poseWindup(overlay, { phase: anim.charge, aim: anim.actionAim });
+ return w;
+ }
+
+ const duration = ACTION_TIME[anim.action] ?? 0.3;
+ const t = anim.actionTime / duration;
+ if (t >= 1) {
+ anim.action = null;
+ return 0;
+ }
+ // Snap in, ease out — a shot should look like it started the instant the
+ // button did, and a slow blend in front of it steals that.
+ const w = t > 1 - ACTION_BLEND / duration
+ ? Math.max(0, (1 - t) * duration / ACTION_BLEND)
+ : Math.min(1, anim.actionTime / (ACTION_BLEND * 0.5));
+
+ const args = { phase: t, power: anim.actionPower, aim: anim.actionAim };
+ if (anim.action === 'shoot') poseShot(overlay, args);
+ else if (anim.action === 'pass') posePass(overlay, args);
+ else posePoke(overlay, args);
+ return w;
+ }
+
+ /** Which socket grip the stick should be using right now, and the blend. */
+ function gripFor() {
+ if (anim.action === 'windup') return ['carry', 'windup', Math.min(1, anim.actionTime / 0.16)];
+ if (anim.action === 'shoot') {
+ const t = anim.actionTime / (ACTION_TIME.shoot);
+ return ['windup', 'follow', Math.min(1, t / 0.45)];
+ }
+ if (anim.action === 'poke') return ['carry', 'poke', Math.min(1, anim.actionTime / 0.1)];
+ if (anim.action === 'pass') return ['carry', 'follow', Math.min(1, anim.actionTime / 0.2) * 0.5];
+ // Resting: hustling pushes the stick out in front on one hand.
+ return ['carry', 'hustle', anim.hustleGrip];
+ }
+
+ const states = {
+ skate: {
+ pre(dt) {
+ advanceCommon(dt);
+ applyMover();
+ mover.updateMatrixWorld(true);
+ },
+ pose(P, t) {
+ poseSkate(P, {
+ gait: anim.gait,
+ speed: anim.moveSpeed,
+ bank: anim.bank,
+ phase: anim.stridePhase,
+ t,
+ });
+ for (const side of ['L', 'R']) {
+ const p = (anim.stridePhase + (side === 'R' ? 0.5 : 0)) % 1;
+ const toe = strideLocal(side, p, anim.gait, _localFoot);
+ writeFoot(P, side, _localFoot, toe);
+ }
+ },
+ },
+
+ stop: {
+ blendTime: 0.12,
+ enter() {
+ // Which way the skater turns to plant depends on which edge is already
+ // loaded, so a stop out of a right-hand turn continues that rotation.
+ anim.stopDir = anim.bank >= 0 ? 1 : -1;
+ },
+ pre(dt) {
+ advanceCommon(dt);
+ applyMover();
+ mover.updateMatrixWorld(true);
+ },
+ pose(P, t) {
+ poseStop(P, { speed: anim.moveSpeed, dir: anim.stopDir, t });
+ const bite = clamp(anim.moveSpeed / 6, 0.25, 1);
+ for (const side of ['L', 'R']) {
+ const toe = stopLocal(side, anim.stopDir, bite, _localFoot);
+ writeFoot(P, side, _localFoot, toe);
+ }
+ },
+ },
+ };
+
+ function snapshot() {
+ for (const n of UPPER.concat(LEGS)) frozen.q[n].copy(B[n].quaternion);
+ frozen.rootOffset.copy(B.root.position);
+ frozen.rootQuat.copy(B.root.quaternion);
+ frozen.foot.L.pos.copy(cur.foot.L.pos);
+ frozen.foot.L.yaw = cur.foot.L.yaw;
+ frozen.foot.R.pos.copy(cur.foot.R.pos);
+ frozen.foot.R.yaw = cur.foot.R.yaw;
+ }
+
+ const _footWorld = new THREE.Vector3();
+
+ /**
+ * Restart the crossfade from whatever pose the skeleton is currently in.
+ *
+ * Used when physics hands the skeleton back after a knockdown: the bones are
+ * wherever the ragdoll left them, and the animator would otherwise snap to a
+ * skating pose on the next frame. Snapshotting the collapsed pose and easing
+ * out of it is the get-up.
+ */
+ anim.rebase = function rebase(blendTime = 0.6) {
+ snapshot();
+ // `snapshot` takes the foot targets from `cur`, which for a skater who has
+ // been lying on the ice still holds wherever their blades were before the
+ // hit. Blending the IK out of a stale target drags the legs across the rink
+ // to catch up. Read the feet where they actually are instead.
+ mover.updateMatrixWorld(true);
+ for (const side of ['L', 'R']) {
+ B[`foot${side}`].getWorldPosition(_footWorld);
+ frozen.foot[side].pos.copy(_footWorld);
+ frozen.foot[side].yaw = anim.originYaw;
+ }
+ anim.blend = 0;
+ anim.transitionTime = Math.max(0.05, blendTime);
+ // The feet are wherever the body fell, not where the last stride put them,
+ // so start the stride cycle from a planted stance rather than mid-push.
+ anim.stridePhase = 0;
+ anim.gait = 0;
+ anim.bank = 0;
+ };
+
+ anim.setState = function setState(name, blendTime = null) {
+ if (name === anim.state || !states[name]) return;
+ snapshot();
+ anim.state = name;
+ anim.stateTime = 0;
+ anim.blend = 0;
+ anim.transitionTime = blendTime ?? states[name].blendTime ?? anim.BLEND_TIME;
+ if (states[name].enter) states[name].enter();
+ };
+
+ // ---- two-bone analytic IK ------------------------------------------------
+ // Lifted from Ludus unchanged. The knee pole is the one skating-specific
+ // detail: it points forward and *outward*, because a skater's knees track
+ // over the outside of the blade rather than straight ahead.
+ const _H = new THREE.Vector3();
+ const _d = new THREE.Vector3();
+ const _pole = new THREE.Vector3();
+ const _e2 = new THREE.Vector3();
+ const _knee = new THREE.Vector3();
+ const _dir = new THREE.Vector3();
+ const _f = new THREE.Vector3();
+ const _r = new THREE.Vector3();
+ const _qP = new THREE.Quaternion();
+ const _q1 = new THREE.Quaternion();
+ const _q2 = new THREE.Quaternion();
+ const _qF = new THREE.Quaternion();
+ const _qInv = new THREE.Quaternion();
+
+ const fwdOf = (yaw, out) => out.set(Math.sin(yaw), 0, Math.cos(yaw));
+ const rightOf = (yaw, out) => out.set(Math.cos(yaw), 0, -Math.sin(yaw));
+
+ function solveLeg(side, targetPos, targetYaw) {
+ const thigh = B['thigh' + side];
+ const shin = B['shin' + side];
+ const foot = B['foot' + side];
+ thigh.getWorldPosition(_H);
+ _d.subVectors(targetPos, _H);
+ let d = _d.length();
+ const a = LEN.thigh;
+ const b = LEN.shin;
+ d = clamp(d, 0.12, a + b - 0.003);
+ _d.normalize();
+ // Cosine rule for the angle between the thigh axis and the hip->target line.
+ const cosA = clamp((a * a + d * d - b * b) / (2 * a * d), -1, 1);
+ const sinA = Math.sqrt(Math.max(0, 1 - cosA * cosA));
+ fwdOf(anim.originYaw, _f);
+ rightOf(anim.originYaw, _r);
+ _pole.copy(_f).addScaledVector(_r, side === 'L' ? 0.34 : -0.34);
+ _pole.y -= 0.2;
+ _e2.copy(_pole).addScaledVector(_d, -_pole.dot(_d));
+ if (_e2.lengthSq() < 1e-8) _e2.copy(_f);
+ _e2.normalize();
+ _knee.copy(_H).addScaledVector(_d, a * cosA).addScaledVector(_e2, a * sinA);
+
+ _dir.subVectors(_knee, _H).normalize();
+ _q1.setFromUnitVectors(restThighDir[side], _dir);
+ thigh.parent.getWorldQuaternion(_qP);
+ _qInv.copy(_qP).invert();
+ thigh.quaternion.copy(_qInv).multiply(_q1);
+
+ _dir.subVectors(targetPos, _knee).normalize();
+ _q2.setFromUnitVectors(restShinDir[side], _dir);
+ _qInv.copy(_q1).invert();
+ shin.quaternion.copy(_qInv).multiply(_q2);
+
+ E(_qF, 0, targetYaw, 0, 'YXZ');
+ _qInv.copy(_q2).invert();
+ foot.quaternion.copy(_qInv).multiply(_qF);
+ B['toe' + side].quaternion.identity();
+ }
+
+ // ---- two-bone arm IK ----------------------------------------------------
+ // Same solver as the legs, different pole. Used only to pin the lower hand
+ // onto the shaft: a two-handed grip where the second hand merely hovers near
+ // the stick is worse than not showing it at all, and no amount of authored
+ // shoulder angle keeps a hand on a pole that the other arm is swinging.
+ const ARM = {
+ upper: B.forearmL.position.length(),
+ fore: B.handL.position.length(),
+ };
+ const restUpperArmDir = {
+ L: B.forearmL.position.clone().normalize(),
+ R: B.forearmR.position.clone().normalize(),
+ };
+ const restForearmDir = {
+ L: B.handL.position.clone().normalize(),
+ R: B.handR.position.clone().normalize(),
+ };
+
+ function solveArm(side, targetPos) {
+ const upper = B[`upperArm${side}`];
+ const fore = B[`forearm${side}`];
+ upper.getWorldPosition(_H);
+ _d.subVectors(targetPos, _H);
+ let d = _d.length();
+ const a = ARM.upper;
+ const b = ARM.fore;
+ // Never fully lock the elbow — a straight arm reads as a mannequin.
+ d = clamp(d, 0.12, a + b - 0.02);
+ _d.normalize();
+ const cosA = clamp((a * a + d * d - b * b) / (2 * a * d), -1, 1);
+ const sinA = Math.sqrt(Math.max(0, 1 - cosA * cosA));
+
+ // Elbow hangs below the shoulder and a little outside the ribs.
+ rightOf(anim.originYaw, _r);
+ _pole.set(0, -1, 0).addScaledVector(_r, side === 'L' ? 0.34 : -0.34);
+ _e2.copy(_pole).addScaledVector(_d, -_pole.dot(_d));
+ if (_e2.lengthSq() < 1e-8) _e2.set(0, -1, 0);
+ _e2.normalize();
+ _knee.copy(_H).addScaledVector(_d, a * cosA).addScaledVector(_e2, a * sinA);
+
+ _dir.subVectors(_knee, _H).normalize();
+ _q1.setFromUnitVectors(restUpperArmDir[side], _dir);
+ upper.parent.getWorldQuaternion(_qP);
+ _qInv.copy(_qP).invert();
+ upper.quaternion.copy(_qInv).multiply(_q1);
+
+ _dir.subVectors(targetPos, _knee).normalize();
+ _q2.setFromUnitVectors(restForearmDir[side], _dir);
+ _qInv.copy(_q1).invert();
+ fore.quaternion.copy(_qInv).multiply(_q2);
+ }
+
+ // ---- per-frame update ---------------------------------------------------
+ const _blendFoot = new THREE.Vector3();
+ const _shaftPoint = new THREE.Vector3();
+ const _stickTarget = new THREE.Vector3();
+ const _shaftA = new THREE.Vector3();
+ const _shaftB = new THREE.Vector3();
+ const _shaftDir = new THREE.Vector3();
+ const _handPos = new THREE.Vector3();
+ const _handQuat = new THREE.Quaternion();
+
+ anim.update = function update(dt) {
+ dt *= anim.speed;
+ anim.time += dt;
+ anim.stateTime += dt;
+ anim.blend = Math.min(1, anim.blend + dt / anim.transitionTime);
+
+ // A hockey stop is worth its own state; everything else is one pose driven
+ // by continuous parameters.
+ anim.setState(anim.braking && anim.moveSpeed > 1.2 ? 'stop' : 'skate');
+
+ const st = states[anim.state];
+ if (st.pre) st.pre(dt);
+ for (const n of UPPER) cur.q[n].identity();
+ cur.rootOffset.set(0, 0, 0);
+ cur.rootQuat.identity();
+ st.pose(cur, anim.stateTime);
+
+ // ---- stickwork layer ---------------------------------------------------
+ // The resting grip: hustling pushes the stick out in front on one hand,
+ // and it eases rather than switching, so half-throttle is half-dangled.
+ // With the puck, both hands stay on — the reference carry is two-handed
+ // even at speed; only a real one-handed dangle (no puck) opens the grip.
+ const hustleTarget = anim.state === 'skate' && !anim.hasPuck
+ ? clamp(anim.effort * 0.6 + clamp(anim.moveSpeed / 7, 0, 1) * 0.6, 0, 1)
+ : anim.hasPuck
+ ? clamp(anim.effort * 0.08, 0, 0.2)
+ : clamp(anim.effort * 0.25, 0, 1);
+ anim.hustleGrip = lerp(anim.hustleGrip, hustleTarget, Math.min(1, 4 * dt));
+
+ // Carry pose first — the arms holding the stick at all — then any action
+ // over the top of it.
+ //
+ // Arms are replaced and spine is *multiplied*. The spine already carries
+ // the skating lean and the bank; a shot's coil is a twist on top of that.
+ // Overwriting it was what stood everybody upright the moment they picked up
+ // a stick.
+ for (const n of STICK_BONES) overlay.q[n].identity();
+ poseCarry(overlay, {
+ hustle: anim.hustleGrip,
+ reach: anim.handling.y,
+ lateral: anim.handling.x,
+ });
+ for (const n of STICK_ARMS) cur.q[n].copy(overlay.q[n]);
+ for (const n of STICK_SPINE) cur.q[n].multiply(overlay.q[n]);
+
+ for (const n of STICK_BONES) overlay.q[n].identity();
+ const actionWeight = advanceAction(dt);
+ if (actionWeight > 0.001) {
+ for (const n of STICK_ARMS) cur.q[n].slerp(overlay.q[n], actionWeight);
+ for (const n of STICK_SPINE) {
+ _actionSpine.identity().slerp(overlay.q[n], actionWeight);
+ cur.q[n].multiply(_actionSpine);
+ }
+ }
+
+ const w = smooth(anim.blend);
+ for (const n of UPPER) B[n].quaternion.slerpQuaternions(frozen.q[n], cur.q[n], w);
+ B.root.position.lerpVectors(frozen.rootOffset, cur.rootOffset, w);
+ B.root.quaternion.slerpQuaternions(frozen.rootQuat, cur.rootQuat, w);
+
+ // Feet are solved after the spine is posed and the matrices refreshed, or
+ // the hip the IK measures from is a frame stale and the legs trail.
+ mover.updateMatrixWorld(true);
+ _blendFoot.lerpVectors(frozen.foot.L.pos, cur.foot.L.pos, w);
+ solveLeg('L', _blendFoot, lerpAngle(frozen.foot.L.yaw, cur.foot.L.yaw, w));
+ _blendFoot.lerpVectors(frozen.foot.R.pos, cur.foot.R.pos, w);
+ solveLeg('R', _blendFoot, lerpAngle(frozen.foot.R.yaw, cur.foot.R.yaw, w));
+ mover.updateMatrixWorld(true);
+
+ // ---- the stick, last ---------------------------------------------------
+ // Socket first, because it hangs off the right hand and the arm has only
+ // just been posed. Then the lower hand is pulled onto the shaft, which
+ // needs the stick already placed — hence the second matrix refresh.
+ if (anim.stick) {
+ const [from, to, t] = gripFor();
+ const roll = anim.stick.stanceTarget(from, to, t, _stickTarget);
+ // Stickhandling moves the *target*, not just the arm pose. Nudging only
+ // the shoulders moved the blade by centimetres; the puck follows the
+ // blade now, so the Skill Stick has to move the blade to mean anything.
+ //
+ // Lateral is *subtracted*: skater local +X is the left side, but the Skill
+ // Stick's +X is "push right". Adding them lined the deke up mirrored —
+ // stick right sent the puck to the skater's left.
+ if (anim.hasPuck) {
+ _stickTarget.x -= anim.handling.x * STICK_REACH.side;
+ _stickTarget.z += anim.handling.y * STICK_REACH.fwd;
+ }
+ _stickTarget.applyMatrix4(mover.matrixWorld);
+ B.handR.getWorldPosition(_handPos);
+ B.handR.getWorldQuaternion(_handQuat);
+ _handQuat.invert();
+ anim.stick.aimAt(_stickTarget, _handPos, _handQuat, roll);
+ mover.updateMatrixWorld(true);
+
+ // Two hands on it whenever the stick is being used for something, and
+ // not while it is being dangled out on one.
+ const twoHanded = (1 - anim.hustleGrip) * (anim.action === 'poke' ? 0.15 : 1);
+ if (twoHanded > 0.05) {
+ // Preferred lower-hand grip is a bit down the shaft (hands apart, the
+ // way the reference draws a carry). If that point is past the arm's
+ // reach, slide up toward the butt until it is — never leave the hand
+ // waving short of the stick, and never stack both hands on the butt.
+ anim.stick.shaftSegment(_shaftA, _shaftB);
+ B.upperArmL.getWorldPosition(_H);
+ _shaftDir.subVectors(_shaftB, _shaftA);
+ const len = _shaftDir.length() || 1;
+ const armReach = ARM.upper + ARM.fore - 0.03;
+ // ~quarter of the way down when we can; closer when we must.
+ let gripT = 0.28;
+ _shaftPoint.copy(_shaftA).addScaledVector(_shaftDir, gripT);
+ if (_H.distanceTo(_shaftPoint) > armReach) {
+ gripT = 0.28;
+ while (gripT > 0.12) {
+ _shaftPoint.copy(_shaftA).addScaledVector(_shaftDir, gripT);
+ if (_H.distanceTo(_shaftPoint) <= armReach) break;
+ gripT -= 0.02;
+ }
+ // Last resort: nearest point on the reachable band of the shaft.
+ if (_H.distanceTo(_shaftPoint) > armReach) {
+ segDist(_H, _shaftA, _shaftB, _shaftPoint);
+ const tNear = clamp(
+ _shaftPoint.clone().sub(_shaftA).dot(_shaftDir) / (len * len),
+ 0.12,
+ 0.55,
+ );
+ gripT = tNear;
+ _shaftPoint.copy(_shaftA).addScaledVector(_shaftDir, gripT);
+ }
+ }
+ solveArm('L', _shaftPoint);
+ mover.updateMatrixWorld(true);
+ }
+ }
+ };
+
+ anim.states = states;
+ anim.stateNames = Object.keys(states);
+ applyMover();
+ return anim;
+}
diff --git a/src/character/body.js b/src/character/body.js
new file mode 100644
index 0000000..1f28fe9
--- /dev/null
+++ b/src/character/body.js
@@ -0,0 +1,245 @@
+import * as THREE from 'three';
+import { physiqueFromBodyStyle } from '../../shared/bodyStyle.js';
+import { FWD, V3, clamp, lerp, mergeGeoms, smooth } from '../core/math.js';
+
+export const PART = { TORSO: 0, HEAD: 1, ARM_L: 2, ARM_R: 3, LEG_L: 4, LEG_R: 5 };
+
+const _t1 = new THREE.Vector3();
+const _t2 = new THREE.Vector3();
+const _t3 = new THREE.Vector3();
+
+/**
+ * Loft a tube along keyframed rings.
+ * keys: [{ t, c: Vector3, rx, rz }] — cross-section radii along the ring basis
+ * u/w, which is derived from the path tangent. `shape` harmonics deform the
+ * silhouette so no two seeds share a profile.
+ */
+export function loftPart(keys, ringCount, radial, partId, shape) {
+ const rings = [];
+ for (let i = 0; i < ringCount; i++) {
+ const t = i / (ringCount - 1);
+ let k = 0;
+ while (k < keys.length - 2 && keys[k + 1].t < t) k++;
+ const a = keys[k];
+ const b = keys[k + 1];
+ const ft = smooth(clamp((t - a.t) / Math.max(1e-6, b.t - a.t), 0, 1));
+ rings.push({ t, c: a.c.clone().lerp(b.c, ft), rx: lerp(a.rx, b.rx, ft), rz: lerp(a.rz, b.rz, ft) });
+ }
+ for (let i = 0; i < ringCount; i++) {
+ const p0 = rings[Math.max(0, i - 1)].c;
+ const p1 = rings[Math.min(ringCount - 1, i + 1)].c;
+ const tan = _t1.subVectors(p1, p0).normalize();
+ let u = _t2.crossVectors(tan, FWD);
+ if (u.lengthSq() < 1e-6) u = _t2.set(1, 0, 0);
+ else u.normalize();
+ const w = _t3.crossVectors(tan, u).normalize();
+ rings[i].u = u.clone();
+ rings[i].w = w.clone();
+ }
+
+ const pos = [], uv = [], aPart = [], aT = [], idx = [];
+ const cols = radial + 1;
+ for (let i = 0; i < ringCount; i++) {
+ const r = rings[i];
+ for (let j = 0; j <= radial; j++) {
+ const th = (j / radial) * Math.PI * 2;
+ const ct = Math.cos(th);
+ const st = Math.sin(th);
+ let sh = 1;
+ if (shape) sh += shape.a1 * Math.cos(2 * th + shape.p1) + shape.a2 * Math.cos(3 * th + shape.p2);
+ const px = r.rx * ct * sh;
+ const pz = r.rz * st * sh;
+ pos.push(
+ r.c.x + r.u.x * px + r.w.x * pz,
+ r.c.y + r.u.y * px + r.w.y * pz,
+ r.c.z + r.u.z * px + r.w.z * pz,
+ );
+ uv.push(j / radial, r.t);
+ aPart.push(partId);
+ aT.push(r.t);
+ }
+ }
+ for (let i = 0; i < ringCount - 1; i++) {
+ for (let j = 0; j < radial; j++) {
+ const a = i * cols + j;
+ const b = a + cols;
+ idx.push(a, a + 1, b, b, a + 1, b + 1);
+ }
+ }
+ const cap = (ringIdx, flip) => {
+ const r = rings[ringIdx];
+ const ci = pos.length / 3;
+ pos.push(r.c.x, r.c.y, r.c.z);
+ uv.push(0.5, r.t);
+ aPart.push(partId);
+ aT.push(r.t);
+ for (let j = 0; j < radial; j++) {
+ const a = ringIdx * cols + j;
+ const b = ringIdx * cols + j + 1;
+ if (flip) idx.push(ci, b, a);
+ else idx.push(ci, a, b);
+ }
+ };
+ cap(0, true);
+ cap(ringCount - 1, false);
+
+ const g = new THREE.BufferGeometry();
+ g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
+ g.setAttribute('uv', new THREE.Float32BufferAttribute(uv, 2));
+ g.setAttribute('aPart', new THREE.Float32BufferAttribute(aPart, 1));
+ g.setAttribute('aT', new THREE.Float32BufferAttribute(aT, 1));
+ g.setIndex(idx);
+ g.computeVertexNormals();
+ return g;
+}
+
+/**
+ * Full body geometry for one fighter. `build` carries the physique parameters
+ * so they can be reported to the physics layer: reach, centre of mass and limb
+ * mass all follow from the same numbers that shaped the mesh (GDD 8).
+ *
+ * @param {*} rng seeded RNG (small natural jitter)
+ * @param {{ mass?: number, muscle?: number, fat?: number } | null} [bodyStyle]
+ * loadout body sliders (dreamfall-style mass / muscle / fat)
+ */
+export function buildBodyGeometry(rng, bodyStyle = null) {
+ const phy = physiqueFromBodyStyle(bodyStyle, rng);
+ const { bulk, waistF, shoulderF, headF, armF, legF } = phy;
+ const parts = [];
+
+ // Girdle half-width: follows physique, but floors so extreme lean never
+ // collapses the clavicle to a point the deltoid cannot meet.
+ const girdleRx = Math.max(0.105, 0.176 * bulk * shoulderF);
+ const collarRx = Math.max(0.092, 0.15 * bulk * shoulderF);
+
+ const tKeys = [
+ { t: 0.0, c: V3(0, 0.885, 0.002), rx: 0.15 * bulk, rz: 0.1 * bulk },
+ { t: 0.08, c: V3(0, 0.935, 0.004), rx: 0.172 * bulk, rz: 0.118 * bulk },
+ { t: 0.18, c: V3(0, 1.0, 0.005), rx: 0.164 * bulk, rz: 0.108 * bulk },
+ { t: 0.32, c: V3(0, 1.075, 0.004), rx: 0.15 * bulk * waistF, rz: 0.1 * bulk * waistF },
+ { t: 0.48, c: V3(0, 1.165, 0.006), rx: 0.156 * bulk, rz: 0.104 * bulk },
+ { t: 0.62, c: V3(0, 1.255, 0.008), rx: 0.168 * bulk, rz: 0.116 * bulk },
+ { t: 0.76, c: V3(0, 1.335, 0.009), rx: girdleRx, rz: 0.12 * bulk },
+ { t: 0.88, c: V3(0, 1.405, 0.01), rx: collarRx, rz: 0.105 * bulk },
+ { t: 0.95, c: V3(0, 1.445, 0.012), rx: 0.078 * bulk, rz: 0.072 * bulk },
+ { t: 1.0, c: V3(0, 1.475, 0.013), rx: 0.058 * bulk, rz: 0.056 * bulk },
+ ];
+ parts.push(
+ loftPart(tKeys, 36, 24, PART.TORSO, {
+ a1: rng.range(-0.03, 0.03), p1: rng.range(0, 6.28),
+ a2: rng.range(-0.02, 0.02), p2: rng.range(0, 6.28),
+ }),
+ );
+
+ const hKeys = [
+ { t: 0.0, c: V3(0, 1.425, 0.012), rx: 0.056, rz: 0.058 },
+ { t: 0.14, c: V3(0, 1.47, 0.014), rx: 0.06 * headF, rz: 0.064 * headF },
+ { t: 0.3, c: V3(0, 1.52, 0.02), rx: 0.074 * headF, rz: 0.08 * headF },
+ { t: 0.48, c: V3(0, 1.575, 0.026), rx: 0.088 * headF, rz: 0.094 * headF },
+ { t: 0.64, c: V3(0, 1.625, 0.024), rx: 0.094 * headF, rz: 0.1 * headF },
+ { t: 0.8, c: V3(0, 1.668, 0.016), rx: 0.084 * headF, rz: 0.088 * headF },
+ { t: 0.92, c: V3(0, 1.7, 0.01), rx: 0.052 * headF, rz: 0.054 * headF },
+ { t: 1.0, c: V3(0, 1.716, 0.008), rx: 0.012, rz: 0.012 },
+ ];
+ parts.push(loftPart(hKeys, 24, 20, PART.HEAD, { a1: rng.range(-0.02, 0.02), p1: rng.range(0, 6.28), a2: 0, p2: 0 }));
+
+ // ---- Arms + spherical shoulder sockets ---------------------------------
+ //
+ // Extreme skinny (mass/muscle floors) used to leave a hole between a thin
+ // torso and a fixed arm root at x=0.15 — the "spike" sockets in the kit
+ // preview. Rebuild the deltoid as a sphere that always spans from the
+ // clavicle root (inside the torso half-width) out to the upper-arm shaft.
+ //
+ // shoulderHalf matches the torso girdle ring (same floor as girdleRx).
+ const shoulderHalf = girdleRx;
+ // Deltoid boulder radius: floors hard so lean builds still have a round
+ // joint; grows with bulk/arm muscle for heavy / cut.
+ const deltoidR = Math.max(
+ 0.064,
+ 0.072 * Math.sqrt(Math.max(bulk, 0.55)) * (0.72 + 0.38 * Math.min(armF, 1.45)),
+ );
+ // Clavicle / socket layout in the coronal plane (absolute X later mirrored).
+ const clavY = 1.402;
+ const clavZ = 0.008;
+ // Root sits inside the torso so the sphere always meets clavicle + neck.
+ const clavRootX = Math.max(0.038, shoulderHalf * 0.42);
+ // Sphere centre sits on the torso shoulder edge.
+ const socketX = Math.max(shoulderHalf * 0.92, clavRootX + deltoidR * 0.55);
+ // Outer deltoid / upper-arm takeoff — past the boulder equator.
+ const armRootX = socketX + deltoidR * 0.72;
+
+ for (const s of [1, -1]) {
+ const partId = s > 0 ? PART.ARM_L : PART.ARM_R;
+ const P = (x, y, z) => V3(s * x, y, z);
+ // Near-equal rx/rz + short arc through one centre ⇒ spherical deltoid.
+ // Mild shape harmonics only on the shaft so the boulder stays round.
+ const aKeys = [
+ // Clavicle root — buried in the torso, always connected.
+ { t: 0.0, c: P(clavRootX, clavY + 0.012, clavZ + 0.004), rx: deltoidR * 0.92, rz: deltoidR * 0.88 },
+ // Inner hemisphere (toward neck / traps).
+ { t: 0.05, c: P(socketX * 0.78, clavY + 0.006, clavZ), rx: deltoidR * 1.02, rz: deltoidR * 0.98 },
+ // Deltoid equator — the shoulder boulder.
+ { t: 0.11, c: P(socketX, clavY, clavZ), rx: deltoidR, rz: deltoidR },
+ // Outer hemisphere → upper-arm takeoff.
+ { t: 0.18, c: P(armRootX, clavY - 0.012, clavZ + 0.002), rx: deltoidR * 0.86, rz: deltoidR * 0.82 },
+ // Upper arm shaft (path kept close to the original A-pose reach).
+ { t: 0.28, c: P(Math.max(0.28, armRootX + 0.06), 1.30, 0.008), rx: 0.056 * armF, rz: 0.052 * armF },
+ { t: 0.40, c: P(0.355, 1.16, 0.01), rx: 0.048 * armF, rz: 0.044 * armF },
+ { t: 0.50, c: P(0.392, 1.098, 0.011), rx: 0.041 * armF, rz: 0.039 * armF },
+ { t: 0.66, c: P(0.445, 0.985, 0.014), rx: 0.045 * armF, rz: 0.042 * armF },
+ { t: 0.78, c: P(0.48, 0.905, 0.017), rx: 0.035 * armF, rz: 0.032 * armF },
+ { t: 0.86, c: P(0.5, 0.855, 0.02), rx: 0.038, rz: 0.026 },
+ { t: 0.95, c: P(0.52, 0.805, 0.024), rx: 0.034, rz: 0.02 },
+ { t: 1.0, c: P(0.53, 0.778, 0.026), rx: 0.012, rz: 0.01 },
+ ];
+ parts.push(
+ // Extra rings through the deltoid so the sphere reads smooth, not faceted.
+ loftPart(aKeys, 32, 20, partId, {
+ a1: rng.range(-0.02, 0.02), p1: rng.range(0, 6.28),
+ a2: rng.range(-0.01, 0.01), p2: rng.range(0, 6.28),
+ }),
+ );
+ }
+
+ for (const s of [1, -1]) {
+ const partId = s > 0 ? PART.LEG_L : PART.LEG_R;
+ const P = (x, y, z) => V3(s * x, y, z);
+ const lKeys = [
+ { t: 0.0, c: P(0.088, 1.02, 0.004), rx: 0.108 * bulk, rz: 0.102 * bulk },
+ { t: 0.1, c: P(0.112, 0.93, 0.006), rx: 0.104 * legF, rz: 0.098 * legF },
+ { t: 0.28, c: P(0.125, 0.76, 0.008), rx: 0.088 * legF, rz: 0.084 * legF },
+ { t: 0.44, c: P(0.13, 0.6, 0.009), rx: 0.068 * legF, rz: 0.064 * legF },
+ { t: 0.52, c: P(0.13, 0.512, 0.008), rx: 0.058 * legF, rz: 0.056 * legF },
+ { t: 0.64, c: P(0.132, 0.38, 0.006), rx: 0.064 * legF, rz: 0.06 * legF },
+ { t: 0.78, c: P(0.133, 0.22, 0.002), rx: 0.05 * legF, rz: 0.046 * legF },
+ { t: 0.86, c: P(0.132, 0.11, -0.004), rx: 0.042, rz: 0.038 },
+ { t: 0.92, c: P(0.13, 0.062, 0.03), rx: 0.044, rz: 0.034 },
+ { t: 0.97, c: P(0.128, 0.04, 0.095), rx: 0.042, rz: 0.028 },
+ { t: 1.0, c: P(0.126, 0.032, 0.155), rx: 0.02, rz: 0.014 },
+ ];
+ parts.push(
+ loftPart(lKeys, 30, 18, partId, {
+ a1: rng.range(-0.03, 0.03), p1: rng.range(0, 6.28),
+ a2: rng.range(-0.015, 0.015), p2: rng.range(0, 6.28),
+ }),
+ );
+ }
+
+ const merged = mergeGeoms(parts);
+ merged.computeVertexNormals();
+ merged.userData.physique = { bulk, waistF, shoulderF, headF, armF, legF, style: phy.style };
+ return merged;
+}
+
+export function buildBodyMesh(geo, skelData, materials) {
+ const mesh = new THREE.SkinnedMesh(geo, materials.skin);
+ mesh.castShadow = true;
+ mesh.receiveShadow = true;
+ mesh.frustumCulled = false;
+ mesh.add(skelData.bones.root);
+ mesh.updateMatrixWorld(true);
+ mesh.bind(skelData.skeleton, mesh.matrixWorld.clone());
+ mesh.userData.heatMat = new THREE.MeshBasicMaterial({ vertexColors: true });
+ mesh.userData.origMat = materials.skin;
+ return mesh;
+}
diff --git a/src/character/gearMesh.js b/src/character/gearMesh.js
new file mode 100644
index 0000000..f9ade2e
--- /dev/null
+++ b/src/character/gearMesh.js
@@ -0,0 +1,382 @@
+import * as THREE from 'three';
+import { clamp, lerp, mergeGeoms, smooth, stripAttrs } from '../core/math.js';
+
+/**
+ * Geometry toolkit for equipment.
+ *
+ * Gear used to be stacks of BoxGeometry, which reads as a pile of crates the
+ * moment the camera gets close. Three builders replace that:
+ *
+ * loft() — one continuous skinned-looking tube through keyed
+ * cross-sections. Pads, gloves, chest, paddle.
+ * carvedShell() — a hand-indexed lat/long shell with real wall thickness and
+ * a hole cut through it. The goalie mask.
+ * tube() — a swept bar along a curve. Cage bars, rims, straps.
+ *
+ * Everything comes back as a plain indexed BufferGeometry in the local space of
+ * whatever bone it will hang off, so the caller only ever sets a position.
+ */
+
+const _u = new THREE.Vector3();
+const _w = new THREE.Vector3();
+const _tan = new THREE.Vector3();
+const _a = new THREE.Vector3();
+const _b = new THREE.Vector3();
+const _n = new THREE.Vector3();
+const _d = new THREE.Vector3();
+const REF_X = new THREE.Vector3(1, 0, 0);
+const WHITE = new THREE.Color(1, 1, 1);
+
+/**
+ * Superellipse profile point on the unit section.
+ *
+ * `e` = 2 is an ellipse; larger values square it off. Pads and blocker boards
+ * are rounded rectangles in cross-section, not ovals — that edge is most of
+ * what makes a pad read as a pad.
+ */
+function profile(theta, e) {
+ const c = Math.cos(theta);
+ const s = Math.sin(theta);
+ if (e === 2) return [c, s];
+ const k = 2 / e;
+ return [Math.sign(c) * Math.abs(c) ** k, Math.sign(s) * Math.abs(s) ** k];
+}
+
+/**
+ * Loft a closed tube through keyed cross-sections.
+ *
+ * Sections are `{ c: Vector3, rx, rz, e?, col? }`:
+ * c — centre of the ring on the path
+ * rx — half-width along the ring's `u` axis (world X for a straight run)
+ * rz — half-depth along `w` (the path's forward side)
+ * e — superellipse exponent, 2 = oval … 8 = nearly a box
+ * col — vertex colour from this ring on; interpolates to the next
+ *
+ * The path is a Catmull-Rom through the section centres so a bend (a pad's toe
+ * kick, a thumb) curves instead of creasing. Radii ease with smoothstep, which
+ * means two sections at the same centre give a hard step — that is how the
+ * stripes and the boot break are cut.
+ *
+ * `part` / `t0` / `t1` write the `aPart` and `aT` attributes the skinning solver
+ * reads. Cloth — a jersey, a pant leg, a sock — has to bend at the joints it
+ * crosses, so it is skinned to the skeleton rather than bolted to one bone, and
+ * those two attributes are what keep the left sleeve off the right arm.
+ */
+export function loft(sections, {
+ radial = 20,
+ sub = 5,
+ ref = REF_X,
+ capStart = true,
+ capEnd = true,
+ tension = 0.5,
+ part = null,
+ t0 = 0,
+ t1 = 1,
+} = {}) {
+ const n = sections.length;
+ if (n < 2) throw new Error('loft needs at least two sections');
+
+ // Fill colours forward then backward so a single tinted section paints the
+ // whole run up to the next one.
+ const cols = sections.map((s) => s.col ?? null);
+ const painted = cols.some(Boolean);
+ if (painted) {
+ for (let i = 1; i < n; i++) if (!cols[i]) cols[i] = cols[i - 1];
+ for (let i = n - 2; i >= 0; i--) if (!cols[i]) cols[i] = cols[i + 1];
+ }
+
+ const curve = new THREE.CatmullRomCurve3(
+ sections.map((s) => s.c.clone()),
+ false,
+ 'catmullrom',
+ tension,
+ );
+
+ const rings = Math.max(2, (n - 1) * sub);
+ const pos = [];
+ const uv = [];
+ const col = [];
+ const aPart = [];
+ const aT = [];
+ const idx = [];
+ const stride = radial + 1; // seam column duplicated so UVs stay sane
+ const centres = [];
+
+ for (let r = 0; r <= rings; r++) {
+ const t = r / rings;
+ const p = (n - 1) * t;
+ const i0 = Math.min(n - 2, Math.floor(p));
+ const f = smooth(clamp(p - i0, 0, 1));
+ const s0 = sections[i0];
+ const s1 = sections[i0 + 1];
+
+ const c = curve.getPoint(t);
+ _tan.copy(curve.getTangent(t)).normalize();
+ _w.crossVectors(_tan, ref);
+ if (_w.lengthSq() < 1e-10) _w.set(0, 0, 1);
+ _w.normalize();
+ _u.crossVectors(_w, _tan).normalize();
+
+ const rx = lerp(s0.rx, s1.rx, f);
+ const rz = lerp(s0.rz, s1.rz, f);
+ const e = lerp(s0.e ?? 2, s1.e ?? 2, f);
+ const tint = painted ? new THREE.Color().lerpColors(cols[i0], cols[i0 + 1], f) : null;
+ centres.push({ c: c.clone(), t });
+
+ for (let j = 0; j <= radial; j++) {
+ const [px, pz] = profile((j / radial) * Math.PI * 2, e);
+ pos.push(
+ c.x + _u.x * px * rx + _w.x * pz * rz,
+ c.y + _u.y * px * rx + _w.y * pz * rz,
+ c.z + _u.z * px * rx + _w.z * pz * rz,
+ );
+ uv.push(j / radial, t);
+ if (painted) col.push(tint.r, tint.g, tint.b);
+ if (part != null) {
+ aPart.push(part);
+ aT.push(t0 + (t1 - t0) * t);
+ }
+ }
+ }
+
+ for (let i = 0; i < rings; i++) {
+ for (let j = 0; j < radial; j++) {
+ const a = i * stride + j;
+ const b = a + stride;
+ idx.push(a, a + 1, b, b, a + 1, b + 1);
+ }
+ }
+
+ const cap = (ring, flip) => {
+ const { c, t } = centres[ring];
+ const ci = pos.length / 3;
+ pos.push(c.x, c.y, c.z);
+ uv.push(0.5, t);
+ if (painted) {
+ const base = (ring * stride) * 3;
+ col.push(col[base], col[base + 1], col[base + 2]);
+ }
+ if (part != null) {
+ aPart.push(part);
+ aT.push(t0 + (t1 - t0) * t);
+ }
+ for (let j = 0; j < radial; j++) {
+ const a = ring * stride + j;
+ const b = a + 1;
+ if (flip) idx.push(ci, b, a);
+ else idx.push(ci, a, b);
+ }
+ };
+ if (capStart) cap(0, true);
+ if (capEnd) cap(rings, false);
+
+ const g = new THREE.BufferGeometry();
+ g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
+ g.setAttribute('uv', new THREE.Float32BufferAttribute(uv, 2));
+ if (painted) g.setAttribute('color', new THREE.Float32BufferAttribute(col, 3));
+ if (part != null) {
+ g.setAttribute('aPart', new THREE.Float32BufferAttribute(aPart, 1));
+ g.setAttribute('aT', new THREE.Float32BufferAttribute(aT, 1));
+ }
+ g.setIndex(idx);
+ g.computeVertexNormals();
+ return g;
+}
+
+/** Sweep a bar of `radius` along a Catmull-Rom through `points`. */
+export function tube(points, radius, {
+ closed = false,
+ radial = 7,
+ segments = null,
+ tension = 0.4,
+} = {}) {
+ const curve = new THREE.CatmullRomCurve3(
+ points.map((p) => p.clone()),
+ closed,
+ 'catmullrom',
+ tension,
+ );
+ const seg = segments ?? Math.max(10, points.length * 4);
+ return new THREE.TubeGeometry(curve, seg, radius, radial, closed);
+}
+
+/** Fold a pile of bars into one geometry (one draw call, one material). */
+export function mergeBars(list) {
+ const merged = mergeGeoms(list.map(stripAttrs));
+ for (const g of list) g.dispose();
+ merged.computeVertexNormals();
+ return merged;
+}
+
+/**
+ * Push a quad as two triangles, wound so its face points along `dir`.
+ *
+ * Winding on a hand-built grid depends on which way the parametrisation runs,
+ * and getting it backwards means the surface renders inside-out. Deciding per
+ * quad from the geometry is cheap and removes the guesswork.
+ */
+function pushQuad(idx, pos, a, b, c, d, dir) {
+ _a.set(pos[b * 3] - pos[a * 3], pos[b * 3 + 1] - pos[a * 3 + 1], pos[b * 3 + 2] - pos[a * 3 + 2]);
+ _b.set(pos[c * 3] - pos[a * 3], pos[c * 3 + 1] - pos[a * 3 + 1], pos[c * 3 + 2] - pos[a * 3 + 2]);
+ _n.crossVectors(_a, _b);
+ if (_n.lengthSq() < 1e-16) return;
+ if (_n.dot(dir) >= 0) idx.push(a, b, c, a, c, d);
+ else idx.push(a, c, b, a, d, c);
+}
+
+/**
+ * A shell with thickness and an optional hole cut through it.
+ *
+ * `surface(theta, v, out)` writes the outer skin for the lat/long parameter
+ * pair — theta wraps, v runs 0 (open bottom edge) → 1 (closed crown). Vertex
+ * normals come from the parametric tangents, and the inner skin is the outer
+ * one pushed back along them, so the wall has an honest thickness you can see
+ * through the hole.
+ *
+ * `port(p, theta, v)` marks outer vertices that fall inside a hole; every quad
+ * touching one is dropped and the exposed border is walled with a rim. That is
+ * what turns a lump into a mask you can see a face through. The surface
+ * parameters come through alongside the position because holes that follow the
+ * shell — vent slots, an ear port — are far easier to place in (theta, v) than
+ * in metres.
+ */
+export function carvedShell({
+ rows = 40,
+ cols = 48,
+ thickness = 0.012,
+ surface,
+ port = null,
+ color = null,
+ center = new THREE.Vector3(),
+ bottomRim = true,
+}) {
+ const outer = [];
+ const param = [];
+ for (let i = 0; i <= rows; i++) {
+ const v = i / rows;
+ for (let j = 0; j < cols; j++) {
+ const theta = (j / cols) * Math.PI * 2;
+ outer.push(surface(theta, v, new THREE.Vector3()));
+ param.push(theta, v);
+ }
+ }
+ const at = (i, j) => outer[i * cols + (((j % cols) + cols) % cols)];
+
+ // Parametric normals: dV × dTheta, flipped to face away from the centre.
+ const normals = [];
+ for (let i = 0; i <= rows; i++) {
+ for (let j = 0; j < cols; j++) {
+ _a.subVectors(at(i, j + 1), at(i, j - 1));
+ _b.subVectors(at(Math.min(rows, i + 1), j), at(Math.max(0, i - 1), j));
+ _n.crossVectors(_b, _a);
+ _d.subVectors(at(i, j), center);
+ if (_n.lengthSq() < 1e-14) _n.copy(_d);
+ _n.normalize();
+ if (_n.dot(_d) < 0) _n.negate();
+ normals.push(_n.clone());
+ }
+ }
+
+ const nOuter = outer.length;
+ const pos = new Array(nOuter * 6);
+ const nor = new Array(nOuter * 6);
+ const uvs = new Array(nOuter * 4);
+ const cols3 = color ? new Array(nOuter * 6) : null;
+
+ for (let k = 0; k < nOuter; k++) {
+ const p = outer[k];
+ const n = normals[k];
+ const i = Math.floor(k / cols);
+ const j = k % cols;
+ const inner = _d.copy(p).addScaledVector(n, -thickness);
+ pos[k * 3] = p.x; pos[k * 3 + 1] = p.y; pos[k * 3 + 2] = p.z;
+ pos[(nOuter + k) * 3] = inner.x;
+ pos[(nOuter + k) * 3 + 1] = inner.y;
+ pos[(nOuter + k) * 3 + 2] = inner.z;
+ nor[k * 3] = n.x; nor[k * 3 + 1] = n.y; nor[k * 3 + 2] = n.z;
+ nor[(nOuter + k) * 3] = -n.x;
+ nor[(nOuter + k) * 3 + 1] = -n.y;
+ nor[(nOuter + k) * 3 + 2] = -n.z;
+ uvs[k * 2] = j / cols; uvs[k * 2 + 1] = i / rows;
+ uvs[(nOuter + k) * 2] = j / cols;
+ uvs[(nOuter + k) * 2 + 1] = i / rows;
+ if (cols3) {
+ const co = color(p, 'outer', param[k * 2], param[k * 2 + 1]);
+ const ci = color(p, 'inner', param[k * 2], param[k * 2 + 1]);
+ cols3[k * 3] = co.r; cols3[k * 3 + 1] = co.g; cols3[k * 3 + 2] = co.b;
+ cols3[(nOuter + k) * 3] = ci.r;
+ cols3[(nOuter + k) * 3 + 1] = ci.g;
+ cols3[(nOuter + k) * 3 + 2] = ci.b;
+ }
+ }
+
+ const holed = port ? outer.map((p, k) => port(p, param[k * 2], param[k * 2 + 1])) : null;
+ const O = (i, j) => i * cols + (((j % cols) + cols) % cols);
+ const I = (i, j) => nOuter + O(i, j);
+ const dropped = (i, j) => {
+ if (!holed) return false;
+ return holed[O(i, j)] || holed[O(i, j + 1)] || holed[O(i + 1, j)] || holed[O(i + 1, j + 1)];
+ };
+
+ const idx = [];
+ const mid = new THREE.Vector3();
+ const midOf = (i, j, out) => out
+ .copy(at(i, j)).add(at(i, j + 1)).add(at(i + 1, j)).add(at(i + 1, j + 1)).multiplyScalar(0.25);
+
+ for (let i = 0; i < rows; i++) {
+ for (let j = 0; j < cols; j++) {
+ if (dropped(i, j)) continue;
+ midOf(i, j, mid);
+ _d.copy(normals[O(i, j)]);
+ pushQuad(idx, pos, O(i, j), O(i, j + 1), O(i + 1, j + 1), O(i + 1, j), _d);
+ _d.negate();
+ pushQuad(idx, pos, I(i, j), I(i, j + 1), I(i + 1, j + 1), I(i + 1, j), _d);
+ }
+ }
+
+ // Wall the hole: every dropped quad that borders a kept one gets a rim face
+ // on the shared edge, pointing into the opening.
+ const holeMid = new THREE.Vector3();
+ const keptMid = new THREE.Vector3();
+ const rim = (i, j, ni, nj, ea, eb) => {
+ if (ni < 0 || ni >= rows) return;
+ if (!dropped(ni, nj)) {
+ midOf(i, j, holeMid);
+ midOf(ni, nj, keptMid);
+ _d.subVectors(holeMid, keptMid).normalize();
+ pushQuad(idx, pos, ea[0], ea[1], eb[1], eb[0], _d);
+ }
+ };
+ if (holed) {
+ for (let i = 0; i < rows; i++) {
+ for (let j = 0; j < cols; j++) {
+ if (!dropped(i, j)) continue;
+ rim(i, j, i, j - 1, [O(i, j), O(i + 1, j)], [I(i, j), I(i + 1, j)]);
+ rim(i, j, i, j + 1, [O(i, j + 1), O(i + 1, j + 1)], [I(i, j + 1), I(i + 1, j + 1)]);
+ rim(i, j, i - 1, j, [O(i, j), O(i, j + 1)], [I(i, j), I(i, j + 1)]);
+ rim(i, j, i + 1, j, [O(i + 1, j), O(i + 1, j + 1)], [I(i + 1, j), I(i + 1, j + 1)]);
+ }
+ }
+ }
+
+ // Open bottom edge gets its own rim so the shell reads as a shell.
+ if (bottomRim) {
+ for (let j = 0; j < cols; j++) {
+ _d.subVectors(at(0, j), at(1, j)).normalize();
+ pushQuad(idx, pos, O(0, j), O(0, j + 1), I(0, j + 1), I(0, j), _d);
+ }
+ }
+
+ const g = new THREE.BufferGeometry();
+ g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
+ g.setAttribute('normal', new THREE.Float32BufferAttribute(nor, 3));
+ g.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
+ if (cols3) g.setAttribute('color', new THREE.Float32BufferAttribute(cols3, 3));
+ g.setIndex(idx);
+ return g;
+}
+
+/** Colour helper — a solid tint for a whole loft section. */
+export function tint(c) {
+ return c instanceof THREE.Color ? c.clone() : new THREE.Color(c ?? WHITE);
+}
diff --git a/src/character/goalie.js b/src/character/goalie.js
new file mode 100644
index 0000000..b508e34
--- /dev/null
+++ b/src/character/goalie.js
@@ -0,0 +1,289 @@
+import * as THREE from 'three';
+import { NET, goalLineX, goalieSpot } from '../../shared/net.js';
+import { CAT, KIND, makeTag, quat, transform, vec3, xyz } from '../physics/bridge.js';
+import { clamp } from '../../shared/scalar.js';
+import { makeRng } from '../core/rng.js';
+import { disposeObject } from '../core/math.js';
+import { buildMaterials, paintKit } from '../render/materials.js';
+import { assertNoNaNBones, buildSkeleton } from './skeleton.js';
+import { buildBodyGeometry, buildBodyMesh } from './body.js';
+import { computeSkin } from './skinning.js';
+import { buildGoalieAnimator } from '../anim/goalieAnimator.js';
+import { buildGoalieGear, buildGoalieMaterials } from './goalieGear.js';
+
+/**
+ * A goalie.
+ *
+ * Deliberately *not* a skater. The skating sim is a carve model — momentum
+ * dragged onto a blade line — and a goalie almost never carves. They shuffle
+ * along an arc, square to the puck, and their whole job is to be in the right
+ * place rather than to travel.
+ *
+ * Presentation matches the skaters: same skeleton, skinned body, bone-socketed
+ * gear (pads, trapper, blocker, mask, paddle). Locomotion and saves stay
+ * purpose-built — angle tracking with a reaction lag, kinematic pad/body
+ * colliders the puck bounces off. No save-percentage roll anywhere.
+ */
+
+export const GOALIE = {
+ /** How far out of the net they play. Deeper is safer, shallower cuts angle. */
+ depth: 0.62,
+ /** Lateral speed, m/s. Real goalies are quick but not instant. */
+ speed: 4.4,
+ /** Seconds of reaction lag on the target. This is the beatable part. */
+ lag: 0.11,
+ /** Pad stack: low and wide — the physics shape, not the visual pad. */
+ padWidth: 0.92,
+ padHeight: 0.46,
+ padDepth: 0.22,
+ /** Upper body plus arms/glove/blocker, as one capsule. */
+ bodyRadius: 0.30,
+ bodyLow: 0.46,
+ bodyHigh: 1.24,
+ /** How far they lunge at a puck that is already past them. */
+ desperation: 0.45,
+ /**
+ * How close / fast a puck has to be before they commit to butterfly/reach.
+ * Tuned so idle crease work stays in ready stance.
+ */
+ threatDist: 9,
+ threatSpeed: 6,
+};
+
+export function createGoalie(physics, scene, {
+ end = 1,
+ index = 40,
+ team = 1,
+ seed = 9000 + Math.abs(end) * 17 + team * 3,
+} = {}) {
+ const line = goalLineX(end);
+ const rng = makeRng(seed);
+ const materials = buildMaterials(rng, team);
+ // Goalies are bulkier in the pads than skaters are in pants.
+ const bodyStyle = { mass: 0.55, muscle: 0.6, fat: 0.45 };
+ const skelData = buildSkeleton();
+
+ const mover = new THREE.Group();
+ mover.name = 'goalie:' + end;
+ scene.add(mover);
+
+ const bodyGeo = buildBodyGeometry(rng, bodyStyle);
+ computeSkin(bodyGeo, skelData);
+ paintKit(bodyGeo, { jersey: materials.team.jersey, skinColor: materials.skinColor });
+ const bodyMesh = buildBodyMesh(bodyGeo, skelData, materials);
+ mover.add(bodyMesh);
+
+ const gearMats = buildGoalieMaterials(materials.team.jersey, materials.team.accent);
+ const gear = buildGoalieGear(gearMats);
+ gear.attachTo(skelData.bones);
+
+ const animator = buildGoalieAnimator(skelData, mover);
+ animator.stick = gear.stick;
+
+ const spawnX = line - end * GOALIE.depth;
+ const facing = end > 0 ? -Math.PI / 2 : Math.PI / 2;
+ mover.position.set(spawnX, 0, 0);
+ mover.rotation.y = facing;
+ animator.setTransform(mover.position, facing);
+ mover.updateMatrixWorld(true);
+ assertNoNaNBones(skelData);
+
+ // ---- colliders ----------------------------------------------------------
+ // Kinematic: the puck bounces off, the goalie does not get pushed around.
+ // Kept as simple pad+body shapes rather than 18 bone capsules — a goalie's
+ // job is to be a wall the puck can hit, not a ragdoll that falls over.
+ let body = null;
+ const api = physics?.api;
+ if (physics) {
+ const bd = api.b3DefaultBodyDef();
+ bd.type = api.b3BodyType.b3_kinematicBody;
+ bd.position = xyz(spawnX, 0, 0);
+ bd.enableSleep = false;
+ body = api.b3CreateBody(physics.world, bd);
+
+ const sd = api.b3DefaultShapeDef();
+ sd.enableContactEvents = true;
+ sd.baseMaterial.friction = 0.5;
+ // Pads absorb. A puck pinging off a goalie like a wall is the single most
+ // arcade-looking thing a hockey game can do.
+ sd.baseMaterial.restitution = 0.18;
+ sd.baseMaterial.userMaterialId = makeTag(KIND.BODY, index, 0);
+ sd.filter.categoryBits = CAT.skater(index % 12);
+ // Puck and skaters only — never the rink, which a kinematic body ignores.
+ sd.filter.maskBits = CAT.PUCK | CAT.PROXY;
+
+ api.b3CreateBoxShape(body, sd, GOALIE.padDepth / 2, GOALIE.padHeight / 2, GOALIE.padWidth / 2);
+ api.b3CreateCapsuleShape(body, sd, {
+ center1: xyz(0, GOALIE.bodyLow, 0),
+ center2: xyz(0, GOALIE.bodyHigh, 0),
+ radius: GOALIE.bodyRadius,
+ });
+ }
+
+ const target = { x: spawnX, z: 0 };
+ const pos = { x: spawnX, z: 0 };
+ /** Lagged puck position, which is what they actually react to. */
+ const seen = { x: 0, z: 0 };
+ /** Last raw puck sample, for a cheap velocity estimate. */
+ const lastPuck = { x: 0, y: 0.05, z: 0 };
+ let seenInit = false;
+ let placed = false;
+ let hadPuck = false;
+
+ const _p = new THREE.Vector3();
+ const _q = new THREE.Quaternion();
+ const _scale = new THREE.Vector3();
+ const _up = new THREE.Vector3(0, 1, 0);
+
+ return {
+ end,
+ index,
+ team,
+ /** @deprecated use mover — kept so older callers that read .group still work */
+ get group() { return mover; },
+ mover,
+ body,
+ pos,
+ animator,
+ skelData,
+ gear,
+ bodyMesh,
+
+ /** Reset to the middle of the crease. */
+ reset() {
+ pos.x = line - end * GOALIE.depth;
+ pos.z = 0;
+ seenInit = false;
+ hadPuck = false;
+ placed = false;
+ const yaw = end > 0 ? -Math.PI / 2 : Math.PI / 2;
+ mover.position.set(pos.x, 0, pos.z);
+ mover.rotation.y = yaw;
+ animator.setTransform(mover.position, yaw);
+ animator.moveSpeed = 0;
+ animator.lateralVel = 0;
+ animator.threatened = 0;
+ animator.setState('ready', 0.05);
+ },
+
+ /**
+ * Track the puck. `dt` on the frame clock.
+ * `puck` is anything with `{x,y,z}` — the shootout passes a Vector3.
+ * Returns the current position so callers can watch it.
+ */
+ update(dt, puck) {
+ const px = puck.x;
+ const py = puck.y ?? 0.05;
+ const pz = puck.z;
+
+ // Reaction lag: they play the puck where they saw it, not where it is.
+ if (!seenInit) {
+ seen.x = px;
+ seen.z = pz;
+ seenInit = true;
+ } else {
+ const k = clamp(dt / Math.max(1e-3, GOALIE.lag), 0, 1);
+ seen.x += (px - seen.x) * k;
+ seen.z += (pz - seen.z) * k;
+ }
+
+ // Velocity from samples — the shootout only hands over a position.
+ let pvx = 0;
+ let pvz = 0;
+ if (hadPuck && dt > 1e-6) {
+ pvx = (px - lastPuck.x) / dt;
+ pvz = (pz - lastPuck.z) / dt;
+ }
+ lastPuck.x = px;
+ lastPuck.y = py;
+ lastPuck.z = pz;
+ hadPuck = true;
+
+ goalieSpot(seen, end, GOALIE.depth, target);
+
+ // A puck already behind them gets a desperation push across, which is
+ // why a slow deke beats them and a fast one sometimes does not.
+ const beaten = end > 0 ? px > pos.x : px < pos.x;
+ const speed = GOALIE.speed * (beaten ? 1 + GOALIE.desperation : 1);
+
+ const dx = target.x - pos.x;
+ const dz = target.z - pos.z;
+ const dist = Math.hypot(dx, dz);
+ const step = speed * dt;
+ const z0 = pos.z;
+ if (dist <= step || dist < 1e-6) {
+ pos.x = target.x;
+ pos.z = target.z;
+ } else {
+ pos.x += (dx / dist) * step;
+ pos.z += (dz / dist) * step;
+ }
+
+ // Square up to the puck.
+ const yaw = Math.atan2(px - pos.x, pz - pos.z);
+ mover.position.set(pos.x, 0, pos.z);
+ mover.rotation.y = yaw;
+
+ // Lateral velocity is along world Z in the crease (nets face ±X).
+ const latVel = dt > 1e-6 ? (pos.z - z0) / dt : 0;
+ const puckDist = Math.hypot(px - pos.x, pz - pos.z);
+ const puckSpeed = Math.hypot(pvx, pvz);
+ const closing = end > 0 ? pvx > 0.5 : pvx < -0.5;
+ // Proximity alone is enough to load a stance — a deke at the crease
+ // should draw a butterfly even if the puck is not a rocket. Speed and
+ // closing just push the same signal harder.
+ const near = clamp(1 - puckDist / GOALIE.threatDist, 0, 1);
+ const rush = clamp(puckSpeed / GOALIE.threatSpeed, 0, 1);
+ const threat = clamp(
+ near * 0.55
+ + near * rush * 0.45
+ + (closing ? near * 0.25 : 0),
+ 0,
+ 1,
+ );
+
+ animator.setTransform(mover.position, yaw);
+ animator.moveSpeed = Math.abs(latVel) + (dist > step ? speed * 0.25 : 0);
+ animator.lateralVel = latVel;
+ animator.puckHeight = py;
+ animator.puckDist = puckDist;
+ animator.threatened = threat;
+ animator.update(dt);
+
+ return pos;
+ },
+
+ /** Push the pose into the kinematic collider, once per substep. */
+ syncPhysics(dt) {
+ if (!body) return;
+ mover.updateWorldMatrix(true, false);
+ mover.matrixWorld.decompose(_p, _q, _scale);
+ // Physics body stays upright on the ice; presentation lean is visual only.
+ _q.setFromAxisAngle(_up, animator.originYaw);
+ _p.y = 0;
+ if (!placed) {
+ api.b3Body_SetTransform(body, vec3(_p), quat(_q));
+ placed = true;
+ return;
+ }
+ api.b3Body_SetTargetTransform(body, transform(_p, _q), dt, true);
+ },
+
+ /** True when the puck is inside the goalie's body — a save in progress. */
+ covers(puck) {
+ const dx = puck.x - pos.x;
+ const dz = puck.z - pos.z;
+ return Math.hypot(dx, dz) < GOALIE.bodyRadius + 0.14;
+ },
+
+ destroy() {
+ if (body && api) api.b3DestroyBody(body);
+ gear.destroy();
+ for (const m of Object.values(gearMats)) m.dispose();
+ scene.remove(mover);
+ disposeObject(mover);
+ },
+ };
+}
+
+export { NET };
diff --git a/src/character/goalieGear.js b/src/character/goalieGear.js
new file mode 100644
index 0000000..a592bfb
--- /dev/null
+++ b/src/character/goalieGear.js
@@ -0,0 +1,678 @@
+import * as THREE from 'three';
+import { clamp, smooth } from '../core/math.js';
+import { carvedShell, loft, mergeBars, tint, tube } from './gearMesh.js';
+
+/**
+ * Goalie equipment, socketed to skeleton bones.
+ *
+ * Placement is tuned against `shots/img2mesh/ref/goalie-equipment.png`:
+ * - pad faces toward the shooter (front of the shin), boot on the ice
+ * - trapper open on the glove-side hip
+ * - blocker as a flat board on the stick hand
+ * - paddle flat in the five-hole, shaft up into the blocker hand
+ * - mask + cage on the head, chest plate snug on the torso
+ *
+ * The pieces that carry the silhouette — pads, mask, gloves, chest — are single
+ * lofted or shelled meshes rather than stacks of boxes. A pad is one surface
+ * from the thigh rise through the knee break to the toe; the mask is a shell
+ * with a hole cut for the face and a cage bent over it.
+ *
+ * Bone axes (rest): every rest rotation is identity, so a bone's local axes are
+ * the mover's. The shin runs almost straight down −Y, but the hands and upper
+ * arms run out *and* down (A-pose), so glove and floater groups are rotated
+ * onto their bone's real direction instead of being hung off −Y.
+ */
+
+/**
+ * Numbers the builders read. Pads, gloves and the chest are described by their
+ * section tables further down rather than by scalars — a loft's shape lives in
+ * its keys — so only the mask, whose surface is a formula, needs constants.
+ */
+export const GEAR = {
+ mask: {
+ /** Skull centre in head-bone-local space (head bone sits at the jaw hinge). */
+ riseY: 0.094,
+ pushZ: -0.006,
+ rx: 0.118,
+ ry: 0.156,
+ rz: 0.128,
+ /** Polar angle the shell starts at — below the chin, open at the neck. */
+ phi0: 0.52,
+ wall: 0.011,
+ /** Face opening, relative to the skull centre. */
+ portW: 0.076,
+ portH: 0.054,
+ portY: -0.004,
+ /** Cage: an ellipse bowed out in front of the opening. */
+ cageW: 0.092,
+ cageH: 0.070,
+ cageBase: 0.088,
+ cageBulge: 0.052,
+ barR: 0.0045,
+ },
+};
+
+/** Rest direction a bone's limb actually points, in that bone's local space. */
+const ARM_DIR = {
+ L: new THREE.Vector3(0.15, -0.252, 0.01).normalize(),
+ R: new THREE.Vector3(-0.15, -0.252, 0.01).normalize(),
+};
+const DOWN = new THREE.Vector3(0, -1, 0);
+
+/** Rest direction the fingers point, from the hand bone. */
+const HAND_DIR = {
+ L: new THREE.Vector3(0.045, -0.095, 0.008).normalize(),
+ R: new THREE.Vector3(-0.045, -0.095, 0.008).normalize(),
+};
+
+/**
+ * Glove grips, in hand-bone-local space.
+ *
+ * Both gloves are modelled facing +Z with the body running down −Y, and both
+ * are put on the hand the same way: −Y is aligned to the hand's own axis, so
+ * the glove carries on out of the wrist the way a hand does, and the *only*
+ * free variable left is the roll about that axis.
+ *
+ * That constraint matters. Solving for a free orientation — "pocket at the
+ * shooter, fingers up" — squares the glove to the puck but stands it off the
+ * wrist at an angle no arm makes. Rolling around the hand keeps the join
+ * honest and still gets the pocket and the board most of the way round.
+ *
+ * The two angles below were solved against the ready stance: for each, the
+ * roll whose pocket normal lands closest to the shooter.
+ */
+const GRIP_ROLL = { trapper: 1.499, blocker: 5.369 };
+
+function handGrip(side, roll) {
+ const dir = HAND_DIR[side];
+ const align = new THREE.Quaternion().setFromUnitVectors(DOWN, dir);
+ return new THREE.Quaternion().setFromAxisAngle(dir, roll).multiply(align);
+}
+
+/**
+ * @param {{
+ * kit: THREE.Material, pad: THREE.Material, painted: THREE.Material,
+ * accent: THREE.Material, leather: THREE.Material, web: THREE.Material,
+ * cage: THREE.Material, dark: THREE.Material,
+ * }} mats
+ */
+export function buildGoalieGear(mats) {
+ const pieces = [];
+ const disposables = [];
+
+ const PAL = {
+ base: tint(mats.pad.color),
+ accent: tint(mats.accent.color),
+ jersey: tint(mats.kit.color),
+ trim: tint(mats.dark.color),
+ };
+
+ function mesh(geo, mat, name) {
+ const m = new THREE.Mesh(geo, mat);
+ m.name = name;
+ m.castShadow = true;
+ m.receiveShadow = true;
+ disposables.push(geo);
+ return m;
+ }
+
+ const V = (x, y, z) => new THREE.Vector3(x, y, z);
+ /** Loft section shorthand. */
+ const S = (c, rx, rz, e, col) => ({ c, rx, rz, e, col });
+
+ /** Point a group's −Y down a bone's real limb direction. */
+ function alignTo(group, dir) {
+ group.quaternion.setFromUnitVectors(DOWN, dir);
+ return group;
+ }
+
+ // ---- leg pads ------------------------------------------------------------
+ // One continuous surface: thigh rise → knee break → shin → boot → toe kick.
+ // The shin bone runs down −Y, so the loft path only has to bend forward at
+ // the ankle for the toe. Bands are cut by doubling sections at the same
+ // height — smoothstep between two rings a centimetre apart is a hard edge.
+ function makePad(side) {
+ const s = side === 'L' ? 1 : -1;
+ const g = new THREE.Group();
+ g.name = `pad${side}`;
+
+ const W = 0.152; // half face width
+ const D = 0.066; // half depth
+ const z0 = 0.052; // pad centre stands proud of the shin front
+
+ // Bands are cut by pairing sections a centimetre apart: smoothstep over
+ // that gap is an edge, over ten centimetres it is a gradient.
+ const face = loft([
+ S(V(0, 0.295, z0 - 0.012), W * 0.74, D * 0.72, 4, PAL.base),
+ S(V(0, 0.225, z0 + 0.008), W * 0.94, D * 0.86, 5),
+ // Knee break — the widest point, with a team band across it.
+ S(V(0, 0.175, z0 + 0.02), W * 1.03, D * 0.97, 6),
+ S(V(0, 0.165, z0 + 0.022), W * 1.04, D * 0.98, 6, PAL.accent),
+ S(V(0, 0.10, z0 + 0.026), W * 1.06, D * 1.0, 6),
+ S(V(0, 0.09, z0 + 0.025), W * 1.03, D * 0.99, 6, PAL.base),
+ S(V(0, -0.02, z0 + 0.012), W, D * 0.94, 6),
+ // Mid-shin stripe pair.
+ S(V(0, -0.135, z0 + 0.007), W, D * 0.92, 6),
+ S(V(0, -0.145, z0 + 0.006), W, D * 0.92, 6, PAL.accent),
+ S(V(0, -0.20, z0 + 0.004), W, D * 0.92, 6),
+ S(V(0, -0.21, z0 + 0.004), W, D * 0.92, 6, PAL.base),
+ S(V(0, -0.37, z0 + 0.008), W * 1.01, D * 0.96, 6),
+ // Boot channel: wider, deeper, and dark like the ref's landing gear.
+ S(V(0, -0.425, z0 + 0.014), W * 1.03, D * 1.02, 6),
+ S(V(0, -0.44, z0 + 0.018), W * 1.05, D * 1.06, 6, PAL.trim),
+ S(V(0, -0.485, z0 + 0.045), W * 0.98, D * 0.86, 5),
+ // Toe kicks forward over the skate, and no further.
+ S(V(0, -0.505, z0 + 0.09), W * 0.84, D * 0.6, 4),
+ S(V(0, -0.512, z0 + 0.128), W * 0.58, D * 0.36, 3),
+ ], { radial: 22, sub: 5 });
+ g.add(mesh(face, mats.painted, `pad${side}Face`));
+
+ // Outer roll — the thick rolled edge that gives a pad its profile.
+ const rail = loft([
+ S(V(s * W * 0.94, 0.21, z0 + 0.01), 0.022, 0.028, 3, PAL.accent),
+ S(V(s * W * 1.0, 0.11, z0 + 0.026), 0.028, 0.036, 3),
+ S(V(s * W * 0.96, -0.05, z0 + 0.014), 0.026, 0.034, 3),
+ S(V(s * W * 0.96, -0.24, z0 + 0.006), 0.026, 0.034, 3),
+ S(V(s * W * 1.0, -0.41, z0 + 0.01), 0.028, 0.036, 3),
+ S(V(s * W * 0.98, -0.48, z0 + 0.038), 0.024, 0.028, 3),
+ ], { radial: 12, sub: 4 });
+ g.add(mesh(rail, mats.painted, `pad${side}Rail`));
+
+ // Knee stack — the block that lands on the ice in a butterfly.
+ const knee = loft([
+ S(V(-s * 0.02, 0.165, z0 - 0.03), W * 0.6, 0.042, 4, PAL.base),
+ S(V(-s * 0.042, 0.105, z0 - 0.048), W * 0.64, 0.05, 4),
+ S(V(-s * 0.055, 0.045, z0 - 0.052), W * 0.54, 0.044, 4),
+ ], { radial: 14, sub: 4 });
+ g.add(mesh(knee, mats.painted, `pad${side}Knee`));
+
+ // Calf wrap so the back of the leg is not naked from the side.
+ const calf = loft([
+ S(V(0, 0.05, -0.028), W * 0.64, 0.048, 4, PAL.trim),
+ S(V(0, -0.14, -0.032), W * 0.68, 0.052, 4),
+ S(V(0, -0.31, -0.028), W * 0.66, 0.048, 4),
+ S(V(0, -0.40, -0.008), W * 0.58, 0.042, 4),
+ ], { radial: 14, sub: 4 });
+ g.add(mesh(calf, mats.painted, `pad${side}Calf`));
+
+ // Toe / boot straps.
+ const strapPts = [
+ V(-W * 1.08, -0.465, z0 + 0.015),
+ V(0, -0.47, z0 + 0.06),
+ V(W * 1.08, -0.465, z0 + 0.015),
+ ];
+ g.add(mesh(tube(strapPts, 0.008, { radial: 6 }), mats.leather, `pad${side}Strap`));
+
+ // Pads sit slightly toed-out on the leg.
+ g.rotation.z = -s * 0.05;
+ pieces.push(g);
+ return g;
+ }
+
+ const padL = makePad('L');
+ const padR = makePad('R');
+
+ // ---- mask ----------------------------------------------------------------
+ // A shell, not a helmet-shaped blob: the surface function carries the jaw
+ // taper, cheekbones, brow ridge and occipital shelf, the face opening is cut
+ // straight out of the mesh (with a walled rim you can see the thickness of),
+ // and the cage is bent over the hole on its own bowed ellipse.
+ const M = GEAR.mask;
+ const skull = new THREE.Vector3(0, M.riseY, M.pushZ);
+
+ function maskSurface(theta, v, out) {
+ const phi = M.phi0 + (Math.PI - M.phi0) * v;
+ const sp = Math.sin(phi);
+ const cp = Math.cos(phi);
+ const f = Math.cos(theta); // +1 dead ahead
+ const sx = Math.sin(theta); // ±1 at the ears
+ const front = Math.max(0, f);
+ const back = Math.max(0, -f);
+ // 1 down at the chin, 0 by the cheekbones.
+ const low = smooth(clamp((0.40 - v) / 0.34, 0, 1));
+
+ let rx = M.rx;
+ let rz = M.rz;
+ // Jaw narrows off the cheekbones; cheeks themselves flare.
+ rx *= 1 - 0.26 * low;
+ rx *= 1 + 0.07 * Math.exp(-(((v - 0.40) / 0.17) ** 2)) * Math.abs(sx);
+ // Back of the head carries the shell out over the occiput.
+ rz *= 1 + 0.13 * back * smooth(clamp((v - 0.10) / 0.5, 0, 1));
+ // Face is a plate, not a dome — flatten the front through the eye band.
+ rz *= 1 - 0.13 * front * front * Math.exp(-(((v - 0.52) / 0.30) ** 2));
+
+ let x = rx * sp * sx;
+ let y = -M.ry * cp;
+ let z = rz * sp * f;
+
+ // Chin cup pushes forward and tucks up under the face.
+ z += 0.032 * low * front;
+ y += 0.016 * low * front;
+ // Brow ridge over the port.
+ const brow = Math.exp(-(((v - 0.60) / 0.085) ** 2)) * front ** 1.5;
+ z += 0.011 * brow;
+ y += 0.004 * brow;
+ // Crown keel — the raised centre spine of a goalie shell.
+ const keel = Math.exp(-((sx / 0.30) ** 2)) * smooth(clamp((v - 0.45) / 0.4, 0, 1));
+ y += 0.006 * keel;
+
+ return out.set(skull.x + x, skull.y + y, skull.z + z);
+ }
+
+ /** Squared-off ellipse over the eyes — the hole the cage covers. */
+ function portField(p) {
+ const dx = Math.abs(p.x) / M.portW;
+ const dy = Math.abs(p.y - skull.y - M.portY) / M.portH;
+ return dx ** 2.3 + dy ** 2.3;
+ }
+ const inPort = (p) => p.z - skull.z > 0.03 && portField(p) < 1;
+
+ const maskColor = (p, kind) => {
+ if (kind === 'inner') return PAL.trim;
+ const dy = p.y - skull.y;
+ const dz = p.z - skull.z;
+ // Dark trim ringing the face opening.
+ if (dz > 0.0 && portField(p) < 1.4) return PAL.trim;
+ // Chin cup and the neck edge below it.
+ if (dy < -0.095) return PAL.trim;
+ // Keel stripe over the crown, front to back — the one graphic on the shell.
+ if (Math.abs(p.x) < 0.024 && dy > 0.0) return PAL.accent;
+ return PAL.base;
+ };
+
+ const mask = new THREE.Group();
+ mask.name = 'mask';
+ const shell = carvedShell({
+ // Dense enough that the brow and jaw read in a goal-cam closeup, no denser
+ // — this is the one piece with a two-sided wall, so rows × cols doubles.
+ rows: 36,
+ cols: 48,
+ thickness: M.wall,
+ center: skull,
+ surface: maskSurface,
+ port: inPort,
+ color: maskColor,
+ });
+ mask.add(mesh(shell, mats.painted, 'maskShell'));
+
+ // Cage: bars ride a forward-bowed ellipse so they stand off the face.
+ const cageAt = (x, dy) => {
+ const k = 1 - (x / M.cageW) ** 2 - (dy / M.cageH) ** 2;
+ const z = skull.z + M.cageBase + M.cageBulge * Math.sqrt(Math.max(0, k));
+ return V(x, skull.y + M.portY + dy, z);
+ };
+ const bars = [];
+ // Horizontal bars, densest across the eyes.
+ for (const dy of [-0.050, -0.028, -0.008, 0.014, 0.038, 0.058]) {
+ const span = M.cageW * Math.sqrt(Math.max(0, 1 - (dy / M.cageH) ** 2));
+ if (span < 0.022) continue;
+ const pts = [];
+ for (let i = 0; i <= 8; i++) {
+ const x = -span + (2 * span * i) / 8;
+ pts.push(cageAt(clamp(x, -span * 0.995, span * 0.995), dy));
+ }
+ bars.push(tube(pts, M.barR, { radial: 6 }));
+ }
+ // Vertical bars.
+ for (const x of [-0.050, -0.018, 0.018, 0.050]) {
+ const span = M.cageH * Math.sqrt(Math.max(0, 1 - (x / M.cageW) ** 2));
+ if (span < 0.02) continue;
+ const pts = [];
+ for (let i = 0; i <= 8; i++) {
+ const dy = -span + (2 * span * i) / 8;
+ pts.push(cageAt(x, clamp(dy, -span * 0.995, span * 0.995)));
+ }
+ bars.push(tube(pts, M.barR, { radial: 6 }));
+ }
+ // Perimeter frame, sunk onto the shell so the cage anchors into it.
+ {
+ const ring = [];
+ for (let i = 0; i < 24; i++) {
+ const a = (i / 24) * Math.PI * 2;
+ const x = M.cageW * 1.02 * Math.cos(a);
+ const dy = M.cageH * 1.02 * Math.sin(a);
+ const p = cageAt(x, dy);
+ p.z -= 0.004;
+ ring.push(p);
+ }
+ bars.push(tube(ring, M.barR * 1.3, { radial: 6, closed: true, segments: 72 }));
+ }
+ mask.add(mesh(mergeBars(bars), mats.cage, 'maskCage'));
+
+ // Throat dangler on its own strap, like the ref photo.
+ const bib = loft([
+ S(V(0, skull.y - 0.155, skull.z + 0.055), 0.055, 0.012, 4, PAL.accent),
+ S(V(0, skull.y - 0.20, skull.z + 0.058), 0.062, 0.013, 4),
+ S(V(0, skull.y - 0.245, skull.z + 0.05), 0.05, 0.012, 4),
+ ], { radial: 12, sub: 4 });
+ mask.add(mesh(bib, mats.painted, 'maskBib'));
+ mask.add(mesh(
+ tube([
+ V(-0.048, skull.y - 0.115, skull.z + 0.04),
+ V(0, skull.y - 0.135, skull.z + 0.06),
+ V(0.048, skull.y - 0.115, skull.z + 0.04),
+ ], 0.005, { radial: 5 }),
+ mats.leather,
+ 'maskBibStrap',
+ ));
+ pieces.push(mask);
+
+ // ---- trapper (catch glove) — left hand ----------------------------------
+ // Built in glove space (fingers down −Y, back of the hand +Z) then rotated
+ // onto the hand bone's real axis. Cuff and pillow are one lofted body; the
+ // pocket is a rim tube with the web recessed inside it.
+ const trapper = new THREE.Group();
+ trapper.name = 'trapper';
+ {
+ const body = loft([
+ S(V(0, 0.045, 0.005), 0.05, 0.048, 3),
+ S(V(0, -0.03, 0.012), 0.058, 0.055, 3),
+ S(V(0, -0.09, 0.022), 0.07, 0.062, 3),
+ S(V(0.008, -0.15, 0.035), 0.076, 0.066, 3),
+ S(V(0.01, -0.20, 0.042), 0.062, 0.054, 3),
+ ], { radial: 16, sub: 5 });
+ trapper.add(mesh(body, mats.leather, 'trapperBody'));
+
+ // Pocket assembly is canted off the hand axis. A catching face built square
+ // to the wrist can only ever aim wherever the forearm happens to point;
+ // real gear is angled across it, which is what lets the pocket face the
+ // shooter while the glove still runs out of the hand.
+ const pocket = new THREE.Group();
+ pocket.name = 'trapperPocket';
+ pocket.rotation.x = -0.32;
+
+ // The catching face is a dish swept forward off the palm: solid leather
+ // backing, squared off like a real mitt rather than a circle.
+ const cup = loft([
+ S(V(0.008, -0.115, 0.01), 0.062, 0.072, 3),
+ S(V(0.01, -0.12, 0.05), 0.09, 0.108, 4),
+ S(V(0.012, -0.125, 0.082), 0.098, 0.118, 4.5),
+ S(V(0.012, -0.125, 0.095), 0.09, 0.108, 4),
+ ], { radial: 20, sub: 5 });
+ pocket.add(mesh(cup, mats.leather, 'trapperCup'));
+
+ // Web pillow proud of the cup mouth — the light face a shooter sees. Sunk
+ // behind the rim it just reads as a black frying pan.
+ const web = loft([
+ S(V(0.012, -0.125, 0.088), 0.078, 0.094, 4),
+ S(V(0.012, -0.125, 0.112), 0.082, 0.098, 4),
+ S(V(0.012, -0.125, 0.124), 0.068, 0.082, 3.5),
+ ], { radial: 18, sub: 4 });
+ pocket.add(mesh(web, mats.web, 'trapperWeb'));
+
+ // Rim binding around the pocket mouth.
+ const rimPts = [];
+ for (let i = 0; i < 24; i++) {
+ const a = (i / 24) * Math.PI * 2;
+ const [cx, cy] = [Math.cos(a), Math.sin(a)];
+ rimPts.push(V(
+ 0.012 + 0.094 * Math.sign(cx) * Math.abs(cx) ** 0.55,
+ -0.125 + 0.112 * Math.sign(cy) * Math.abs(cy) ** 0.55,
+ 0.104,
+ ));
+ }
+ pocket.add(mesh(
+ tube(rimPts, 0.012, { radial: 7, closed: true, segments: 72 }),
+ mats.accent,
+ 'trapperRim',
+ ));
+ trapper.add(pocket);
+
+ // Thumb stall curls off the inside edge.
+ const thumb = loft([
+ S(V(0.07, -0.04, 0.03), 0.028, 0.026, 3),
+ S(V(0.105, -0.075, 0.06), 0.03, 0.028, 3),
+ S(V(0.115, -0.13, 0.085), 0.026, 0.024, 3),
+ ], { radial: 12, sub: 4 });
+ trapper.add(mesh(thumb, mats.leather, 'trapperThumb'));
+
+ // Cuff.
+ const cuff = loft([
+ S(V(0, 0.10, -0.005), 0.055, 0.052, 4, PAL.base),
+ S(V(0, 0.035, 0.0), 0.062, 0.058, 4),
+ ], { radial: 14, sub: 4 });
+ trapper.add(mesh(cuff, mats.painted, 'trapperCuff'));
+ }
+ trapper.quaternion.copy(handGrip('L', GRIP_ROLL.trapper));
+ pieces.push(trapper);
+
+ // ---- blocker — right hand -----------------------------------------------
+ // The board is one lofted slab: rounded rectangle in section, swept forward
+ // off the back of the hand so the face squares to the shooter.
+ const blocker = new THREE.Group();
+ blocker.name = 'blocker';
+ {
+ // Board is canted off the hand for the same reason the trapper pocket is:
+ // square to the wrist, it lies flat whenever the arm reaches forward.
+ const face = new THREE.Group();
+ face.name = 'blockerFace';
+ face.rotation.x = 0.66;
+
+ const board = loft([
+ S(V(-0.005, -0.10, 0.018), 0.082, 0.125, 5, PAL.trim),
+ S(V(-0.005, -0.10, 0.045), 0.098, 0.145, 6, PAL.base),
+ S(V(-0.005, -0.10, 0.078), 0.098, 0.145, 6),
+ S(V(-0.005, -0.10, 0.098), 0.084, 0.128, 5, PAL.accent),
+ ], { radial: 20, sub: 5 });
+ face.add(mesh(board, mats.painted, 'blockerBoard'));
+
+ // Sidewall down the outside edge of the board.
+ const wall = loft([
+ S(V(-0.09, 0.02, 0.055), 0.016, 0.03, 4, PAL.trim),
+ S(V(-0.098, -0.10, 0.058), 0.018, 0.034, 4),
+ S(V(-0.09, -0.215, 0.052), 0.016, 0.03, 4),
+ ], { radial: 10, sub: 4 });
+ face.add(mesh(wall, mats.painted, 'blockerWall'));
+ blocker.add(face);
+
+ // Glove hand behind the board — the part that holds the stick.
+ const palm = loft([
+ S(V(0, 0.05, 0.0), 0.05, 0.048, 3),
+ S(V(0, -0.035, 0.008), 0.058, 0.055, 3),
+ S(V(0, -0.13, 0.014), 0.055, 0.052, 3),
+ S(V(0, -0.19, 0.012), 0.042, 0.04, 3),
+ ], { radial: 14, sub: 4 });
+ blocker.add(mesh(palm, mats.leather, 'blockerPalm'));
+
+ const cuff = loft([
+ S(V(0, 0.105, -0.006), 0.05, 0.048, 4, PAL.base),
+ S(V(0, 0.04, 0.0), 0.058, 0.055, 4),
+ ], { radial: 12, sub: 4 });
+ blocker.add(mesh(cuff, mats.painted, 'blockerCuff'));
+ }
+ blocker.quaternion.copy(handGrip('R', GRIP_ROLL.blocker));
+ pieces.push(blocker);
+
+ // ---- chest protector -----------------------------------------------------
+ // One shell from the collar down over the belly, wrapping the torso instead
+ // of floating in front of it.
+ const chest = new THREE.Group();
+ chest.name = 'chest';
+ {
+ const body = loft([
+ S(V(0, 0.15, 0.008), 0.066, 0.062, 3, PAL.trim),
+ S(V(0, 0.10, 0.012), 0.095, 0.082, 4, PAL.jersey),
+ S(V(0, 0.055, 0.014), 0.185, 0.115, 5),
+ S(V(0, -0.03, 0.018), 0.196, 0.126, 5),
+ S(V(0, -0.10, 0.02), 0.19, 0.126, 5, PAL.base),
+ S(V(0, -0.175, 0.018), 0.182, 0.122, 5),
+ S(V(0, -0.235, 0.014), 0.176, 0.116, 5, PAL.jersey),
+ S(V(0, -0.32, 0.01), 0.162, 0.108, 5),
+ S(V(0, -0.38, 0.004), 0.138, 0.095, 4, PAL.trim),
+ ], { radial: 24, sub: 5 });
+ chest.add(mesh(body, mats.painted, 'chestBody'));
+
+ // Sternum plate, standing proud like a real chest-and-arm unit.
+ const plate = loft([
+ S(V(0, 0.05, 0.10), 0.088, 0.024, 4, PAL.base),
+ S(V(0, -0.04, 0.115), 0.10, 0.026, 4),
+ S(V(0, -0.14, 0.112), 0.096, 0.024, 4, PAL.accent),
+ S(V(0, -0.22, 0.10), 0.078, 0.02, 4),
+ ], { radial: 14, sub: 4 });
+ chest.add(mesh(plate, mats.painted, 'chestPlate'));
+ }
+ pieces.push(chest);
+
+ // Shoulder floaters — parented to the upper arms, aligned to the A-pose axis
+ // so they actually sit on the arm instead of hovering beside it.
+ function makeFloater(side) {
+ const g = new THREE.Group();
+ g.name = `floater${side}`;
+ const cap = loft([
+ S(V(0, 0.055, 0.01), 0.075, 0.072, 3, PAL.base),
+ S(V(0, -0.015, 0.012), 0.094, 0.086, 4),
+ S(V(0, -0.075, 0.01), 0.088, 0.08, 4, PAL.jersey),
+ S(V(0, -0.145, 0.008), 0.076, 0.068, 3),
+ ], { radial: 16, sub: 4 });
+ g.add(mesh(cap, mats.painted, `floater${side}Cap`));
+ const arm = loft([
+ S(V(0, -0.16, 0.006), 0.072, 0.066, 3, PAL.jersey),
+ S(V(0, -0.26, 0.004), 0.066, 0.06, 3),
+ S(V(0, -0.315, 0.002), 0.052, 0.048, 3, PAL.trim),
+ ], { radial: 14, sub: 4 });
+ g.add(mesh(arm, mats.painted, `floater${side}Arm`));
+ alignTo(g, ARM_DIR[side]);
+ pieces.push(g);
+ return g;
+ }
+ const floaterL = makeFloater('L');
+ const floaterR = makeFloater('R');
+
+ // ---- goalie stick -------------------------------------------------------
+ // Shaft down −Y from the blocker hand into a wide paddle, then a blade that
+ // sits flat on the ice across the five-hole.
+ const stick = new THREE.Group();
+ stick.name = 'goalieStick';
+ let paddleMesh = null;
+ {
+ const shaft = loft([
+ S(V(0, 0.02, 0), 0.014, 0.011, 5, PAL.trim),
+ S(V(0, -0.16, 0.004), 0.014, 0.012, 5),
+ S(V(0, -0.34, 0.008), 0.016, 0.014, 5),
+ S(V(0, -0.46, 0.012), 0.019, 0.018, 5),
+ ], { radial: 10, sub: 4 });
+ stick.add(mesh(shaft, mats.painted, 'stickShaft'));
+
+ // Paddle: the wide flat section between shaft and blade. This is the part
+ // that reads as "goalie stick" from twenty metres away, so it is generous.
+ const paddleGeo = loft([
+ S(V(0, -0.46, 0.01), 0.02, 0.03, 5, PAL.trim),
+ S(V(0.004, -0.50, 0.022), 0.019, 0.055, 6),
+ S(V(0.005, -0.515, 0.026), 0.019, 0.07, 6, PAL.base),
+ S(V(0.008, -0.60, 0.055), 0.018, 0.078, 6),
+ S(V(0.01, -0.65, 0.072), 0.017, 0.072, 6),
+ S(V(0.011, -0.665, 0.078), 0.017, 0.06, 6, PAL.trim),
+ ], { radial: 14, sub: 5 });
+ const paddle = mesh(paddleGeo, mats.painted, 'paddle');
+ stick.add(paddle);
+
+ // Blade, running across the crease with a curled toe.
+ const blade = loft([
+ S(V(0.012, -0.685, 0.02), 0.016, 0.03, 5, PAL.trim),
+ S(V(0.012, -0.695, 0.12), 0.015, 0.032, 5),
+ S(V(0.014, -0.695, 0.22), 0.014, 0.03, 5),
+ S(V(0.02, -0.688, 0.30), 0.012, 0.024, 4),
+ ], { radial: 12, sub: 5 });
+ stick.add(mesh(blade, mats.painted, 'stickBlade'));
+
+ // Knob at the top of the shaft.
+ stick.add(mesh(
+ loft([
+ S(V(0, 0.055, -0.002), 0.017, 0.015, 4, PAL.base),
+ S(V(0, 0.02, 0), 0.016, 0.014, 4),
+ ], { radial: 10, sub: 3 }),
+ mats.painted,
+ 'stickKnob',
+ ));
+
+ // Default grip: overwritten by the animator each frame, but a sane editor
+ // default (paddle toward the ice, slightly in front).
+ stick.position.set(0.03, -0.02, 0.04);
+ stick.rotation.set(0.9, 0.35, 0.55);
+ pieces.push(stick);
+ paddleMesh = paddle;
+ }
+
+ return {
+ padL,
+ padR,
+ trapper,
+ blocker,
+ mask,
+ chest,
+ floaterL,
+ floaterR,
+ stick,
+ paddle: paddleMesh,
+ pieces,
+
+ attachTo(bones) {
+ bones.shinL.add(padL);
+ bones.shinR.add(padR);
+ bones.handL.add(trapper);
+ bones.handR.add(blocker);
+ bones.handR.add(stick);
+ bones.head.add(mask);
+ bones.spine3.add(chest);
+ bones.upperArmL.add(floaterL);
+ bones.upperArmR.add(floaterR);
+ },
+
+ destroy() {
+ for (const p of pieces) p.removeFromParent();
+ for (const g of disposables) g.dispose();
+ },
+ };
+}
+
+export function buildGoalieMaterials(teamJersey, teamAccent = 0xf0e6d2) {
+ return {
+ kit: new THREE.MeshStandardMaterial({
+ color: teamJersey,
+ roughness: 0.72,
+ metalness: 0.04,
+ }),
+ /** Vertex-coloured gear: pads, mask shell, chest, paddle all share it. */
+ painted: new THREE.MeshStandardMaterial({
+ color: 0xffffff,
+ vertexColors: true,
+ roughness: 0.46,
+ metalness: 0.04,
+ }),
+ pad: new THREE.MeshStandardMaterial({
+ color: 0xf7f4ec,
+ roughness: 0.8,
+ metalness: 0.02,
+ }),
+ accent: new THREE.MeshStandardMaterial({
+ color: teamJersey,
+ roughness: 0.6,
+ metalness: 0.03,
+ }),
+ trimAccent: new THREE.MeshStandardMaterial({
+ color: teamAccent,
+ roughness: 0.7,
+ metalness: 0.02,
+ }),
+ leather: new THREE.MeshStandardMaterial({
+ color: 0x1a1a20,
+ roughness: 0.88,
+ metalness: 0.04,
+ }),
+ web: new THREE.MeshStandardMaterial({
+ color: 0xcfc3a8,
+ roughness: 0.92,
+ metalness: 0.0,
+ }),
+ cage: new THREE.MeshStandardMaterial({
+ color: 0x2a2e35,
+ roughness: 0.32,
+ metalness: 0.8,
+ }),
+ dark: new THREE.MeshStandardMaterial({
+ color: 0x121218,
+ roughness: 0.5,
+ metalness: 0.22,
+ }),
+ };
+}
diff --git a/src/character/skater.js b/src/character/skater.js
new file mode 100644
index 0000000..2c621fd
--- /dev/null
+++ b/src/character/skater.js
@@ -0,0 +1,334 @@
+import * as THREE from 'three';
+import { makeRng } from '../core/rng.js';
+import { disposeObject } from '../core/math.js';
+import { buildMaterials, paintUnderLayer } from '../render/materials.js';
+import { assertNoNaNBones, buildSkeleton } from './skeleton.js';
+import { buildBodyGeometry, buildBodyMesh } from './body.js';
+import { computeSkin } from './skinning.js';
+import { buildSkaterGear, buildSkaterGearMaterials, hideCoveredBody } from './skaterGear.js';
+import { buildAnimator } from '../anim/skateAnimator.js';
+import { REACTION_ATTACK, createRagdoll } from '../physics/ragdoll.js';
+import { createBodyProxy } from '../physics/bodyProxy.js';
+import { buildStick } from './stick.js';
+import { HIT } from '../game/hits.js';
+
+const clamp01 = (x) => (x < 0 ? 0 : x > 1 ? 1 : x);
+
+/**
+ * One skater: mesh, skeleton, ragdoll, proxy capsule, animator.
+ *
+ * This is Ludus's `createFighter` with the loadout, armor, cloth and weapon
+ * systems removed — everything that remains is the part the hockey game needs.
+ * Rebuilding one is a full teardown: geometry and skin weights are derived from
+ * the seed, so there is no partial-update path worth the complexity.
+ *
+ * What it does *not* own: position, velocity, or any decision. Those live in
+ * the sim state and the brain, and arrive here through `applyState`.
+ */
+export function createSkater({
+ seed,
+ scene,
+ physics,
+ index = 0,
+ team = 0,
+ position = { x: 0, z: 0 },
+ facing = 0,
+ bodyStyle = null,
+}) {
+ const rng = makeRng(seed);
+ const materials = buildMaterials(rng, team);
+ const skelData = buildSkeleton();
+
+ const mover = new THREE.Group();
+ mover.name = 'skater:' + index;
+ mover.position.set(position.x, 0, position.z);
+ mover.rotation.y = facing;
+ scene.add(mover);
+
+ const bodyGeo = buildBodyGeometry(rng, bodyStyle);
+ computeSkin(bodyGeo, skelData);
+ paintUnderLayer(bodyGeo, { skinColor: materials.skinColor });
+ const bodyMesh = buildBodyMesh(bodyGeo, skelData, materials);
+ mover.add(bodyMesh);
+
+ // Kit over the top: cloth skinned to the same skeleton, hard shells socketed
+ // to the bones they never bend away from. Sized off the physique the body
+ // loft was built from, so a heavy build gets a bigger jersey.
+ const gearMats = buildSkaterGearMaterials(materials.team.jersey, materials.team.accent);
+ const gear = buildSkaterGear(gearMats, skelData, bodyGeo.userData.physique);
+ gear.attachTo(skelData.bones, mover);
+ // Everything the kit encloses stops being drawn — no body poking through a
+ // seam when a shoulder rolls, and a good chunk of the body's triangles saved.
+ hideCoveredBody(bodyGeo);
+
+ const animator = buildAnimator(skelData, mover);
+ animator.setTransform(mover.position, facing);
+
+ // Socketed to the right hand, not to the mover: the arm pose decides where
+ // the stick is, which is the correct dependency order and the only way the
+ // hands can actually be on it.
+ const stick = buildStick(materials, physics, index);
+ stick.attachTo(skelData.bones.handR);
+ stick.setGrip('carry');
+ animator.stick = stick;
+
+ mover.updateMatrixWorld(true);
+ assertNoNaNBones(skelData);
+
+ // The 18-capsule rig, kinematic and chasing the animation. Nothing pushes it
+ // yet; it is here so that when hits land in a later spike the bodies, joints
+ // and limits already exist and are already in the right place.
+ const ragdoll = physics ? createRagdoll(physics, skelData, { skaterIndex: index }) : null;
+ // The one dynamic body. This is what the boards and other skaters actually
+ // collide with.
+ const proxy = physics ? createBodyProxy(physics, { index, position }) : null;
+
+ const _look = new THREE.Vector3();
+ const _moverInv = new THREE.Matrix4();
+ const _pelvis = new THREE.Vector3();
+ const _chest = new THREE.Vector3();
+ const _flat = new THREE.Vector3();
+ const _scale = new THREE.Vector3();
+ const _rootWorld = new THREE.Matrix4();
+ const _correction = new THREE.Matrix4();
+
+ /**
+ * Stagger envelope: how much of the rendered pose physics owns, over time.
+ * Bites almost instantly, then decays back to the animation — anything
+ * slower on the attack reads as the skater choosing to flinch rather than
+ * being moved by the hit.
+ */
+ const reaction = { active: false, t: 0, duration: 0, weight: 0, peak: 0 };
+
+ function advanceReaction(dt) {
+ if (!reaction.active) return;
+ reaction.t += dt;
+ if (reaction.t >= reaction.duration) {
+ reaction.active = false;
+ reaction.weight = 0;
+ if (ragdoll && ragdoll.mode === 'reacting') {
+ ragdoll.setJointStiffness(0);
+ ragdoll.setMode('driven');
+ }
+ return;
+ }
+ reaction.weight = reaction.t < REACTION_ATTACK
+ ? reaction.peak * (reaction.t / REACTION_ATTACK)
+ : reaction.peak
+ * Math.pow(1 - (reaction.t - REACTION_ATTACK) / Math.max(1e-4, reaction.duration - REACTION_ATTACK), 1.6);
+ }
+
+ const skater = {
+ index,
+ seed,
+ team,
+ rng,
+ materials,
+ skelData,
+ mover,
+ bodyGeo,
+ bodyMesh,
+ gear,
+ animator,
+ ragdoll,
+ proxy,
+ stick,
+ reaction,
+
+ /** True while the ragdoll owns the skeleton and the proxy is switched off. */
+ limp: false,
+ /** Seconds left before a downed skater starts getting up. Null when up. */
+ downFor: null,
+ /** Seconds left of the get-up. Intent is damped while it runs. */
+ rising: 0,
+ /** The hit that put them here, for the HUD and for debugging. */
+ lastHit: null,
+
+ /**
+ * Push one frame of sim state into the presentation layer.
+ *
+ * `yawRate` is the turn rate of the *velocity* vector, not of the body:
+ * the animator banks the skater into the arc they are actually carving,
+ * which is not the same as the way they are pointing.
+ */
+ applyState(s, yawRate) {
+ _look.set(s.x, 0, s.z);
+ animator.setTransform(_look, s.yaw);
+ animator.moveSpeed = Math.hypot(s.vx, s.vz);
+ animator.bladeSpeed = s.bladeSpeed;
+ animator.effort = s.effort;
+ animator.yawRate = yawRate;
+ animator.braking = !!s.brake;
+ },
+
+ /** Advance animation, the reaction envelope, and the get-up timer. */
+ update(dt) {
+ if (!skater.limp) {
+ if (skater.rising > 0) skater.rising = Math.max(0, skater.rising - dt);
+ animator.update(dt);
+ advanceReaction(dt);
+ }
+ // Bone velocities are measured on the frame clock, continuously, even
+ // though they are only read at the moment a rig goes dynamic — they have
+ // to already be there when that moment arrives.
+ if (ragdoll && !skater.limp) ragdoll.sampleVelocities(dt);
+ },
+
+ /** Countdown while down; returns true on the frame they should get up. */
+ tickDown(dt) {
+ if (!skater.limp || skater.downFor == null) return false;
+ skater.downFor -= dt;
+ return skater.downFor <= 0;
+ },
+
+ /**
+ * Read the physics pose back onto the skeleton.
+ *
+ * Fully while limp; blended against the animated pose during a stagger, so
+ * a flinch deflects the body without erasing the skating underneath it.
+ */
+ syncFromPhysics() {
+ if (!ragdoll) return;
+ if (skater.limp) {
+ _moverInv.copy(mover.matrixWorld).invert();
+ ragdoll.syncToSkeleton(_moverInv);
+ mover.updateMatrixWorld(true);
+ } else if (reaction.active && reaction.weight > 0) {
+ _moverInv.copy(mover.matrixWorld).invert();
+ // Root excluded: displacing it slides the skater across the ice, which
+ // reads as teleporting rather than as being hit. The proxy owns
+ // position and has already taken the momentum from the collision.
+ ragdoll.blendToSkeleton(_moverInv, reaction.weight, { includeRoot: false });
+ mover.updateMatrixWorld(true);
+ }
+ },
+
+ /**
+ * Take a hit without going down: the rig goes dynamic with stiff joints for
+ * a moment, then is blended back onto the animation.
+ */
+ stagger(hit) {
+ if (!ragdoll || skater.limp) return;
+ skater.lastHit = hit;
+ const s = clamp01((hit.severity - HIT.bump) / (HIT.knockdown - HIT.bump));
+ reaction.active = true;
+ reaction.t = 0;
+ reaction.peak = 0.38 + 0.5 * s;
+ reaction.duration = 0.3 + 0.5 * s;
+ ragdoll.setJointStiffness(HIT.staggerStiffness);
+ ragdoll.setMode('reacting');
+ },
+
+ /**
+ * Go down.
+ *
+ * The handoff: the ragdoll goes dynamic and becomes the body, and the proxy
+ * capsule is switched off. Leaving the proxy enabled would have two bodies
+ * claiming the same skater — the sim would keep driving a capsule around
+ * the rink while the visible ragdoll lay on the ice behind it.
+ */
+ goDown(hit) {
+ if (!ragdoll || skater.limp) return;
+ skater.lastHit = hit ?? null;
+ skater.limp = true;
+ skater.downFor = HIT.downTime;
+ skater.rising = 0;
+ reaction.active = false;
+ reaction.weight = 0;
+ ragdoll.setJointStiffness(0);
+ ragdoll.setMode('limp');
+ proxy?.disable();
+ },
+
+ /**
+ * Get back up.
+ *
+ * The reverse handoff, and the fiddly half of it. The naive version — read
+ * the pelvis, move the sim there, crossfade — makes the skater visibly fly
+ * out and snap back, for a reason worth writing down:
+ *
+ * While limp, the ragdoll writes the body's displacement into the *root
+ * bone*, because the mover has been parked where they fell for the whole
+ * knockdown. So the world pose is `moverAtFallPosition × bigRootOffset`.
+ * Teleporting the mover onto the pelvis without touching that offset applies
+ * the displacement a second time — the body jumps by however far it slid —
+ * and the crossfade then drags it back as the root offset decays to its
+ * skating value.
+ *
+ * The fix is to re-express the root in the *new* mover frame so the world
+ * pose across the handoff is bit-for-bit identical. Then the crossfade has
+ * no position to undo and only has to interpolate lying → skating, which is
+ * the movement we actually want to see.
+ */
+ getUp(state) {
+ if (!ragdoll || !skater.limp) return;
+
+ mover.updateMatrixWorld(true);
+ const root = skelData.bones.root;
+ const pelvisBone = ragdoll.parts.pelvis.bone;
+ pelvisBone.getWorldPosition(_pelvis);
+
+ // Which way is this body pointing? The pelvis' own forward axis is no use
+ // — on someone lying face-down it points at the ice. The pelvis→chest
+ // line flattened onto the ice is the body's long axis and survives any
+ // orientation, so a skater stands up facing the way they were sprawled
+ // rather than spinning on the spot to recover a stale yaw.
+ ragdoll.parts.spine3.bone.getWorldPosition(_chest);
+ _flat.set(_chest.x - _pelvis.x, 0, _chest.z - _pelvis.z);
+ const yaw = _flat.lengthSq() > 1e-4
+ ? Math.atan2(_flat.x, _flat.z)
+ : (state?.yaw ?? animator.originYaw);
+
+ // Remember the root's exact world transform before anything moves.
+ root.updateWorldMatrix(true, false);
+ _rootWorld.copy(root.matrixWorld);
+
+ // Move the mover onto the body, now, rather than letting the animator do
+ // it next frame — the correction below has to be computed against the
+ // frame the pose will actually be drawn in.
+ mover.position.set(_pelvis.x, 0, _pelvis.z);
+ mover.rotation.set(0, yaw, 0);
+ mover.updateMatrixWorld(true);
+ animator.setTransform(mover.position, yaw);
+
+ // Re-express the root so the skeleton lands in exactly the same world
+ // pose it was already in.
+ _moverInv.copy(mover.matrixWorld).invert();
+ _correction.multiplyMatrices(_moverInv, _rootWorld);
+ _correction.decompose(root.position, root.quaternion, _scale);
+ mover.updateMatrixWorld(true);
+
+ skater.limp = false;
+ skater.downFor = null;
+ // Counted down in update(); the match damps intent while it runs so they
+ // stand up where they fell instead of skating off mid-rise.
+ skater.rising = HIT.riseTime;
+ ragdoll.setJointStiffness(0);
+ // Snaps the bodies onto the skeleton — which has not moved in world
+ // space, so this costs nothing and cannot fling anything.
+ ragdoll.setMode('driven');
+
+ if (state) {
+ state.x = _pelvis.x;
+ state.z = _pelvis.z;
+ state.yaw = yaw;
+ state.vx = 0;
+ state.vz = 0;
+ }
+ proxy?.enable(_pelvis.x, _pelvis.z);
+ animator.rebase(HIT.riseTime);
+ },
+
+ dispose() {
+ stick.destroy(physics?.api);
+ gear.destroy();
+ for (const m of Object.values(gearMats)) m.dispose();
+ if (ragdoll) ragdoll.destroy();
+ if (proxy) proxy.destroy();
+ scene.remove(mover);
+ disposeObject(mover);
+ },
+ };
+
+ return skater;
+}
diff --git a/src/character/skaterGear.js b/src/character/skaterGear.js
new file mode 100644
index 0000000..5b8a808
--- /dev/null
+++ b/src/character/skaterGear.js
@@ -0,0 +1,693 @@
+import * as THREE from 'three';
+import { mergeGeoms } from '../core/math.js';
+import { PART } from './body.js';
+import { computeSkin } from './skinning.js';
+import { carvedShell, loft, mergeBars, tint, tube } from './gearMesh.js';
+
+/**
+ * Skater equipment, in layers.
+ *
+ * A hockey player is dressed, not painted, and the order is the order it goes
+ * on in a dressing room:
+ *
+ * 1. shoulder pads and elbow caps — the under layer that gives the torso its
+ * shape. Mostly hidden, which is the point: the jersey drapes over it.
+ * 2. jersey — long sleeves, hem past the waist, cut wide enough to clear the
+ * pads underneath.
+ * 3. pants — waist-high padded shorts down to just above the knee.
+ * 4. socks over shin guards, taped at the top and bottom of the wrap.
+ * 5. skates, gloves, helmet.
+ *
+ * ### Skinned vs socketed
+ *
+ * Anything that crosses a joint is skinned to the same skeleton the body uses
+ * (`computeSkin`, then bound as a second SkinnedMesh sharing `skelData`). A
+ * jersey bolted to the chest bone tears open at the shoulder the first time an
+ * arm swings; a pant leg bolted to the pelvis passes through the thigh on a
+ * knee bend. Cloth is authored in rest space, exactly like the body geometry.
+ *
+ * Boots, gloves and the helmet are rigid shells that genuinely do not bend, so
+ * they are socketed to the foot, hand and head bones and cost nothing to skin.
+ *
+ * ### Fit
+ *
+ * Every radius scales off the physique factors the body loft was built from
+ * (`bodyGeo.userData.physique`), so a heavy build gets a bigger jersey instead
+ * of wearing its chest through the front of it.
+ */
+
+/** Rest direction the upper arm points, in its own bone space (A-pose). */
+const ARM_DIR = {
+ L: new THREE.Vector3(0.15, -0.252, 0.01).normalize(),
+ R: new THREE.Vector3(-0.15, -0.252, 0.01).normalize(),
+};
+/** Rest direction the fingers point, from the hand bone. */
+const HAND_DIR = {
+ L: new THREE.Vector3(0.045, -0.095, 0.008).normalize(),
+ R: new THREE.Vector3(-0.045, -0.095, 0.008).normalize(),
+};
+const DOWN = new THREE.Vector3(0, -1, 0);
+
+export const KIT = {
+ helmet: {
+ /** Skull centre in head-bone-local space. */
+ riseY: 0.094,
+ pushZ: -0.004,
+ rx: 0.114,
+ ry: 0.148,
+ rz: 0.125,
+ /**
+ * Polar angle the shell starts at. This is the number that decides whether
+ * you get a helmet or a beanie: the bottom ring sits at
+ * riseY − ry·cos(phi0), so it has to come out *below* the ear line.
+ */
+ phi0: 0.36,
+ wall: 0.009,
+ /** Brow line: everything in front of and below this is open face. */
+ browY: 0.03,
+ earY: -0.022,
+ },
+ /** Blade bottom, in foot-bone-local metres. Feet plant at y ≈ 0.09. */
+ bladeY: -0.09,
+};
+
+/**
+ * What the kit covers, as `aT` ranges per body part.
+ *
+ * The body underneath a dressed skater is wasted work and a source of
+ * poke-through: a shoulder rolls, a hip flexes, and a sliver of the layer below
+ * pushes through a seam. Ludus solved it by dropping the covered body faces
+ * once the clothing went on, and the same applies here.
+ *
+ * Ranges are deliberately short of the seams. A triangle is only dropped when
+ * *all three* of its vertices are covered, which leaves a one-triangle fringe
+ * under every edge of the gear — cheap insurance against a gap opening up at
+ * the collar or the cuff when the pose moves.
+ */
+export const COVERAGE = {
+ // Jersey and pants, up to the collar. The neck and above stay.
+ [PART.TORSO]: [0.0, 0.9],
+ // Sleeve and glove, deltoid to fingertips. The shoulder ball has to be in
+ // here: it is the widest thing on the arm and it sits exactly where the
+ // sleeve meets the yoke, so leaving it visible shows it through the seam.
+ [PART.ARM_L]: [0.0, 1.0],
+ [PART.ARM_R]: [0.0, 1.0],
+ // Pants, socks and boots enclose the leg end to end.
+ [PART.LEG_L]: [0.0, 1.0],
+ [PART.LEG_R]: [0.0, 1.0],
+};
+
+/**
+ * Drop the body faces the kit covers. Call after `computeSkin` and after the
+ * body has been painted — it only rewrites the index.
+ */
+export function hideCoveredBody(geo, coverage = COVERAGE) {
+ const partAttr = geo.attributes.aPart;
+ const tAttr = geo.attributes.aT;
+ if (!partAttr || !tAttr || !geo.index) return geo;
+
+ const covered = (v) => {
+ const range = coverage[partAttr.getX(v)];
+ if (!range) return false;
+ const t = tAttr.getX(v);
+ return t >= range[0] && t <= range[1];
+ };
+
+ const idx = geo.index.array;
+ const keep = [];
+ for (let f = 0; f < idx.length; f += 3) {
+ const a = idx[f];
+ const b = idx[f + 1];
+ const c = idx[f + 2];
+ if (covered(a) && covered(b) && covered(c)) continue;
+ keep.push(a, b, c);
+ }
+ geo.setIndex(keep);
+ return geo;
+}
+
+/**
+ * @param {*} mats from `buildSkaterGearMaterials`
+ * @param {*} skelData the skeleton the cloth binds to
+ * @param {{bulk:number,waistF:number,shoulderF:number,armF:number,legF:number,headF:number}} phys
+ */
+export function buildSkaterGear(mats, skelData, phys) {
+ const pieces = [];
+ const skinned = [];
+ const disposables = [];
+
+ const bulk = phys?.bulk ?? 1;
+ const shoulder = (phys?.shoulderF ?? 1) * bulk;
+ const waist = (phys?.waistF ?? 1) * bulk;
+ const armF = phys?.armF ?? 1;
+ const legF = phys?.legF ?? 1;
+ const headF = phys?.headF ?? 1;
+
+ const PAL = {
+ jersey: tint(mats.jersey.color),
+ accent: tint(mats.accent.color),
+ trim: tint(mats.trim.color),
+ pad: tint(mats.pad.color),
+ tape: tint(mats.tape.color),
+ };
+
+ const V = (x, y, z = 0) => new THREE.Vector3(x, y, z);
+ const S = (c, rx, rz, e, col) => ({ c, rx, rz, e, col });
+
+ function mesh(geo, mat, name) {
+ const m = new THREE.Mesh(geo, mat);
+ m.name = name;
+ m.castShadow = true;
+ m.receiveShadow = true;
+ disposables.push(geo);
+ return m;
+ }
+
+ /** Point a group's −Y down a bone's real limb direction. */
+ function alignTo(group, dir) {
+ group.quaternion.setFromUnitVectors(DOWN, dir);
+ return group;
+ }
+
+ /**
+ * Merge rest-space pieces, solve skin weights, and bind to the body's
+ * skeleton. `computeSkin` overwrites the colour attribute with its debug
+ * heatmap, so the kit colours are stashed and put back afterwards — same
+ * dance `paintKit` does for the body.
+ */
+ function skin(parts, mat, name) {
+ const geo = mergeGeoms(parts);
+ for (const p of parts) p.dispose();
+ const colors = geo.attributes.color.array.slice();
+ computeSkin(geo, skelData);
+ geo.userData.heatColors = geo.attributes.color.array.slice();
+ geo.setAttribute('color', new THREE.BufferAttribute(colors, 3));
+ geo.computeVertexNormals();
+
+ const m = new THREE.SkinnedMesh(geo, mat);
+ m.name = name;
+ m.castShadow = true;
+ m.receiveShadow = true;
+ m.frustumCulled = false;
+ // Bound before parenting, so the bind matrix is identity — matching the
+ // body mesh. The root bone stays parented to the body; a second mesh only
+ // borrows the skeleton.
+ m.updateMatrixWorld(true);
+ m.bind(skelData.skeleton, m.matrixWorld.clone());
+ disposables.push(geo);
+ skinned.push(m);
+ pieces.push(m);
+ return m;
+ }
+
+ // ---- 1. under layer: shoulder pads -------------------------------------
+ // Sits between skin and jersey. Barely seen, but it is what makes the jersey
+ // sit square across the shoulders instead of shrink-wrapping the deltoids.
+ // Kept a clear centimetre inside the jersey at every ring. Two skinned
+ // meshes never deform identically — their vertices sit in different places,
+ // so the distance-field solve hands them different weights — and a pad that
+ // merely *touches* the inside of a sweater will tear through it on a shoulder
+ // roll. What actually shows is the collar, standing above the neckline.
+ const padChest = loft([
+ S(V(0, 1.18, 0.006), 0.156 * bulk, 0.108 * bulk, 4, PAL.pad),
+ S(V(0, 1.26, 0.008), 0.17 * shoulder, 0.116 * bulk, 4),
+ S(V(0, 1.335, 0.008), 0.186 * shoulder, 0.12 * bulk, 4),
+ S(V(0, 1.392, 0.01), 0.16 * shoulder, 0.106 * bulk, 4),
+ S(V(0, 1.428, 0.012), 0.1 * bulk, 0.09 * bulk, 3),
+ S(V(0, 1.452, 0.013), 0.094 * bulk, 0.085 * bulk, 3),
+ ], { radial: 16, sub: 3, part: PART.TORSO, t0: 0.5, t1: 0.96 });
+ skin([padChest], mats.padded, 'shoulderPads');
+
+ // Deltoid caps ride the upper arms so they follow the shoulder, not the ribs.
+ function makeCap(side) {
+ const g = new THREE.Group();
+ g.name = `shoulderCap${side}`;
+ // Kept under the sleeve radius at every ring: the cap is rigid on the bone
+ // and the sleeve is skinned, so anything close to the same size pushes
+ // through the cloth the moment the arm swings.
+ const cap = loft([
+ S(V(0, 0.04, 0.008), 0.062 * armF, 0.058 * armF, 3, PAL.pad),
+ S(V(0, -0.025, 0.01), 0.074 * armF, 0.07 * armF, 4),
+ S(V(0, -0.09, 0.008), 0.068 * armF, 0.064 * armF, 4),
+ S(V(0, -0.14, 0.006), 0.054 * armF, 0.05 * armF, 3),
+ ], { radial: 14, sub: 3 });
+ g.add(mesh(cap, mats.padded, `shoulderCap${side}Shell`));
+ alignTo(g, ARM_DIR[side]);
+ pieces.push(g);
+ return g;
+ }
+ const capL = makeCap('L');
+ const capR = makeCap('R');
+
+ // ---- 2. jersey ----------------------------------------------------------
+ // Torso plus two long sleeves, merged into one skinned mesh. Waist stripes
+ // and cuff bands are cut the same way the goalie's pad bands are: two
+ // sections a centimetre apart.
+ const jerseyParts = [];
+ jerseyParts.push(loft([
+ // Hem hangs over the pants, so it has to clear the widest part of them.
+ S(V(0, 0.878, 0.004), 0.226 * bulk, 0.17 * bulk, 4, PAL.jersey),
+ S(V(0, 0.905, 0.004), 0.232 * bulk, 0.174 * bulk, 4, PAL.accent),
+ S(V(0, 0.94, 0.004), 0.233 * bulk, 0.175 * bulk, 4),
+ S(V(0, 0.95, 0.004), 0.232 * bulk, 0.174 * bulk, 4, PAL.trim),
+ S(V(0, 0.98, 0.005), 0.229 * bulk, 0.171 * bulk, 4),
+ S(V(0, 0.99, 0.005), 0.228 * bulk, 0.17 * bulk, 4, PAL.jersey),
+ S(V(0, 1.075, 0.005), 0.207 * waist, 0.152 * waist, 4),
+ S(V(0, 1.165, 0.007), 0.202 * bulk, 0.148 * bulk, 4),
+ S(V(0, 1.255, 0.009), 0.212 * bulk, 0.155 * bulk, 4),
+ // Over the shoulder pads — the widest point of a dressed player.
+ S(V(0, 1.335, 0.01), 0.242 * shoulder, 0.16 * bulk, 5),
+ S(V(0, 1.395, 0.012), 0.222 * shoulder, 0.142 * bulk, 4),
+ S(V(0, 1.418, 0.013), 0.17 * shoulder, 0.12 * bulk, 4),
+ S(V(0, 1.432, 0.013), 0.108 * bulk, 0.098 * bulk, 3, PAL.trim),
+ S(V(0, 1.462, 0.014), 0.098 * bulk, 0.09 * bulk, 3),
+ ], { radial: 20, sub: 3, part: PART.TORSO, t0: 0.0, t1: 0.98 }));
+
+ for (const side of ['L', 'R']) {
+ const s = side === 'L' ? 1 : -1;
+ const P = (x, y, z = 0) => V(s * x, y, z);
+ jerseyParts.push(loft([
+ // Wide enough at the top to swallow the deltoid ball, and buried in the
+ // torso shell so the shoulder seam never opens.
+ S(P(0.10, 1.415, 0.008), 0.108 * armF, 0.10 * armF, 3, PAL.jersey),
+ S(P(0.175, 1.385, 0.01), 0.118 * armF, 0.112 * armF, 3),
+ S(P(0.245, 1.325, 0.01), 0.105 * armF, 0.10 * armF, 3),
+ S(P(0.30, 1.27, 0.01), 0.09 * armF, 0.086 * armF, 3),
+ S(P(0.355, 1.16, 0.012), 0.072 * armF, 0.068 * armF, 3),
+ // Elbow cap under the sleeve.
+ S(P(0.397, 1.095, 0.013), 0.076 * armF, 0.072 * armF, 3),
+ S(P(0.447, 0.985, 0.016), 0.064 * armF, 0.06 * armF, 3),
+ S(P(0.472, 0.93, 0.018), 0.058 * armF, 0.055 * armF, 3, PAL.accent),
+ S(P(0.487, 0.898, 0.02), 0.057 * armF, 0.054 * armF, 3),
+ S(P(0.497, 0.876, 0.022), 0.056 * armF, 0.053 * armF, 3, PAL.trim),
+ S(P(0.512, 0.844, 0.024), 0.053 * armF, 0.05 * armF, 3),
+ ], {
+ radial: 14,
+ sub: 3,
+ part: side === 'L' ? PART.ARM_L : PART.ARM_R,
+ t0: 0.1,
+ t1: 0.94,
+ }));
+ }
+ skin(jerseyParts, mats.cloth, 'jersey');
+
+ // ---- 3. pants -----------------------------------------------------------
+ // Waist-high padded shorts: a hip shell plus two thigh tubes that stop above
+ // the knee. Stiff, so they are wide and barely taper.
+ const pantParts = [];
+ pantParts.push(loft([
+ S(V(0, 1.115, 0.004), 0.178 * waist, 0.132 * waist, 4, PAL.trim),
+ S(V(0, 1.09, 0.004), 0.186 * waist, 0.138 * waist, 4),
+ S(V(0, 1.08, 0.004), 0.19 * waist, 0.142 * waist, 4, PAL.accent),
+ S(V(0, 1.055, 0.005), 0.196 * waist, 0.146 * waist, 4),
+ S(V(0, 1.045, 0.005), 0.198 * waist, 0.148 * waist, 4, PAL.trim),
+ S(V(0, 0.99, 0.005), 0.205 * bulk, 0.152 * bulk, 5),
+ S(V(0, 0.94, 0.005), 0.207 * bulk, 0.154 * bulk, 5),
+ S(V(0, 0.90, 0.004), 0.198 * bulk, 0.146 * bulk, 5),
+ ], { radial: 18, sub: 3, part: PART.TORSO, t0: 0.02, t1: 0.34 }));
+
+ for (const side of ['L', 'R']) {
+ const s = side === 'L' ? 1 : -1;
+ const P = (x, y, z = 0) => V(s * x, y, z);
+ pantParts.push(loft([
+ S(P(0.098, 0.97, 0.004), 0.142 * legF, 0.132 * legF, 4, PAL.trim),
+ S(P(0.112, 0.90, 0.006), 0.138 * legF, 0.13 * legF, 4),
+ S(P(0.12, 0.80, 0.008), 0.13 * legF, 0.122 * legF, 4),
+ S(P(0.126, 0.71, 0.008), 0.122 * legF, 0.114 * legF, 4),
+ S(P(0.127, 0.688, 0.008), 0.119 * legF, 0.111 * legF, 4, PAL.accent),
+ S(P(0.128, 0.668, 0.008), 0.116 * legF, 0.108 * legF, 4),
+ S(P(0.1285, 0.658, 0.008), 0.114 * legF, 0.106 * legF, 4, PAL.trim),
+ S(P(0.129, 0.645, 0.008), 0.112 * legF, 0.104 * legF, 4),
+ ], {
+ radial: 14,
+ sub: 3,
+ part: side === 'L' ? PART.LEG_L : PART.LEG_R,
+ t0: 0.02,
+ t1: 0.34,
+ }));
+ }
+ skin(pantParts, mats.padded, 'pants');
+
+ // ---- 4. socks over shin guards -----------------------------------------
+ // The sock is the visible layer; the guard underneath is read as the bulge at
+ // the knee and the flat down the front of the shin. Tape bands at the top and
+ // bottom of the wrap, where a player actually tapes.
+ const sockParts = [];
+ for (const side of ['L', 'R']) {
+ const s = side === 'L' ? 1 : -1;
+ const part = side === 'L' ? PART.LEG_L : PART.LEG_R;
+ const P = (x, y, z = 0) => V(s * x, y, z);
+ sockParts.push(loft([
+ S(P(0.124, 0.735, 0.008), 0.098 * legF, 0.094 * legF, 3, PAL.jersey),
+ S(P(0.128, 0.66, 0.01), 0.094 * legF, 0.09 * legF, 3),
+ // Tape at the top of the wrap.
+ S(P(0.129, 0.638, 0.01), 0.093 * legF, 0.089 * legF, 3, PAL.tape),
+ S(P(0.13, 0.60, 0.012), 0.092 * legF, 0.088 * legF, 3),
+ S(P(0.13, 0.578, 0.012), 0.092 * legF, 0.088 * legF, 3, PAL.jersey),
+ // Knee.
+ S(P(0.131, 0.53, 0.016), 0.096 * legF, 0.094 * legF, 3),
+ S(P(0.132, 0.45, 0.014), 0.086 * legF, 0.082 * legF, 3),
+ S(P(0.133, 0.35, 0.01), 0.079 * legF, 0.074 * legF, 3),
+ S(P(0.133, 0.26, 0.006), 0.072 * legF, 0.066 * legF, 3),
+ // Tape at the bottom of the wrap.
+ S(P(0.133, 0.232, 0.005), 0.07 * legF, 0.064 * legF, 3, PAL.tape),
+ S(P(0.132, 0.20, 0.004), 0.068 * legF, 0.062 * legF, 3),
+ S(P(0.132, 0.18, 0.003), 0.066 * legF, 0.06 * legF, 3, PAL.jersey),
+ S(P(0.131, 0.135, 0.002), 0.06 * legF, 0.056 * legF, 3),
+ S(P(0.131, 0.105, 0.004), 0.056 * legF, 0.052 * legF, 3, PAL.trim),
+ ], { radial: 14, sub: 3, part, t0: 0.30, t1: 0.87 }));
+
+ // Knee cap: a dome off the front of the wrap.
+ sockParts.push(loft([
+ S(P(0.131, 0.545, 0.02), 0.062 * legF, 0.058 * legF, 3, PAL.jersey),
+ S(P(0.131, 0.542, 0.058), 0.07 * legF, 0.066 * legF, 3),
+ S(P(0.131, 0.538, 0.088), 0.058 * legF, 0.054 * legF, 3),
+ S(P(0.131, 0.534, 0.104), 0.03 * legF, 0.028 * legF, 3),
+ ], { radial: 14, sub: 3, part, t0: 0.48, t1: 0.54 }));
+ }
+ skin(sockParts, mats.cloth, 'socks');
+
+ // ---- 5. skates ----------------------------------------------------------
+ // Foot-bone local: +Z is forward past the toe, the sole sits a little under
+ // the bone, the blade hangs where the ice is.
+ function makeSkate(side) {
+ const g = new THREE.Group();
+ g.name = `skate${side}`;
+
+ const boot = loft([
+ S(V(0, -0.014, -0.088), 0.036, 0.042, 4, PAL.trim),
+ S(V(0, -0.02, -0.05), 0.046, 0.05, 4),
+ S(V(0, -0.026, 0.01), 0.05, 0.048, 4),
+ S(V(0, -0.03, 0.07), 0.048, 0.042, 4),
+ S(V(0, -0.034, 0.125), 0.04, 0.032, 4),
+ S(V(0, -0.038, 0.162), 0.022, 0.018, 3),
+ ], { radial: 16, sub: 4 });
+ g.add(mesh(boot, mats.hard, `skate${side}Boot`));
+
+ // Ankle cuff — the kit stops at the ankle, as asked.
+ const cuff = loft([
+ S(V(0, -0.012, -0.05), 0.048, 0.05, 4, PAL.trim),
+ S(V(0, 0.03, -0.045), 0.05, 0.048, 4),
+ S(V(0, 0.062, -0.038), 0.047, 0.044, 4, PAL.pad),
+ S(V(0, 0.078, -0.032), 0.041, 0.038, 3),
+ ], { radial: 14, sub: 3 });
+ g.add(mesh(cuff, mats.hard, `skate${side}Cuff`));
+
+ // Tongue up the front of the ankle.
+ const tongue = loft([
+ S(V(0, -0.01, 0.03), 0.03, 0.014, 3, PAL.trim),
+ S(V(0, 0.03, 0.012), 0.033, 0.015, 3),
+ S(V(0, 0.07, 0.0), 0.031, 0.014, 3, PAL.accent),
+ ], { radial: 10, sub: 3 });
+ g.add(mesh(tongue, mats.hard, `skate${side}Tongue`));
+
+ // Holder: two posts off the sole down to the runner.
+ const holder = [];
+ for (const z of [-0.045, 0.085]) {
+ holder.push(tube([
+ V(0, -0.05, z),
+ V(0, -0.062, z + (z < 0 ? 0.008 : -0.008)),
+ V(0, -0.072, z + (z < 0 ? 0.012 : -0.012)),
+ ], 0.011, { radial: 6 }));
+ }
+ holder.push(tube([
+ V(0, -0.073, -0.075), V(0, -0.076, 0), V(0, -0.073, 0.13),
+ ], 0.008, { radial: 6 }));
+ g.add(mesh(mergeBars(holder), mats.holder, `skate${side}Holder`));
+
+ // Runner: a thin steel blade with the toe and heel curling up off the ice.
+ const blade = loft([
+ S(V(0, KIT.bladeY + 0.028, -0.108), 0.0035, 0.012, 3, PAL.trim),
+ S(V(0, KIT.bladeY + 0.012, -0.088), 0.0035, 0.013, 3),
+ S(V(0, KIT.bladeY + 0.012, 0.12), 0.0035, 0.013, 3),
+ S(V(0, KIT.bladeY + 0.03, 0.145), 0.0035, 0.012, 3),
+ ], { radial: 6, sub: 4 });
+ g.add(mesh(blade, mats.steel, `skate${side}Blade`));
+
+ // Laces.
+ const laces = [];
+ for (const y of [0.0, 0.022, 0.044]) {
+ laces.push(tube([
+ V(-0.03, y - 0.005, 0.03 - y * 0.4),
+ V(0, y + 0.004, 0.022 - y * 0.4),
+ V(0.03, y - 0.005, 0.03 - y * 0.4),
+ ], 0.004, { radial: 5 }));
+ }
+ g.add(mesh(mergeBars(laces), mats.lace, `skate${side}Laces`));
+
+ pieces.push(g);
+ return g;
+ }
+ const skateL = makeSkate('L');
+ const skateR = makeSkate('R');
+
+ // ---- 6. gloves ----------------------------------------------------------
+ // Glove space: fingers down −Y, back of the hand +Z, then rotated onto the
+ // hand bone's real axis. The stick is aimed from the same bone, so the glove
+ // has to stay a shell around the hand and not swallow the shaft.
+ function makeGlove(side) {
+ const s = side === 'L' ? 1 : -1;
+ const g = new THREE.Group();
+ g.name = `glove${side}`;
+
+ const body = loft([
+ // Flared cuff roll at the wrist.
+ S(V(0, 0.085, -0.004), 0.056, 0.054, 3, PAL.trim),
+ S(V(0, 0.062, -0.002), 0.068, 0.064, 3, PAL.accent),
+ S(V(0, 0.03, 0.002), 0.074, 0.068, 3),
+ S(V(0, 0.012, 0.004), 0.076, 0.07, 3, PAL.jersey),
+ S(V(0, -0.04, 0.01), 0.08, 0.068, 4),
+ S(V(0, -0.105, 0.014), 0.082, 0.066, 4),
+ S(V(0, -0.16, 0.014), 0.076, 0.06, 4),
+ S(V(0, -0.19, 0.012), 0.062, 0.05, 4, PAL.trim),
+ S(V(0, -0.215, 0.008), 0.042, 0.034, 3),
+ ], { radial: 16, sub: 4 });
+ g.add(mesh(body, mats.hard, `glove${side}Body`));
+
+ // Backhand rolls — the padded ridges across the knuckles.
+ for (const [y, r] of [[-0.06, 0.026], [-0.115, 0.024]]) {
+ const roll = loft([
+ S(V(-s * 0.058, y + 0.012, 0.05), r * 0.8, r * 0.7, 3, PAL.accent),
+ S(V(0, y, 0.062), r, r * 0.9, 3),
+ S(V(s * 0.058, y + 0.012, 0.05), r * 0.8, r * 0.7, 3),
+ ], { radial: 10, sub: 4 });
+ g.add(mesh(roll, mats.hard, `glove${side}Roll`));
+ }
+
+ // Thumb, curling toward the shaft.
+ const thumb = loft([
+ S(V(s * 0.058, -0.005, 0.03), 0.03, 0.028, 3, PAL.jersey),
+ S(V(s * 0.09, -0.065, 0.052), 0.028, 0.026, 3),
+ S(V(s * 0.092, -0.12, 0.066), 0.023, 0.022, 3, PAL.trim),
+ ], { radial: 10, sub: 4 });
+ g.add(mesh(thumb, mats.hard, `glove${side}Thumb`));
+
+ alignTo(g, HAND_DIR[side]);
+ g.rotateY(s * 0.25);
+ pieces.push(g);
+ return g;
+ }
+ const gloveL = makeGlove('L');
+ const gloveR = makeGlove('R');
+
+ // ---- 7. helmet ----------------------------------------------------------
+ // Same carved-shell builder as the goalie mask, cut differently: the whole
+ // lower front is open face, with ear ports at the sides.
+ const H = KIT.helmet;
+ const skull = new THREE.Vector3(0, H.riseY, H.pushZ);
+
+ function helmetSurface(theta, v, out) {
+ const phi = H.phi0 + (Math.PI - H.phi0) * v;
+ const sp = Math.sin(phi);
+ const cp = Math.cos(phi);
+ const f = Math.cos(theta);
+ const sx = Math.sin(theta);
+ const front = Math.max(0, f);
+ const back = Math.max(0, -f);
+
+ let rx = H.rx * headF;
+ let rz = H.rz * headF;
+ // Occipital shell carries out over the back of the skull.
+ rz *= 1 + 0.10 * back * v;
+ // Slight flat across the forehead.
+ rz *= 1 - 0.06 * front * front * v;
+
+ const x = rx * sp * sx;
+ const y = -H.ry * headF * cp;
+ let z = rz * sp * f;
+ // Brow lip juts forward over the eyes.
+ const lip = Math.exp(-(((v - 0.08) / 0.12) ** 2)) * front ** 2;
+ z += 0.008 * lip;
+
+ return out.set(skull.x + x, skull.y + y, skull.z + z);
+ }
+
+ /** Open face below the brow, plus a port over each ear. */
+ const helmetPort = (p) => {
+ const dy = p.y - skull.y;
+ const dz = p.z - skull.z;
+ const ax = Math.abs(p.x);
+ // The face: front-centre below the brow. Narrow, so the shell keeps its
+ // cheek coverage instead of turning into a cap.
+ if (dz > 0.028 && dy < H.browY && ax < 0.072) return true;
+ // Ear ports, covered by the cups.
+ if (ax > 0.088 && dy < H.earY + 0.026 && dy > H.earY - 0.042 && Math.abs(dz + 0.014) < 0.038) {
+ return true;
+ }
+ return false;
+ };
+
+ const helmetColor = (p, kind) => {
+ if (kind === 'inner') return PAL.pad;
+ const dy = p.y - skull.y;
+ // Dark brim around the bottom edge of the shell.
+ if (dy < -0.028) return PAL.trim;
+ // Centre stripe over the crown.
+ if (Math.abs(p.x) < 0.019 && dy > 0.03) return PAL.accent;
+ return PAL.jersey;
+ };
+
+ const helmet = new THREE.Group();
+ helmet.name = 'helmet';
+ helmet.add(mesh(
+ carvedShell({
+ rows: 26,
+ cols: 36,
+ thickness: H.wall,
+ center: skull,
+ surface: helmetSurface,
+ port: helmetPort,
+ color: helmetColor,
+ }),
+ mats.hard,
+ 'helmetShell',
+ ));
+
+ // Ear cups over the ports, on their own straps.
+ for (const s of [1, -1]) {
+ const cup = loft([
+ S(V(s * 0.09, skull.y + H.earY, skull.z - 0.014), 0.028, 0.026, 3, PAL.trim),
+ S(V(s * 0.104, skull.y + H.earY, skull.z - 0.014), 0.03, 0.028, 3),
+ S(V(s * 0.111, skull.y + H.earY, skull.z - 0.014), 0.023, 0.021, 3),
+ ], { radial: 12, sub: 3, ref: new THREE.Vector3(0, 1, 0) });
+ helmet.add(mesh(cup, mats.hard, 'helmetEar'));
+ }
+
+ // Chin strap under the jaw.
+ helmet.add(mesh(
+ tube([
+ V(-0.105, skull.y + H.earY - 0.012, skull.z - 0.01),
+ V(-0.07, skull.y - 0.12, skull.z + 0.03),
+ V(0, skull.y - 0.145, skull.z + 0.05),
+ V(0.07, skull.y - 0.12, skull.z + 0.03),
+ V(0.105, skull.y + H.earY - 0.012, skull.z - 0.01),
+ ], 0.006, { radial: 6 }),
+ mats.strap,
+ 'helmetStrap',
+ ));
+
+ // Half visor: eye level only. Run it down over the whole face and the player
+ // reads as a welder.
+ {
+ const arc = [];
+ for (let i = 0; i <= 10; i++) {
+ const a = -0.82 + (1.64 * i) / 10;
+ arc.push(V(
+ Math.sin(a) * 0.106 * headF,
+ skull.y + 0.004,
+ skull.z + Math.cos(a) * 0.116 * headF,
+ ));
+ }
+ // The ring axes here are u = up, w = front-to-back, so `rx` is the shield's
+ // height and `rz` is its thickness. Swap those two and you get a shelf
+ // sticking out of the face instead of a shield hanging over the eyes.
+ const visor = loft(
+ arc.map((c, i) => S(c, i === 0 || i === arc.length - 1 ? 0.026 : 0.038, 0.003, 3)),
+ { radial: 8, sub: 2, ref: new THREE.Vector3(0, 1, 0) },
+ );
+ helmet.add(mesh(visor, mats.visor, 'helmetVisor'));
+ }
+ pieces.push(helmet);
+
+ return {
+ padChest,
+ capL,
+ capR,
+ skateL,
+ skateR,
+ gloveL,
+ gloveR,
+ helmet,
+ /** Skinned cloth meshes — these go on the mover, not on a bone. */
+ skinned,
+ pieces,
+
+ attachTo(bones, mover) {
+ for (const m of skinned) mover.add(m);
+ bones.upperArmL.add(capL);
+ bones.upperArmR.add(capR);
+ bones.footL.add(skateL);
+ bones.footR.add(skateR);
+ bones.handL.add(gloveL);
+ bones.handR.add(gloveR);
+ bones.head.add(helmet);
+ },
+
+ destroy() {
+ for (const p of pieces) p.removeFromParent();
+ for (const g of disposables) g.dispose();
+ },
+ };
+}
+
+export function buildSkaterGearMaterials(teamJersey, teamAccent = 0xf0e6d2) {
+ return {
+ /** Cloth: jersey, socks. Vertex-coloured, matte. */
+ cloth: new THREE.MeshStandardMaterial({
+ color: 0xffffff,
+ vertexColors: true,
+ roughness: 0.88,
+ metalness: 0.0,
+ }),
+ /** Padded shells: pants, shoulder pads. */
+ padded: new THREE.MeshStandardMaterial({
+ color: 0xffffff,
+ vertexColors: true,
+ roughness: 0.72,
+ metalness: 0.02,
+ }),
+ /** Hard shells: helmet, skate boots, gloves. */
+ hard: new THREE.MeshStandardMaterial({
+ color: 0xffffff,
+ vertexColors: true,
+ roughness: 0.38,
+ metalness: 0.06,
+ }),
+ steel: new THREE.MeshStandardMaterial({
+ color: 0xc8ccd4,
+ roughness: 0.22,
+ metalness: 0.85,
+ }),
+ holder: new THREE.MeshStandardMaterial({
+ color: 0x16181d,
+ roughness: 0.45,
+ metalness: 0.1,
+ }),
+ lace: new THREE.MeshStandardMaterial({ color: 0xdad6cc, roughness: 0.9 }),
+ strap: new THREE.MeshStandardMaterial({ color: 0x14141a, roughness: 0.85 }),
+ visor: new THREE.MeshPhysicalMaterial({
+ color: 0x9fb8c8,
+ roughness: 0.08,
+ metalness: 0.0,
+ transparent: true,
+ opacity: 0.32,
+ side: THREE.DoubleSide,
+ }),
+ // Colour sources for the vertex-painted pieces.
+ jersey: new THREE.MeshStandardMaterial({ color: teamJersey }),
+ accent: new THREE.MeshStandardMaterial({ color: teamAccent }),
+ trim: new THREE.MeshStandardMaterial({ color: 0x16181d }),
+ pad: new THREE.MeshStandardMaterial({ color: 0x3a3f4a }),
+ tape: new THREE.MeshStandardMaterial({ color: 0xe8e4d8 }),
+ };
+}
diff --git a/src/character/skeleton.js b/src/character/skeleton.js
new file mode 100644
index 0000000..d7d1020
--- /dev/null
+++ b/src/character/skeleton.js
@@ -0,0 +1,114 @@
+import * as THREE from 'three';
+import { V3, assert } from '../core/math.js';
+
+// [name, parent, local offset] — rest local rotations are all identity, so the
+// rest pose is an A-pose and every rest world position falls out of the offsets.
+export const BONEDEF = [
+ ['root', null, [0, 0, 0]],
+ ['pelvis', 'root', [0, 1.0, 0]],
+ ['spine1', 'pelvis', [0, 0.09, 0.004]],
+ ['spine2', 'spine1', [0, 0.12, 0.005]],
+ ['spine3', 'spine2', [0, 0.13, 0.005]],
+ ['neck', 'spine3', [0, 0.1, 0.012]],
+ ['head', 'neck', [0, 0.075, 0.008]],
+ ['clavicleL', 'spine3', [0.075, 0.048, 0]],
+ ['upperArmL', 'clavicleL', [0.135, -0.022, 0]],
+ ['forearmL', 'upperArmL', [0.15, -0.252, 0.01]],
+ ['handL', 'forearmL', [0.105, -0.227, 0.016]],
+ ['clavicleR', 'spine3', [-0.075, 0.048, 0]],
+ ['upperArmR', 'clavicleR', [-0.135, -0.022, 0]],
+ ['forearmR', 'upperArmR', [-0.15, -0.252, 0.01]],
+ ['handR', 'forearmR', [-0.105, -0.227, 0.016]],
+ ['thighL', 'pelvis', [0.105, -0.05, 0.005]],
+ ['shinL', 'thighL', [0.02, -0.44, 0.006]],
+ ['footL', 'shinL', [0.005, -0.437, -0.012]],
+ ['toeL', 'footL', [-0.004, -0.055, 0.112]],
+ ['thighR', 'pelvis', [-0.105, -0.05, 0.005]],
+ ['shinR', 'thighR', [-0.02, -0.44, 0.006]],
+ ['footR', 'shinR', [-0.005, -0.437, -0.012]],
+ ['toeR', 'footR', [0.004, -0.055, 0.112]],
+];
+
+/** Child bone that defines each bone's capsule segment axis. */
+export const SEG_CHILD = {
+ pelvis: 'spine1', spine1: 'spine2', spine2: 'spine3', spine3: 'neck', neck: 'head',
+ clavicleL: 'upperArmL', upperArmL: 'forearmL', forearmL: 'handL',
+ clavicleR: 'upperArmR', upperArmR: 'forearmR', forearmR: 'handR',
+ thighL: 'shinL', shinL: 'footL', footL: 'toeL',
+ thighR: 'shinR', shinR: 'footR', footR: 'toeR',
+ root: null, head: null, handL: null, handR: null, toeL: null, toeR: null,
+};
+
+/** Per-bone skin influence radius for the capsule falloff. */
+export const BONE_RADIUS = {
+ root: 0.2, pelvis: 0.175, spine1: 0.165, spine2: 0.17, spine3: 0.175, neck: 0.08, head: 0.125,
+ clavicleL: 0.07, upperArmL: 0.078, forearmL: 0.068, handL: 0.06,
+ clavicleR: 0.07, upperArmR: 0.078, forearmR: 0.068, handR: 0.06,
+ thighL: 0.125, shinL: 0.098, footL: 0.075, toeL: 0.055,
+ thighR: 0.125, shinR: 0.098, footR: 0.075, toeR: 0.055,
+};
+
+/**
+ * Body regions from GDD 5.3. Every bone belongs to exactly one region, and
+ * damage, armor coverage and ragdoll limb-disable all key off these.
+ */
+export const REGION = {
+ HEAD: 'head',
+ TORSO: 'torso',
+ UPPER_ARM_L: 'upperArmL', LOWER_ARM_L: 'lowerArmL',
+ UPPER_ARM_R: 'upperArmR', LOWER_ARM_R: 'lowerArmR',
+ UPPER_LEG_L: 'upperLegL', LOWER_LEG_L: 'lowerLegL',
+ UPPER_LEG_R: 'upperLegR', LOWER_LEG_R: 'lowerLegR',
+};
+
+export const BONE_REGION = {
+ head: REGION.HEAD, neck: REGION.HEAD,
+ pelvis: REGION.TORSO, spine1: REGION.TORSO, spine2: REGION.TORSO, spine3: REGION.TORSO,
+ clavicleL: REGION.TORSO, clavicleR: REGION.TORSO,
+ upperArmL: REGION.UPPER_ARM_L, forearmL: REGION.LOWER_ARM_L, handL: REGION.LOWER_ARM_L,
+ upperArmR: REGION.UPPER_ARM_R, forearmR: REGION.LOWER_ARM_R, handR: REGION.LOWER_ARM_R,
+ thighL: REGION.UPPER_LEG_L, shinL: REGION.LOWER_LEG_L, footL: REGION.LOWER_LEG_L, toeL: REGION.LOWER_LEG_L,
+ thighR: REGION.UPPER_LEG_R, shinR: REGION.LOWER_LEG_R, footR: REGION.LOWER_LEG_R, toeR: REGION.LOWER_LEG_R,
+};
+
+export function buildSkeleton() {
+ const bones = {};
+ const list = [];
+ for (const [name, parentName, off] of BONEDEF) {
+ const b = new THREE.Bone();
+ b.name = name;
+ b.position.set(off[0], off[1], off[2]);
+ if (parentName) bones[parentName].add(b);
+ bones[name] = b;
+ list.push(b);
+ }
+ const root = bones.root;
+ root.updateMatrixWorld(true);
+ const restWorld = {};
+ for (const b of list) restWorld[b.name] = b.getWorldPosition(new THREE.Vector3());
+ const skeleton = new THREE.Skeleton(list);
+ const index = {};
+ list.forEach((b, i) => { index[b.name] = i; });
+ return { bones, list, index, skeleton, restWorld, rootBone: root };
+}
+
+/** The capsule segment a bone deforms, in rest world space. */
+export function boneSegment(name, restWorld) {
+ const a = restWorld[name];
+ const child = SEG_CHILD[name];
+ let b;
+ if (child) b = restWorld[child];
+ else if (name === 'head') b = a.clone().add(V3(0, 0.15, 0.012));
+ else if (name.startsWith('hand')) {
+ const s = name.endsWith('L') ? 1 : -1;
+ b = a.clone().add(V3(s * 0.045, -0.095, 0.008));
+ } else b = a.clone().add(V3(0, -0.012, 0.085)); // toes
+ return { a, b, r: BONE_RADIUS[name] };
+}
+
+export function assertNoNaNBones(skelData) {
+ for (const b of skelData.list) {
+ const e = b.matrixWorld.elements;
+ for (let i = 0; i < 16; i++) assert(Number.isFinite(e[i]), 'NaN in bone matrix ' + b.name);
+ }
+}
diff --git a/src/character/skinning.js b/src/character/skinning.js
new file mode 100644
index 0000000..590642b
--- /dev/null
+++ b/src/character/skinning.js
@@ -0,0 +1,213 @@
+import * as THREE from 'three';
+import { assert, clamp, segDist } from '../core/math.js';
+import { PART } from './body.js';
+import { boneSegment } from './skeleton.js';
+
+const TORSO_BONES = new Set([
+ 'pelvis', 'spine1', 'spine2', 'spine3', 'neck', 'clavicleL', 'clavicleR',
+]);
+const HEAD_BONES = new Set(['spine3', 'neck', 'head']);
+const ARM_L_BONES = new Set(['spine3', 'clavicleL', 'upperArmL', 'forearmL', 'handL']);
+const ARM_R_BONES = new Set(['spine3', 'clavicleR', 'upperArmR', 'forearmR', 'handR']);
+const LEG_L_BONES = new Set(['pelvis', 'thighL', 'shinL', 'footL', 'toeL']);
+const LEG_R_BONES = new Set(['pelvis', 'thighR', 'shinR', 'footR', 'toeR']);
+const PART_BONES = {
+ [PART.TORSO]: TORSO_BONES,
+ [PART.HEAD]: HEAD_BONES,
+ [PART.ARM_L]: ARM_L_BONES,
+ [PART.ARM_R]: ARM_R_BONES,
+ [PART.LEG_L]: LEG_L_BONES,
+ [PART.LEG_R]: LEG_R_BONES,
+};
+
+/**
+ * Keep the distance field inside the authored body region.
+ *
+ * The body lofts overlap at the shoulders and hips. Distance alone therefore
+ * gives some chest vertices almost entirely to an upper-arm bone and lets one
+ * thigh influence the other leg. Those weights look plausible in the rest
+ * pose, but pull the armpit into a spike and shear the legs as the pelvis turns.
+ *
+ * The top of each leg is an authored pelvis/thigh blend. Leg IK cancels pelvis
+ * rotation in the thigh's local transform, so letting the pelvis own that whole
+ * band would leave the skin behind even after opposite-side bleed is removed.
+ */
+function constrainPartWeights(wAll, vertex, segs, part, t, point, closestPoint) {
+ const allowed = PART_BONES[part];
+ if (!allowed) return;
+
+ const base = vertex * segs.length;
+ let allowedTotal = 0;
+ for (let s = 0; s < segs.length; s++) {
+ if (!allowed.has(segs[s].name)) wAll[base + s] = 0;
+ else allowedTotal += wAll[base + s];
+ }
+
+ // A wide generated silhouette can sit outside every same-region capsule
+ // even though an overlapping limb capsule reached it. Never let semantic
+ // filtering turn that valid distance-field result into an unbound vertex.
+ if (allowedTotal <= 1e-6) {
+ let nearest = -1;
+ let nearestDistance = Infinity;
+ for (let s = 0; s < segs.length; s++) {
+ if (!allowed.has(segs[s].name)) continue;
+ const distance = segDist(point, segs[s].a, segs[s].b, closestPoint);
+ if (distance < nearestDistance) {
+ nearest = s;
+ nearestDistance = distance;
+ }
+ }
+ assert(nearest >= 0, `part ${part} has no valid skin bones`);
+ wAll[base + nearest] = 1;
+ }
+
+ const side = part === PART.LEG_L ? 'L' : part === PART.LEG_R ? 'R' : null;
+ if (!side || t > 0.2) return;
+
+ // Pelvis-led at the groin cap, easing to full thigh ownership below the
+ // crease. The thigh share is enough to follow IK without opening a hip seam.
+ const u = clamp(t / 0.2, 0, 1);
+ const eased = u * u * (3 - 2 * u);
+ const thighWeight = 0.25 + 0.75 * eased;
+ for (let s = 0; s < segs.length; s++) wAll[base + s] = 0;
+ wAll[base + segs.findIndex((seg) => seg.name === 'pelvis')] = 1 - thighWeight;
+ wAll[base + segs.findIndex((seg) => seg.name === `thigh${side}`)] = thighWeight;
+}
+
+/**
+ * Capsule-segment distance falloff with a Laplacian smoothing pass.
+ *
+ * The raw falloff alone produces candy-wrapper collapse at the joints, because
+ * neighbouring vertices can land on very different influence sets. Smoothing
+ * over mesh adjacency before the top-4 reduction fixes that without needing
+ * hand-painted weights.
+ */
+export function computeSkin(geo, skelData) {
+ const pos = geo.attributes.position;
+ const partAttr = geo.attributes.aPart;
+ const tAttr = geo.attributes.aT;
+ const n = pos.count;
+ const bones = skelData.list;
+ const boneIndex = skelData.index;
+
+ const segs = [];
+ for (const b of bones) {
+ if (b.name === 'root') continue;
+ const s = boneSegment(b.name, skelData.restWorld);
+ segs.push({ name: b.name, idx: boneIndex[b.name], a: s.a, b: s.b, r: s.r });
+ }
+ const S = segs.length;
+ const wAll = new Float32Array(n * S);
+ const p = new THREE.Vector3();
+ const cp = new THREE.Vector3();
+
+ for (let i = 0; i < n; i++) {
+ p.fromBufferAttribute(pos, i);
+ let maxW = 0;
+ for (let s = 0; s < S; s++) {
+ const seg = segs[s];
+ const d = segDist(p, seg.a, seg.b, cp);
+ const x = clamp(1 - (d / seg.r) * (d / seg.r), 0, 1);
+ const w = x * x; // smooth compact support inside the influence radius
+ wAll[i * S + s] = w;
+ if (w > maxW) maxW = w;
+ }
+ if (maxW <= 1e-6) {
+ // Outside every capsule: hard-bind to the nearest segment.
+ let bd = 1e9;
+ let bs = 0;
+ for (let s = 0; s < S; s++) {
+ const d = segDist(p, segs[s].a, segs[s].b, cp);
+ if (d < bd) { bd = d; bs = s; }
+ }
+ wAll[i * S + bs] = 1;
+ }
+ if (partAttr && tAttr) {
+ constrainPartWeights(wAll, i, segs, partAttr.getX(i), tAttr.getX(i), p, cp);
+ }
+ }
+
+ const adj = new Array(n);
+ for (let i = 0; i < n; i++) adj[i] = [];
+ const idx = geo.index.array;
+ for (let f = 0; f < idx.length; f += 3) {
+ const a = idx[f];
+ const b = idx[f + 1];
+ const c = idx[f + 2];
+ adj[a].push(b, c);
+ adj[b].push(a, c);
+ adj[c].push(a, b);
+ }
+ const tmp = new Float32Array(S);
+ for (let iter = 0; iter < 3; iter++) {
+ const prev = wAll.slice();
+ for (let i = 0; i < n; i++) {
+ const nb = adj[i];
+ if (!nb.length) continue;
+ tmp.fill(0);
+ for (const j of nb) {
+ for (let s = 0; s < S; s++) tmp[s] += prev[j * S + s];
+ }
+ const inv = 1 / nb.length;
+ for (let s = 0; s < S; s++) wAll[i * S + s] = prev[i * S + s] * 0.55 + tmp[s] * inv * 0.45;
+ }
+ }
+
+ const skinIndex = new Uint16Array(n * 4);
+ const skinWeight = new Float32Array(n * 4);
+ for (let i = 0; i < n; i++) {
+ const tops = [];
+ for (let s = 0; s < S; s++) {
+ const w = wAll[i * S + s];
+ if (w <= 1e-5) continue;
+ tops.push([w, s]);
+ }
+ tops.sort((a, b) => b[0] - a[0]);
+ let total = 0;
+ for (let k = 0; k < 4; k++) {
+ if (k < tops.length) {
+ skinIndex[i * 4 + k] = segs[tops[k][1]].idx;
+ skinWeight[i * 4 + k] = tops[k][0];
+ total += tops[k][0];
+ }
+ }
+ assert(total > 0, 'vertex ' + i + ' has zero total skin weight');
+ for (let k = 0; k < 4; k++) skinWeight[i * 4 + k] /= total;
+ }
+ geo.setAttribute('skinIndex', new THREE.BufferAttribute(skinIndex, 4));
+ geo.setAttribute('skinWeight', new THREE.BufferAttribute(skinWeight, 4));
+
+ // Debug heatmap: dominant bone hue, brightness by weight.
+ const colors = new Float32Array(n * 3);
+ const col = new THREE.Color();
+ for (let i = 0; i < n; i++) {
+ let bw = 0;
+ let bi = 0;
+ for (let k = 0; k < 4; k++) {
+ if (skinWeight[i * 4 + k] > bw) { bw = skinWeight[i * 4 + k]; bi = skinIndex[i * 4 + k]; }
+ }
+ col.setHSL((bi * 0.61803) % 1, 0.85, 0.25 + 0.45 * bw);
+ colors[i * 3] = col.r;
+ colors[i * 3 + 1] = col.g;
+ colors[i * 3 + 2] = col.b;
+ }
+ geo.setAttribute('color', new THREE.BufferAttribute(colors, 3));
+}
+
+/**
+ * CPU skinning of one vertex, matching the GPU path exactly:
+ * out = bindInverse * (sum_k w_k * boneMatrix_k) * bind * v
+ * Used by the skirt push-out guard and by armor debris baking, both of which
+ * need posed world positions on the JS side.
+ */
+export function skinVertex(out, base, i, siAttr, swAttr, boneMats, bind, bindInv, scratchMat) {
+ const te = scratchMat.elements;
+ te.fill(0);
+ for (let k = 0; k < 4; k++) {
+ const w = swAttr.getComponent(i, k);
+ if (w === 0) continue;
+ const ae = boneMats[siAttr.getComponent(i, k)].elements;
+ for (let e = 0; e < 16; e++) te[e] += ae[e] * w;
+ }
+ return out.fromArray(base, i * 3).applyMatrix4(bind).applyMatrix4(scratchMat).applyMatrix4(bindInv);
+}
diff --git a/src/character/stick.js b/src/character/stick.js
new file mode 100644
index 0000000..00f7bd9
--- /dev/null
+++ b/src/character/stick.js
@@ -0,0 +1,296 @@
+import * as THREE from 'three';
+import { KIND, makeTag, quat, stickFilter, transform, vec3 } from '../physics/bridge.js';
+
+/**
+ * A hockey stick, socketed to the hand.
+ *
+ * ### What changed, and why it matters
+ *
+ * The first version hung the stick off the mover and positioned it so the blade
+ * sat wherever the puck was being carried. That put the blade in the right
+ * place and the hands nowhere near it — the stick floated.
+ *
+ * Now it is parented to a socket on the right hand bone, the way Ludus sockets
+ * a weapon, and the geometry is authored in *grip space*: the origin is the top
+ * hand, the shaft runs down −Y, the blade is at the far end. The hand carries
+ * the stick, which is the correct dependency order — a player's hands decide
+ * where their stick is, not the other way round.
+ *
+ * That inverts the puck relationship too. `possession` no longer picks a carry
+ * point and drags the stick to it; it reads where the blade actually is and
+ * carries the puck there. Stickhandling is an arm pose, which is what it is in
+ * real life.
+ *
+ * ### Aimed, not bolted
+ *
+ * The stick is *aimed* from the hand at a per-stance target rather than bolted
+ * on at a per-stance rotation. See the note on `GRIP` — a fixed rotation
+ * composes with whatever the arm is doing and the blade ends up in the air.
+ */
+
+export const STICK = {
+ /** Butt (top hand) to heel of the blade. */
+ shaftLength: 1.10,
+ shaftRadius: 0.016,
+ bladeLength: 0.31,
+ bladeHeight: 0.075,
+ bladeThickness: 0.022,
+ /** How far down the shaft the lower hand grips, 0 = butt, 1 = heel. */
+ lowerHandAt: 0.28,
+};
+
+/**
+ * Stances, as a blade *target* in the skater's local frame plus a roll about
+ * the shaft.
+ *
+ * The obvious authoring — a fixed rotation in the hand's bone space — does not
+ * survive contact with an animated arm. That rotation composes with the hand's
+ * own world rotation, so a socket tuned to put the blade on the ice for one arm
+ * pose swings it into the air the moment the arm moves, and every stride is a
+ * different arm pose. Measured: the blade sat between 0.55 m and 0.97 m off the
+ * ice depending on gait.
+ *
+ * Aiming at a target instead makes the constraint the thing we actually care
+ * about — "the blade is on the ice, this far ahead" — and leaves the wrist
+ * angle as the free variable, which is what a wrist is for. `roll` is the blade
+ * face angle about the shaft, which is the part that genuinely is authored.
+ *
+ * +X is the skater's left, +Z is forward, so a right-hander carries at −X.
+ */
+export const GRIP = {
+ /**
+ * Normal carry: blade on the ice, in front and a little to the forehand
+ * side — the "puck carry while skating" frame on the reference sheet, not
+ * parked on the hip. Kept close enough that the off-hand can reach the shaft.
+ */
+ carry: { target: [-0.16, 0.03, 0.70], roll: 0.08 },
+ /** Hustling: stick dangles out in front on one hand. */
+ hustle: { target: [-0.14, 0.03, 1.05], roll: 0.14 },
+ /**
+ * Wind-up: blade high and back behind the head, not hanging down from the
+ * hands. y well above the shoulders, z behind the body.
+ */
+ windup: { target: [-0.28, 1.55, -0.48], roll: -0.2 },
+ /** Follow-through: swept across the body and finishing high. */
+ follow: { target: [0.34, 0.95, 0.85], roll: 0.55 },
+ /** Poke: thrust out flat, as far ahead as the arm reaches. */
+ poke: { target: [-0.18, 0.03, 1.42], roll: 0.05 },
+};
+
+/** Small fixed offset of the butt from the hand bone. */
+const GRIP_OFFSET = [0.015, -0.02, 0.03];
+
+const _euler = new THREE.Euler();
+const clampUnit = (v) => (v < -1 ? -1 : v > 1 ? 1 : v);
+
+export function buildStick(materials, physics, index) {
+ const group = new THREE.Group();
+ group.name = 'stick';
+
+ const wood = new THREE.MeshStandardMaterial({ color: 0x1a1a1e, roughness: 0.5, metalness: 0.05 });
+ const tape = new THREE.MeshStandardMaterial({ color: 0x111114, roughness: 0.85 });
+
+ // Grip space: origin at the butt, shaft straight down −Y, blade at the end.
+ // Everything that aims the stick is a rotation of this group, which keeps the
+ // geometry itself trivially correct.
+ const shaft = new THREE.Mesh(
+ new THREE.CylinderGeometry(STICK.shaftRadius, STICK.shaftRadius * 1.08, STICK.shaftLength, 8),
+ wood,
+ );
+ shaft.position.y = -STICK.shaftLength / 2;
+ shaft.castShadow = true;
+ group.add(shaft);
+
+ const blade = new THREE.Mesh(
+ new THREE.BoxGeometry(STICK.bladeThickness, STICK.bladeHeight, STICK.bladeLength),
+ tape,
+ );
+ // Heel at the bottom of the shaft, toe forward, with a little lie angle so it
+ // sits flat on the ice rather than on its edge.
+ blade.position.set(0, -STICK.shaftLength - STICK.bladeHeight * 0.35, STICK.bladeLength * 0.4);
+ blade.rotation.x = 0.34;
+ blade.castShadow = true;
+ group.add(blade);
+
+ // ---- blade collider ----------------------------------------------------
+ // Kinematic, driven to the blade's world transform each substep. It knocks a
+ // loose puck around; a carried puck is the possession model's business.
+ let body = null;
+ let shape = null;
+ /** False until the collider has been put where the blade actually is. */
+ let placed = false;
+ if (physics) {
+ const { api, world } = physics;
+ const bd = api.b3DefaultBodyDef();
+ bd.type = api.b3BodyType.b3_kinematicBody;
+ bd.enableSleep = false;
+ body = api.b3CreateBody(world, bd);
+ const sd = api.b3DefaultShapeDef();
+ sd.density = 700;
+ sd.enableContactEvents = true;
+ sd.baseMaterial.friction = 0.3;
+ sd.baseMaterial.restitution = 0.25;
+ sd.baseMaterial.userMaterialId = makeTag(KIND.STICK, index, 0);
+ const filter = stickFilter();
+ sd.filter.categoryBits = filter.category;
+ sd.filter.maskBits = filter.mask;
+ shape = api.b3CreateBoxShape(
+ body,
+ sd,
+ STICK.bladeThickness / 2,
+ STICK.bladeHeight / 2,
+ STICK.bladeLength / 2,
+ );
+ }
+
+ const _bladeWorld = new THREE.Vector3();
+ const _bladeQuat = new THREE.Quaternion();
+ const _scratch = new THREE.Vector3();
+ const _fromPos = new THREE.Vector3();
+ const _toPos = new THREE.Vector3();
+ const _aimDir = new THREE.Vector3();
+ /** Aim direction brought into the hand's bone space. */
+ const _aimLocal = new THREE.Vector3();
+ const _aimQuat = new THREE.Quaternion();
+ const _rollQuat = new THREE.Quaternion();
+ // The axis that must end up pointing at the target is the grip-to-*blade*
+ // direction, not the shaft's −Y. The blade sits forward of the shaft end by
+ // the toe offset, which puts it ~6° off axis — aiming −Y instead left the
+ // blade 10 cm above where the height solve said it would be.
+ const _bladeAxis = blade.position.clone().normalize();
+ /** Grip origin to blade centre: the stick's effective reach. */
+ const reach = blade.position.length();
+
+ return {
+ group,
+ blade,
+ shaft,
+ body,
+ shape,
+ /** Parent bone once the skeleton exists. */
+ attachTo(bone) {
+ bone.add(group);
+ return group;
+ },
+
+ /**
+ * Blade target and roll for a blend between two named stances, in the
+ * skater's local frame. The animator turns this into an aim.
+ */
+ stanceTarget(from, to = from, t = 0, outTarget) {
+ const a = GRIP[from] ?? GRIP.carry;
+ const b = GRIP[to] ?? a;
+ const k = t < 0 ? 0 : t > 1 ? 1 : t;
+ _fromPos.fromArray(a.target);
+ _toPos.fromArray(b.target);
+ outTarget.lerpVectors(_fromPos, _toPos, k);
+ return a.roll + (b.roll - a.roll) * k;
+ },
+
+ /**
+ * Point the stick from the hand at a world-space target.
+ *
+ * The group lives in the hand's bone space, so the aim rotation has to be
+ * solved there — not in world space. `setFromUnitVectors` picks the
+ * shortest rotation, which leaves a free twist around the shaft; doing that
+ * in world and then left-multiplying by `handQuatInverse` does *not*
+ * cancel the parent's yaw. Measured: the stick's local quaternion spun as
+ * the skater turned, even when the blade target was fixed in the skater's
+ * frame — the stick rotated with the body instead of staying put in the
+ * socket. Solving the same aim entirely in hand space keeps the local pose
+ * stable under body rotation; only a real change of target moves it.
+ *
+ * Height is solved exactly, direction is aimed. Pointing straight at the
+ * target and hoping the length works out puts the blade wherever the stick
+ * happens to end — short of an on-ice target means *above* it, so the blade
+ * floats again the moment the arm pose changes the distance. Solving `dy`
+ * from the height difference makes blade height exact for any arm pose and
+ * any stick length; the horizontal aim is then whatever is left of the
+ * unit vector. The blade lands on that ray at one stick length, so targets
+ * are authored at about that distance — the aim is what has to be right,
+ * not the reach.
+ */
+ aimAt(worldTarget, handWorldPos, handQuatInverse, roll = 0) {
+ group.position.fromArray(GRIP_OFFSET);
+
+ const dy = clampUnit((worldTarget.y - handWorldPos.y) / reach);
+ const horiz = Math.sqrt(Math.max(0, 1 - dy * dy));
+ _aimDir.set(worldTarget.x - handWorldPos.x, 0, worldTarget.z - handWorldPos.z);
+ if (_aimDir.lengthSq() < 1e-8) _aimDir.set(0, 0, 1);
+ _aimDir.normalize().multiplyScalar(horiz);
+ _aimDir.y = dy;
+
+ // World aim → hand bone space, then rotate the blade axis onto it.
+ _aimLocal.copy(_aimDir).applyQuaternion(handQuatInverse);
+ if (_aimLocal.lengthSq() < 1e-12) _aimLocal.set(0, -1, 0);
+ else _aimLocal.normalize();
+
+ _aimQuat.setFromUnitVectors(_bladeAxis, _aimLocal);
+ if (roll) {
+ _rollQuat.setFromAxisAngle(_aimLocal, roll);
+ _aimQuat.premultiply(_rollQuat);
+ }
+ group.quaternion.copy(_aimQuat);
+ },
+
+ /** Static placement, for a rig with no animator driving it. */
+ setGrip(name = 'carry') {
+ const g = GRIP[name] ?? GRIP.carry;
+ group.position.fromArray(GRIP_OFFSET);
+ group.quaternion.setFromEuler(_euler.set(-0.9, 0, g.roll, 'XYZ'));
+ },
+
+ /**
+ * A point on the shaft in world space, `t` down from the butt.
+ */
+ shaftPoint(t, out) {
+ group.updateWorldMatrix(true, false);
+ out.set(0, -STICK.shaftLength * t, 0).applyMatrix4(group.matrixWorld);
+ return out;
+ },
+
+ /** The shaft as a world-space segment, butt to heel. */
+ shaftSegment(outButt, outHeel) {
+ group.updateWorldMatrix(true, false);
+ outButt.set(0, 0, 0).applyMatrix4(group.matrixWorld);
+ outHeel.set(0, -STICK.shaftLength, 0).applyMatrix4(group.matrixWorld);
+ return outButt;
+ },
+
+ /** Blade position in world space. */
+ bladeWorld(out) {
+ blade.updateWorldMatrix(true, false);
+ return out.setFromMatrixPosition(blade.matrixWorld);
+ },
+
+ /**
+ * Push the blade's world transform into the kinematic collider.
+ *
+ * The first call *teleports*. `SetTargetTransform` derives the velocity
+ * needed to reach the target over `dt`, so a body still sitting at the
+ * world origin on frame one derives a velocity of several hundred metres a
+ * second — and a stick moving at 270 m/s launches the puck off the map. It
+ * happened; the puck was 1.7 km away inside ten seconds.
+ */
+ syncPhysics(api, dt) {
+ if (!body) return;
+ blade.updateWorldMatrix(true, false);
+ blade.matrixWorld.decompose(_bladeWorld, _bladeQuat, _scratch);
+ if (!placed) {
+ api.b3Body_SetTransform(body, vec3(_bladeWorld), quat(_bladeQuat));
+ placed = true;
+ return;
+ }
+ api.b3Body_SetTargetTransform(body, transform(_bladeWorld, _bladeQuat), dt, true);
+ },
+
+ destroy(api) {
+ if (body && api) api.b3DestroyBody(body);
+ group.removeFromParent();
+ shaft.geometry.dispose();
+ blade.geometry.dispose();
+ wood.dispose();
+ tape.dispose();
+ },
+ };
+}
diff --git a/src/core/math.js b/src/core/math.js
new file mode 100644
index 0000000..7339c3c
--- /dev/null
+++ b/src/core/math.js
@@ -0,0 +1,179 @@
+import * as THREE from 'three';
+
+export const V3 = (x = 0, y = 0, z = 0) => new THREE.Vector3(x, y, z);
+export const UP = V3(0, 1, 0);
+export const FWD = V3(0, 0, 1);
+
+export const clamp = (x, a, b) => (x < a ? a : x > b ? b : x);
+export const lerp = (a, b, t) => a + (b - a) * t;
+export const smooth = (t) => t * t * (3 - 2 * t);
+
+export function assert(cond, msg) {
+ if (!cond) throw new Error('ASSERT FAILED: ' + msg);
+}
+
+export function lerpAngle(a, b, t) {
+ let d = b - a;
+ while (d > Math.PI) d -= Math.PI * 2;
+ while (d < -Math.PI) d += Math.PI * 2;
+ return a + d * t;
+}
+
+const _sd1 = new THREE.Vector3();
+const _sd2 = new THREE.Vector3();
+
+/** Distance from point `p` to segment a-b; writes the closest point into `out`. */
+export function segDist(p, a, b, out) {
+ _sd1.subVectors(b, a);
+ _sd2.subVectors(p, a);
+ const t = clamp(_sd2.dot(_sd1) / Math.max(1e-9, _sd1.lengthSq()), 0, 1);
+ out.copy(a).addScaledVector(_sd1, t);
+ return p.distanceTo(out);
+}
+
+const _u = new THREE.Vector3();
+const _v = new THREE.Vector3();
+const _w = new THREE.Vector3();
+
+/**
+ * Closest distance between two segments, writing the closest point on each
+ * into `outA` / `outB`.
+ *
+ * Used to work out which limb hit which limb: both ragdolls are 18 capsules,
+ * and a capsule is a segment plus a radius, so the nearest pair of segments is
+ * the nearest pair of body parts. Standard Ericson clamped-parameter solve —
+ * the degenerate cases (either segment a point, or the two parallel) all fall
+ * out of the denominator guards rather than needing separate branches.
+ */
+export function segSegDistance(p1, q1, p2, q2, outA, outB) {
+ _u.subVectors(q1, p1);
+ _v.subVectors(q2, p2);
+ _w.subVectors(p1, p2);
+ const a = _u.dot(_u);
+ const b = _u.dot(_v);
+ const c = _v.dot(_v);
+ const d = _u.dot(_w);
+ const e = _v.dot(_w);
+ const D = a * c - b * b;
+ let sN;
+ let sD = D;
+ let tN;
+ let tD = D;
+
+ if (D < 1e-9) {
+ // Parallel or degenerate: pin the first parameter and solve the second.
+ sN = 0;
+ sD = 1;
+ tN = e;
+ tD = c;
+ } else {
+ sN = b * e - c * d;
+ tN = a * e - b * d;
+ if (sN < 0) {
+ sN = 0;
+ tN = e;
+ tD = c;
+ } else if (sN > sD) {
+ sN = sD;
+ tN = e + b;
+ tD = c;
+ }
+ }
+
+ if (tN < 0) {
+ tN = 0;
+ if (-d < 0) sN = 0;
+ else if (-d > a) sN = sD;
+ else {
+ sN = -d;
+ sD = a;
+ }
+ } else if (tN > tD) {
+ tN = tD;
+ if (-d + b < 0) sN = 0;
+ else if (-d + b > a) sN = sD;
+ else {
+ sN = -d + b;
+ sD = a;
+ }
+ }
+
+ const s = Math.abs(sD) < 1e-9 ? 0 : sN / sD;
+ const t = Math.abs(tD) < 1e-9 ? 0 : tN / tD;
+ outA.copy(p1).addScaledVector(_u, s);
+ outB.copy(p2).addScaledVector(_v, t);
+ return outA.distanceTo(outB);
+}
+
+const _euler = new THREE.Euler();
+/** Write XYZ euler angles into an existing quaternion without allocating. */
+export function E(out, x, y, z, order) {
+ _euler.set(x, y, z, order || 'XYZ');
+ return out.setFromEuler(_euler);
+}
+export { _euler };
+
+/** Merge indexed BufferGeometries that share an attribute set. */
+export function mergeGeoms(list) {
+ let vTotal = 0;
+ let iTotal = 0;
+ const attrNames = Object.keys(list[0].attributes);
+ for (const g of list) {
+ vTotal += g.attributes.position.count;
+ iTotal += g.index.count;
+ }
+ const out = new THREE.BufferGeometry();
+ const arrays = {};
+ for (const name of attrNames) {
+ const itemSize = list[0].attributes[name].itemSize;
+ const Ctor = list[0].attributes[name].array.constructor;
+ arrays[name] = new Ctor(vTotal * itemSize);
+ }
+ const index = new (vTotal > 65535 ? Uint32Array : Uint16Array)(iTotal);
+ let vOff = 0;
+ let iOff = 0;
+ for (const g of list) {
+ const n = g.attributes.position.count;
+ for (const name of attrNames) {
+ arrays[name].set(g.attributes[name].array, vOff * g.attributes[name].itemSize);
+ }
+ const gi = g.index.array;
+ for (let i = 0; i < gi.length; i++) index[iOff + i] = gi[i] + vOff;
+ vOff += n;
+ iOff += gi.length;
+ }
+ for (const name of attrNames) {
+ out.setAttribute(name, new THREE.BufferAttribute(arrays[name], list[0].attributes[name].itemSize));
+ }
+ out.setIndex(new THREE.BufferAttribute(index, 1));
+ return out;
+}
+
+/** Normalize an arbitrary geometry to position/normal/uv + index so it can merge. */
+export function stripAttrs(g) {
+ const out = new THREE.BufferGeometry();
+ out.setAttribute('position', g.attributes.position);
+ out.setAttribute('normal', g.attributes.normal);
+ const n = g.attributes.position.count;
+ out.setAttribute('uv', g.attributes.uv || new THREE.Float32BufferAttribute(new Float32Array(n * 2), 2));
+ if (g.index) out.setIndex(g.index);
+ else {
+ const idx = [];
+ for (let i = 0; i < n; i++) idx.push(i);
+ out.setIndex(idx);
+ }
+ return out;
+}
+
+export function disposeObject(root) {
+ root.traverse((o) => {
+ if (o.geometry) o.geometry.dispose();
+ if (o.material) {
+ const mats = Array.isArray(o.material) ? o.material : [o.material];
+ for (const m of mats) {
+ for (const k of Object.keys(m)) if (m[k] && m[k].isTexture) m[k].dispose();
+ m.dispose();
+ }
+ }
+ });
+}
diff --git a/src/core/rng.js b/src/core/rng.js
new file mode 100644
index 0000000..e769b0a
--- /dev/null
+++ b/src/core/rng.js
@@ -0,0 +1,27 @@
+// Seeded PRNG. One integer seed drives every generated detail of a fighter.
+//
+// The showcase this grew out of used a module-level generator, which is fine
+// for one character on screen. A match has at least two, and they have to be
+// independently reproducible from their own seeds, so the generator is an
+// object that gets threaded through the builders instead.
+
+export function makeRng(seed) {
+ let a = seed | 0;
+ const f = () => {
+ a |= 0;
+ a = (a + 0x6d2b79f5) | 0;
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+ };
+ return {
+ seed,
+ f,
+ range: (lo, hi) => lo + (hi - lo) * f(),
+ int: (lo, hi) => Math.floor(lo + (hi + 0.9999 - lo) * f()),
+ pick: (arr) => arr[Math.floor(f() * arr.length) % arr.length],
+ // Independent sub-stream, so adding a generator in one place doesn't shift
+ // every value drawn after it.
+ fork: (salt) => makeRng((Math.imul(seed ^ salt, 0x9e3779b1) ^ (seed >>> 3)) | 0),
+ };
+}
diff --git a/src/game/hits.js b/src/game/hits.js
new file mode 100644
index 0000000..a9e67fa
--- /dev/null
+++ b/src/game/hits.js
@@ -0,0 +1,281 @@
+import * as THREE from 'three';
+import { segSegDistance } from '../core/math.js';
+import { KIND, readTag } from '../physics/bridge.js';
+import { REGION } from '../character/skeleton.js';
+import { clamp } from '../../shared/scalar.js';
+
+/**
+ * Body checks.
+ *
+ * Two problems have to be solved separately, and conflating them is what makes
+ * hits feel like one canned event:
+ *
+ * *Did* a hit land — a physics question, answered by the proxy capsules,
+ * which are what actually collide. Closing speed and mass give severity.
+ *
+ * *What kind* of hit was it — a pose question, and the proxy cannot answer
+ * it. A capsule contact point tells you two bodies met at roughly hip height;
+ * it cannot tell you a shoulder went through a chest. So on the frame a hit
+ * lands we go back to the two 18-capsule ragdolls, which *are* posed, and
+ * find the closest pair of limbs. That pair is the hit: `upperArmR → spine2`
+ * is a shoulder into the chest, `pelvis → thighL` is a hip check, `spine3 →
+ * head` is the one that should draw a penalty.
+ *
+ * 324 segment-segment tests sounds like a lot until you notice it only runs on
+ * the frame of an actual impact, which is a handful of times a match.
+ */
+
+export const HIT = {
+ /**
+ * Closing speed thresholds, m/s. Below `bump` nothing happens beyond the
+ * momentum the solver already exchanged.
+ */
+ bump: 2.6,
+ stagger: 4.4,
+ knockdown: 7.0,
+ /** Impulse per m/s of closing speed, per kg of effective mass. */
+ impulseScale: 0.55,
+ /**
+ * How much of the impulse goes into the struck limb at the contact point,
+ * versus into the pelvis through its centre.
+ *
+ * All of it at the contact point is what launches people: the point is on
+ * the chest, well above the centre of mass, so a linear impulse there is
+ * mostly torque and the victim cartwheels over the hitter. Driving most of
+ * the mass from the middle and using the limb share only to shape the fall
+ * is what makes a check read as being knocked *down and back*.
+ */
+ limbShare: 0.35,
+ /**
+ * Upward fraction. A check lifts a skater slightly off their edges; it does
+ * not throw them in the air.
+ */
+ liftKnockdown: 0.15,
+ liftStagger: 0.08,
+ /** A hit to the head or an unbraced back is worth more than a square one. */
+ blindsideBonus: 1.5,
+ headBonus: 1.4,
+ /** Joint stiffness for a stagger — stiff enough to stay on the feet. */
+ staggerStiffness: 5,
+ /** Seconds a downed skater stays down before getting up. */
+ downTime: 1.5,
+ /** Seconds of get-up blend from the collapsed pose back to skating. */
+ riseTime: 0.7,
+ /** Ignore repeat contacts between the same pair for this long. */
+ refractory: 0.45,
+};
+
+/** Which part of the *attacker* delivered it — this is what varies the hit. */
+const DELIVERED_BY = {
+ upperArmL: 'shoulder', upperArmR: 'shoulder', spine3: 'shoulder',
+ spine1: 'body', spine2: 'body',
+ pelvis: 'hip', thighL: 'hip', thighR: 'hip',
+ forearmL: 'arm', forearmR: 'arm',
+ shinL: 'leg', shinR: 'leg',
+};
+
+/**
+ * Parts that can deliver a check.
+ *
+ * Not a fudge — a rule of the game. A skater at speed is pitched ~30° forward,
+ * which makes the *head* the geometrically leading part of the body, so an
+ * unrestricted nearest-pair search credits almost every hit to a headbutt. You
+ * check with a shoulder, a chest, a hip or a thigh.
+ *
+ * The victim side stays unrestricted, deliberately: a shoulder that arrives at
+ * someone's head is exactly the hit that should register as a head shot.
+ */
+const CAN_DELIVER = new Set(Object.keys(DELIVERED_BY));
+
+/** Human-readable label, for the HUD and for tests to assert against. */
+export function describeHit(hit) {
+ const where = hit.victimRegion === REGION.HEAD ? 'head'
+ : hit.victimRegion === REGION.TORSO ? 'body'
+ : hit.victimRegion.startsWith('upperLeg') || hit.victimRegion.startsWith('lowerLeg') ? 'legs'
+ : 'arm';
+ return `${hit.by} to the ${where}`;
+}
+
+const _a1 = new THREE.Vector3();
+const _b1 = new THREE.Vector3();
+const _rel = new THREE.Vector3();
+const _dir = new THREE.Vector3();
+const _impulse = new THREE.Vector3();
+const _point = new THREE.Vector3();
+
+/**
+ * Closest limb pair between two posed ragdolls.
+ * Returns `{ attackerPart, victimPart, point, distance }`, or null if the two
+ * rigs are somehow nowhere near each other.
+ */
+export function closestLimbs(attacker, victim, { deliveringOnly = true } = {}) {
+ const A = attacker.worldSegments();
+ // `worldSegments` reuses its scratch array, so the first result has to be
+ // copied out before the second call overwrites it.
+ const aCopy = A
+ .filter((s) => !deliveringOnly || CAN_DELIVER.has(s.part.name))
+ .map((s) => ({ part: s.part, a: s.a.clone(), b: s.b.clone(), radius: s.radius }));
+ const B = victim.worldSegments();
+
+ let best = null;
+ let bestGap = Infinity;
+ for (const sa of aCopy) {
+ for (const sb of B) {
+ const d = segSegDistance(sa.a, sa.b, sb.a, sb.b, _a1, _b1) - sa.radius - sb.radius;
+ if (d < bestGap) {
+ bestGap = d;
+ if (!best) best = { attackerPart: null, victimPart: null, point: new THREE.Vector3(), distance: 0 };
+ best.attackerPart = sa.part;
+ best.victimPart = sb.part;
+ // Midway between the two surfaces is where the impact reads as having
+ // happened, and is where the impulse should be applied.
+ best.point.addVectors(_a1, _b1).multiplyScalar(0.5);
+ best.distance = d;
+ }
+ }
+ }
+ return best;
+}
+
+/**
+ * Wire up hit detection for a match.
+ *
+ * `onHit` is called with a description of every landed check, for the HUD,
+ * audio and (later) penalties.
+ */
+export function createHitResolver({ physics, skaters, states, onHit = null }) {
+ // Last time each unordered pair traded a hit, so one collision does not fire
+ // every substep it stays in contact.
+ const lastHit = new Map();
+ let clock = 0;
+
+ const pairKey = (i, j) => (i < j ? `${i}|${j}` : `${j}|${i}`);
+
+ function resolve(event) {
+ const a = readTag(event.userMaterialIdA);
+ const b = readTag(event.userMaterialIdB);
+ // Only proxy-on-proxy counts as a check. Limb contacts happen constantly
+ // once someone is down and are not hits.
+ if (a.kind !== KIND.PROXY || b.kind !== KIND.PROXY) return;
+ if (a.skater === b.skater) return;
+
+ const speed = event.approachSpeed;
+ if (speed < HIT.bump) return;
+
+ const key = pairKey(a.skater, b.skater);
+ if (clock - (lastHit.get(key) ?? -Infinity) < HIT.refractory) return;
+
+ // Whoever is carrying more speed into the contact is the one throwing it.
+ const sa = states[a.skater];
+ const sb = states[b.skater];
+ _rel.set(sb.x - sa.x, 0, sb.z - sa.z);
+ const len = _rel.length() || 1;
+ _rel.multiplyScalar(1 / len);
+ const closingA = sa.vx * _rel.x + sa.vz * _rel.z;
+ const closingB = -(sb.vx * _rel.x + sb.vz * _rel.z);
+ const attackerIndex = closingA >= closingB ? a.skater : b.skater;
+ const victimIndex = attackerIndex === a.skater ? b.skater : a.skater;
+
+ const attacker = skaters[attackerIndex];
+ const victim = skaters[victimIndex];
+ // Neither a body already on the ice nor a body being slid into by one is
+ // throwing a check. Those contacts are real and the solver handles them;
+ // they are just not hits, and attributing one to a limp skater's flailing
+ // hand produces nonsense like "arm to the legs" as a headline event.
+ if (!attacker?.ragdoll || !victim?.ragdoll) return;
+ if (attacker.limp || victim.limp) return;
+
+ const pair = closestLimbs(attacker.ragdoll, victim.ragdoll);
+ if (!pair) return;
+
+ // Direction of the blow: attacker's travel, which is what the victim
+ // actually has to absorb.
+ const attackerState = states[attackerIndex];
+ const victimState = states[victimIndex];
+ _dir.set(attackerState.vx - victimState.vx, 0, attackerState.vz - victimState.vz);
+ if (_dir.lengthSq() < 1e-6) _dir.set(_rel.x, 0, _rel.z);
+ _dir.normalize();
+
+ // A hit taken from behind or side-on is worth more than one you can brace
+ // for: `facing` is +1 square on, -1 straight in the back.
+ const victimFacing = Math.sin(victimState.yaw) * -_dir.x + Math.cos(victimState.yaw) * -_dir.z;
+ const blindside = clamp((1 - victimFacing) / 2, 0, 1);
+
+ const by = DELIVERED_BY[pair.attackerPart.name] ?? 'body';
+ const region = pair.victimPart.region;
+ const headshot = region === REGION.HEAD;
+
+ let severity = speed
+ * (1 + blindside * (HIT.blindsideBonus - 1))
+ * (headshot ? HIT.headBonus : 1);
+ // A hit thrown with an arm or a trailing leg is a brush, not a check.
+ if (by === 'arm' || by === 'leg') severity *= 0.55;
+
+ const outcome = severity >= HIT.knockdown ? 'knockdown'
+ : severity >= HIT.stagger ? 'stagger'
+ : 'bump';
+
+ lastHit.set(key, clock);
+
+ const hit = {
+ attacker: attackerIndex,
+ victim: victimIndex,
+ by,
+ attackerPart: pair.attackerPart.name,
+ victimPart: pair.victimPart.name,
+ victimRegion: region,
+ speed,
+ severity,
+ blindside,
+ headshot,
+ outcome,
+ point: pair.point.clone(),
+ direction: _dir.clone(),
+ };
+
+ apply(hit);
+ if (onHit) onHit(hit);
+ }
+
+ /** Turn a resolved hit into forces on the victim's skeleton. */
+ function apply(hit) {
+ if (hit.outcome === 'bump') return;
+ const victim = skaters[hit.victim];
+ const body = victim.ragdoll;
+
+ const knockdown = hit.outcome === 'knockdown';
+ // Impulse scaled by the mass actually being moved, aimed slightly upward —
+ // a purely horizontal shove on a body standing on near-frictionless ice
+ // just slides it along without ever putting it on the floor.
+ const mag = hit.severity * HIT.impulseScale * body.totalMass() * 0.08;
+ const lift = knockdown ? HIT.liftKnockdown : HIT.liftStagger;
+
+ if (knockdown) victim.goDown(hit);
+ else victim.stagger(hit);
+
+ // Most of it through the pelvis centre, which moves the whole body; the
+ // rest at the contact point, which is what tips them over.
+ _impulse.copy(hit.direction).multiplyScalar(mag * (1 - HIT.limbShare));
+ _impulse.y += mag * lift * (1 - HIT.limbShare);
+ body.applyImpulse('pelvis', _impulse, null);
+
+ _impulse.copy(hit.direction).multiplyScalar(mag * HIT.limbShare);
+ _impulse.y += mag * lift * HIT.limbShare;
+ _point.copy(hit.point);
+ body.applyImpulse(hit.victimPart, _impulse, _point);
+ }
+
+ const off = physics.onHit(resolve);
+
+ return {
+ /** Advance the refractory clock. Call once per frame. */
+ tick(dt) {
+ clock += dt;
+ },
+ get time() { return clock; },
+ destroy() {
+ off();
+ lastHit.clear();
+ },
+ };
+}
diff --git a/src/game/input.js b/src/game/input.js
new file mode 100644
index 0000000..a2c9aa2
--- /dev/null
+++ b/src/game/input.js
@@ -0,0 +1,389 @@
+import { clamp } from '../../shared/scalar.js';
+
+/**
+ * Player input: Xbox pad first, keyboard as a fallback.
+ *
+ * Two things this module is careful about.
+ *
+ * **Screen space, not world space.** Sticks come out as `x` right / `y` away
+ * from the camera. Converting to a world direction needs the camera yaw, which
+ * belongs to the match. Keeping input ignorant of the camera means the same
+ * reading works for a follow cam, a broadcast cam or a fixed overhead one.
+ *
+ * **Semantics, not button indices.** Everything downstream asks for `pass` or
+ * `hustle`, never `buttons[7]`. Remapping then happens in one table, and the
+ * game code does not care whether a shot came from the Skill Stick or a key.
+ *
+ * The output object is reused every frame and mutated in place — `match`
+ * holds a reference to it, so handing it over once is enough.
+ */
+
+/**
+ * W3C "standard" gamepad layout, which is what an Xbox pad reports.
+ * Named for what they do in this game rather than for the letter on the pad,
+ * except where the letter *is* the convention players expect.
+ */
+export const PAD = {
+ A: 0, B: 1, X: 2, Y: 3,
+ LB: 4, RB: 5, LT: 6, RT: 7,
+ BACK: 8, START: 9, LS: 10, RS: 11,
+ DPAD_UP: 12, DPAD_DOWN: 13, DPAD_LEFT: 14, DPAD_RIGHT: 15,
+};
+
+/** Action → pad button. One table, so remapping is a one-line change. */
+const BINDING = {
+ pass: PAD.A,
+ shoot: PAD.X,
+ poke: PAD.B,
+ dump: PAD.Y,
+ switchPlayer: PAD.LB,
+ deke: PAD.RB,
+ start: PAD.START,
+ camera: PAD.BACK,
+};
+
+/** Action → keyboard codes. Arrows drive the Skill Stick, WASD skates. */
+const KEYS = {
+ up: ['KeyW'],
+ down: ['KeyS'],
+ left: ['KeyA'],
+ right: ['KeyD'],
+ hustle: ['ShiftLeft', 'ShiftRight'],
+ protect: ['Space'],
+ skillUp: ['ArrowUp'],
+ skillDown: ['ArrowDown'],
+ skillLeft: ['ArrowLeft'],
+ skillRight: ['ArrowRight'],
+ pass: ['KeyJ'],
+ shoot: ['KeyK'],
+ poke: ['KeyL'],
+ dump: ['KeyU'],
+ switchPlayer: ['KeyQ'],
+ deke: ['KeyE'],
+};
+
+/** Sticks rest off-centre when worn; triggers rest slightly pressed. */
+const STICK_DEADZONE = 0.18;
+const TRIGGER_DEADZONE = 0.06;
+
+/**
+ * Skill Stick shot gesture, as the NHL games do it: pull the right stick back,
+ * then push it forward. How long and how far you pulled sets the power, so a
+ * flick is a wrist shot and a full wind-up is a slapshot.
+ */
+const SHOT = {
+ /** Right stick Y below this counts as winding up. */
+ windAt: -0.5,
+ /** ...and above this, having wound up, releases. */
+ releaseAt: 0.35,
+ /** Wind-up time for full power, seconds. */
+ fullWind: 0.55,
+ /** A wind-up abandoned for this long is forgotten rather than fired. */
+ timeout: 1.6,
+ /** Floor so a quick snap still does something. */
+ minPower: 0.25,
+};
+
+const rising = () => ({
+ pass: false, shoot: false, poke: false, dump: false,
+ switchPlayer: false, deke: false, start: false, camera: false,
+});
+
+export function createInput(target = window) {
+ const held = new Set();
+
+ const onDown = (e) => {
+ if (Object.values(KEYS).some((list) => list.includes(e.code))) e.preventDefault();
+ held.add(e.code);
+ };
+ const onUp = (e) => held.delete(e.code);
+ // A keyup that lands while the tab is unfocused never arrives, which leaves a
+ // skater sprinting into the boards forever. Clear everything on blur.
+ const onBlur = () => held.clear();
+
+ target.addEventListener('keydown', onDown);
+ target.addEventListener('keyup', onUp);
+ window.addEventListener('blur', onBlur);
+
+ let padIndex = null;
+ const onConnect = (e) => { padIndex = e.gamepad.index; };
+ const onDisconnect = (e) => { if (padIndex === e.gamepad.index) padIndex = null; };
+ window.addEventListener('gamepadconnected', onConnect);
+ window.addEventListener('gamepaddisconnected', onDisconnect);
+
+ const any = (codes) => codes.some((c) => held.has(c));
+
+ // Previous frame's button state, for edge detection.
+ const wasDown = rising();
+
+ /** Wind-up state for the Skill Stick. */
+ const wind = { active: false, t: 0, depth: 0, aim: 0, idle: 0 };
+
+ const state = {
+ // ---- the movement contract the match consumes --------------------------
+ x: 0,
+ y: 0,
+ sprint: false,
+ brake: false,
+ cameraYaw: 0,
+
+ // ---- richer view for everything else -----------------------------------
+ /** Left stick, screen space. Same numbers as x/y. */
+ move: { x: 0, y: 0 },
+ /** Right stick — the Skill Stick. */
+ skill: { x: 0, y: 0 },
+ /** Analog triggers, 0..1. */
+ hustle: 0,
+ protect: 0,
+ /** Held this frame. */
+ held: rising(),
+ /** True only on the frame the button went down. */
+ pressed: rising(),
+ /**
+ * Set on the frame a Skill Stick wind-up is released, then cleared.
+ * `{ power: 0..1, aim: -1..1 }` — aim is the stick's lateral position at
+ * release, which is where the shot is being placed.
+ */
+ shot: null,
+ /** How wound up the shot is right now, 0..1. Drives the wind-up pose. */
+ charge: 0,
+ source: 'none',
+ padId: null,
+ };
+
+ function readPad() {
+ const pads = navigator.getGamepads?.() ?? [];
+ if (padIndex != null && pads[padIndex]) return pads[padIndex];
+ // The connect event does not fire if the pad was already held when the page
+ // loaded, so fall back to scanning.
+ for (const p of pads) if (p?.connected) return p;
+ return null;
+ }
+
+ /**
+ * Radial deadzone, rescaled so the first movement past it is slow.
+ *
+ * Direction comes from the raw axes and magnitude is rescaled and capped
+ * separately. Clamping the two components instead would let a pad that
+ * reports a square range rather than a circular one hand back a diagonal of
+ * length 1.41 — a stick that is 41% faster on the diagonals.
+ */
+ function stick(rawX, rawY, out) {
+ const mag = Math.hypot(rawX, rawY);
+ if (mag <= STICK_DEADZONE) {
+ out.x = 0;
+ out.y = 0;
+ return false;
+ }
+ const scaled = clamp((mag - STICK_DEADZONE) / (1 - STICK_DEADZONE), 0, 1);
+ out.x = (rawX / mag) * scaled;
+ out.y = (-rawY / mag) * scaled; // pad Y is positive downward
+ return true;
+ }
+
+ /**
+ * Advance the shot gesture. Returns a shot on the frame of release.
+ *
+ * Kept here rather than in the game because it is a property of the input
+ * device — the same pull-back-and-push has to mean the same thing whatever
+ * is holding the puck.
+ */
+ function advanceShot(dt) {
+ const y = state.skill.y;
+ if (!wind.active) {
+ if (y < SHOT.windAt) {
+ wind.active = true;
+ wind.t = 0;
+ wind.depth = Math.abs(y);
+ wind.aim = state.skill.x;
+ }
+ state.charge = 0;
+ return null;
+ }
+
+ wind.t += dt;
+ wind.depth = Math.max(wind.depth, Math.abs(Math.min(0, y)));
+ wind.aim = state.skill.x;
+ state.charge = clamp(wind.t / SHOT.fullWind, 0, 1) * wind.depth;
+
+ if (y > SHOT.releaseAt) {
+ const power = clamp(
+ SHOT.minPower + (1 - SHOT.minPower) * clamp(wind.t / SHOT.fullWind, 0, 1) * wind.depth,
+ 0,
+ 1,
+ );
+ wind.active = false;
+ state.charge = 0;
+ return { power, aim: clamp(state.skill.x, -1, 1) };
+ }
+ // Held back forever without releasing: drop it rather than firing later.
+ if (wind.t > SHOT.timeout) {
+ wind.active = false;
+ state.charge = 0;
+ }
+ return null;
+ }
+
+ return {
+ state,
+
+ /** Which pad we are reading, or null. */
+ get padIndex() { return padIndex; },
+ get connected() { return readPad() != null; },
+
+ /**
+ * Sample this frame's input.
+ * @param {number} dt seconds, for the shot gesture timing
+ */
+ read(dt = 1 / 60) {
+ const pad = readPad();
+ let source = 'none';
+
+ // ---- sticks ------------------------------------------------------------
+ let moved = false;
+ let skilled = false;
+ if (pad) {
+ moved = stick(pad.axes[0] ?? 0, pad.axes[1] ?? 0, state.move);
+ skilled = stick(pad.axes[2] ?? 0, pad.axes[3] ?? 0, state.skill);
+ state.padId = pad.id;
+ } else {
+ state.move.x = 0;
+ state.move.y = 0;
+ state.skill.x = 0;
+ state.skill.y = 0;
+ state.padId = null;
+ }
+
+ if (!moved) {
+ // Keyboard only fills in when the stick is centred, so a pad in hand
+ // always wins and a stuck key cannot fight it.
+ let kx = 0;
+ let ky = 0;
+ if (any(KEYS.right)) kx += 1;
+ if (any(KEYS.left)) kx -= 1;
+ if (any(KEYS.up)) ky += 1;
+ if (any(KEYS.down)) ky -= 1;
+ const len = Math.hypot(kx, ky);
+ if (len > 0) {
+ state.move.x = kx / Math.max(1, len);
+ state.move.y = ky / Math.max(1, len);
+ source = 'keyboard';
+ }
+ } else {
+ source = 'gamepad';
+ }
+
+ if (!skilled) {
+ let sx = 0;
+ let sy = 0;
+ if (any(KEYS.skillRight)) sx += 1;
+ if (any(KEYS.skillLeft)) sx -= 1;
+ if (any(KEYS.skillUp)) sy += 1;
+ if (any(KEYS.skillDown)) sy -= 1;
+ const len = Math.hypot(sx, sy);
+ if (len > 0) {
+ state.skill.x = sx / Math.max(1, len);
+ state.skill.y = sy / Math.max(1, len);
+ if (source === 'none') source = 'keyboard';
+ }
+ } else if (source === 'none') {
+ source = 'gamepad';
+ }
+
+ // ---- triggers ----------------------------------------------------------
+ // Analog, not boolean: hustle is a throttle, and half-pressing it is how
+ // you keep speed without over-committing.
+ const trigger = (i) => {
+ const b = pad?.buttons?.[i];
+ if (!b) return 0;
+ const v = typeof b.value === 'number' ? b.value : (b.pressed ? 1 : 0);
+ return v <= TRIGGER_DEADZONE ? 0 : (v - TRIGGER_DEADZONE) / (1 - TRIGGER_DEADZONE);
+ };
+ state.hustle = trigger(PAD.RT);
+ state.protect = trigger(PAD.LT);
+ if (state.hustle > 0 || state.protect > 0) source = 'gamepad';
+ if (any(KEYS.hustle)) state.hustle = 1;
+ if (any(KEYS.protect)) state.protect = 1;
+ if ((any(KEYS.hustle) || any(KEYS.protect)) && source === 'none') source = 'keyboard';
+
+ // ---- buttons -----------------------------------------------------------
+ for (const action of Object.keys(BINDING)) {
+ const padDown = !!pad?.buttons?.[BINDING[action]]?.pressed;
+ const keyDown = KEYS[action] ? any(KEYS[action]) : false;
+ const down = padDown || keyDown;
+ state.pressed[action] = down && !wasDown[action];
+ state.held[action] = down;
+ wasDown[action] = down;
+ if (down) source = padDown ? 'gamepad' : 'keyboard';
+ }
+
+ // ---- derived contract --------------------------------------------------
+ state.x = state.move.x;
+ state.y = state.move.y;
+ // Above half-throttle counts as the sprint stride. The sim takes a
+ // boolean today; when it takes a throttle this is the line that changes.
+ state.sprint = state.hustle > 0.5;
+ state.brake = state.protect > 0.5;
+ state.source = source;
+
+ // ---- Skill Stick -------------------------------------------------------
+ state.shot = advanceShot(dt);
+ // Pressing the shoot button is the same event as a stick release, so a
+ // player who never learns the Skill Stick can still shoot.
+ if (!state.shot && state.pressed.shoot) {
+ state.shot = { power: 0.6, aim: clamp(state.skill.x, -1, 1) };
+ }
+
+ return state;
+ },
+
+ /**
+ * Rumble. Silently does nothing on a pad or browser without haptics, which
+ * is most of them — never let feedback become a hard dependency.
+ */
+ rumble(strong = 0.5, weak = 0.3, ms = 120) {
+ const pad = readPad();
+ const actuator = pad?.vibrationActuator;
+ if (!actuator?.playEffect) return false;
+ try {
+ actuator.playEffect('dual-rumble', {
+ duration: ms,
+ strongMagnitude: clamp(strong, 0, 1),
+ weakMagnitude: clamp(weak, 0, 1),
+ });
+ return true;
+ } catch {
+ return false;
+ }
+ },
+
+ destroy() {
+ target.removeEventListener('keydown', onDown);
+ target.removeEventListener('keyup', onUp);
+ window.removeEventListener('blur', onBlur);
+ window.removeEventListener('gamepadconnected', onConnect);
+ window.removeEventListener('gamepaddisconnected', onDisconnect);
+ held.clear();
+ },
+ };
+}
+
+/**
+ * Turn a screen-space stick into a world-space intent, given where the camera
+ * is looking.
+ *
+ * The camera orbits at `cameraYaw`, sitting at `+(sin, cos)` from its target,
+ * so it looks along `-(sin, cos)` and its right is `(cos, -sin)`. Pushing the
+ * stick away from yourself has to mean "away from the camera" regardless of
+ * which way the skater currently faces, or steering becomes unusable the moment
+ * the camera swings round behind them.
+ */
+export function stickToWorld(stick, cameraYaw, out = { ix: 0, iz: 0 }) {
+ const s = Math.sin(cameraYaw);
+ const c = Math.cos(cameraYaw);
+ out.ix = c * stick.x - s * stick.y;
+ out.iz = -s * stick.x - c * stick.y;
+ return out;
+}
+
+export { SHOT };
diff --git a/src/game/match.js b/src/game/match.js
new file mode 100644
index 0000000..f19ca43
--- /dev/null
+++ b/src/game/match.js
@@ -0,0 +1,508 @@
+import * as THREE from 'three';
+import { createSkater } from '../character/skater.js';
+import { createBrain, spawnLineup, steer } from '../../shared/ai.js';
+import { applyIntent, createSkaterState, stepSkater } from '../../shared/skaterSim.js';
+import { stickToWorld } from './input.js';
+import { createHitResolver } from './hits.js';
+import { PUCK, createPuck } from '../physics/puck.js';
+import { NET, goalLineX } from '../../shared/net.js';
+import { createPossession } from './possession.js';
+import { makeRng } from '../core/rng.js';
+import { clamp, wrapAngle } from '../../shared/scalar.js';
+
+/**
+ * The match loop.
+ *
+ * Order per frame is the whole design in six lines, so it is worth being
+ * explicit about why it is this order:
+ *
+ * 1. brains produce intent — decisions, once per frame
+ * 2. physics substeps, and inside each one:
+ * a. read position/velocity out of the proxy capsules
+ * b. step the skating sim, which edits that velocity
+ * c. write it back, then let Box3D solve boards and body contact
+ * 3. animation runs on the frame clock from the resolved state
+ * 4. the kinematic ragdolls chase the animated skeleton
+ *
+ * The sim living *inside* the substep loop is the part that matters. Skating
+ * is momentum, and momentum only survives a collision if the thing that
+ * resolved the collision and the thing that integrates the motion agree about
+ * the timestep. Running the sim once per frame and Box3D six times would mean
+ * a board hit gets partly overwritten by a stale velocity.
+ */
+
+export function createMatch({ scene, physics, perTeam = 3, teams = 2, seed = 20260802 }) {
+ const rng = makeRng(seed);
+ const spawns = spawnLineup(perTeam, teams);
+ const count = spawns.length;
+
+ const states = [];
+ const brains = [];
+ const skaters = [];
+ /** Previous velocity heading per skater, for the animator's bank. */
+ const prevVelYaw = [];
+
+ for (let i = 0; i < count; i++) {
+ const spawn = spawns[i];
+ const team = spawn.team;
+ const s = createSkaterState(i, spawn, {
+ seed: seed + i * 977,
+ team,
+ name: `${team === 0 ? 'Home' : 'Away'} ${(i % perTeam) + 1}`,
+ });
+ states.push(s);
+ brains.push(createBrain(rng.f, {}));
+ prevVelYaw.push(spawn.yaw);
+ skaters.push(createSkater({
+ seed: seed + i * 977,
+ scene,
+ physics,
+ index: i,
+ team,
+ position: { x: spawn.x, z: spawn.z },
+ facing: spawn.yaw,
+ // A little variety in build so three placeholder bodies are not clones.
+ bodyStyle: {
+ mass: rng.range(-0.35, 0.5),
+ muscle: rng.range(0.1, 0.75),
+ fat: rng.range(0, 0.25),
+ },
+ }));
+ skaters[i].proxy?.teleport(spawn.x, spawn.z);
+ }
+
+ /**
+ * Skaters being driven by something other than their brain, by index.
+ *
+ * A map rather than a single index because there is no reason for there to be
+ * only one: local versus is two entries, and a test driving both sides of a
+ * collision is a third case. The value is a live object that is *read* each
+ * frame — `input.read()` returns the same object every call, so handing it
+ * over once is enough.
+ */
+ const controls = new Map();
+ const _worldIntent = { ix: 0, iz: 0 };
+
+ // ---- puck ---------------------------------------------------------------
+ const puck = createPuck(physics, { position: { x: 0, y: 0.05, z: 0 } });
+ /** Puck events, newest first, for the HUD. */
+ const recentPlays = [];
+ const possession = createPossession({
+ puck,
+ skaters,
+ states,
+ onEvent(e) {
+ recentPlays.unshift({ ...e, at: performance.now?.() ?? 0 });
+ if (recentPlays.length > 8) recentPlays.pop();
+ },
+ });
+
+ /** Landed checks, newest first, for the HUD. */
+ const recentHits = [];
+ const hits = createHitResolver({
+ physics,
+ skaters,
+ states,
+ onHit(hit) {
+ recentHits.unshift({ ...hit, at: hits.time });
+ if (recentHits.length > 8) recentHits.pop();
+ // Getting hit costs you the puck. A stagger is enough — needing a full
+ // knockdown to force a turnover made the carrier effectively untouchable.
+ if (hit.outcome !== 'bump' && possession.carrier === hit.victim) {
+ possession.jar(hit.severity / 8);
+ }
+ },
+ });
+
+
+ /**
+ * Turn a controller's shot and pass buttons into puck events.
+ *
+ * Aim comes from where the skater is facing plus the Skill Stick's lateral
+ * position, so you place a shot by holding the stick off to one side as you
+ * release. A pass looks for the nearest teammate ahead instead.
+ */
+ function handleShooting(i, control) {
+ if (possession.carrier !== i) return;
+ const state = states[i];
+
+ if (control.shot) {
+ // Up to ~35° of placement either side of where they are pointing.
+ // Skill Stick +X is "push right"; positive yaw is a left turn in this
+ // frame, so aim subtracts — otherwise every placed shot went the wrong way.
+ const stickAim = control.shot.aim ?? 0;
+ const aimYaw = state.yaw - stickAim * 0.6;
+ possession.shoot(control.shot.power, aimYaw);
+ skaters[i].animator.playAction('shoot', {
+ power: control.shot.power,
+ aim: stickAim,
+ });
+ return;
+ }
+
+ if (control.pressed?.pass) {
+ const mate = nearestTeammate(i);
+ if (mate !== null) {
+ const dx = states[mate].x - state.x;
+ const dz = states[mate].z - state.z;
+ // Lead the target a little; a pass to where someone was is a turnover.
+ const lead = 0.35;
+ const aimYaw = Math.atan2(dx + states[mate].vx * lead, dz + states[mate].vz * lead);
+ const range = Math.hypot(dx, dz);
+ possession.shoot(clamp(range / 18, 0.3, 1), aimYaw, { pass: true });
+ skaters[i].animator.playAction('pass', {
+ aim: clamp(wrapAngle(aimYaw - state.yaw), -1, 1),
+ });
+ } else {
+ // Nobody to hit — dump it forward rather than eating the input.
+ possession.shoot(0.7, state.yaw, { pass: true });
+ skaters[i].animator.playAction('pass');
+ }
+ }
+ }
+
+ /**
+ * Bot shooting and passing.
+ *
+ * Previously `handleShooting` sat behind `if (control)`, so only a human
+ * could ever shoot — a bot picked the puck up and carried it until somebody
+ * poked it away. A minute of play produced zero shots.
+ *
+ * The decision is deliberately simple: inside range of the net, shoot; a
+ * teammate much better placed, pass; otherwise keep skating. Accuracy falls
+ * off with distance so bots miss, which is the difference between a goalie
+ * being tested and a goalie being beaten every time.
+ */
+ // Deliberately short. Bots used to fire from 14 m at full spread and miss
+ // wide; a shootout is about getting in close, not about point shots.
+ const SHOT_RANGE = 8;
+ function botShoot(i, dt) {
+ const b = brains[i];
+ b.shotCool = (b.shotCool ?? 0) - dt;
+ if (b.shotCool > 0) return;
+
+ const s = states[i];
+ const goalX = goalLineX(s.team === 0 ? 1 : -1);
+
+ // Pick a corner, not the middle. Aiming at the centre of the net means
+ // aiming at the goalie, who is standing on exactly that line by
+ // construction — thirty attempts produced thirty saves and no goals.
+ // Alternating sides also stops a bot grooving the same shot every time.
+ b.shotSide = b.shotSide === 1 ? -1 : 1;
+ const targetZ = b.shotSide * (NET.width / 2 - 0.22);
+
+ const dx = goalX - s.x;
+ const dz = targetZ - s.z;
+ const range = Math.hypot(goalX - s.x, -s.z);
+
+ // Only shoot when actually facing the net; a bot firing over its shoulder
+ // reads as a bug rather than as a highlight.
+ const toGoal = Math.atan2(dx, dz);
+ const facing = Math.abs(wrapAngle(toGoal - s.yaw));
+ if (range > SHOT_RANGE || facing > 0.7) {
+ // Look for a teammate in a better spot before giving up on the play.
+ const mate = nearestTeammate(i);
+ if (mate !== null && b.passCool == null) b.passCool = 0;
+ return;
+ }
+
+ // Aim, with a spread that grows with range. The scale matters more than it
+ // looks: 0.22 rad at 8 m is ±1.76 m of scatter against a net that is 1.83 m
+ // *wide*, so bots were missing the target more often than hitting it. A
+ // shot has to land inside the posts often enough for the goalie to be the
+ // thing that stops it.
+ const spread = clamp(range / SHOT_RANGE, 0, 1) * 0.055;
+ const aimYaw = toGoal + (rng.f() * 2 - 1) * spread;
+ const power = clamp(0.45 + range / SHOT_RANGE * 0.55, 0.4, 1);
+ possession.shoot(power, aimYaw);
+ skaters[i].animator.playAction('shoot', { power });
+ b.shotCool = 1.2;
+ }
+
+ function nearestTeammate(i) {
+ let best = null;
+ let bestD = Infinity;
+ for (let j = 0; j < count; j++) {
+ if (j === i || states[j].team !== states[i].team || skaters[j].limp) continue;
+ const d = Math.hypot(states[j].x - states[i].x, states[j].z - states[i].z);
+ if (d < bestD) {
+ bestD = d;
+ best = j;
+ }
+ }
+ return best;
+ }
+
+ /**
+ * Extra work to run inside each physics substep, before the solve.
+ * Modes register kinematic bodies of their own here — the goalie, today.
+ */
+ const substepSyncs = new Set();
+
+ /** What the brains are told about the puck, rebuilt each frame. */
+ const play = {
+ puck: { x: 0, z: 0 },
+ carrier: null,
+ carrierTeam: null,
+ /** Index of the one skater per team who is going for the puck. */
+ chaser: new Array(teams).fill(null),
+ };
+
+ /** @param {number} dt */
+ function update(dt) {
+ const pp = puck.position();
+ play.puck.x = pp.x;
+ play.puck.z = pp.z;
+ play.carrier = possession.carrier;
+ play.carrierTeam = possession.carrier === null ? null : states[possession.carrier].team;
+
+ // Nearest upright skater per side goes for the puck; everyone else finds
+ // space. Recomputed every frame, which means the job passes between
+ // teammates as the play moves rather than being assigned once.
+ play.chaser.fill(null);
+ const bestGap = new Array(teams).fill(Infinity);
+ for (let i = 0; i < count; i++) {
+ if (skaters[i].limp) continue;
+ const t = states[i].team;
+ const d = Math.hypot(states[i].x - pp.x, states[i].z - pp.z);
+ if (d < bestGap[t]) {
+ bestGap[t] = d;
+ play.chaser[t] = i;
+ }
+ }
+
+ // ---- 1. decisions ------------------------------------------------------
+ // `steer` writes intent straight onto the state. The player's skater goes
+ // through `applyIntent` instead, which clamps and normalises — the same
+ // path a network message would take, so the sim never has to trust input.
+ for (let i = 0; i < count; i++) {
+ // A downed skater makes no decisions. Their state is frozen where they
+ // fell; the ragdoll is doing the moving.
+ //
+ // Someone still getting up makes none either. Letting intent through
+ // mid-rise means they skate away while the pose is still interpolating
+ // out of a body on the ice, which reads as the corpse sliding off — the
+ // whole point of the get-up is that almost nothing moves but the pose.
+ if (skaters[i].limp || skaters[i].rising > 0) {
+ states[i].ix = 0;
+ states[i].iz = 0;
+ states[i].sprint = false;
+ states[i].brake = true;
+ continue;
+ }
+ const control = controls.get(i);
+ if (control) {
+ // The Skill Stick moves the puck, and only for whoever is carrying it.
+ if (possession.carrier === i && control.skill) {
+ possession.handling.x = control.skill.x;
+ possession.handling.y = control.skill.y;
+ }
+ handleShooting(i, control);
+ stickToWorld(control, control.cameraYaw ?? 0, _worldIntent);
+ applyIntent(states[i], {
+ ix: _worldIntent.ix,
+ iz: _worldIntent.iz,
+ sprint: control.sprint,
+ brake: control.brake,
+ });
+ // Keep the brain's waypoint fresh so handing control back does not
+ // send them skating off to somewhere chosen a minute ago.
+ brains[i].target = null;
+ if (control.pressed?.poke) {
+ // The reach always animates, whether or not it connects — a poke
+ // that only shows when it works gives the player no feedback on the
+ // ones that miss, which is most of them.
+ skaters[i].animator.playAction('poke');
+ possession.poke(i);
+ }
+ } else {
+ steer(brains[i], states[i], states, dt, play);
+ if (possession.carrier === i) botShoot(i, dt);
+ // Bots reach in when they get close enough, on a cooldown so they are
+ // not spamming it every frame they are in range.
+ if (possession.carrier !== null
+ && states[possession.carrier].team !== states[i].team) {
+ brains[i].pokeCool = (brains[i].pokeCool ?? 0) - dt;
+ if (brains[i].pokeCool <= 0) {
+ skaters[i].animator.playAction('poke');
+ if (possession.poke(i)) brains[i].pokeCool = 0.9;
+ else brains[i].pokeCool = 0.45;
+ }
+ }
+ }
+ }
+
+ // ---- 2. sim + physics, on the fixed step ------------------------------
+ physics.step(dt, (fixedDt) => {
+ for (let i = 0; i < count; i++) {
+ // While down, the ragdoll is the body and the proxy is switched off.
+ // Stepping the sim would drive a disabled capsule around the rink and
+ // then teleport the skater to it on the way up.
+ if (skaters[i].limp) continue;
+ const s = states[i];
+ const proxy = skaters[i].proxy;
+ if (proxy) proxy.read(s);
+ // Box3D owns board contact via the proxy, so the sim's own clamp
+ // would fight it — but keep it on when there is no proxy at all.
+ stepSkater(s, fixedDt, { clampBoards: !proxy });
+ if (proxy) proxy.write(s);
+ }
+ // The ragdolls chase wherever the animation left the skeleton. This has
+ // to happen *before* the solve, not after: SetTargetTransform derives the
+ // velocity that carries a kinematic body to its target over the coming
+ // step, so setting it afterwards would apply it a step late.
+ for (const sk of skaters) {
+ if (sk.ragdoll && !sk.limp && sk.ragdoll.mode === 'driven') {
+ sk.ragdoll.syncFromSkeleton(fixedDt);
+ }
+ // The blade collider follows the stick the same way, and for the same
+ // reason: SetTargetTransform derives the velocity that carries it over
+ // the coming step, so it has to be set before the solve or a blade
+ // sweeping through a loose puck arrives a step late and misses.
+ sk.stick.syncPhysics(physics.api, fixedDt);
+ }
+ for (const fn of substepSyncs) fn(fixedDt);
+ });
+
+ // A collision can hand the puck more speed than any shot ever should —
+ // `setVelocity` caps what *we* apply, but the solver is not bound by it.
+ // Cheap insurance against one bad contact putting the puck in orbit.
+ if (puck.speed() > PUCK.maxSpeed) {
+ const v = puck.velocity();
+ const k = PUCK.maxSpeed / puck.speed();
+ puck.setVelocity(v.x * k, v.y * k, v.z * k);
+ }
+
+ // ---- 3. hits, knockdowns and getting up --------------------------------
+ hits.tick(dt);
+ for (let i = 0; i < count; i++) {
+ if (skaters[i].tickDown(dt)) skaters[i].getUp(states[i]);
+ }
+
+ // ---- 3b. possession ----------------------------------------------------
+ // Once per frame, not per substep: capture and release are gameplay
+ // decisions, and running them at 120 Hz only makes the cooldowns fiddly.
+ // The carrier's stick decays back to neutral so a released Skill Stick
+ // brings the puck back in front rather than leaving it stranded wide.
+ if (possession.carrier === null || !controls.has(possession.carrier)) {
+ possession.handling.x *= Math.max(0, 1 - 6 * dt);
+ possession.handling.y *= Math.max(0, 1 - 6 * dt);
+ }
+ possession.update(dt);
+
+ // ---- 4. animation ------------------------------------------------------
+ for (let i = 0; i < count; i++) {
+ const s = states[i];
+ // Turn rate of the velocity vector, not of the body. Only meaningful
+ // while actually moving; a standing skater has no heading to turn.
+ const speed = Math.hypot(s.vx, s.vz);
+ let yawRate = 0;
+ if (speed > 0.4) {
+ const velYaw = Math.atan2(s.vx, s.vz);
+ yawRate = wrapAngle(velYaw - prevVelYaw[i]) / Math.max(1e-4, dt);
+ prevVelYaw[i] = velYaw;
+ }
+ // Stickwork inputs. The animator owns where the stick *is*; this only
+ // tells it what the skater is trying to do with it.
+ const anim = skaters[i].animator;
+ anim.hasPuck = possession.carrier === i;
+ const ctrl = controls.get(i);
+ anim.charge = anim.hasPuck ? (ctrl?.charge ?? 0) : 0;
+ if (anim.hasPuck) {
+ anim.handling.x = possession.handling.x;
+ anim.handling.y = possession.handling.y;
+ } else {
+ anim.handling.x *= Math.max(0, 1 - 8 * dt);
+ anim.handling.y *= Math.max(0, 1 - 8 * dt);
+ }
+ // Holding the Skill Stick back is a wind-up; letting it go ends one.
+ if (anim.hasPuck && anim.charge > 0.05 && anim.action === null) {
+ anim.action = 'windup';
+ anim.actionTime = 0;
+ } else if (anim.action === 'windup' && (!anim.hasPuck || anim.charge <= 0.05)) {
+ anim.action = null;
+ }
+
+ skaters[i].applyState(s, yawRate);
+ skaters[i].update(dt);
+ skaters[i].syncFromPhysics();
+
+
+ }
+ }
+
+ return {
+ states,
+ brains,
+ skaters,
+ perTeam,
+ teams,
+ update,
+ hits,
+ recentHits,
+ puck,
+ /** Register a callback to run inside every physics substep. */
+ addSubstepSync(fn) {
+ substepSyncs.add(fn);
+ return () => substepSyncs.delete(fn);
+ },
+ possession,
+ recentPlays,
+
+ controls,
+ /** The first externally driven skater — what the HUD and camera care about. */
+ get playerIndex() {
+ for (const i of controls.keys()) return i;
+ return null;
+ },
+
+ /**
+ * Drive a skater from something other than its brain. Pass `null` to hand
+ * it back. `control` is read every frame, so a live input object works.
+ */
+ setControl(index, control) {
+ if (index == null || index < 0 || index >= count) return null;
+ if (!control) {
+ controls.delete(index);
+ return null;
+ }
+ controls.set(index, control);
+ // Drop any intent the brain had queued so control starts from neutral
+ // rather than from whatever the bot was mid-way through doing.
+ states[index].ix = 0;
+ states[index].iz = 0;
+ states[index].sprint = false;
+ states[index].brake = false;
+ return index;
+ },
+
+ /** Skater states belonging to one team. */
+ team(index) {
+ return states.filter((s) => s.team === index);
+ },
+
+ /** Drop everyone back on their spawn, momentum cleared. */
+ reset() {
+ for (let i = 0; i < count; i++) {
+ const spawn = spawns[i];
+ // Anyone lying on the ice has to be stood up before being placed, or
+ // their proxy stays disabled and they spawn as a corpse.
+ if (skaters[i].limp) skaters[i].getUp(states[i]);
+ Object.assign(states[i], { x: spawn.x, z: spawn.z, vx: 0, vz: 0, yaw: spawn.yaw });
+ skaters[i].proxy?.teleport(spawn.x, spawn.z);
+ brains[i].target = null;
+ }
+ recentHits.length = 0;
+ recentPlays.length = 0;
+ // Faceoff: puck at centre ice, dead.
+ possession.reset();
+ puck.place(0, 0.05, 0);
+ },
+
+ destroy() {
+ hits.destroy();
+ puck.destroy();
+ for (const sk of skaters) sk.dispose();
+ },
+ };
+}
diff --git a/src/game/possession.js b/src/game/possession.js
new file mode 100644
index 0000000..63a03c7
--- /dev/null
+++ b/src/game/possession.js
@@ -0,0 +1,313 @@
+import * as THREE from 'three';
+import { PUCK } from '../physics/puck.js';
+import { clamp, lerp } from '../../shared/scalar.js';
+
+/**
+ * Who has the puck, and what "having it" means.
+ *
+ * This is the one genuinely undecided piece of the game, so it is built as a
+ * dial rather than as an answer. `magnetism` runs 0..1 between the two models:
+ *
+ * 0 Pure physics. The puck is always a free rigid body and the only thing
+ * that moves it is the blade collider pushing it. Authentic, and skittery
+ * to the point of being unplayable — you lose it to contacts you never
+ * intended and can never quite line up a shot.
+ *
+ * 1 Hard attach. The puck is placed at the carry point every frame. Totally
+ * controllable, looks glued, and kills the scrambles that are the reason
+ * to build a physics-driven hockey game at all.
+ *
+ * In between, the puck's velocity is blended toward whatever would carry it to
+ * the stick, so it *mostly* follows but can be jostled off the blade by a hit,
+ * a poke or a body in the way. Where that dial should sit is a feel question,
+ * so it is tunable at runtime (`[` and `]` in the browser) rather than baked.
+ *
+ * Everything else here follows from that: capture is a proximity test, release
+ * is either deliberate (shot, pass) or forced (hit, poke, the puck getting too
+ * far from the blade).
+ */
+
+export const CARRY = {
+ /** Default dial position. Tuned by hand; see the note above. */
+ magnetism: 0.72,
+ /** A loose puck this close to the blade gets picked up. */
+ captureRadius: 0.55,
+ /**
+ * Possession breaks if the puck gets this far from the blade.
+ *
+ * Has to be generous relative to how far the blade sits in front of the body
+ * (~1.35 m). At 1.15 m a shooter accelerating from a standstill outran their
+ * own puck every time — twelve of nineteen shootout attempts ended with the
+ * puck sitting on the ice at centre and nobody ever taking a shot.
+ */
+ breakRadius: 2.0,
+ /** How hard the puck is pulled onto the carry point, 1/s. */
+ stiffness: 20,
+ /** Seconds after losing it before the same skater can re-capture. */
+ reclaimDelay: 0.35,
+ /** Seconds after a shot or pass before anyone can capture. */
+ looseDelay: 0.18,
+ /** How far the Skill Stick can push the puck fore/aft and side to side. */
+ reachFwd: 0.34,
+ reachSide: 0.42,
+ /** Shot speed at full power, m/s. ~45 is a real slapshot. */
+ shotSpeed: 45,
+ /** Passes are firm but not shots. */
+ passSpeed: 18,
+ /** A shot lifts slightly; a pass stays flat. */
+ shotLift: 0.1,
+ /** How far a poke check reaches, blade to puck. */
+ pokeRadius: 1.25,
+ /** How hard a poke or a check knocks the puck away, m/s. */
+ pokeSpeed: 5.5,
+ /** How far the puck is stepped clear of the blade on release, metres. */
+ releaseGap: 0.4,
+};
+
+const _carryWorld = new THREE.Vector3();
+const _toTarget = new THREE.Vector3();
+const _desired = new THREE.Vector3();
+const _puckPos = new THREE.Vector3();
+const _puckVel = new THREE.Vector3();
+const _dir = new THREE.Vector3();
+
+/**
+ * @param {object} opts
+ * @param {object} opts.puck from createPuck
+ * @param {object[]} opts.skaters
+ * @param {object[]} opts.states
+ */
+export function createPossession({ puck, skaters, states, onEvent = null }) {
+ /** Index of the carrier, or null. */
+ let carrier = null;
+ /** Per-skater cooldown before they may capture again. */
+ const cooldown = new Array(skaters.length).fill(0);
+ /** Global cooldown after a deliberate release. */
+ let looseFor = 0;
+ const tuning = { ...CARRY };
+
+ /** Skill Stick offset applied to the carry point, -1..1 each. */
+ const handling = { x: 0, y: 0 };
+
+ /**
+ * Where the puck should sit for skater `i`, in world space.
+ *
+ * Read off the actual blade rather than computed from a fixed offset. That
+ * inversion is the point of socketing the stick to the hand: the arms decide
+ * where the blade is, and the puck goes where the blade is. Stickhandling is
+ * then an arm pose rather than a number added to a carry point, and the puck
+ * cannot end up somewhere the stick is not.
+ */
+ function bladePoint(i, out) {
+ const sk = skaters[i];
+ if (!sk?.stick) return out.set(0, 0, 0);
+ sk.stick.bladeWorld(out);
+ // The puck rides on the ice at the blade's XZ, not at the blade's centre —
+ // the blade has height and a lie angle, and a puck floating at its middle
+ // reads as hovering.
+ out.y = PUCK.thickness / 2;
+ return out;
+ }
+
+ function emit(type, payload) {
+ if (onEvent) onEvent({ type, ...payload });
+ }
+
+ /** Hand the puck to nobody, optionally locking capture for a moment. */
+ function release(reason, delay = tuning.reclaimDelay) {
+ if (carrier === null) return;
+ const was = carrier;
+ cooldown[was] = delay;
+ carrier = null;
+ looseFor = Math.max(looseFor, tuning.looseDelay);
+ emit('lost', { skater: was, reason });
+ }
+
+ function capture(index) {
+ if (carrier === index) return;
+ if (carrier !== null) {
+ const was = carrier;
+ cooldown[was] = tuning.reclaimDelay;
+ emit('stolen', { skater: index, from: was });
+ } else {
+ emit('gained', { skater: index });
+ }
+ carrier = index;
+ cooldown[index] = 0;
+ }
+
+ /**
+ * Poke check: reach in and knock the puck off whoever has it.
+ *
+ * Range is measured blade-to-puck, so it depends on where the poker's stick
+ * actually is. Without this — and without contact dislodging the puck — a
+ * carrier is untouchable, and a minute of play is one skater holding the puck
+ * for the entire minute while five others follow them around.
+ */
+ function poke(byIndex) {
+ if (carrier === null || carrier === byIndex) return false;
+ if (skaters[byIndex]?.limp) return false;
+ bladePoint(byIndex, _carryWorld);
+ _puckPos.copy(puck.position());
+ if (_puckPos.distanceTo(_carryWorld) > tuning.pokeRadius) return false;
+
+ // Knock it away from the carrier, roughly along the poke.
+ _dir.subVectors(_puckPos, _carryWorld).setY(0);
+ if (_dir.lengthSq() < 1e-6) _dir.set(1, 0, 0);
+ _dir.normalize().multiplyScalar(tuning.pokeSpeed);
+ puck.setVelocity(_dir.x, 0, _dir.z);
+ release('poked', tuning.reclaimDelay);
+ emit('poke', { skater: byIndex, from: carrier });
+ return true;
+ }
+
+ /**
+ * Contact dislodges the puck. Called when a check lands on the carrier —
+ * a stagger is enough, it does not need a knockdown.
+ */
+ function jar(severity = 1) {
+ if (carrier === null) return false;
+ _puckPos.copy(puck.position());
+ _dir.set(Math.random() - 0.5, 0, Math.random() - 0.5);
+ if (_dir.lengthSq() < 1e-6) _dir.set(1, 0, 0);
+ _dir.normalize().multiplyScalar(tuning.pokeSpeed * clamp(severity, 0.4, 1.6));
+ puck.setVelocity(_dir.x, 0, _dir.z);
+ release('jarred loose', tuning.reclaimDelay);
+ return true;
+ }
+
+ /** Fire the puck. `power` 0..1, `aimYaw` world radians. */
+ function shoot(power, aimYaw, { pass = false } = {}) {
+ if (carrier === null) return null;
+ const from = carrier;
+ const speed = (pass ? tuning.passSpeed : tuning.shotSpeed) * clamp(power, 0.15, 1);
+ _dir.set(Math.sin(aimYaw), 0, Math.cos(aimYaw));
+ const state = states[from];
+
+ // Step the puck off the blade before releasing it.
+ //
+ // It is sitting *exactly* on the blade — that is what carrying it means —
+ // and the follow-through animation immediately sweeps that kinematic
+ // collider through the same point at speed. Shots were being smashed
+ // sideways by the shooter's own stick: measured, they stopped six metres
+ // short of the net or flew twelve metres wide, and nothing ever scored.
+ _puckPos.copy(puck.position());
+ puck.place(
+ _puckPos.x + _dir.x * tuning.releaseGap,
+ PUCK.thickness / 2,
+ _puckPos.z + _dir.z * tuning.releaseGap,
+ { keepMotion: true },
+ );
+ // A shot inherits the shooter's momentum. Skating into it is worth speed,
+ // which is the whole reason a one-timer off the rush is dangerous.
+ puck.setVelocity(
+ _dir.x * speed + state.vx * 0.4,
+ pass ? 0 : speed * tuning.shotLift,
+ _dir.z * speed + state.vz * 0.4,
+ );
+ release(pass ? 'pass' : 'shot', tuning.reclaimDelay);
+ emit(pass ? 'pass' : 'shot', { skater: from, power, speed, aimYaw });
+ return { from, speed, power };
+ }
+
+ return {
+ tuning,
+ handling,
+ get carrier() { return carrier; },
+ get loose() { return carrier === null; },
+ shoot,
+ poke,
+ jar,
+ release,
+ capture,
+ bladePoint,
+
+ /** Where the puck is being carried, in world space. Null if loose. */
+ carryPoint(out) {
+ if (carrier === null) return null;
+ return bladePoint(carrier, out);
+ },
+
+ /**
+ * Advance possession by `dt`.
+ *
+ * Called once per rendered frame rather than per physics substep: capture
+ * and release are gameplay decisions, and running them at 120 Hz just makes
+ * the cooldowns six times as fiddly for no gain in fidelity.
+ */
+ update(dt) {
+ for (let i = 0; i < cooldown.length; i++) cooldown[i] = Math.max(0, cooldown[i] - dt);
+ looseFor = Math.max(0, looseFor - dt);
+
+ puck.position(); // refresh the cached vector
+ _puckPos.copy(puck.position());
+ _puckVel.copy(puck.velocity());
+
+ // ---- forced release ---------------------------------------------------
+ if (carrier !== null) {
+ const holder = skaters[carrier];
+ if (holder.limp) {
+ release('knocked down', 0.8);
+ } else {
+ this.carryPoint(_carryWorld);
+ const gap = _puckPos.distanceTo(_carryWorld);
+ if (gap > tuning.breakRadius) release('lost the handle');
+ }
+ }
+
+ // ---- capture ----------------------------------------------------------
+ if (carrier === null && looseFor <= 0) {
+ let best = null;
+ let bestGap = tuning.captureRadius;
+ for (let i = 0; i < skaters.length; i++) {
+ if (skaters[i].limp || cooldown[i] > 0) continue;
+ bladePoint(i, _carryWorld);
+ const gap = _puckPos.distanceTo(_carryWorld);
+ if (gap < bestGap) {
+ bestGap = gap;
+ best = i;
+ }
+ }
+ if (best !== null) capture(best);
+ }
+
+ // ---- carry ------------------------------------------------------------
+ if (carrier === null) return;
+ this.carryPoint(_carryWorld);
+ _toTarget.subVectors(_carryWorld, _puckPos);
+
+ const state = states[carrier];
+ // The velocity that would put the puck on the carry point, given that the
+ // carry point is itself moving with the skater.
+ _desired.set(
+ state.vx + _toTarget.x * tuning.stiffness,
+ _toTarget.y * tuning.stiffness,
+ state.vz + _toTarget.z * tuning.stiffness,
+ );
+
+ const m = clamp(tuning.magnetism, 0, 1);
+ puck.setVelocity(
+ lerp(_puckVel.x, _desired.x, m),
+ lerp(_puckVel.y, _desired.y, m),
+ lerp(_puckVel.z, _desired.z, m),
+ );
+
+ // There was a second "fumble" test here, a function of stiffness and
+ // magnetism, meant to catch a puck the magnetism was papering over. It
+ // was redundant with `breakRadius` and, after stiffness went up, fired
+ // *tighter* than it — at 1.16 m against a 1.7 m break — so it silently
+ // stripped the puck off every shooter accelerating out of centre ice.
+ // Twenty of twenty-four shootout attempts ended with nobody shooting.
+ // One distance test is enough, and it is the one above.
+ },
+
+ /** Clear everything — faceoffs and resets. */
+ reset() {
+ carrier = null;
+ looseFor = 0;
+ cooldown.fill(0);
+ handling.x = 0;
+ handling.y = 0;
+ },
+ };
+}
diff --git a/src/game/shootout.js b/src/game/shootout.js
new file mode 100644
index 0000000..9486a66
--- /dev/null
+++ b/src/game/shootout.js
@@ -0,0 +1,237 @@
+import * as THREE from 'three';
+import { createGoalie } from '../character/goalie.js';
+import { buildNetMesh, createNet } from '../physics/net.js';
+import { NET, attemptLive, goalLineX, isGoal, shootoutStart } from '../../shared/net.js';
+import { RINK } from '../../shared/rink.js';
+import { PUCK } from '../physics/puck.js';
+
+/**
+ * A shootout.
+ *
+ * The smallest thing that is actually hockey: one shooter, one goalie, one
+ * puck, and a result. No lines, no rules, no positional play — all of which
+ * makes it the right MVP, because everything it does need is the part that has
+ * to feel good anyway.
+ *
+ * Flow is a small state machine over one attempt:
+ *
+ * ready → the puck is on the dot, the shooter waits a few metres back
+ * live → they skate onto the puck and in on the goalie. Losing the handle
+ * is not the end of it — go and get it back.
+ * result → goal or save, held long enough to read
+ * ...then the other team shoots.
+ *
+ * Attempts alternate, so "1-on-1" is two players trading chances rather than a
+ * single endless drill.
+ */
+
+export const SHOOTOUT = {
+ /** Seconds on the clock for one attempt before it is called a miss. */
+ attemptTime: 15,
+ /** How long a goal or save is held on screen before the next shooter. */
+ resultTime: 2.2,
+ /** Countdown before the shooter is released. */
+ readyTime: 1.1,
+ /**
+ * How far behind the puck the shooter starts, metres.
+ *
+ * They skate onto it rather than spawning holding it — picking the puck up is
+ * part of the attempt, and starting glued to it skipped the only moment where
+ * the carry model has to prove it can *gain* possession rather than keep it.
+ */
+ startBack: 4.5,
+ /** Rounds each side gets before it goes to sudden death. */
+ rounds: 5,
+};
+
+export function createShootout({ scene, physics, match }) {
+ const { puck, possession, states, skaters } = match;
+
+ // Nets and goalies at both ends, because the sides alternate.
+ const nets = [createNet(physics, 1), createNet(physics, -1)];
+ const netMeshes = [buildNetMesh(scene, 1), buildNetMesh(scene, -1)];
+ const goalies = {
+ 1: createGoalie(physics, scene, { end: 1, index: 40, team: 1 }),
+ '-1': createGoalie(physics, scene, { end: -1, index: 41, team: 0 }),
+ };
+
+ const state = {
+ phase: 'ready',
+ /** Which team is shooting: 0 shoots at the +X end, 1 at −X. */
+ shootingTeam: 0,
+ /** Index of the shooter, and which end they are attacking. */
+ shooter: 0,
+ end: 1,
+ round: 1,
+ score: [0, 0],
+ attempts: [0, 0],
+ /** Last result, for the HUD. */
+ last: null,
+ clock: 0,
+ };
+
+ const _puckPos = new THREE.Vector3();
+
+ /** Everyone who is not shooting gets parked out of the way. */
+ function parkBystanders() {
+ let n = 0;
+ for (let i = 0; i < states.length; i++) {
+ if (i === state.shooter) continue;
+ const s = states[i];
+ const side = n % 2 === 0 ? 1 : -1;
+ s.x = -state.end * (RINK.halfX * 0.55);
+ s.z = side * (RINK.halfZ * 0.78) + Math.floor(n / 2) * side * 1.4;
+ s.vx = 0;
+ s.vz = 0;
+ s.yaw = state.end > 0 ? Math.PI / 2 : -Math.PI / 2;
+ if (skaters[i].limp) skaters[i].getUp(s);
+ skaters[i].proxy?.teleport(s.x, s.z);
+ match.setControl(i, { x: 0, y: 0, sprint: false, brake: false, cameraYaw: 0 });
+ n++;
+ }
+ }
+
+ /** Set up the next attempt. */
+ function nextAttempt() {
+ // Alternate ends so each team shoots at the other's goalie.
+ state.shootingTeam = state.attempts[0] <= state.attempts[1] ? 0 : 1;
+ state.end = state.shootingTeam === 0 ? 1 : -1;
+ // The shooter is the first upright skater on that team.
+ const perTeam = match.perTeam;
+ state.shooter = state.shootingTeam * perTeam + (state.round - 1) % perTeam;
+
+ const start = shootoutStart(state.end);
+ const s = states[state.shooter];
+ if (skaters[state.shooter].limp) skaters[state.shooter].getUp(s);
+ // Behind the puck, facing the net they are attacking.
+ s.x = start.x - state.end * SHOOTOUT.startBack;
+ s.z = start.z;
+ s.yaw = start.yaw;
+ s.vx = 0;
+ s.vz = 0;
+ skaters[state.shooter].proxy?.teleport(s.x, s.z);
+ match.setControl(state.shooter, null);
+
+ parkBystanders();
+
+ possession.reset();
+ // Puck on the dot at centre ice. Nobody starts holding it.
+ puck.place(start.x, PUCK.thickness / 2, start.z);
+
+ goalies[1].reset();
+ goalies[-1].reset();
+
+ state.phase = 'ready';
+ state.clock = SHOOTOUT.readyTime;
+ }
+
+ function finish(result, detail = '') {
+ state.phase = 'result';
+ state.clock = SHOOTOUT.resultTime;
+ state.attempts[state.shootingTeam]++;
+ if (result === 'goal') state.score[state.shootingTeam]++;
+ state.last = {
+ result,
+ detail,
+ team: state.shootingTeam,
+ shooter: state.shooter,
+ round: state.round,
+ score: [...state.score],
+ };
+ // A round is complete once both sides have had the same number of goes.
+ if (state.attempts[0] === state.attempts[1]) state.round++;
+ }
+
+ /** The goalie defending the end currently being shot at. */
+ const activeGoalie = () => goalies[state.end];
+
+ function update(dt) {
+ _puckPos.copy(puck.position());
+
+ // Both goalies track, so the idle one still looks alive; only the active
+ // one can be scored on.
+ goalies[1].update(dt, _puckPos);
+ goalies[-1].update(dt, _puckPos);
+
+ state.clock -= dt;
+
+ if (state.phase === 'ready') {
+ // Hold the shooter still while the countdown runs. The puck sits on the
+ // dot untouched; picking it up is the first thing they do when released.
+ const s = states[state.shooter];
+ s.ix = 0;
+ s.iz = 0;
+ s.sprint = false;
+ if (state.clock <= 0) {
+ state.phase = 'live';
+ state.clock = SHOOTOUT.attemptTime;
+ // Hand control back to whoever is driving, or let the brain take it.
+ if (pendingControl) match.setControl(state.shooter, pendingControl);
+ }
+ return;
+ }
+
+ if (state.phase === 'result') {
+ if (state.clock <= 0) nextAttempt();
+ return;
+ }
+
+ // ---- live --------------------------------------------------------------
+ if (isGoal(_puckPos, state.end, PUCK.radius)) {
+ finish('goal');
+ return;
+ }
+
+ if (activeGoalie().covers(_puckPos) && puck.speed() < 3) {
+ finish('save', 'covered');
+ return;
+ }
+
+ // Losing the handle does *not* end the attempt. In a one-on-one the puck
+ // getting away from you is part of the attempt, not the end of it — go and
+ // get it back. Only the clock, the goalie, or the puck leaving the picture
+ // finishes an attempt.
+ if (!attemptLive(_puckPos, state.end)) {
+ finish('save', 'wide');
+ return;
+ }
+
+ if (state.clock <= 0) finish('save', 'time');
+ }
+
+ /** Control object handed to whoever is shooting, or null for AI. */
+ let pendingControl = null;
+
+ return {
+ state,
+ goalies,
+ nets,
+ netMeshes,
+ update,
+ nextAttempt,
+
+ /** Drive every shooter with this control object. Null hands them to the AI. */
+ setShooterControl(control) {
+ pendingControl = control;
+ if (state.phase === 'live') match.setControl(state.shooter, control);
+ },
+
+ /** Restart the whole shootout. */
+ reset() {
+ state.score = [0, 0];
+ state.attempts = [0, 0];
+ state.round = 1;
+ state.last = null;
+ nextAttempt();
+ },
+
+ destroy() {
+ for (const n of nets) n.destroy();
+ for (const m of netMeshes) scene.remove(m);
+ goalies[1].destroy();
+ goalies[-1].destroy();
+ },
+ };
+}
+
+export { NET, goalLineX };
diff --git a/src/main.js b/src/main.js
new file mode 100644
index 0000000..ae23670
--- /dev/null
+++ b/src/main.js
@@ -0,0 +1,275 @@
+import * as THREE from 'three';
+import { createPhysicsWorld, initPhysics } from './physics/world.js';
+import { buildPuckMesh, buildRink } from './render/rink.js';
+import { PUCK } from './physics/puck.js';
+import { createCamera } from './render/camera.js';
+import { createMatch } from './game/match.js';
+import { createInput } from './game/input.js';
+import { describeHit } from './game/hits.js';
+import { createShootout } from './game/shootout.js';
+import { RINK } from '../shared/rink.js';
+
+/**
+ * Spike 1 boot: three AI skaters on a rink.
+ *
+ * Everything gameplay-shaped lives in `game/match.js`; this file is the shell —
+ * renderer, lights, resize, the frame loop and a small debug HUD.
+ */
+
+const canvas = document.getElementById('stage');
+const boot = document.getElementById('boot');
+const hud = document.getElementById('hud');
+
+const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: 'high-performance' });
+renderer.shadowMap.enabled = true;
+// PCFSoft is deprecated as of three r185 and silently falls back to PCF anyway.
+renderer.shadowMap.type = THREE.PCFShadowMap;
+renderer.toneMapping = THREE.ACESFilmicToneMapping;
+renderer.toneMappingExposure = 1.05;
+
+const scene = new THREE.Scene();
+scene.background = new THREE.Color(0x0a0e14);
+scene.fog = new THREE.Fog(0x0a0e14, 70, 150);
+
+// Arena lighting: a broad soft fill so the ice reads as lit from a roof rather
+// than from a single sun, plus one shadow-casting key over centre ice.
+scene.add(new THREE.HemisphereLight(0xdce8f5, 0x20242c, 1.5));
+const key = new THREE.DirectionalLight(0xffffff, 1.6);
+key.position.set(14, 30, 10);
+key.castShadow = true;
+key.shadow.mapSize.set(2048, 2048);
+key.shadow.camera.near = 5;
+key.shadow.camera.far = 110;
+// The ortho box has to contain the whole rink as seen from the light, or the
+// depth texture clamps at its border and everything outside renders fully
+// shadowed — a hard black wedge across the far ice, not a subtle artefact.
+// Half the rink diagonal is the worst case, whatever angle the light is at.
+const shadowSpan = Math.hypot(RINK.halfX, RINK.halfZ) + 6;
+key.shadow.camera.left = -shadowSpan;
+key.shadow.camera.right = shadowSpan;
+key.shadow.camera.top = shadowSpan;
+key.shadow.camera.bottom = -shadowSpan;
+key.shadow.bias = -0.0006;
+scene.add(key);
+const rim = new THREE.DirectionalLight(0x9fc4e8, 0.5);
+rim.position.set(-20, 14, -18);
+scene.add(rim);
+
+const cam = createCamera(canvas, window.innerWidth / window.innerHeight);
+
+/**
+ * Match the drawing buffer and the CSS box to the window.
+ *
+ * `setSize(w, h)` must set the CSS size too — passing `false` for `updateStyle`
+ * only works if the stylesheet already sizes the canvas, and an absolutely
+ * positioned canvas with `width: auto` falls back to its *intrinsic* size
+ * instead. At DPR 2 that made the element twice the window and showed the
+ * top-left quarter of the render.
+ *
+ * The pixel ratio is re-applied here rather than once at startup so that
+ * dragging the window between a retina and a non-retina display re-resolves it.
+ */
+function resize() {
+ const w = window.innerWidth;
+ const h = window.innerHeight;
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
+ renderer.setSize(w, h);
+ cam.resize(w, h);
+}
+window.addEventListener('resize', resize);
+resize();
+
+const stats = { fps: 0, steps: 0, top: 0 };
+const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
+
+async function boot3() {
+ await initPhysics();
+ const physics = createPhysicsWorld();
+ buildRink(scene);
+ const match = createMatch({ scene, physics, perTeam: 3, teams: 2 });
+ const puckView = buildPuckMesh(scene, PUCK);
+
+ // The shootout owns the nets and the goalies, and drives its own kinematic
+ // bodies inside the physics substep.
+ const shootout = createShootout({ scene, physics, match });
+ match.addSubstepSync((fixedDt) => {
+ shootout.goalies[1].syncPhysics(fixedDt);
+ shootout.goalies[-1].syncPhysics(fixedDt);
+ });
+ shootout.reset();
+
+ const input = createInput(window);
+ // One live input object, refreshed each frame and read by the match.
+ const stick = input.state;
+
+ // Rumble on contact the player is part of. Strength tracks the outcome, so
+ // the pad tells you whether you laid someone out or just brushed them, and
+ // taking one buzzes harder than giving one.
+ const RUMBLE = {
+ knockdown: [1.0, 0.7, 260],
+ stagger: [0.55, 0.35, 150],
+ bump: [0.22, 0.12, 70],
+ };
+ let lastHitSeen = -1;
+
+ /**
+ * Take control of a skater, or give them back.
+ *
+ * Taking control snaps the camera onto whoever you just grabbed — driving a
+ * skater you cannot see is the kind of thing that reads as a broken build.
+ */
+ let playerShooting = false;
+ /**
+ * Take the shooter, or hand them back. In a shootout there is only one
+ * skater worth driving, and which one it is changes every attempt — so
+ * control follows the shooter rather than being pinned to an index.
+ */
+ function toggleControl() {
+ playerShooting = !playerShooting;
+ shootout.setShooterControl(playerShooting ? stick : null);
+ if (playerShooting) {
+ cam.state.mode = 'follow';
+ cam.state.followIndex = shootout.state.shooter;
+ cam.state.distance = 9;
+ cam.state.pitch = 0.3;
+ }
+ }
+
+ window.addEventListener('keydown', (e) => {
+ if (e.key === 'c' || e.key === 'C') cam.cycleMode(match.skaters.length);
+ if (e.key === 'r' || e.key === 'R') shootout.reset();
+ if (e.key === 'p' || e.key === 'P' || e.code === 'Tab') {
+ e.preventDefault();
+ toggleControl();
+ }
+ // The possession dial, live. This is the undecided design question, so it
+ // is adjustable while playing rather than a constant to recompile — the
+ // answer is a feel judgement and has to be made with hands on the pad.
+ const t = match.possession.tuning;
+ if (e.key === '[') t.magnetism = Math.max(0, +(t.magnetism - 0.05).toFixed(2));
+ if (e.key === ']') t.magnetism = Math.min(1, +(t.magnetism + 0.05).toFixed(2));
+ });
+
+ // Debug handle. The capture tool drives the camera through this to frame
+ // repeatable shots, and it is the fastest way to poke at a skater from the
+ // console while tuning.
+ window.tilt = { match, shootout, cam, physics, scene, renderer, stats, input, toggleControl };
+
+ boot.remove();
+
+ let last = performance.now();
+ let fpsAccum = 0;
+ let fpsFrames = 0;
+
+ function frame(now) {
+ // Clamped so a background tab does not come back and teleport everyone
+ // across the rink in one step.
+ const dt = Math.min(0.05, (now - last) / 1000);
+ last = now;
+
+ // The camera yaw rides along with the stick so the match can turn a
+ // screen-space push into a world direction. Sampled before the update so
+ // input and simulation are one frame consistent.
+ input.read(dt);
+ stick.cameraYaw = cam.state.yaw;
+ match.update(dt);
+ shootout.update(dt);
+
+ // Follow whoever is shooting, so the camera never has to be told.
+ if (cam.state.mode === 'follow') cam.state.followIndex = shootout.state.shooter;
+
+ // Haptics for anything the player was part of.
+ const newest = match.recentHits[0];
+ if (newest && newest.at !== lastHitSeen) {
+ lastHitSeen = newest.at;
+ const me = match.playerIndex;
+ if (me !== null && (newest.attacker === me || newest.victim === me)) {
+ const [strong, weak, ms] = RUMBLE[newest.outcome] ?? RUMBLE.bump;
+ // Taking a hit shakes harder than landing one.
+ const k = newest.victim === me ? 1 : 0.7;
+ input.rumble(strong * k, weak * k, ms);
+ }
+ }
+
+ puckView.mesh.position.copy(match.puck.position());
+ puckView.mesh.quaternion.copy(match.puck.rotation());
+ puckView.ring.visible = match.possession.loose;
+
+ cam.update(dt, match.states);
+ renderer.render(scene, cam.camera);
+
+ fpsAccum += dt;
+ fpsFrames++;
+ if (fpsAccum >= 0.5) {
+ stats.fps = Math.round(fpsFrames / fpsAccum);
+ stats.steps = physics.stepCount;
+ stats.top = match.states.reduce((m, s) => Math.max(m, Math.hypot(s.vx, s.vz)), 0);
+ fpsAccum = 0;
+ fpsFrames = 0;
+ }
+ // Drawn every frame, not on the half-second tick: the hustle and shot
+ // meters are feedback, and feedback at 2 Hz is worse than none.
+ drawHud();
+
+ requestAnimationFrame(frame);
+ }
+
+ const bar = (v) => '▮'.repeat(Math.round(clamp01(v) * 8)).padEnd(8, '▯');
+
+ function drawHud() {
+ const watching = cam.state.mode === 'follow'
+ ? match.states[cam.state.followIndex]?.name ?? 'broadcast'
+ : 'broadcast';
+ const player = match.playerIndex !== null ? match.states[match.playerIndex] : null;
+ const down = match.skaters.filter((s) => s.limp).length;
+ const feed = match.recentHits
+ .filter((h) => h.outcome !== 'bump')
+ .slice(0, 3)
+ .map((h) => ` ${match.states[h.attacker].name} — ${describeHit(h)}`
+ + `${h.outcome === 'knockdown' ? ' DOWN' : ''}${h.headshot ? ' (head)' : ''}`)
+ .join('\n');
+ const pad = input.connected
+ ? `pad: ${(stick.padId ?? '').slice(0, 30) || 'connected'}`
+ : 'pad: none — keyboard';
+
+ const so = shootout.state;
+ const teamName = (t) => (t === 0 ? 'HOME' : 'AWAY');
+ const scoreLine = `${teamName(0)} ${so.score[0]} — ${so.score[1]} ${teamName(1)}`
+ + ` round ${so.round}`;
+ const phaseLine = so.phase === 'ready'
+ ? `${teamName(so.shootingTeam)} to shoot…`
+ : so.phase === 'result'
+ ? (so.last?.result === 'goal'
+ ? `GOAL — ${teamName(so.last.team)}`
+ : `SAVE${so.last?.detail ? ` (${so.last.detail})` : ''}`)
+ : `${teamName(so.shootingTeam)} shooting · ${Math.max(0, so.clock).toFixed(1)}s`;
+
+ const carrier = match.possession.carrier;
+ const puckLine = carrier === null
+ ? `puck: loose ${match.puck.speed().toFixed(1)} m/s`
+ : `puck: ${match.states[carrier].name}${carrier === match.playerIndex ? ' ← YOU' : ''}`;
+ const mag = match.possession.tuning.magnetism;
+
+ hud.textContent = `${scoreLine}`
+ + `\n${phaseLine}`
+ + `\n`
+ + `\n${stats.fps} fps · ${puckLine}`
+ + `\n${pad}`
+ + `\n[P] ${playerShooting ? 'let the AI shoot' : 'take the shooter'} [C] camera [R] restart`
+ + `\nmagnetism ${bar(mag)} ${mag.toFixed(2)} [ ] to tune`
+ + (player
+ ? (input.connected
+ ? '\nL-stick skate · RT hustle · LT stop · R-stick Skill Stick\nA pass · X shoot · B poke'
+ : '\nWASD skate · Shift hustle · Space stop · arrows Skill Stick\nJ pass · K shoot · L poke')
+ + `\nhustle ${bar(stick.hustle)} wind-up ${bar(stick.charge)}`
+ : '')
+ + (feed ? `\n\nhits:\n${feed}` : '');
+ }
+
+ requestAnimationFrame(frame);
+}
+
+boot3().catch((err) => {
+ console.error(err);
+ boot.textContent = 'FAILED TO START — ' + (err?.message ?? err);
+});
diff --git a/src/physics/bodyProxy.js b/src/physics/bodyProxy.js
new file mode 100644
index 0000000..64554bc
--- /dev/null
+++ b/src/physics/bodyProxy.js
@@ -0,0 +1,143 @@
+import { KIND, makeTag, proxyFilter, xyz } from './bridge.js';
+import { SKATE } from '../../shared/skaterSim.js';
+
+/**
+ * One dynamic capsule per skater — the body that Box3D actually solves.
+ *
+ * The 18-capsule ragdoll is kinematic while a skater is on their feet, and
+ * kinematic bodies do not respond to each other: two rigs driven through one
+ * another would generate contacts and resolve none of them. So physical
+ * presence lives in a single dynamic capsule instead, and the ragdoll rides
+ * along on top purely as the visible, hittable skeleton.
+ *
+ * The loop is:
+ *
+ * read — pull position and velocity out of Box3D into the sim state
+ * step — the skating sim edits that velocity (stride, carve, drag)
+ * write — put the edited velocity back on the body, then let Box3D solve
+ *
+ * Reading velocity back rather than only writing it is the whole point: a
+ * board hit or a shoulder from another skater arrives as a change to `vx/vz`
+ * that the sim then carries forward as momentum, so contact costs speed and
+ * knocks a skater off their line instead of being overwritten next frame.
+ *
+ * Rotation and vertical motion are locked. Upright-ness is an animation
+ * concern here, not a physics one — and an unlocked capsule on near-frictionless
+ * ice will happily lie down and roll to the far boards.
+ */
+
+/** Capsule spans knee to shoulder; below that is legs, above is head. */
+const LOW = 0.5;
+const HIGH = 1.28;
+
+/** Skater plus pads, kg. Sets how much of a shove a check transfers. */
+const MASS = 88;
+
+const capsuleVolume = (r, len) => Math.PI * r * r * len + (4 / 3) * Math.PI * r * r * r;
+
+export function createBodyProxy(physics, { index = 0, position = { x: 0, z: 0 } } = {}) {
+ const { api, world } = physics;
+ const filter = proxyFilter();
+
+ const bd = api.b3DefaultBodyDef();
+ bd.type = api.b3BodyType.b3_dynamicBody;
+ bd.position = xyz(position.x, 0, position.z);
+ // Never sleep: a skater standing still still has to be shoved when hit, and
+ // a sleeping body ignores the velocity we write to it.
+ bd.enableSleep = false;
+ bd.motionLocks = {
+ linearX: false,
+ linearY: true,
+ linearZ: false,
+ angularX: true,
+ angularY: true,
+ angularZ: true,
+ };
+ const body = api.b3CreateBody(world, bd);
+
+ const sd = api.b3DefaultShapeDef();
+ sd.density = MASS / capsuleVolume(SKATE.radius, HIGH - LOW);
+ sd.enableContactEvents = true;
+ sd.enableHitEvents = true;
+ // Skater-on-skater should shove, not stick. Friction between two bodies on
+ // ice is what would make a brush past turn into a drag along.
+ sd.baseMaterial.friction = 0.1;
+ sd.baseMaterial.restitution = 0.05;
+ sd.baseMaterial.userMaterialId = makeTag(KIND.PROXY, index, 0);
+ sd.filter.categoryBits = filter.category;
+ sd.filter.maskBits = filter.mask;
+ const shape = api.b3CreateCapsuleShape(body, sd, {
+ center1: xyz(0, LOW, 0),
+ center2: xyz(0, HIGH, 0),
+ radius: SKATE.radius,
+ });
+
+ // Gravity is pointless with linearY locked, and leaving it on means the
+ // solver spends every step fighting the lock.
+ api.b3Body_SetGravityScale(body, 0);
+ // No damping: the skating sim is the only thing allowed to remove speed,
+ // otherwise top speed and glide length quietly depend on solver settings.
+ api.b3Body_SetLinearDamping(body, 0);
+
+ return {
+ body,
+ shape,
+ index,
+ mass: api.b3Body_GetMass(body),
+
+ /** Box3D → sim. Call before stepping the sim. */
+ read(state) {
+ const p = api.b3Body_GetPosition(body);
+ const v = api.b3Body_GetLinearVelocity(body);
+ state.x = p.x;
+ state.z = p.z;
+ state.vx = v.x;
+ state.vz = v.z;
+ },
+
+ /** Sim → Box3D. Call after stepping the sim, before the world step. */
+ write(state) {
+ api.b3Body_SetLinearVelocity(body, xyz(state.vx, 0, state.vz));
+ api.b3Body_SetAwake(body, true);
+ },
+
+ /**
+ * Hard placement, for spawning and respawns. Clears momentum so a skater
+ * dropped onto the ice does not inherit whatever the last body was doing.
+ */
+ teleport(x, z) {
+ api.b3Body_SetTransform(body, xyz(x, 0, z), { v: { x: 0, y: 0, z: 0 }, s: 1 });
+ api.b3Body_SetLinearVelocity(body, xyz(0, 0, 0));
+ },
+
+ /** True while this capsule is taking part in the simulation. */
+ enabled: true,
+
+ /**
+ * Switch the capsule off while the ragdoll is the body.
+ *
+ * Not just "stop writing velocity to it": a body left enabled still
+ * occupies space, so a downed skater would leave an invisible upright
+ * bollard on the ice for everyone else to skate into.
+ */
+ disable() {
+ if (!this.enabled) return;
+ api.b3Body_Disable(body);
+ this.enabled = false;
+ },
+
+ /** Put the capsule back, wherever the body actually ended up. */
+ enable(x, z) {
+ if (this.enabled) return;
+ api.b3Body_Enable(body);
+ api.b3Body_SetTransform(body, xyz(x, 0, z), { v: { x: 0, y: 0, z: 0 }, s: 1 });
+ api.b3Body_SetLinearVelocity(body, xyz(0, 0, 0));
+ api.b3Body_SetAwake(body, true);
+ this.enabled = true;
+ },
+
+ destroy() {
+ api.b3DestroyBody(body);
+ },
+ };
+}
diff --git a/src/physics/bridge.js b/src/physics/bridge.js
new file mode 100644
index 0000000..866a785
--- /dev/null
+++ b/src/physics/bridge.js
@@ -0,0 +1,129 @@
+/**
+ * three.js <-> Box3D type conversion.
+ *
+ * The one real trap: Box3D's embind structs use the vector/scalar quaternion
+ * form `{ v: {x,y,z}, s }`, while three.js uses `{x,y,z,w}`. Passing a three
+ * quaternion straight into a joint or transform throws `Missing field: "v"`
+ * from embind, so everything crossing the boundary goes through here.
+ */
+
+export const IDENTITY_QUAT = Object.freeze({ v: { x: 0, y: 0, z: 0 }, s: 1 });
+
+export const vec3 = (v) => ({ x: v.x, y: v.y, z: v.z });
+export const xyz = (x, y, z) => ({ x, y, z });
+
+/** three.Quaternion -> b3Quat */
+export const quat = (q) => ({ v: { x: q.x, y: q.y, z: q.z }, s: q.w });
+
+/** b3Quat -> three.Quaternion (in place) */
+export const toThreeQuat = (out, bq) => out.set(bq.v.x, bq.v.y, bq.v.z, bq.s);
+
+/** b3Vec3 -> three.Vector3 (in place) */
+export const toThreeVec = (out, bv) => out.set(bv.x, bv.y, bv.z);
+
+/** three position + quaternion -> b3Transform */
+export const transform = (p, q) => ({ p: vec3(p), q: quat(q) });
+
+/** Copy a body's pose onto an Object3D that lives in world space. */
+export function applyBodyToObject(b3, bodyId, obj) {
+ const t = b3.b3Body_GetTransform(bodyId);
+ obj.position.set(t.p.x, t.p.y, t.p.z);
+ obj.quaternion.set(t.q.v.x, t.q.v.y, t.q.v.z, t.q.s);
+}
+
+/**
+ * Shape tags.
+ *
+ * Box3D has no per-body user data, but hit events carry the `userMaterialId`
+ * of both shapes, so identity is packed into that 64-bit field:
+ *
+ * bits 0..7 kind (KIND.*)
+ * bits 8..15 skater index of the owning skater, 0xff for none
+ * bits 16..31 slot region or piece index within that skater
+ *
+ * A hit event therefore tells us who was struck, where, and by what, without
+ * any side lookup in the hot path.
+ */
+export const KIND = {
+ NONE: 0,
+ BODY: 1, // ragdoll limb
+ PROXY: 2, // the skater's single dynamic capsule
+ STICK: 3, // reserved — spike 2
+ PUCK: 4, // reserved — spike 2
+ RINK: 5, // ice / boards
+};
+
+export function makeTag(kind, skater, slot) {
+ return (BigInt(kind & 0xff)) | (BigInt((skater ?? 0xff) & 0xff) << 8n) | (BigInt(slot & 0xffff) << 16n);
+}
+
+/**
+ * Collision layers.
+ *
+ * Bit 0 is the rink (ice + boards). Bit 15 is the proxy layer: the one dynamic
+ * capsule per skater that Box3D actually solves — board contact, and skater
+ * against skater, both happen there.
+ *
+ * Each skater also owns one bit from bit 1 up for their 18 ragdoll capsules.
+ * Those are kinematic in this spike and exist so the rig is already wired for
+ * impulses later; they deliberately do *not* collide with any proxy, because a
+ * kinematic limb driving through the dynamic capsule that carries the same
+ * body would fight it every frame.
+ *
+ * Getting this wrong is silent: a body whose mask excludes bit 0 simply falls
+ * through the world with no error anywhere.
+ */
+export const CAT = {
+ RINK: 1n,
+ PROXY: 1n << 15n,
+ PUCK: 1n << 16n,
+ STICK: 1n << 17n,
+ skater: (index) => 1n << BigInt(1 + index),
+};
+
+const ALL_BITS = 0xffffffffffffffffn;
+
+/**
+ * The dynamic body capsule: hits the boards, every other skater's proxy, and
+ * the puck. Not sticks — a stick is a kinematic collider and would shove
+ * skaters around without ever being pushed back.
+ */
+export function proxyFilter() {
+ return { category: CAT.PROXY, mask: CAT.RINK | CAT.PROXY | CAT.PUCK };
+}
+
+/**
+ * The stick blade: touches the puck and nothing else.
+ *
+ * Kinematic bodies push dynamic ones without being pushed back, which is
+ * exactly right for a stick batting a puck and exactly wrong for a stick
+ * batting a person. Same trap as the ragdoll limbs, resolved the same way —
+ * by keeping the mask narrow rather than by hoping.
+ */
+export function stickFilter() {
+ return { category: CAT.STICK, mask: CAT.PUCK };
+}
+
+/**
+ * Ragdoll limbs: include the self bit so distant parts collide (hand vs
+ * torso, thigh vs thigh) once the rig goes dynamic. Adjacent pairs are
+ * rejected by the world custom filter using the userMaterialId slot indices.
+ * Proxies are masked out — see the note above.
+ */
+export function ragdollFilter(index) {
+ const self = CAT.skater(index);
+ return { category: self, mask: ALL_BITS & ~CAT.PROXY };
+}
+
+export function rinkFilter() {
+ return { category: CAT.RINK, mask: ALL_BITS };
+}
+
+export function readTag(tag) {
+ const t = BigInt(tag);
+ return {
+ kind: Number(t & 0xffn),
+ skater: Number((t >> 8n) & 0xffn),
+ slot: Number((t >> 16n) & 0xffffn),
+ };
+}
diff --git a/src/physics/net.js b/src/physics/net.js
new file mode 100644
index 0000000..c6aa750
--- /dev/null
+++ b/src/physics/net.js
@@ -0,0 +1,124 @@
+import * as THREE from 'three';
+import { NET, goalLineX } from '../../shared/net.js';
+import { CAT, KIND, makeTag, xyz } from './bridge.js';
+
+/**
+ * The goal frame: posts, crossbar, and a mesh back that stops the puck.
+ *
+ * Static bodies, because a net that moves is a rule (it comes off its moorings)
+ * rather than a feature, and not one worth having before there is a game.
+ *
+ * The back and sides are solid boxes rather than a real mesh. A puck that goes
+ * in should stay in and settle, and modelling twine is a lot of work to make a
+ * puck stop moving.
+ */
+export function createNet(physics, end) {
+ const { api, world } = physics;
+ const line = goalLineX(end);
+ const halfW = NET.width / 2;
+ const r = NET.postRadius;
+
+ const sd = api.b3DefaultShapeDef();
+ sd.baseMaterial.friction = 0.4;
+ // Posts ring; the back eats everything so the puck settles in the net.
+ sd.baseMaterial.restitution = 0.35;
+ sd.baseMaterial.userMaterialId = makeTag(KIND.RINK, 0xff, end > 0 ? 10 : 11);
+ sd.filter.categoryBits = CAT.RINK;
+ sd.filter.maskBits = 0xffffffffffffffffn;
+
+ const bodies = [];
+ const box = (x, y, z, hx, hy, hz, restitution = null) => {
+ const bd = api.b3DefaultBodyDef();
+ bd.position = xyz(x, y, z);
+ const b = api.b3CreateBody(world, bd);
+ if (restitution !== null) sd.baseMaterial.restitution = restitution;
+ api.b3CreateBoxShape(b, sd, hx, hy, hz);
+ sd.baseMaterial.restitution = 0.35;
+ bodies.push(b);
+ return b;
+ };
+
+ // Posts, on the line.
+ box(line, NET.height / 2, halfW, r, NET.height / 2, r);
+ box(line, NET.height / 2, -halfW, r, NET.height / 2, r);
+ // Crossbar.
+ box(line, NET.height, 0, r, r, halfW);
+ // Back and sides, deadened so the puck does not fire back out.
+ //
+ // The net extends *away* from centre ice, `line + end * depth`. Getting this
+ // sign backwards put the back panel a metre in front of the goal line — a
+ // solid wall across the mouth — and every shot in the game bounced off it
+ // before it could cross. Nothing ever scored, and the symptom looked like a
+ // goalie problem.
+ box(line + end * NET.depth, NET.height / 2, 0, 0.04, NET.height / 2, halfW, 0.02);
+ box(line + end * NET.depth * 0.5, NET.height / 2, halfW, NET.depth / 2, NET.height / 2, 0.03, 0.05);
+ box(line + end * NET.depth * 0.5, NET.height / 2, -halfW, NET.depth / 2, NET.height / 2, 0.03, 0.05);
+
+ return {
+ end,
+ bodies,
+ destroy() {
+ for (const b of bodies) api.b3DestroyBody(b);
+ },
+ };
+}
+
+/** The rendered net: frame tubes plus a translucent mesh bag. */
+export function buildNetMesh(scene, end) {
+ const line = goalLineX(end);
+ const halfW = NET.width / 2;
+ const group = new THREE.Group();
+ group.name = 'net:' + end;
+
+ const frame = new THREE.MeshStandardMaterial({ color: 0xc0332c, roughness: 0.45, metalness: 0.25 });
+ const mesh = new THREE.MeshStandardMaterial({
+ color: 0xf2f4f8,
+ roughness: 0.9,
+ transparent: true,
+ opacity: 0.28,
+ side: THREE.DoubleSide,
+ depthWrite: false,
+ });
+
+ const tube = (len, x, y, z, axis) => {
+ const g = new THREE.CylinderGeometry(NET.postRadius, NET.postRadius, len, 10);
+ const m = new THREE.Mesh(g, frame);
+ if (axis === 'z') m.rotation.x = Math.PI / 2;
+ if (axis === 'x') m.rotation.z = Math.PI / 2;
+ m.position.set(x, y, z);
+ m.castShadow = true;
+ group.add(m);
+ };
+ tube(NET.height, line, NET.height / 2, halfW, 'y');
+ tube(NET.height, line, NET.height / 2, -halfW, 'y');
+ tube(NET.width, line, NET.height, 0, 'z');
+ // Back frame, so the net reads as a box rather than as a doorway.
+ tube(NET.depth, line + end * NET.depth / 2, 0.06, halfW, 'x');
+ tube(NET.depth, line + end * NET.depth / 2, 0.06, -halfW, 'x');
+
+ const back = new THREE.Mesh(new THREE.PlaneGeometry(NET.width, NET.height), mesh);
+ back.position.set(line + end * NET.depth, NET.height / 2, 0);
+ back.rotation.y = Math.PI / 2;
+ group.add(back);
+ for (const s of [1, -1]) {
+ const side = new THREE.Mesh(new THREE.PlaneGeometry(NET.depth, NET.height), mesh);
+ side.position.set(line + end * NET.depth / 2, NET.height / 2, s * halfW);
+ group.add(side);
+ }
+ const top = new THREE.Mesh(new THREE.PlaneGeometry(NET.depth, NET.width), mesh);
+ top.rotation.x = -Math.PI / 2;
+ top.position.set(line + end * NET.depth / 2, NET.height, 0);
+ group.add(top);
+
+ // Crease paint.
+ const crease = new THREE.Mesh(
+ new THREE.CircleGeometry(NET.creaseRadius, 24, end > 0 ? -Math.PI / 2 : Math.PI / 2, Math.PI),
+ new THREE.MeshBasicMaterial({ color: 0x77b3e0, transparent: true, opacity: 0.45, depthWrite: false }),
+ );
+ crease.rotation.x = -Math.PI / 2;
+ crease.position.set(line, 0.004, 0);
+ group.add(crease);
+
+ scene.add(group);
+ return group;
+}
diff --git a/src/physics/puck.js b/src/physics/puck.js
new file mode 100644
index 0000000..fae49cd
--- /dev/null
+++ b/src/physics/puck.js
@@ -0,0 +1,146 @@
+import * as THREE from 'three';
+import { CAT, KIND, makeTag, xyz } from './bridge.js';
+
+/**
+ * The puck.
+ *
+ * Regulation: 76 mm across, 25.4 mm thick, 170 g. Those are not decoration —
+ * the size is what makes this the one body in the world that genuinely needs
+ * continuous collision, and the mass is what makes a 45 m/s shot carry about
+ * the same momentum as a slow-walking person.
+ *
+ * ### Why it is a bullet
+ *
+ * A hard shot travels ~45 m/s. At the 1/120 s fixed step that is 0.37 m per
+ * step — nearly ten times the puck's own radius — and even at Box3D's internal
+ * 1/480 substep it is still 2.4× radius. Without continuous collision it goes
+ * straight through the boards, the net and anybody standing in the way, and the
+ * symptom (a puck that vanishes on hard shots only) is miserable to chase.
+ *
+ * ### Why it is a cylinder, and why it cannot tip over
+ *
+ * A sphere would roll, and a box would catch its corners. Box3D can build a
+ * cylinder hull directly. Angular X and Z are then locked so the puck stays
+ * flat on the ice and only ever spins about its own axis — a puck rolling
+ * around the rink on its edge is technically possible and always reads as a
+ * bug. Vertical motion stays free, because a shot lifting off the ice is real
+ * hockey.
+ */
+
+export const PUCK = {
+ radius: 0.0381,
+ thickness: 0.0254,
+ mass: 0.170,
+ /** Ice is slippery; a dumped puck should travel the length of the rink. */
+ iceFriction: 0.05,
+ /** Boards are lively for something this light. */
+ boardRestitution: 0.35,
+ /** Terminal sanity: nothing in hockey exceeds this. */
+ maxSpeed: 55,
+};
+
+const HULL_SIDES = 16;
+
+export function createPuck(physics, { position = { x: 0, y: 0.02, z: 0 } } = {}) {
+ const { api, world } = physics;
+
+ const bd = api.b3DefaultBodyDef();
+ bd.type = api.b3BodyType.b3_dynamicBody;
+ bd.position = xyz(position.x, position.y, position.z);
+ bd.isBullet = true;
+ // Never sleep. A puck sitting still in a corner still has to react the
+ // instant a skate touches it.
+ bd.enableSleep = false;
+ bd.motionLocks = {
+ linearX: false,
+ linearY: false,
+ linearZ: false,
+ angularX: true,
+ angularY: false,
+ angularZ: true,
+ };
+ const body = api.b3CreateBody(world, bd);
+ api.b3Body_SetBullet(body, true);
+
+ // `b3CreateCylinder` builds *upward from* `yOffset` rather than centring on
+ // it, so the offset has to be half the thickness or the body origin sits on
+ // the puck's bottom face — the puck then rests with its origin at y=0 and the
+ // rendered mesh, which is centred, is drawn half-sunk into the ice.
+ const hull = api.b3CreateCylinder(PUCK.thickness, PUCK.radius, -PUCK.thickness / 2, HULL_SIDES);
+ const sd = api.b3DefaultShapeDef();
+ sd.density = PUCK.mass / (Math.PI * PUCK.radius * PUCK.radius * PUCK.thickness);
+ sd.enableContactEvents = true;
+ sd.enableHitEvents = true;
+ sd.baseMaterial.friction = PUCK.iceFriction;
+ sd.baseMaterial.restitution = PUCK.boardRestitution;
+ sd.baseMaterial.userMaterialId = makeTag(KIND.PUCK, 0xff, 0);
+ sd.filter.categoryBits = CAT.PUCK;
+ // Everything solid: the rink, skater bodies, downed ragdolls and sticks.
+ sd.filter.maskBits = 0xffffffffffffffffn;
+ const shape = api.b3CreateHullShape(body, sd, hull);
+ // Damping stands in for air resistance and blade scrape; without it a puck
+ // dumped down the ice never slows at all on a 0.05 friction surface.
+ api.b3Body_SetLinearDamping(body, 0.22);
+ api.b3Body_SetAngularDamping(body, 0.4);
+
+ const _pos = new THREE.Vector3();
+ const _vel = new THREE.Vector3();
+ const _quat = new THREE.Quaternion();
+
+ return {
+ body,
+ shape,
+ mass: api.b3Body_GetMass(body),
+
+ /** World position, into a reused vector. */
+ position() {
+ const p = api.b3Body_GetPosition(body);
+ return _pos.set(p.x, p.y, p.z);
+ },
+
+ velocity() {
+ const v = api.b3Body_GetLinearVelocity(body);
+ return _vel.set(v.x, v.y, v.z);
+ },
+
+ rotation() {
+ const t = api.b3Body_GetTransform(body);
+ return _quat.set(t.q.v.x, t.q.v.y, t.q.v.z, t.q.s);
+ },
+
+ speed() {
+ const v = api.b3Body_GetLinearVelocity(body);
+ return Math.hypot(v.x, v.y, v.z);
+ },
+
+ setVelocity(x, y, z) {
+ const speed = Math.hypot(x, y, z);
+ if (speed > PUCK.maxSpeed) {
+ const k = PUCK.maxSpeed / speed;
+ api.b3Body_SetLinearVelocity(body, xyz(x * k, y * k, z * k));
+ } else {
+ api.b3Body_SetLinearVelocity(body, xyz(x, y, z));
+ }
+ api.b3Body_SetAwake(body, true);
+ },
+
+ applyImpulse(x, y, z) {
+ api.b3Body_ApplyLinearImpulseToCenter(body, xyz(x, y, z), true);
+ },
+
+ /** Hard placement — faceoffs, resets, and the carry when fully magnetised. */
+ place(x, y, z, { keepMotion = false } = {}) {
+ api.b3Body_SetTransform(body, xyz(x, y, z), { v: { x: 0, y: 0, z: 0 }, s: 1 });
+ if (!keepMotion) {
+ api.b3Body_SetLinearVelocity(body, xyz(0, 0, 0));
+ api.b3Body_SetAngularVelocity(body, xyz(0, 0, 0));
+ }
+ api.b3Body_SetAwake(body, true);
+ },
+
+ destroy() {
+ api.b3DestroyBody(body);
+ api.b3DestroyHull(hull);
+ },
+ };
+}
diff --git a/src/physics/ragdoll.js b/src/physics/ragdoll.js
new file mode 100644
index 0000000..09e33e4
--- /dev/null
+++ b/src/physics/ragdoll.js
@@ -0,0 +1,676 @@
+import * as THREE from 'three';
+import { BONE_RADIUS, BONE_REGION, SEG_CHILD } from '../character/skeleton.js';
+import { CAT, IDENTITY_QUAT, KIND, makeTag, quat, ragdollFilter, transform, vec3 } from './bridge.js';
+// Reaction curve: a blow bites almost instantly, then bleeds off over the
+// recovery window. Anything slower on the attack reads as the skater choosing
+// to flinch rather than being moved by the hit.
+// Reach full physics weight fast so the flinch is visible on the first frames
+// after the impulse (was 55 ms — most of a light hit was over before peak).
+export const REACTION_ATTACK = 0.04;
+
+/**
+ * Physical body built from the animation skeleton.
+ *
+ * Each part's collider is authored in *bone-local* space — capsule from the
+ * bone origin to its child's local offset — and the rigid body is placed at
+ * the bone's world transform. That sidesteps any axis-alignment math: the
+ * capsule matches the bone exactly by construction, whatever direction the
+ * bone happens to point.
+ *
+ * Two modes:
+ * 'driven' bodies are kinematic and chase the animated skeleton. This is
+ * everything spike 1 uses — the rig is here so that hits later have
+ * something to push, not because anything pushes it yet.
+ * 'limp' bodies go dynamic and the joints take over. Bone velocity at the
+ * moment of transition is carried across, so a skater taken off
+ * their feet mid-stride keeps the momentum of that stride.
+ *
+ * Carried over from Ludus with the collision filters retargeted (see
+ * bridge.js) and nothing else changed: it is the same 18 capsules and 17
+ * joints, and the reaction/limp paths are known-good.
+ */
+
+const HINGE_FRAME = { v: { x: 0, y: Math.SQRT1_2, z: 0 }, s: Math.SQRT1_2 }; // local Z -> local X
+
+// Body density by tissue type. Box3D derives mass and inertia from the shapes,
+// so these are the only mass numbers we author — but see CALIBRATION below.
+const DENSITY = { bone: 1350, limb: 1050, torso: 1010, head: 1090 };
+
+// Adjacent bone capsules deliberately overlap so the rig has no gaps at the
+// joints, which means summing their volumes counts the overlaps twice and lands
+// around 175 kg of "flesh" for a normal build. Rather than fudge the densities
+// (and lose the physical relationship between tissue types), the whole rig is
+// scaled once at build time to hit a plausible total. Re-setting the shape
+// density and letting Box3D recompute keeps each body's inertia tensor
+// consistent with its new mass; scaling the tensor by hand would not.
+const TARGET_BODY_MASS = 86; // kg, before pads and stick
+
+/**
+ * Parts, parent-first. `hinge` marks a joint that should only bend one way
+ * (elbows, knees); everything else is a cone-limited ball joint.
+ */
+const PARTS = [
+ { name: 'pelvis', bone: 'pelvis', parent: null, density: DENSITY.torso, radiusScale: 1.15 },
+ { name: 'spine1', bone: 'spine1', parent: 'pelvis', density: DENSITY.torso, cone: 0.34, twist: 0.5 },
+ { name: 'spine2', bone: 'spine2', parent: 'spine1', density: DENSITY.torso, cone: 0.34, twist: 0.5 },
+ { name: 'spine3', bone: 'spine3', parent: 'spine2', density: DENSITY.torso, cone: 0.3, twist: 0.4 },
+ { name: 'neck', bone: 'neck', parent: 'spine3', density: DENSITY.head, cone: 0.5, twist: 0.7 },
+ { name: 'head', bone: 'head', parent: 'neck', density: DENSITY.head, cone: 0.55, twist: 0.8, radiusScale: 1.0 },
+
+ { name: 'upperArmL', bone: 'upperArmL', parent: 'spine3', density: DENSITY.limb, cone: 1.5, twist: 1.1 },
+ { name: 'forearmL', bone: 'forearmL', parent: 'upperArmL', density: DENSITY.limb, hinge: [-0.12, 2.5] },
+ { name: 'handL', bone: 'handL', parent: 'forearmL', density: DENSITY.limb, cone: 0.7, twist: 0.6 },
+ { name: 'upperArmR', bone: 'upperArmR', parent: 'spine3', density: DENSITY.limb, cone: 1.5, twist: 1.1 },
+ { name: 'forearmR', bone: 'forearmR', parent: 'upperArmR', density: DENSITY.limb, hinge: [-0.12, 2.5] },
+ { name: 'handR', bone: 'handR', parent: 'forearmR', density: DENSITY.limb, cone: 0.7, twist: 0.6 },
+
+ // Knee hinge is about bone-local +X (HINGE_FRAME maps joint Z → X). With the
+ // rest limb along −Y, *positive* angle swings the foot back (−Z) — flexion.
+ // Negative angle is hyperextension (foot forward). The old limits were
+ // inverted ([-2.4, -0.12]), so limp legs only bent the wrong way.
+ // Residual +0.12 rad of flex stops a perfectly straight column from standing
+ // forever under gravity, and blocks reverse bend.
+ { name: 'thighL', bone: 'thighL', parent: 'pelvis', density: DENSITY.limb, cone: 1.15, twist: 0.5 },
+ { name: 'shinL', bone: 'shinL', parent: 'thighL', density: DENSITY.limb, hinge: [0.12, 2.4] },
+ { name: 'footL', bone: 'footL', parent: 'shinL', density: DENSITY.bone, cone: 0.5, twist: 0.3 },
+ { name: 'thighR', bone: 'thighR', parent: 'pelvis', density: DENSITY.limb, cone: 1.15, twist: 0.5 },
+ { name: 'shinR', bone: 'shinR', parent: 'thighR', density: DENSITY.limb, hinge: [0.12, 2.4] },
+ { name: 'footR', bone: 'footR', parent: 'shinR', density: DENSITY.bone, cone: 0.5, twist: 0.3 },
+];
+
+const _wp = new THREE.Vector3();
+const _wq = new THREE.Quaternion();
+const _ws = new THREE.Vector3();
+const _prevP = new THREE.Vector3();
+const _prevQ = new THREE.Quaternion();
+const _pq = new THREE.Quaternion();
+const _pqi = new THREE.Quaternion();
+const _dq = new THREE.Quaternion();
+const _axis = new THREE.Vector3();
+const _zAxis = new THREE.Vector3(0, 0, 1);
+
+/**
+ * Adjacency (by part name) for self-collision filtering.
+ * Adjacent capsules deliberately overlap at joints; they must never generate
+ * contacts. Parts two links away still often rest inside each other in bind
+ * pose (spine1↔spine3), so we cull graph distance ≤ 2 as well.
+ */
+function partDistance(a, b) {
+ if (a === b) return 0;
+ // BFS on the undirected tree. PARTS is small (18), so this is free.
+ const adj = new Map();
+ for (const p of PARTS) {
+ if (!adj.has(p.name)) adj.set(p.name, []);
+ if (p.parent) {
+ adj.get(p.name).push(p.parent);
+ if (!adj.has(p.parent)) adj.set(p.parent, []);
+ adj.get(p.parent).push(p.name);
+ }
+ }
+ const q = [[a, 0]];
+ const seen = new Set([a]);
+ while (q.length) {
+ const [n, d] = q.shift();
+ if (n === b) return d;
+ for (const m of adj.get(n) ?? []) {
+ if (seen.has(m)) continue;
+ seen.add(m);
+ q.push([m, d + 1]);
+ }
+ }
+ return 99;
+}
+
+/**
+ * Precomputed "too close to collide" pairs keyed by part name.
+ * Distance ≤ 1 = joint neighbours (capsules deliberately overlap).
+ * Distance 2 on the *spine* only — limb forks (thighL↔thighR = 2 via pelvis)
+ * must still collide so a limp body can tangle.
+ */
+const NO_COLLIDE = new Set();
+{
+ const names = PARTS.map((p) => p.name);
+ const spine = new Set(['pelvis', 'spine1', 'spine2', 'spine3', 'neck', 'head']);
+ for (let i = 0; i < names.length; i++) {
+ for (let j = i + 1; j < names.length; j++) {
+ const d = partDistance(names[i], names[j]);
+ const bothSpine = spine.has(names[i]) && spine.has(names[j]);
+ if (d <= 1 || (d <= 2 && bothSpine)) {
+ NO_COLLIDE.add(`${names[i]}|${names[j]}`);
+ NO_COLLIDE.add(`${names[j]}|${names[i]}`);
+ }
+ }
+ }
+}
+
+/** True when two body part *names* on the same rig may generate contacts. */
+export function ragdollPartsCollide(nameA, nameB) {
+ if (nameA === nameB) return false;
+ return !NO_COLLIDE.has(`${nameA}|${nameB}`);
+}
+
+/** Slot indices match the order PARTS is walked when building the ragdoll. */
+const SLOT_NAMES = PARTS.map((p) => p.name);
+
+/** True when two body *slots* on the same rig may generate contacts. */
+export function slotsShouldCollide(slotA, slotB) {
+ const a = SLOT_NAMES[slotA];
+ const b = SLOT_NAMES[slotB];
+ if (a == null || b == null) return true;
+ return ragdollPartsCollide(a, b);
+}
+
+export function createRagdoll(physics, skelData, { skaterIndex = 0 } = {}) {
+ const { api, world } = physics;
+ const bones = skelData.bones;
+ skelData.rootBone.updateMatrixWorld(true);
+
+ const filter = ragdollFilter(skaterIndex);
+ // Two masks, swapped by setMode. `driven` keeps limbs out of the proxy layer
+ // so an animated arm cannot shove anybody; `limp` lets a falling body hit
+ // people. The rig's own proxy is disabled while it is down, so nothing here
+ // has to special-case self.
+ const drivenMask = filter.mask & ~CAT.PROXY;
+ const limpMask = filter.mask | CAT.PROXY;
+ const _filter = { categoryBits: filter.category, maskBits: drivenMask, groupIndex: 0 };
+ const parts = {};
+ const order = [];
+
+ for (const def of PARTS) {
+ const bone = bones[def.bone];
+ if (!bone) continue;
+ const childName = SEG_CHILD[def.bone];
+ const child = childName ? bones[childName] : null;
+
+ // Capsule endpoints in bone-local space.
+ const c1 = new THREE.Vector3(0, 0, 0);
+ const c2 = child
+ ? child.position.clone()
+ : def.bone === 'head'
+ ? new THREE.Vector3(0, 0.15, 0.012)
+ : def.bone.startsWith('hand')
+ ? new THREE.Vector3(def.bone.endsWith('L') ? 0.045 : -0.045, -0.095, 0.008)
+ : new THREE.Vector3(0, -0.012, 0.085);
+
+ const radius = BONE_RADIUS[def.bone] * (def.radiusScale ?? 0.72);
+ // A degenerate capsule (endpoints closer than the radius) is just a sphere
+ // and confuses the solver; nudge it out along its own axis instead.
+ if (c2.length() < radius * 0.5) c2.setLength(radius * 0.5 + 1e-3);
+
+ bone.matrixWorld.decompose(_wp, _wq, _ws);
+
+ const bd = api.b3DefaultBodyDef();
+ // Created dynamic so Box3D computes mass and inertia from the shapes, then
+ // switched to kinematic below. A kinematic body reports zero mass, so this
+ // is the only moment the real figure is available.
+ bd.type = api.b3BodyType.b3_dynamicBody;
+ bd.position = vec3(_wp);
+ bd.rotation = quat(_wq);
+ bd.enableSleep = false;
+ const body = api.b3CreateBody(world, bd);
+
+ const sd = api.b3DefaultShapeDef();
+ sd.density = def.density;
+ sd.enableHitEvents = true;
+ sd.enableContactEvents = true;
+ // Custom filter rejects adjacent limbs of the same skater (see world.js).
+ sd.enableCustomFiltering = true;
+ sd.baseMaterial.friction = 0.75;
+ sd.baseMaterial.restitution = 0.05;
+ sd.baseMaterial.userMaterialId = makeTag(KIND.BODY, skaterIndex, order.length);
+ // Self bit is included: distant limbs collide when limp. Adjacent pairs
+ // are culled by the world custom filter (and joints keep collideConnected off).
+ sd.filter.categoryBits = filter.category;
+ sd.filter.maskBits = drivenMask;
+ sd.filter.groupIndex = 0;
+ const shape = api.b3CreateCapsuleShape(body, sd, {
+ center1: vec3(c1),
+ center2: vec3(c2),
+ radius,
+ });
+ api.b3Body_EnableHitEvents(body, true);
+ const mass = api.b3Body_GetMass(body);
+
+ const part = {
+ name: def.name,
+ def,
+ bone,
+ body,
+ shape,
+ radius,
+ mass,
+ // Capsule endpoints in bone-local space, kept so the segment can be
+ // rebuilt in world space for limb-level hit queries without asking
+ // Box3D to hand the shape back every frame.
+ localA: c1.clone(),
+ localB: c2.clone(),
+ region: BONE_REGION[def.bone],
+ index: order.length,
+ prevPos: _wp.clone(),
+ prevQuat: _wq.clone(),
+ linVel: new THREE.Vector3(),
+ angVel: new THREE.Vector3(),
+ disabled: false,
+ };
+ parts[def.name] = part;
+ order.push(part);
+ }
+
+ // ---- mass calibration ---------------------------------------------------
+ // Runs while the bodies are still dynamic: a kinematic body has no mass to
+ // recompute, so calibrating after the switch would silently do nothing.
+ {
+ let raw = 0;
+ for (const part of order) raw += part.mass;
+ if (raw > 1e-6) {
+ const k = TARGET_BODY_MASS / raw;
+ for (const part of order) {
+ api.b3Shape_SetDensity(part.shape, part.def.density * k, false);
+ api.b3Body_ApplyMassFromShapes(part.body);
+ part.mass = api.b3Body_GetMass(part.body);
+ }
+ }
+ }
+ for (const part of order) api.b3Body_SetType(part.body, api.b3BodyType.b3_kinematicBody);
+
+ // ---- joints -------------------------------------------------------------
+ const joints = [];
+ for (const def of PARTS) {
+ if (!def.parent) continue;
+ const a = parts[def.parent];
+ const b = parts[def.name];
+ if (!a || !b) continue;
+
+ // The anchor is the child bone's origin: (0,0,0) in the child's frame, and
+ // the child's local offset in the parent's frame.
+ const localA = b.bone.position.clone();
+ const localB = new THREE.Vector3(0, 0, 0);
+
+ let jointId;
+ if (def.hinge) {
+ const jd = api.b3DefaultRevoluteJointDef();
+ jd.base.bodyIdA = a.body;
+ jd.base.bodyIdB = b.body;
+ jd.base.localFrameA = { p: vec3(localA), q: HINGE_FRAME };
+ jd.base.localFrameB = { p: vec3(localB), q: HINGE_FRAME };
+ // Stiffer limit solver on hinges so a heavy impact cannot soft-blow past
+ // the hyperextension stop (knees) or the elbow lock.
+ jd.base.constraintHertz = 90;
+ jd.base.constraintDampingRatio = 3;
+ jd.enableLimit = true;
+ jd.lowerAngle = def.hinge[0];
+ jd.upperAngle = def.hinge[1];
+ // Springs start off — see setJointStiffness.
+ jd.enableSpring = false;
+ jd.hertz = 0;
+ jd.dampingRatio = 0.7;
+ jointId = api.b3CreateRevoluteJoint(world, jd);
+ } else {
+ // Cone axis is frame Z, so point Z down the limb.
+ _axis.copy(localA).normalize();
+ const frameQ = localA.lengthSq() > 1e-9
+ ? quat(_dq.setFromUnitVectors(_zAxis, _axis))
+ : IDENTITY_QUAT;
+ const jd = api.b3DefaultSphericalJointDef();
+ jd.base.bodyIdA = a.body;
+ jd.base.bodyIdB = b.body;
+ jd.base.localFrameA = { p: vec3(localA), q: frameQ };
+ jd.base.localFrameB = { p: vec3(localB), q: frameQ };
+ jd.enableConeLimit = true;
+ jd.coneAngle = def.cone ?? 0.6;
+ jd.enableTwistLimit = true;
+ jd.lowerTwistAngle = -(def.twist ?? 0.5);
+ jd.upperTwistAngle = def.twist ?? 0.5;
+ // Springs start off. A spring pulls each joint toward its neutral (bind
+ // pose) rotation, and at any usable stiffness that turns the rig into a
+ // self-supporting mannequin: it balances on straight legs and never
+ // collapses. Stiffness is applied deliberately via setJointStiffness for
+ // the partial "spring-damper blend" reaction, and left at zero for a real
+ // collapse.
+ jd.enableSpring = false;
+ jd.hertz = 0;
+ jd.dampingRatio = 0.65;
+ jointId = api.b3CreateSphericalJoint(world, jd);
+ }
+ joints.push({ id: jointId, a: a.name, b: b.name, def, hinge: !!def.hinge });
+ }
+
+ let mode = 'driven';
+ let stiffness = 0;
+
+ /**
+ * Joint stiffness — the spring-damper blend.
+ *
+ * `hertz` 0 gives a fully limp rig that collapses under its own weight; the
+ * useful range for a reaction that recovers its pose is roughly 2–6 Hz. High
+ * values make the rig self-supporting, which is right for a stumble and wrong
+ * for a death.
+ */
+ function setJointStiffness(hertz, dampingRatio = 0.65) {
+ stiffness = hertz;
+ const on = hertz > 0.01;
+ for (const j of joints) {
+ if (j.severed) continue;
+ if (j.hinge) {
+ api.b3RevoluteJoint_EnableSpring(j.id, on);
+ if (on) {
+ api.b3RevoluteJoint_SetSpringHertz(j.id, hertz);
+ api.b3RevoluteJoint_SetSpringDampingRatio(j.id, dampingRatio);
+ }
+ } else {
+ api.b3SphericalJoint_EnableSpring(j.id, on);
+ if (on) {
+ api.b3SphericalJoint_SetSpringHertz(j.id, hertz);
+ api.b3SphericalJoint_SetSpringDampingRatio(j.id, dampingRatio);
+ }
+ }
+ }
+ }
+
+ /** Push the animated skeleton into the physics bodies (driven mode). */
+ function syncFromSkeleton(dt) {
+ for (const part of order) {
+ part.bone.matrixWorld.decompose(_wp, _wq, _ws);
+ api.b3Body_SetTargetTransform(part.body, transform(_wp, _wq), dt, true);
+ }
+ }
+
+ // Sanity ceilings for the handoff. A limb tip in a hard stride runs well under
+ // these; anything above is a sampling artefact, and letting it through
+ // launches the whole rig into the air the instant it goes limp.
+ const MAX_LIN = 12; // m/s
+ const MAX_ANG = 30; // rad/s
+
+ /**
+ * Sample bone velocities, once per rendered frame.
+ *
+ * This deliberately does *not* live in syncFromSkeleton. That runs once per
+ * fixed substep while the skeleton only moves once per rendered frame, so a
+ * delta measured there gets divided by the substep duration rather than the
+ * frame duration — inflating velocity by the substep count and leaving the
+ * stored value dependent on which substep happened to run last.
+ */
+ function sampleVelocities(frameDt) {
+ const inv = frameDt > 1e-5 ? 1 / frameDt : 0;
+ for (const part of order) {
+ part.bone.matrixWorld.decompose(_wp, _wq, _ws);
+
+ part.linVel.subVectors(_wp, part.prevPos).multiplyScalar(inv);
+ if (part.linVel.lengthSq() > MAX_LIN * MAX_LIN) part.linVel.setLength(MAX_LIN);
+
+ _prevQ.copy(part.prevQuat).invert();
+ _dq.copy(_wq).multiply(_prevQ);
+ if (_dq.w < 0) _dq.set(-_dq.x, -_dq.y, -_dq.z, -_dq.w); // shortest arc
+ const angle = 2 * Math.acos(Math.min(1, _dq.w));
+ if (angle > 1e-5) {
+ const s = Math.sqrt(Math.max(1e-12, 1 - _dq.w * _dq.w));
+ part.angVel.set(_dq.x / s, _dq.y / s, _dq.z / s).multiplyScalar(angle * inv);
+ if (part.angVel.lengthSq() > MAX_ANG * MAX_ANG) part.angVel.setLength(MAX_ANG);
+ } else part.angVel.set(0, 0, 0);
+
+ part.prevPos.copy(_wp);
+ part.prevQuat.copy(_wq);
+ }
+ }
+
+ // Bone name -> the world quaternion its body currently reports.
+ const bodyWorldQ = new Map();
+ // Accumulated world quaternion per bone during the write-back walk.
+ const accumQ = new Map();
+ const _mq = new THREE.Quaternion();
+
+ /**
+ * Read the physics bodies back onto the skeleton (limp mode).
+ *
+ * Two passes, because a bone's local rotation depends on its parent's *new*
+ * world rotation. Reading `parent.matrixWorld` mid-walk would use last
+ * frame's value and skew every limb down the chain.
+ *
+ * The walk also has to handle bones with no body of their own (root,
+ * clavicles, toes): they keep their current local rotation and simply pass
+ * the accumulated world rotation through. That matters because upperArm's
+ * *bone* parent is the clavicle while its *joint* parent is spine3.
+ */
+ const _moverQinv = new THREE.Quaternion();
+ const _physWorld = new THREE.Quaternion();
+ const _animWorld = new THREE.Quaternion();
+ const _localTarget = new THREE.Quaternion();
+ const _rootTarget = new THREE.Vector3();
+
+ /**
+ * Write the physics pose onto the skeleton, blended against the pose the
+ * animator just produced.
+ *
+ * `weight` 1 is a full ragdoll; anything between is the spring-damper
+ * blend — the body is deflected by the blow but the animation still shows
+ * through, and as the weight decays the skater recovers their stance.
+ *
+ * Blending happens in *world* space per bone and is converted back to a local
+ * rotation afterwards. Slerping local rotations instead would compound down
+ * the chain: a half-weight shoulder followed by a half-weight elbow does not
+ * put the hand halfway between the two poses.
+ */
+ function blendToSkeleton(moverMatrixInverse, weight = 1, { includeRoot = true } = {}) {
+ if (weight <= 0.0005) return;
+ const w = Math.min(1, weight);
+
+ bodyWorldQ.clear();
+ accumQ.clear();
+ for (const part of order) {
+ const t = api.b3Body_GetTransform(part.body);
+ bodyWorldQ.set(part.bone.name, _mq.set(t.q.v.x, t.q.v.y, t.q.v.z, t.q.s).clone());
+ }
+
+ // The mover may be rotated, so body world rotations have to be brought into
+ // the mover's frame before they become bone locals.
+ _moverQinv.identity();
+ if (moverMatrixInverse) _moverQinv.setFromRotationMatrix(moverMatrixInverse);
+
+ const walk = (bone, parentWorld) => {
+ const phys = bodyWorldQ.get(bone.name);
+ // The animated world rotation this bone would have had, given the already
+ // blended parent above it.
+ _animWorld.copy(parentWorld).multiply(bone.quaternion);
+ let world;
+ if (phys) {
+ _physWorld.copy(_moverQinv).multiply(phys);
+ world = _animWorld.clone().slerp(_physWorld, w);
+ _pqi.copy(parentWorld).invert();
+ _localTarget.copy(_pqi).multiply(world);
+ bone.quaternion.copy(_localTarget);
+ } else {
+ world = _animWorld.clone();
+ }
+ accumQ.set(bone.name, world);
+ for (const child of bone.children) if (child.isBone) walk(child, world);
+ };
+
+ const rootBone = skelData.bones.root;
+ const animRootQ = rootBone.quaternion.clone();
+ rootBone.quaternion.identity();
+ walk(rootBone, new THREE.Quaternion());
+ if (w < 1) rootBone.quaternion.slerpQuaternions(animRootQ, rootBone.quaternion, w);
+
+ // The pelvis carries the rig's position; every other bone is rotation-only,
+ // so the hierarchy keeps the limbs attached to it. Partial reactions leave
+ // the root alone — displacing it slides the skater across the ice, which
+ // reads as teleporting rather than as being hit.
+ const pelvis = parts.pelvis;
+ if (includeRoot && pelvis) {
+ const t = api.b3Body_GetTransform(pelvis.body);
+ _wp.set(t.p.x, t.p.y, t.p.z);
+ if (moverMatrixInverse) _wp.applyMatrix4(moverMatrixInverse);
+ _rootTarget.copy(_wp).sub(pelvis.bone.position);
+ rootBone.position.lerp(_rootTarget, w);
+ }
+ }
+
+ /** Full ragdoll write-back. */
+ function syncToSkeleton(moverMatrixInverse) {
+ blendToSkeleton(moverMatrixInverse, 1, { includeRoot: true });
+ }
+
+ /**
+ * Snap the physics bodies onto the current skeleton pose.
+ *
+ * Needed when handing control back to animation: the bodies are wherever the
+ * simulation left them, and driving a kinematic body toward a distant target
+ * makes Box3D derive a huge velocity, which would fling anything it touches.
+ */
+ function snapToSkeleton() {
+ for (const part of order) {
+ part.bone.matrixWorld.decompose(_wp, _wq, _ws);
+ api.b3Body_SetTransform(part.body, vec3(_wp), quat(_wq));
+ api.b3Body_SetLinearVelocity(part.body, { x: 0, y: 0, z: 0 });
+ api.b3Body_SetAngularVelocity(part.body, { x: 0, y: 0, z: 0 });
+ part.prevPos.copy(_wp);
+ part.prevQuat.copy(_wq);
+ }
+ }
+
+ /**
+ * Modes:
+ * 'driven' kinematic, chases the animation exactly
+ * 'reacting' dynamic with stiff joints — deflects under a blow and is
+ * expected to be blended back toward the animated pose
+ * 'limp' dynamic and slack; gravity wins
+ */
+ function setMode(next) {
+ if (next === mode) return;
+ const dynamic = next === 'limp' || next === 'reacting';
+ if (!dynamic) snapToSkeleton();
+ // Limbs only join the collision world while the rig is dynamic.
+ //
+ // A kinematic limb cannot be pushed, but it *can* push: a driven skater's
+ // arm swinging through its stride would shove other skaters' proxy capsules
+ // around, so an idle bystander could be checked by someone's elbow. Once
+ // the rig goes dynamic that is exactly what we want — a falling body should
+ // take people's legs out — so the mask is widened here rather than being
+ // fixed once at build time.
+ const mask = dynamic ? limpMask : drivenMask;
+ for (const part of order) {
+ if (part.filterMask !== mask) {
+ _filter.categoryBits = filter.category;
+ _filter.maskBits = mask;
+ _filter.groupIndex = 0;
+ api.b3Shape_SetFilter(part.shape, _filter, true);
+ part.filterMask = mask;
+ }
+ api.b3Body_SetType(part.body, dynamic ? api.b3BodyType.b3_dynamicBody : api.b3BodyType.b3_kinematicBody);
+ if (dynamic) {
+ // Carry the animated motion across so the reaction continues the motion.
+ api.b3Body_SetLinearVelocity(part.body, vec3(part.linVel));
+ api.b3Body_SetAngularVelocity(part.body, vec3(part.angVel));
+ if (next === 'reacting') {
+ // Damping holds the flinch together without killing the impulse.
+ // (1.6/2.2 made light hits die in place; recover via blend weight instead.)
+ api.b3Body_SetLinearDamping(part.body, 0.85);
+ api.b3Body_SetAngularDamping(part.body, 1.15);
+ } else {
+ api.b3Body_SetLinearDamping(part.body, 0.1);
+ api.b3Body_SetAngularDamping(part.body, 0.25);
+ }
+ }
+ api.b3Body_SetAwake(part.body, true);
+ }
+ mode = next;
+ }
+
+ /** Apply a world-space impulse at a world point to one part. */
+ function applyImpulse(partName, impulse, worldPoint) {
+ const part = parts[partName];
+ if (!part) return;
+ api.b3Body_ApplyLinearImpulse(
+ part.body,
+ vec3(impulse),
+ worldPoint ? vec3(worldPoint) : api.b3Body_GetPosition(part.body),
+ true,
+ );
+ }
+
+ function applyTorqueImpulse(partName, torque) {
+ const part = parts[partName];
+ if (!part) return;
+ api.b3Body_ApplyAngularImpulse(part.body, vec3(torque), true);
+ }
+
+ /**
+ * Total mass of the rig, for stagger thresholds. Uses the figures captured at
+ * build time rather than querying the bodies, which report zero while kinematic.
+ */
+ function totalMass() {
+ let m = 0;
+ for (const part of order) m += part.mass;
+ return m;
+ }
+
+ /**
+ * Sever a joint: the limb below it becomes independent debris still made of
+ * the same bodies, so it keeps colliding and can be sent flying.
+ */
+ function severJoint(childPartName) {
+ const j = joints.find((x) => x.b === childPartName);
+ if (!j || j.severed) return false;
+ api.b3DestroyJoint(j.id, true);
+ j.severed = true;
+ const part = parts[childPartName];
+ if (part) part.disabled = true;
+ return true;
+ }
+
+ function partForRegion(region) {
+ return order.filter((p) => p.region === region);
+ }
+
+ /**
+ * Write each capsule's segment into world space.
+ *
+ * Read off the bone matrices rather than off the Box3D bodies, so the answer
+ * is correct in both modes: while driven the bodies chase the bones a substep
+ * behind, and a hit resolved against last substep's pose picks the wrong limb
+ * at speed. Reuses one array of scratch vectors — the caller must not hold on
+ * to what it gets back.
+ */
+ const _segments = order.map(() => ({
+ part: null, a: new THREE.Vector3(), b: new THREE.Vector3(), radius: 0,
+ }));
+ function worldSegments() {
+ for (let i = 0; i < order.length; i++) {
+ const part = order[i];
+ const seg = _segments[i];
+ part.bone.updateWorldMatrix(true, false);
+ seg.part = part;
+ seg.a.copy(part.localA).applyMatrix4(part.bone.matrixWorld);
+ seg.b.copy(part.localB).applyMatrix4(part.bone.matrixWorld);
+ seg.radius = part.radius;
+ }
+ return _segments;
+ }
+
+ function destroy() {
+ for (const j of joints) if (!j.severed) api.b3DestroyJoint(j.id, false);
+ for (const part of order) api.b3DestroyBody(part.body);
+ }
+
+ return {
+ parts,
+ order,
+ joints,
+ get mode() { return mode; },
+ get stiffness() { return stiffness; },
+ setMode,
+ setJointStiffness,
+ sampleVelocities,
+ syncFromSkeleton,
+ syncToSkeleton,
+ blendToSkeleton,
+ snapToSkeleton,
+ applyImpulse,
+ applyTorqueImpulse,
+ severJoint,
+ partForRegion,
+ worldSegments,
+ totalMass,
+ destroy,
+ };
+}
diff --git a/src/physics/world.js b/src/physics/world.js
new file mode 100644
index 0000000..37172b6
--- /dev/null
+++ b/src/physics/world.js
@@ -0,0 +1,189 @@
+import Box3DFactory from 'box3d.js';
+import { KIND, makeTag, readTag, rinkFilter, xyz } from './bridge.js';
+import { slotsShouldCollide } from './ragdoll.js';
+import { RINK, rinkOutline } from '../../shared/rink.js';
+
+/**
+ * Box3D world wrapper.
+ *
+ * Runs on a fixed timestep with an accumulator so the simulation stays
+ * reproducible regardless of frame rate. That matters more here than it looks:
+ * the skating sim reads its velocity back out of Box3D every substep, so a
+ * variable step would make how hard you can carve depend on your frame rate.
+ */
+
+export const FIXED_DT = 1 / 120;
+const MAX_SUBSTEPS = 6;
+
+let b3 = null;
+
+/** Load and initialise the wasm module. Safe to call more than once. */
+export async function initPhysics() {
+ if (!b3) b3 = await Box3DFactory();
+ return b3;
+}
+
+export function getB3() {
+ if (!b3) throw new Error('physics not initialised — await initPhysics() first');
+ return b3;
+}
+
+/**
+ * Build the rink: an ice slab and a ring of boards, both static.
+ *
+ * The boards are a ring of boxes rather than a mesh because a body slammed
+ * into one should bounce off a flat face the way it would off real dasher
+ * boards, and because a box ring is cheap enough that we can afford enough
+ * segments for the corners to read as round.
+ */
+export function createPhysicsWorld({ gravity = -16 } = {}) {
+ const api = getB3();
+
+ const wd = api.b3DefaultWorldDef();
+ wd.gravity = xyz(0, gravity, 0);
+ // Two skaters closing at 14 m/s combined will visibly interpenetrate at the
+ // default contact stiffness — a fifth of a metre, which on bodies this size
+ // reads as one skating through the other's shoulder. Stiffer contacts and a
+ // faster push-out cost nothing at this body count.
+ wd.contactHertz = 60;
+ wd.contactDampingRatio = 8;
+ wd.contactSpeed = 6;
+ wd.enableContinuous = true;
+ const world = api.b3CreateWorld(wd);
+ api.b3World_SetHitEventThreshold(world, 1.2);
+
+ // Self-collision: ragdoll limbs enable custom filtering. Adjacent capsules
+ // (and one skip) would fight the joints if they contacted; distant pairs
+ // (hand vs torso, crossed legs) must still collide when limp.
+ // Called only for awake dynamic pairs — exactly the limp case.
+ api.b3World_SetCustomFilterCallback(world, (shapeA, shapeB) => {
+ try {
+ const matA = api.b3Shape_GetSurfaceMaterial(shapeA);
+ const matB = api.b3Shape_GetSurfaceMaterial(shapeB);
+ const a = readTag(matA.userMaterialId);
+ const b = readTag(matB.userMaterialId);
+ if (
+ a.kind === KIND.BODY && b.kind === KIND.BODY
+ && a.skater === b.skater && a.skater !== 0xff
+ ) {
+ return slotsShouldCollide(a.slot, b.slot);
+ }
+ } catch {
+ // Embind can throw if a shape was destroyed mid-step; default to collide.
+ }
+ return true;
+ });
+
+ const rink = rinkFilter();
+
+ // ---- ice ---------------------------------------------------------------
+ const iceDef = api.b3DefaultBodyDef();
+ iceDef.position = xyz(0, -0.5, 0);
+ const ice = api.b3CreateBody(world, iceDef);
+ const iceShape = api.b3DefaultShapeDef();
+ // Ice, not sand. The skating sim owns blade friction entirely; anything the
+ // solver adds here on top of that is a second, invisible drag term.
+ iceShape.baseMaterial.friction = 0.04;
+ iceShape.baseMaterial.restitution = 0.0;
+ iceShape.baseMaterial.userMaterialId = makeTag(KIND.RINK, 0xff, 0);
+ iceShape.filter.categoryBits = rink.category;
+ iceShape.filter.maskBits = rink.mask;
+ api.b3CreateBoxShape(ice, iceShape, RINK.halfX + 4, 0.5, RINK.halfZ + 4);
+
+ // ---- boards ------------------------------------------------------------
+ const boardShape = api.b3DefaultShapeDef();
+ boardShape.baseMaterial.friction = 0.28;
+ // Dasher boards flex and eat most of the impact. A lively wall would ping
+ // skaters back into open ice and read as rubber.
+ boardShape.baseMaterial.restitution = 0.1;
+ boardShape.baseMaterial.userMaterialId = makeTag(KIND.RINK, 0xff, 1);
+ boardShape.filter.categoryBits = rink.category;
+ boardShape.filter.maskBits = rink.mask;
+
+ const outline = rinkOutline(10);
+ const boardBodies = [];
+ const halfH = RINK.boardHeight / 2;
+ for (let i = 0; i < outline.length; i++) {
+ const a = outline[i];
+ const b = outline[(i + 1) % outline.length];
+ const dx = b.x - a.x;
+ const dz = b.z - a.z;
+ const len = Math.hypot(dx, dz);
+ if (len < 1e-4) continue;
+ // Each segment is a thin box centred on the chord, its local +Z along the
+ // wall. Overlapping the ends slightly (len/2 + thickness) keeps a skater
+ // from catching the seam between two corner segments.
+ const yaw = Math.atan2(dx, dz);
+ const bd = api.b3DefaultBodyDef();
+ // Pushed half a thickness outward so the *inner* face sits on the outline.
+ const nx = dz / len;
+ const nz = -dx / len;
+ const thickness = 0.2;
+ bd.position = xyz(
+ (a.x + b.x) / 2 - nx * thickness,
+ halfH,
+ (a.z + b.z) / 2 - nz * thickness,
+ );
+ bd.rotation = { v: { x: 0, y: Math.sin(yaw / 2), z: 0 }, s: Math.cos(yaw / 2) };
+ const seg = api.b3CreateBody(world, bd);
+ api.b3CreateBoxShape(seg, boardShape, thickness, halfH, len / 2 + thickness);
+ boardBodies.push(seg);
+ }
+
+ // ---- event plumbing ----------------------------------------------------
+ const eventsBuffer = api.createEventsBuffer();
+ const hitOut = api.createContactHitEvent();
+ const beginOut = api.createContactTouchEvent();
+
+ let accumulator = 0;
+ let stepCount = 0;
+ const hitListeners = new Set();
+ const beginListeners = new Set();
+
+ function pumpEvents() {
+ api.getEvents(eventsBuffer, world);
+ const nHits = api.getNumContactHitEvents(eventsBuffer);
+ for (let i = 0; i < nHits; i++) {
+ api.getContactHitEventAt(hitOut, eventsBuffer, i);
+ for (const fn of hitListeners) fn(hitOut);
+ }
+ const nBegin = api.getNumContactBeginEvents(eventsBuffer);
+ for (let i = 0; i < nBegin; i++) {
+ api.getContactBeginEventAt(beginOut, eventsBuffer, i);
+ for (const fn of beginListeners) fn(beginOut);
+ }
+ }
+
+ return {
+ api,
+ world,
+ ice,
+ boardBodies,
+ get stepCount() { return stepCount; },
+
+ /** Advance by real elapsed time, stepping the fixed simulation as needed. */
+ step(dt, onPreStep) {
+ accumulator += Math.min(dt, 0.25);
+ let steps = 0;
+ while (accumulator >= FIXED_DT && steps < MAX_SUBSTEPS) {
+ if (onPreStep) onPreStep(FIXED_DT);
+ api.b3World_Step(world, FIXED_DT, 4);
+ pumpEvents();
+ accumulator -= FIXED_DT;
+ steps++;
+ stepCount++;
+ }
+ // Bail out rather than spiral if we ever fall badly behind.
+ if (steps === MAX_SUBSTEPS) accumulator = 0;
+ return steps;
+ },
+
+ onHit(fn) { hitListeners.add(fn); return () => hitListeners.delete(fn); },
+ onBeginTouch(fn) { beginListeners.add(fn); return () => beginListeners.delete(fn); },
+
+ destroy() {
+ api.destroyEventsBuffer(eventsBuffer);
+ api.b3DestroyWorld(world);
+ },
+ };
+}
diff --git a/src/render/camera.js b/src/render/camera.js
new file mode 100644
index 0000000..5dde97c
--- /dev/null
+++ b/src/render/camera.js
@@ -0,0 +1,140 @@
+import * as THREE from 'three';
+import { RINK } from '../../shared/rink.js';
+import { clamp, wrapAngle } from '../../shared/scalar.js';
+
+/**
+ * Broadcast camera.
+ *
+ * Two modes, because they answer different questions about the spike:
+ * 'broadcast' sits off the side boards and pans with the action — the view
+ * you judge whether the skating reads from.
+ * 'follow' rides behind one skater, which is the only way to tell whether
+ * the stride and the carve actually line up with the motion.
+ *
+ * Drag orbits, wheel zooms, and the target is smoothed rather than snapped so
+ * a bot changing direction does not whip the camera.
+ */
+export function createCamera(canvas, aspect) {
+ const camera = new THREE.PerspectiveCamera(52, aspect, 0.1, 400);
+
+ const state = {
+ mode: 'broadcast',
+ /** Orbit angles, radians. */
+ yaw: 0,
+ pitch: 0.62,
+ distance: 34,
+ target: new THREE.Vector3(),
+ /** Index of the skater 'follow' rides, or null. */
+ followIndex: null,
+ };
+
+ const _want = new THREE.Vector3();
+ const _offset = new THREE.Vector3();
+
+ let dragging = false;
+ let lastX = 0;
+ let lastY = 0;
+
+ canvas.addEventListener('pointerdown', (e) => {
+ dragging = true;
+ lastX = e.clientX;
+ lastY = e.clientY;
+ canvas.setPointerCapture(e.pointerId);
+ });
+ canvas.addEventListener('pointermove', (e) => {
+ if (!dragging) return;
+ // Keep yaw on the circle. Unbounded accumulation is what broke the follow
+ // chase after a few spins: JS `%` on a large negative offset is not a
+ // positive modulo, so the "shortest turn" picked the long way round and
+ // the orbit fought the stick until the skater felt stuck.
+ state.yaw = wrapAngle(state.yaw - (e.clientX - lastX) * 0.005);
+ state.pitch = clamp(state.pitch - (e.clientY - lastY) * 0.004, 0.08, 1.45);
+ lastX = e.clientX;
+ lastY = e.clientY;
+ });
+ const endDrag = (e) => {
+ dragging = false;
+ if (e.pointerId != null && canvas.hasPointerCapture?.(e.pointerId)) {
+ canvas.releasePointerCapture(e.pointerId);
+ }
+ };
+ canvas.addEventListener('pointerup', endDrag);
+ canvas.addEventListener('pointercancel', endDrag);
+ canvas.addEventListener('wheel', (e) => {
+ e.preventDefault();
+ state.distance = clamp(state.distance * (1 + e.deltaY * 0.0012), 6, 90);
+ }, { passive: false });
+
+ return {
+ camera,
+ state,
+
+ resize(w, h) {
+ camera.aspect = w / h;
+ camera.updateProjectionMatrix();
+ },
+
+ /** Cycle broadcast → follow each skater → broadcast. */
+ cycleMode(count) {
+ if (state.mode === 'broadcast') {
+ state.mode = 'follow';
+ state.followIndex = 0;
+ } else if (state.followIndex + 1 < count) {
+ state.followIndex += 1;
+ } else {
+ state.mode = 'broadcast';
+ state.followIndex = null;
+ }
+ state.distance = state.mode === 'follow' ? 9 : 34;
+ state.pitch = state.mode === 'follow' ? 0.3 : 0.62;
+ },
+
+ /**
+ * @param {number} dt
+ * @param {{x:number,z:number,yaw:number}[]} skaters
+ */
+ update(dt, skaters) {
+ // How hard the camera chases its target. Broadcast wants to be lazy;
+ // follow cannot be, because a skater doing 7 m/s outruns a soft lerp and
+ // ends up drifting to the edge of frame while the camera trails behind.
+ let chase = 2.4;
+ if (state.mode === 'follow' && skaters[state.followIndex]) {
+ const s = skaters[state.followIndex];
+ chase = 11;
+ _want.set(s.x, 1.1, s.z);
+ // Ease the orbit around behind whoever we are following, but let a
+ // drag override it — the yaw chases only while the pointer is idle.
+ if (!dragging) {
+ const behind = s.yaw + Math.PI;
+ // wrapAngle, not `%`: see the pointermove note. The old
+ // `((d + 3π) % 2π) - π` form only works while yaw stays near zero.
+ state.yaw = wrapAngle(state.yaw + wrapAngle(behind - state.yaw) * Math.min(1, 1.6 * dt));
+ }
+ } else {
+ // Centroid of everyone, clamped so the camera never leaves the barn.
+ _want.set(0, 0.8, 0);
+ if (skaters.length) {
+ let x = 0;
+ let z = 0;
+ for (const s of skaters) {
+ x += s.x;
+ z += s.z;
+ }
+ _want.set(x / skaters.length, 0.8, z / skaters.length);
+ }
+ _want.x = clamp(_want.x, -RINK.halfX * 0.6, RINK.halfX * 0.6);
+ _want.z = clamp(_want.z, -RINK.halfZ * 0.6, RINK.halfZ * 0.6);
+ }
+ state.target.lerp(_want, Math.min(1, chase * dt));
+
+ const cp = Math.cos(state.pitch);
+ _offset.set(
+ Math.sin(state.yaw) * cp,
+ Math.sin(state.pitch),
+ Math.cos(state.yaw) * cp,
+ ).multiplyScalar(state.distance);
+ camera.position.copy(state.target).add(_offset);
+ camera.lookAt(state.target);
+ },
+ };
+}
diff --git a/src/render/materials.js b/src/render/materials.js
new file mode 100644
index 0000000..cae07f2
--- /dev/null
+++ b/src/render/materials.js
@@ -0,0 +1,144 @@
+import * as THREE from 'three';
+import { PART } from '../character/body.js';
+
+/**
+ * Materials for one skater.
+ *
+ * Placeholder by design: spike 1 renders the bare procedural body from Ludus,
+ * team-tinted so three agents can be told apart at a glance. Real gear is a
+ * later swap onto the same meshes. The only thing that has to hold now is that
+ * every skater owns its own material instances, so recolouring one never
+ * touches another.
+ */
+
+export const TEAMS = [
+ { name: 'home', jersey: 0xb8342c, accent: 0xf0e6d2 },
+ { name: 'away', jersey: 0x2b5d8f, accent: 0xf0e6d2 },
+ { name: 'third', jersey: 0x3d8c5a, accent: 0xf0e6d2 },
+];
+
+const SKIN_TONES = [0xd8a07a, 0xc98d63, 0xa86b45, 0x8a5334, 0xe8bd9a];
+const PANTS = 0x1c1f26;
+
+export function buildMaterials(rng, teamIndex = 0) {
+ const team = TEAMS[teamIndex % TEAMS.length];
+ const skinColor = rng.pick(SKIN_TONES);
+
+ // One material, vertex-coloured. `paintKit` writes the colours; keeping it to
+ // a single material means the skinned body is still one draw call.
+ const skin = new THREE.MeshStandardMaterial({
+ color: 0xffffff,
+ vertexColors: true,
+ roughness: 0.68,
+ metalness: 0.03,
+ });
+ skin.userData.skinColor = new THREE.Color(skinColor);
+
+ return { skin, team, teamIndex: teamIndex % TEAMS.length, skinColor };
+}
+
+/**
+ * Write the placeholder kit into the geometry's vertex colours.
+ *
+ * The loft carries `aPart` (which limb) and `aT` (0..1 along it), so the kit
+ * can be blocked in without any texture work: sweater over the torso and arms,
+ * pants over the hips and thighs, socks in the team colour down the shin.
+ *
+ * Overwrites the skin-weight heatmap `computeSkin` leaves behind; that array is
+ * kept on `userData` so the debug view can still be switched back on.
+ */
+export function paintKit(geo, { jersey, skinColor }) {
+ const partAttr = geo.attributes.aPart;
+ const tAttr = geo.attributes.aT;
+ const existing = geo.attributes.color;
+ if (existing && !geo.userData.heatColors) geo.userData.heatColors = existing.array.slice();
+
+ const n = geo.attributes.position.count;
+ const colors = new Float32Array(n * 3);
+ const c = new THREE.Color();
+ const flesh = new THREE.Color(skinColor);
+ const sweater = new THREE.Color(jersey);
+ const pants = new THREE.Color(PANTS);
+
+ for (let i = 0; i < n; i++) {
+ const part = partAttr ? partAttr.getX(i) : PART.TORSO;
+ const t = tAttr ? tAttr.getX(i) : 0.5;
+ if (part === PART.HEAD) {
+ // Helmet from the crown down to the brow; face left bare.
+ c.copy(t > 0.62 ? sweater : flesh);
+ } else if (part === PART.TORSO) {
+ c.copy(t < 0.16 ? pants : sweater);
+ } else if (part === PART.ARM_L || part === PART.ARM_R) {
+ // Sleeve, then a dark glove at the cuff.
+ c.copy(t > 0.88 ? pants : sweater);
+ } else {
+ // Leg: pants to mid-thigh, team sock below, black skate at the ankle.
+ c.copy(t < 0.36 ? pants : t > 0.87 ? pants : sweater);
+ }
+ colors[i * 3] = c.r;
+ colors[i * 3 + 1] = c.g;
+ colors[i * 3 + 2] = c.b;
+ }
+ geo.setAttribute('color', new THREE.BufferAttribute(colors, 3));
+}
+
+/**
+ * Base layer for a skater who is actually wearing gear.
+ *
+ * `paintKit` draws the kit *onto* the body, which is the right answer while the
+ * body is all there is. Once a jersey, pants and socks are real meshes over the
+ * top, painting a second jersey underneath only shows up as the wrong colour
+ * peeking out at a collar or a cuff. So: face and neck bare, everything else
+ * the dark under layer a player has on beneath the pads.
+ */
+export function paintUnderLayer(geo, { skinColor, under = 0x24262c }) {
+ const partAttr = geo.attributes.aPart;
+ const existing = geo.attributes.color;
+ if (existing && !geo.userData.heatColors) geo.userData.heatColors = existing.array.slice();
+
+ const n = geo.attributes.position.count;
+ const colors = new Float32Array(n * 3);
+ const flesh = new THREE.Color(skinColor);
+ const base = new THREE.Color(under);
+
+ for (let i = 0; i < n; i++) {
+ const part = partAttr ? partAttr.getX(i) : PART.TORSO;
+ const c = part === PART.HEAD ? flesh : base;
+ colors[i * 3] = c.r;
+ colors[i * 3 + 1] = c.g;
+ colors[i * 3 + 2] = c.b;
+ }
+ geo.setAttribute('color', new THREE.BufferAttribute(colors, 3));
+}
+
+/** Shared rink materials — one set for the whole scene, not per skater. */
+export function buildRinkMaterials() {
+ return {
+ ice: new THREE.MeshStandardMaterial({
+ color: 0xeaf2fa,
+ roughness: 0.16,
+ metalness: 0.0,
+ }),
+ lines: new THREE.MeshBasicMaterial({ color: 0xffffff }),
+ boards: new THREE.MeshStandardMaterial({
+ color: 0xf2f2f0,
+ roughness: 0.5,
+ metalness: 0.02,
+ side: THREE.DoubleSide,
+ }),
+ kickplate: new THREE.MeshStandardMaterial({
+ color: 0xd6c33c,
+ roughness: 0.6,
+ side: THREE.DoubleSide,
+ }),
+ glass: new THREE.MeshStandardMaterial({
+ color: 0xc4dcea,
+ roughness: 0.06,
+ metalness: 0,
+ transparent: true,
+ opacity: 0.1,
+ side: THREE.DoubleSide,
+ depthWrite: false,
+ }),
+ };
+}
diff --git a/src/render/rink.js b/src/render/rink.js
new file mode 100644
index 0000000..6658338
--- /dev/null
+++ b/src/render/rink.js
@@ -0,0 +1,216 @@
+import * as THREE from 'three';
+import { MARKINGS, RINK, rinkOutline } from '../../shared/rink.js';
+import { buildRinkMaterials } from './materials.js';
+
+/**
+ * The rendered rink.
+ *
+ * Geometry comes from the same `rinkOutline` the physics boards are built
+ * from, so the wall a skater bounces off is the wall they can see — the single
+ * most annoying class of bug to chase in a game like this, and free to avoid.
+ *
+ * Markings are drawn into a canvas texture rather than as meshes. Blue lines,
+ * circles and dots as geometry means a dozen extra draw calls and z-fighting
+ * against the ice; one texture is faster and easier to iterate on.
+ */
+
+const PIXELS_PER_METRE = 22;
+
+function markingsTexture() {
+ const w = Math.round(RINK.halfX * 2 * PIXELS_PER_METRE);
+ const h = Math.round(RINK.halfZ * 2 * PIXELS_PER_METRE);
+ const canvas = document.createElement('canvas');
+ canvas.width = w;
+ canvas.height = h;
+ const ctx = canvas.getContext('2d');
+
+ // Canvas space: +x right is rink +X, +y down is rink +Z.
+ const tx = (x) => (x + RINK.halfX) * PIXELS_PER_METRE;
+ const tz = (z) => (z + RINK.halfZ) * PIXELS_PER_METRE;
+ const m = (v) => v * PIXELS_PER_METRE;
+
+ ctx.fillStyle = '#f2f7fc';
+ ctx.fillRect(0, 0, w, h);
+
+ const vline = (x, colour, widthM) => {
+ ctx.strokeStyle = colour;
+ ctx.lineWidth = m(widthM);
+ ctx.beginPath();
+ ctx.moveTo(tx(x), 0);
+ ctx.lineTo(tx(x), h);
+ ctx.stroke();
+ };
+ const circle = (x, z, r, colour, widthM, fill = false) => {
+ ctx.beginPath();
+ ctx.arc(tx(x), tz(z), m(r), 0, Math.PI * 2);
+ if (fill) {
+ ctx.fillStyle = colour;
+ ctx.fill();
+ } else {
+ ctx.strokeStyle = colour;
+ ctx.lineWidth = m(widthM);
+ ctx.stroke();
+ }
+ };
+
+ const RED = '#c8322c';
+ const BLUE = '#2f5fa8';
+
+ vline(0, RED, 0.3);
+ vline(-MARKINGS.blueLine, BLUE, 0.3);
+ vline(MARKINGS.blueLine, BLUE, 0.3);
+ vline(-MARKINGS.goalLine, RED, 0.06);
+ vline(MARKINGS.goalLine, RED, 0.06);
+
+ circle(0, 0, MARKINGS.centreCircleR, BLUE, 0.06);
+ circle(0, 0, 0.3, BLUE, 0, true);
+
+ // Four end-zone faceoff circles plus the two neutral-zone dots.
+ for (const sx of [-1, 1]) {
+ for (const sz of [-1, 1]) {
+ circle(sx * MARKINGS.zoneDotX, sz * MARKINGS.faceoffDotZ, MARKINGS.faceoffCircleR, RED, 0.06);
+ circle(sx * MARKINGS.zoneDotX, sz * MARKINGS.faceoffDotZ, 0.3, RED, 0, true);
+ circle(sx * MARKINGS.faceoffDotX, sz * MARKINGS.faceoffDotZ, 0.3, RED, 0, true);
+ }
+ }
+
+ // Goal creases, as filled arcs facing centre ice.
+ for (const sx of [-1, 1]) {
+ ctx.beginPath();
+ ctx.arc(tx(sx * MARKINGS.goalLine), tz(0), m(1.83), sx > 0 ? Math.PI / 2 : -Math.PI / 2, sx > 0 ? Math.PI * 1.5 : Math.PI / 2);
+ ctx.closePath();
+ ctx.fillStyle = 'rgba(120, 175, 225, 0.5)';
+ ctx.fill();
+ ctx.strokeStyle = RED;
+ ctx.lineWidth = m(0.06);
+ ctx.stroke();
+ }
+
+ const tex = new THREE.CanvasTexture(canvas);
+ tex.colorSpace = THREE.SRGBColorSpace;
+ tex.anisotropy = 8;
+ return tex;
+}
+
+/**
+ * Extrude the board outline into a wall.
+ *
+ * Built as one non-indexed strip: the outline is a closed loop, so a wall is
+ * two triangles per segment and there is no reason to pay for a Shape/Extrude
+ * pass or for the corner mitring it would do.
+ */
+function boardBand(outline, y0, y1, inset = 0) {
+ const pos = [];
+ const uv = [];
+ const n = outline.length;
+ for (let i = 0; i < n; i++) {
+ const a = outline[i];
+ const b = outline[(i + 1) % n];
+ // Inset pushes the band outward along the local normal, so the glass can
+ // sit flush on top of the boards rather than intersecting them.
+ const dx = b.x - a.x;
+ const dz = b.z - a.z;
+ const len = Math.hypot(dx, dz) || 1;
+ const nx = (dz / len) * inset;
+ const nz = (-dx / len) * inset;
+ const ax = a.x - nx;
+ const az = a.z - nz;
+ const bx = b.x - nx;
+ const bz = b.z - nz;
+ const u0 = i / n;
+ const u1 = (i + 1) / n;
+ pos.push(ax, y0, az, bx, y0, bz, bx, y1, bz);
+ pos.push(ax, y0, az, bx, y1, bz, ax, y1, az);
+ uv.push(u0, 0, u1, 0, u1, 1, u0, 0, u1, 1, u0, 1);
+ }
+ const g = new THREE.BufferGeometry();
+ g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
+ g.setAttribute('uv', new THREE.Float32BufferAttribute(uv, 2));
+ g.computeVertexNormals();
+ return g;
+}
+
+/** The puck mesh — a black disc, driven from the Box3D body each frame. */
+export function buildPuckMesh(scene, { radius, thickness }) {
+ const mesh = new THREE.Mesh(
+ new THREE.CylinderGeometry(radius, radius, thickness, 20),
+ new THREE.MeshStandardMaterial({ color: 0x0b0b0d, roughness: 0.72, metalness: 0.02 }),
+ );
+ mesh.castShadow = true;
+ mesh.receiveShadow = true;
+ // A regulation puck is 76 mm across, which is a handful of pixels from the
+ // broadcast camera. The ring is a readability aid, not decoration — without
+ // something to catch the eye the puck is genuinely impossible to follow.
+ const ring = new THREE.Mesh(
+ new THREE.RingGeometry(radius * 1.6, radius * 2.4, 24),
+ new THREE.MeshBasicMaterial({
+ color: 0xffd166, transparent: true, opacity: 0.45, depthWrite: false,
+ }),
+ );
+ ring.rotation.x = -Math.PI / 2;
+ ring.position.y = -thickness / 2 + 0.002;
+ ring.renderOrder = 1;
+ mesh.add(ring);
+ scene.add(mesh);
+ return { mesh, ring };
+}
+
+export function buildRink(scene) {
+ const mats = buildRinkMaterials();
+ const group = new THREE.Group();
+ group.name = 'rink';
+
+ // ---- ice ---------------------------------------------------------------
+ // A plane clipped to the rounded rectangle, so the surface ends at the
+ // boards instead of running under them.
+ const shape = new THREE.Shape();
+ const outline = rinkOutline(16);
+ shape.moveTo(outline[0].x, outline[0].z);
+ for (let i = 1; i < outline.length; i++) shape.lineTo(outline[i].x, outline[i].z);
+ shape.closePath();
+ const iceGeo = new THREE.ShapeGeometry(shape, 24);
+ // ShapeGeometry lives in XY; lay it flat, then rebuild UVs so the markings
+ // texture maps to rink coordinates rather than to the shape's bounding box.
+ iceGeo.rotateX(-Math.PI / 2);
+ const p = iceGeo.attributes.position;
+ const uv = new Float32Array(p.count * 2);
+ for (let i = 0; i < p.count; i++) {
+ uv[i * 2] = (p.getX(i) + RINK.halfX) / (RINK.halfX * 2);
+ uv[i * 2 + 1] = 1 - (p.getZ(i) + RINK.halfZ) / (RINK.halfZ * 2);
+ }
+ iceGeo.setAttribute('uv', new THREE.BufferAttribute(uv, 2));
+ mats.ice.map = markingsTexture();
+ const ice = new THREE.Mesh(iceGeo, mats.ice);
+ ice.receiveShadow = true;
+ group.add(ice);
+
+ // ---- boards, kickplate, glass ------------------------------------------
+ const boards = new THREE.Mesh(boardBand(outline, 0.22, RINK.boardHeight), mats.boards);
+ boards.receiveShadow = true;
+ group.add(boards);
+
+ const kick = new THREE.Mesh(boardBand(outline, 0, 0.22), mats.kickplate);
+ group.add(kick);
+
+ const glass = new THREE.Mesh(
+ boardBand(outline, RINK.boardHeight, RINK.boardHeight + RINK.glassHeight, 0.02),
+ mats.glass,
+ );
+ glass.renderOrder = 2;
+ group.add(glass);
+
+ // ---- surround ----------------------------------------------------------
+ // A dark apron so the rink does not float in the void when the camera swings
+ // low. Cheap, and it stops the horizon from reading as a bug.
+ const apron = new THREE.Mesh(
+ new THREE.PlaneGeometry(RINK.halfX * 4, RINK.halfZ * 6),
+ new THREE.MeshStandardMaterial({ color: 0x14181f, roughness: 0.95 }),
+ );
+ apron.rotation.x = -Math.PI / 2;
+ apron.position.y = -0.05;
+ apron.receiveShadow = true;
+ group.add(apron);
+
+ scene.add(group);
+ return { group, materials: mats };
+}
diff --git a/src/studio/img2mesh.js b/src/studio/img2mesh.js
new file mode 100644
index 0000000..4a1f080
--- /dev/null
+++ b/src/studio/img2mesh.js
@@ -0,0 +1,760 @@
+import * as THREE from 'three';
+import { createSkater } from '../character/skater.js';
+import { createGoalie } from '../character/goalie.js';
+import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
+
+/**
+ * img2mesh — isolated character studio for equipment + animation iteration.
+ *
+ * No match, no physics, no AI. Just a skater and a goalie on a ground plane,
+ * pose presets, fixed camera views, and a `window.img2mesh` API the headless
+ * capture tool drives to dump a shot sheet.
+ *
+ * Open: http://localhost:5174/character.html
+ * CLI: npm run img2mesh
+ */
+
+const canvas = document.getElementById('stage');
+const boot = document.getElementById('boot');
+const hud = document.getElementById('hud');
+const subjectSel = document.getElementById('subject');
+const poseSel = document.getElementById('pose');
+const viewSel = document.getElementById('view');
+
+// ---- renderer / scene -----------------------------------------------------
+const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: 'high-performance' });
+renderer.shadowMap.enabled = true;
+renderer.shadowMap.type = THREE.PCFShadowMap;
+renderer.toneMapping = THREE.ACESFilmicToneMapping;
+renderer.toneMappingExposure = 1.1;
+
+const scene = new THREE.Scene();
+scene.background = new THREE.Color(0x0c1018);
+scene.fog = new THREE.Fog(0x0c1018, 18, 40);
+
+scene.add(new THREE.HemisphereLight(0xe8f0fa, 0x1a2030, 1.35));
+const key = new THREE.DirectionalLight(0xffffff, 1.7);
+key.position.set(4, 10, 6);
+key.castShadow = true;
+key.shadow.mapSize.set(2048, 2048);
+key.shadow.camera.near = 1;
+key.shadow.camera.far = 30;
+key.shadow.camera.left = -6;
+key.shadow.camera.right = 6;
+key.shadow.camera.top = 6;
+key.shadow.camera.bottom = -6;
+key.shadow.bias = -0.0004;
+scene.add(key);
+const fill = new THREE.DirectionalLight(0xa8c8e8, 0.55);
+fill.position.set(-6, 5, -4);
+scene.add(fill);
+const rim = new THREE.DirectionalLight(0xffe0c0, 0.35);
+rim.position.set(2, 3, -8);
+scene.add(rim);
+
+// Ground grid — reads scale and foot contact without a full rink.
+const ground = new THREE.Mesh(
+ new THREE.CircleGeometry(8, 48),
+ new THREE.MeshStandardMaterial({ color: 0x1a2430, roughness: 0.92, metalness: 0.05 }),
+);
+ground.rotation.x = -Math.PI / 2;
+ground.receiveShadow = true;
+scene.add(ground);
+const grid = new THREE.GridHelper(10, 20, 0x3a5a78, 0x1e3044);
+grid.position.y = 0.002;
+scene.add(grid);
+
+// Height markers so pad/hand/head heights are obvious.
+for (const h of [0.5, 1.0, 1.5, 2.0]) {
+ const ring = new THREE.Mesh(
+ new THREE.RingGeometry(0.35, 0.38, 32),
+ new THREE.MeshBasicMaterial({ color: 0x2a4058, side: THREE.DoubleSide, transparent: true, opacity: 0.5 }),
+ );
+ ring.rotation.x = -Math.PI / 2;
+ ring.position.y = h;
+ scene.add(ring);
+}
+
+const camera = new THREE.PerspectiveCamera(40, 1, 0.05, 80);
+const controls = new OrbitControls(camera, canvas);
+controls.enableDamping = true;
+controls.dampingFactor = 0.08;
+controls.target.set(0, 0.9, 0);
+controls.minDistance = 1.2;
+controls.maxDistance = 14;
+controls.maxPolarAngle = Math.PI * 0.49;
+
+function resize() {
+ const w = window.innerWidth;
+ const h = window.innerHeight;
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
+ renderer.setSize(w, h);
+ camera.aspect = w / h;
+ camera.updateProjectionMatrix();
+}
+window.addEventListener('resize', resize);
+resize();
+
+// ---- subjects -------------------------------------------------------------
+/** @type {ReturnType | null} */
+let player = null;
+/** @type {ReturnType | null} */
+let goalie = null;
+
+const state = {
+ subject: 'player', // player | goalie | both
+ pose: 'carry',
+ view: 'threequarter',
+ showBones: false,
+ showGear: false,
+ time: 0,
+};
+
+// ---- pose catalogs --------------------------------------------------------
+const PLAYER_POSES = {
+ stand: {
+ label: 'stand / glide',
+ apply(sk, t) {
+ const a = sk.animator;
+ a.moveSpeed = 0.4;
+ a.bladeSpeed = 0.4;
+ a.effort = 0;
+ a.yawRate = 0;
+ a.braking = false;
+ a.hasPuck = false;
+ a.charge = 0;
+ a.action = null;
+ a.handling.x = 0;
+ a.handling.y = 0;
+ a.setTransform(sk.mover.position, 0);
+ a.update(1 / 60);
+ },
+ },
+ stride: {
+ label: 'full stride',
+ apply(sk) {
+ const a = sk.animator;
+ a.moveSpeed = 7;
+ a.bladeSpeed = 7;
+ a.effort = 1;
+ a.yawRate = 0;
+ a.braking = false;
+ a.hasPuck = true;
+ a.charge = 0;
+ a.action = null;
+ a.handling.x = 0;
+ a.handling.y = 0;
+ a.setTransform(sk.mover.position, 0);
+ a.update(1 / 60);
+ },
+ },
+ carve: {
+ label: 'carve right',
+ apply(sk) {
+ const a = sk.animator;
+ a.moveSpeed = 6.5;
+ a.bladeSpeed = 6.5;
+ a.effort = 0.7;
+ a.yawRate = 1.4;
+ a.braking = false;
+ a.hasPuck = true;
+ a.action = null;
+ a.handling.x = 0;
+ a.handling.y = 0;
+ a.setTransform(sk.mover.position, 0);
+ a.update(1 / 60);
+ },
+ },
+ carry: {
+ label: 'puck carry',
+ apply(sk) {
+ const a = sk.animator;
+ a.moveSpeed = 4;
+ a.bladeSpeed = 4;
+ a.effort = 0.25;
+ a.yawRate = 0;
+ a.braking = false;
+ a.hasPuck = true;
+ a.charge = 0;
+ a.action = null;
+ a.handling.x = 0;
+ a.handling.y = 0;
+ a.setTransform(sk.mover.position, 0);
+ a.update(1 / 60);
+ },
+ },
+ handleRight: {
+ label: 'stickhandle right',
+ apply(sk) {
+ const a = sk.animator;
+ a.moveSpeed = 3;
+ a.bladeSpeed = 3;
+ a.effort = 0.2;
+ a.hasPuck = true;
+ a.action = null;
+ a.handling.x = 1;
+ a.handling.y = 0;
+ a.setTransform(sk.mover.position, 0);
+ a.update(1 / 60);
+ },
+ },
+ handleLeft: {
+ label: 'stickhandle left',
+ apply(sk) {
+ const a = sk.animator;
+ a.moveSpeed = 3;
+ a.bladeSpeed = 3;
+ a.effort = 0.2;
+ a.hasPuck = true;
+ a.action = null;
+ a.handling.x = -1;
+ a.handling.y = 0;
+ a.setTransform(sk.mover.position, 0);
+ a.update(1 / 60);
+ },
+ },
+ windup: {
+ label: 'shot wind-up',
+ apply(sk) {
+ const a = sk.animator;
+ a.moveSpeed = 2;
+ a.bladeSpeed = 2;
+ a.effort = 0.3;
+ a.hasPuck = true;
+ a.charge = 1;
+ a.action = 'windup';
+ a.actionTime = 1;
+ a.handling.x = 0;
+ a.handling.y = -1;
+ a.setTransform(sk.mover.position, 0);
+ a.update(1 / 60);
+ },
+ },
+ shoot: {
+ label: 'shot follow-through',
+ apply(sk) {
+ const a = sk.animator;
+ a.moveSpeed = 2;
+ a.bladeSpeed = 2;
+ a.effort = 0.3;
+ a.hasPuck = true;
+ a.charge = 0;
+ if (a.action !== 'shoot') a.playAction('shoot', { power: 1 });
+ a.actionTime = 0.18;
+ a.setTransform(sk.mover.position, 0);
+ a.update(1 / 60);
+ },
+ },
+ stop: {
+ label: 'hockey stop',
+ apply(sk) {
+ const a = sk.animator;
+ a.moveSpeed = 5;
+ a.bladeSpeed = 5;
+ a.effort = 1;
+ a.braking = true;
+ a.hasPuck = true;
+ a.action = null;
+ a.setTransform(sk.mover.position, 0);
+ a.update(1 / 60);
+ },
+ },
+ poke: {
+ label: 'poke check',
+ apply(sk) {
+ const a = sk.animator;
+ a.moveSpeed = 4;
+ a.bladeSpeed = 4;
+ a.effort = 0.5;
+ a.hasPuck = false;
+ if (a.action !== 'poke') a.playAction('poke');
+ a.actionTime = 0.12;
+ a.setTransform(sk.mover.position, 0);
+ a.update(1 / 60);
+ },
+ },
+};
+
+const GOALIE_POSES = {
+ ready: {
+ label: 'ready stance',
+ apply(g) {
+ // Far puck, mid height — stays in ready.
+ g.animator.threatened = 0.1;
+ g.animator.puckHeight = 0.5;
+ g.animator.puckDist = 12;
+ g.animator.moveSpeed = 0;
+ g.animator.lateralVel = 0;
+ g.animator.setState('ready', 0.05);
+ g.animator.setTransform(g.mover.position, 0);
+ g.animator.update(1 / 60);
+ },
+ },
+ shuffle: {
+ label: 'lateral shuffle',
+ apply(g) {
+ g.animator.threatened = 0.2;
+ g.animator.puckHeight = 0.4;
+ g.animator.puckDist = 8;
+ g.animator.moveSpeed = 3.2;
+ g.animator.lateralVel = 2.4;
+ g.animator.setState('shuffle', 0.05);
+ g.animator.setTransform(g.mover.position, 0);
+ g.animator.update(1 / 60);
+ },
+ },
+ butterfly: {
+ label: 'butterfly',
+ apply(g) {
+ g.animator.threatened = 0.9;
+ g.animator.puckHeight = 0.1;
+ g.animator.puckDist = 2;
+ g.animator.moveSpeed = 0;
+ g.animator.lateralVel = 0;
+ g.animator.setState('butterfly', 0.05);
+ g.animator.setTransform(g.mover.position, 0);
+ g.animator.update(1 / 60);
+ },
+ },
+ reachGlove: {
+ label: 'glove reach',
+ apply(g) {
+ g.animator.threatened = 0.8;
+ g.animator.puckHeight = 1.3;
+ g.animator.puckDist = 2.5;
+ g.animator.moveSpeed = 0;
+ g.animator.lateralVel = -0.5;
+ g.animator.setState('reach', 0.05);
+ g.animator.setTransform(g.mover.position, 0);
+ g.animator.update(1 / 60);
+ },
+ },
+ reachBlocker: {
+ label: 'blocker reach',
+ apply(g) {
+ g.animator.threatened = 0.8;
+ g.animator.puckHeight = 1.25;
+ g.animator.puckDist = 2.5;
+ g.animator.moveSpeed = 0;
+ g.animator.lateralVel = 0.8;
+ g.animator.setState('reach', 0.05);
+ g.animator.setTransform(g.mover.position, 0);
+ g.animator.update(1 / 60);
+ },
+ },
+};
+
+// ---- views ----------------------------------------------------------------
+const VIEWS = {
+ front: { pos: [0, 1.15, 4.2], target: [0, 0.9, 0] },
+ threequarter: { pos: [2.6, 1.35, 3.4], target: [0, 0.9, 0] },
+ side: { pos: [4.4, 1.1, 0.15], target: [0, 0.85, 0] },
+ back: { pos: [0.2, 1.2, -4.0], target: [0, 0.9, 0] },
+ top: { pos: [0.1, 6.5, 0.2], target: [0, 0.2, 0] },
+ closeup: { pos: [1.1, 1.35, 1.7], target: [0, 1.15, 0.15] },
+ gear: { pos: [1.6, 0.55, 2.0], target: [0, 0.45, 0.1] },
+};
+
+function applyView(name) {
+ const v = VIEWS[name] ?? VIEWS.threequarter;
+ camera.position.set(...v.pos);
+ controls.target.set(...v.target);
+ controls.update();
+ state.view = name;
+ viewSel.value = name;
+}
+
+// ---- bone / gear debug ----------------------------------------------------
+const boneHelpers = new THREE.Group();
+boneHelpers.visible = false;
+scene.add(boneHelpers);
+const gearHelpers = new THREE.Group();
+gearHelpers.visible = false;
+scene.add(gearHelpers);
+
+function rebuildHelpers() {
+ while (boneHelpers.children.length) boneHelpers.remove(boneHelpers.children[0]);
+ while (gearHelpers.children.length) gearHelpers.remove(gearHelpers.children[0]);
+
+ const subjects = [];
+ if (player && (state.subject === 'player' || state.subject === 'both')) subjects.push(player);
+ if (goalie && (state.subject === 'goalie' || state.subject === 'both')) subjects.push(goalie);
+
+ for (const sub of subjects) {
+ const bones = sub.skelData?.bones;
+ if (!bones) continue;
+ for (const b of Object.values(bones)) {
+ const axes = new THREE.AxesHelper(0.08);
+ axes.name = `bone:${b.name}`;
+ b.add(axes);
+ boneHelpers.userData[b.uuid] = axes;
+ }
+ if (sub.gear) {
+ for (const p of sub.gear.pieces ?? []) {
+ const box = new THREE.BoxHelper(p, 0x66ccff);
+ box.name = `gear:${p.name}`;
+ gearHelpers.add(box);
+ }
+ }
+ if (sub.stick?.group) {
+ gearHelpers.add(new THREE.BoxHelper(sub.stick.group, 0xffaa44));
+ }
+ }
+}
+
+function clearBoneAxes() {
+ // Axes were parented onto bones; remove them.
+ const strip = (root) => {
+ if (!root) return;
+ const kill = [];
+ root.traverse((o) => {
+ if (o.isAxesHelper) kill.push(o);
+ });
+ for (const o of kill) o.removeFromParent();
+ };
+ strip(player?.mover);
+ strip(goalie?.mover);
+}
+
+// ---- build subjects -------------------------------------------------------
+function buildPlayer() {
+ if (player) {
+ player.dispose();
+ player = null;
+ }
+ player = createSkater({
+ seed: 42,
+ scene,
+ physics: null,
+ index: 0,
+ team: 0,
+ position: { x: state.subject === 'both' ? -0.85 : 0, z: 0 },
+ facing: 0,
+ });
+ // Settle a few frames so blend weights and stick aim land.
+ for (let i = 0; i < 30; i++) {
+ player.animator.moveSpeed = 0;
+ player.animator.effort = 0;
+ player.animator.hasPuck = true;
+ player.animator.setTransform(player.mover.position, 0);
+ player.animator.update(1 / 60);
+ }
+}
+
+function buildGoalie() {
+ if (goalie) {
+ goalie.destroy();
+ goalie = null;
+ }
+ goalie = createGoalie(null, scene, {
+ end: 1,
+ team: 1,
+ seed: 77,
+ index: 40,
+ });
+ // Park in studio space facing +Z (camera front), not the net frame.
+ const x = state.subject === 'both' ? 0.85 : 0;
+ goalie.mover.position.set(x, 0, 0);
+ goalie.mover.rotation.y = 0;
+ goalie.pos.x = x;
+ goalie.pos.z = 0;
+ goalie.animator.setTransform(goalie.mover.position, 0);
+ for (let i = 0; i < 30; i++) {
+ goalie.animator.threatened = 0.1;
+ goalie.animator.puckHeight = 0.5;
+ goalie.animator.puckDist = 12;
+ goalie.animator.setState('ready', 0.02);
+ goalie.animator.update(1 / 60);
+ }
+}
+
+function layoutSubjects() {
+ if (player) {
+ const x = state.subject === 'both' ? -0.85 : 0;
+ player.mover.position.set(x, 0, 0);
+ player.animator.setTransform(player.mover.position, 0);
+ }
+ if (goalie) {
+ const x = state.subject === 'both' ? 0.85 : 0;
+ goalie.mover.position.set(x, 0, 0);
+ goalie.pos.x = x;
+ goalie.pos.z = 0;
+ goalie.animator.setTransform(goalie.mover.position, 0);
+ }
+ if (player) player.mover.visible = state.subject !== 'goalie';
+ if (goalie) goalie.mover.visible = state.subject !== 'player';
+}
+
+// ---- pose application -----------------------------------------------------
+function poseList() {
+ if (state.subject === 'goalie') return Object.keys(GOALIE_POSES);
+ if (state.subject === 'player') return Object.keys(PLAYER_POSES);
+ // both: union with player first
+ return [...Object.keys(PLAYER_POSES), ...Object.keys(GOALIE_POSES).map((k) => `g:${k}`)];
+}
+
+function fillPoseSelect() {
+ const list = poseList();
+ poseSel.innerHTML = '';
+ for (const id of list) {
+ const opt = document.createElement('option');
+ opt.value = id;
+ if (id.startsWith('g:')) {
+ opt.textContent = `G · ${GOALIE_POSES[id.slice(2)].label}`;
+ } else if (state.subject === 'goalie') {
+ opt.textContent = GOALIE_POSES[id].label;
+ } else {
+ opt.textContent = PLAYER_POSES[id]?.label ?? id;
+ }
+ poseSel.appendChild(opt);
+ }
+ if (!list.includes(state.pose)) state.pose = list[0];
+ poseSel.value = state.pose;
+}
+
+/** Hold a pose for several frames so blends settle before capture. */
+function applyPose(poseId, settleFrames = 45) {
+ state.pose = poseId;
+ poseSel.value = poseId;
+
+ for (let i = 0; i < settleFrames; i++) {
+ state.time += 1 / 60;
+ if (player && player.mover.visible) {
+ const id = poseId.startsWith('g:') ? 'carry' : poseId;
+ const def = PLAYER_POSES[id] ?? PLAYER_POSES.carry;
+ def.apply(player, state.time);
+ }
+ if (goalie && goalie.mover.visible) {
+ const id = poseId.startsWith('g:') ? poseId.slice(2) : (GOALIE_POSES[poseId] ? poseId : 'ready');
+ const def = GOALIE_POSES[id] ?? GOALIE_POSES.ready;
+ // Bypass the live tracking loop; drive the animator directly.
+ def.apply(goalie);
+ }
+ }
+ if (state.showGear) {
+ for (const c of gearHelpers.children) {
+ if (c.isBoxHelper) c.update();
+ }
+ }
+}
+
+function setSubject(sub) {
+ state.subject = sub;
+ subjectSel.value = sub;
+ if ((sub === 'player' || sub === 'both') && !player) buildPlayer();
+ if ((sub === 'goalie' || sub === 'both') && !goalie) buildGoalie();
+ layoutSubjects();
+ fillPoseSelect();
+ // Default pose per subject.
+ if (sub === 'goalie' && !GOALIE_POSES[state.pose] && !state.pose.startsWith('g:')) {
+ state.pose = 'ready';
+ }
+ if (sub === 'player' && !PLAYER_POSES[state.pose]) state.pose = 'carry';
+ applyPose(state.pose);
+ clearBoneAxes();
+ if (state.showBones) rebuildHelpers();
+}
+
+// ---- measurements HUD -----------------------------------------------------
+const _v = new THREE.Vector3();
+function measure(sub) {
+ if (!sub) return null;
+ const bones = sub.skelData.bones;
+ const inv = new THREE.Matrix4().copy(sub.mover.matrixWorld).invert();
+ // Clone each result — a shared scratch vector would make every field the
+ // last bone written (everything looked like foot height).
+ const local = (bone) => {
+ bone.getWorldPosition(_v);
+ return _v.clone().applyMatrix4(inv);
+ };
+ const head = local(bones.head);
+ const handL = local(bones.handL);
+ const handR = local(bones.handR);
+ const footL = local(bones.footL);
+ const footR = local(bones.footR);
+ return {
+ headY: head.y,
+ handLY: handL.y,
+ handRY: handR.y,
+ footLY: footL.y,
+ footRY: footR.y,
+ stanceW: Math.abs(footL.x - footR.x),
+ anim: sub.animator?.state ?? sub.animator?.action ?? '—',
+ };
+}
+
+function refreshHud() {
+ const lines = [
+ `img2mesh subject=${state.subject} pose=${state.pose} view=${state.view}`,
+ ];
+ if (player?.mover.visible) {
+ const m = measure(player);
+ lines.push(
+ `player anim=${m.anim} headY=${m.headY.toFixed(2)} hands=${m.handLY.toFixed(2)}/${m.handRY.toFixed(2)} feetY=${m.footLY.toFixed(2)} width=${m.stanceW.toFixed(2)}`,
+ );
+ }
+ if (goalie?.mover.visible) {
+ const m = measure(goalie);
+ lines.push(
+ `goalie anim=${m.anim} headY=${m.headY.toFixed(2)} hands=${m.handLY.toFixed(2)}/${m.handRY.toFixed(2)} feetY=${m.footLY.toFixed(2)} width=${m.stanceW.toFixed(2)}`,
+ );
+ }
+ hud.textContent = lines.join('\n');
+}
+
+// ---- public API for the CLI harness ---------------------------------------
+/**
+ * Shot sheet the headless tool walks. Keep names filesystem-safe.
+ * @returns {{ subject: string, pose: string, view: string, file: string }[]}
+ */
+function shotSheet({ subjects = ['player', 'goalie'], views = null, poses = null } = {}) {
+ const viewIds = views ?? ['front', 'threequarter', 'side', 'closeup', 'gear'];
+ const out = [];
+ for (const sub of subjects) {
+ const poseIds = poses
+ ?? (sub === 'goalie' ? Object.keys(GOALIE_POSES) : Object.keys(PLAYER_POSES));
+ for (const pose of poseIds) {
+ for (const view of viewIds) {
+ out.push({
+ subject: sub,
+ pose,
+ view,
+ file: `${sub}_${pose}_${view}.png`,
+ });
+ }
+ }
+ }
+ return out;
+}
+
+async function captureShot({ subject, pose, view, settleMs = 80 }) {
+ setSubject(subject);
+ applyView(view);
+ applyPose(pose, 50);
+ // One render so WebGL presents the settled pose.
+ controls.update();
+ renderer.render(scene, camera);
+ await new Promise((r) => setTimeout(r, settleMs));
+ renderer.render(scene, camera);
+ return {
+ subject,
+ pose,
+ view,
+ measures: {
+ player: player?.mover.visible ? measure(player) : null,
+ goalie: goalie?.mover.visible ? measure(goalie) : null,
+ },
+ };
+}
+
+window.img2mesh = {
+ state,
+ shotSheet,
+ captureShot,
+ setSubject,
+ applyPose,
+ applyView,
+ get player() { return player; },
+ get goalie() { return goalie; },
+ /** Data URL of the current canvas (png). */
+ async screenshotDataURL() {
+ controls.update();
+ renderer.render(scene, camera);
+ return canvas.toDataURL('image/png');
+ },
+ /** Pose / view catalogs for external tools. */
+ catalogs: {
+ playerPoses: () => Object.fromEntries(Object.entries(PLAYER_POSES).map(([k, v]) => [k, v.label])),
+ goaliePoses: () => Object.fromEntries(Object.entries(GOALIE_POSES).map(([k, v]) => [k, v.label])),
+ views: () => Object.keys(VIEWS),
+ },
+};
+
+// ---- UI wiring ------------------------------------------------------------
+function cycle(list, cur, dir) {
+ const i = list.indexOf(cur);
+ return list[(i + dir + list.length) % list.length];
+}
+
+subjectSel.addEventListener('change', () => setSubject(subjectSel.value));
+poseSel.addEventListener('change', () => applyPose(poseSel.value));
+viewSel.addEventListener('change', () => applyView(viewSel.value));
+
+document.getElementById('prevPose').onclick = () => {
+ applyPose(cycle(poseList(), state.pose, -1));
+};
+document.getElementById('nextPose').onclick = () => {
+ applyPose(cycle(poseList(), state.pose, 1));
+};
+document.getElementById('prevView').onclick = () => {
+ applyView(cycle(Object.keys(VIEWS), state.view, -1));
+};
+document.getElementById('nextView').onclick = () => {
+ applyView(cycle(Object.keys(VIEWS), state.view, 1));
+};
+document.getElementById('cycle').onclick = async () => {
+ const sheet = shotSheet({ subjects: [state.subject === 'both' ? 'player' : state.subject] });
+ for (const s of sheet.slice(0, 12)) {
+ await captureShot(s);
+ refreshHud();
+ await new Promise((r) => setTimeout(r, 120));
+ }
+};
+
+window.addEventListener('keydown', (e) => {
+ if (e.target.matches?.('select,input,textarea')) return;
+ if (e.key === '1') setSubject('player');
+ if (e.key === '2') setSubject('goalie');
+ if (e.key === '3') setSubject('both');
+ if (e.key === '[') applyPose(cycle(poseList(), state.pose, -1));
+ if (e.key === ']') applyPose(cycle(poseList(), state.pose, 1));
+ if (e.key === ',') applyView(cycle(Object.keys(VIEWS), state.view, -1));
+ if (e.key === '.') applyView(cycle(Object.keys(VIEWS), state.view, 1));
+ if (e.key === 'b' || e.key === 'B') {
+ state.showBones = !state.showBones;
+ if (state.showBones) rebuildHelpers();
+ else clearBoneAxes();
+ boneHelpers.visible = state.showBones;
+ }
+ if (e.key === 'g' || e.key === 'G') {
+ state.showGear = !state.showGear;
+ if (state.showGear) rebuildHelpers();
+ gearHelpers.visible = state.showGear;
+ }
+});
+
+// ---- boot -----------------------------------------------------------------
+buildPlayer();
+buildGoalie();
+setSubject('player');
+applyView('threequarter');
+applyPose('carry');
+boot.remove();
+
+let last = performance.now();
+function frame(now) {
+ const dt = Math.min(0.05, (now - last) / 1000);
+ last = now;
+ state.time += dt;
+ // Live-update the current pose so stride cycles and breath read while idle.
+ if (player?.mover.visible) {
+ const id = state.pose.startsWith('g:') ? 'carry' : state.pose;
+ (PLAYER_POSES[id] ?? PLAYER_POSES.carry).apply(player, state.time);
+ }
+ if (goalie?.mover.visible) {
+ const id = state.pose.startsWith('g:')
+ ? state.pose.slice(2)
+ : (GOALIE_POSES[state.pose] ? state.pose : 'ready');
+ (GOALIE_POSES[id] ?? GOALIE_POSES.ready).apply(goalie);
+ }
+ if (state.showGear) {
+ for (const c of gearHelpers.children) {
+ if (c.isBoxHelper) c.update();
+ }
+ }
+ controls.update();
+ renderer.render(scene, camera);
+ refreshHud();
+ requestAnimationFrame(frame);
+}
+requestAnimationFrame(frame);
diff --git a/test/ai.mjs b/test/ai.mjs
new file mode 100644
index 0000000..aaebe91
--- /dev/null
+++ b/test/ai.mjs
@@ -0,0 +1,162 @@
+import { createBrain, spawnLineup, steer } from '../shared/ai.js';
+import { SKATE, createSkaterState, speedOf, stepSkater } from '../shared/skaterSim.js';
+import { RINK, insideRink } from '../shared/rink.js';
+import { done, near, ok, section } from './harness.mjs';
+
+const DT = 1 / 120;
+
+/** Deterministic PRNG so a failure here is reproducible. */
+function rng(seed) {
+ let a = seed | 0;
+ return () => {
+ a = (a + 0x6d2b79f5) | 0;
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+ };
+}
+
+/**
+ * A whole match's worth of skaters and brains, stepped headlessly.
+ * Board contact is the sim's clamp here rather than Box3D's, which is the
+ * point: the AI must not need the physics world to behave.
+ */
+function simulate(perTeam, seconds, seed = 7, teams = 2) {
+ const rand = rng(seed);
+ const spawns = spawnLineup(perTeam, teams);
+ const count = spawns.length;
+ const states = spawns.map((sp, i) => createSkaterState(i, sp, { team: sp.team }));
+ const brains = spawns.map(() => createBrain(rand));
+ const trace = states.map(() => ({ minSpeed: Infinity, maxSpeed: 0, offIce: 0, touches: 0, distance: 0 }));
+
+ const steps = Math.round(seconds / DT);
+ for (let n = 0; n < steps; n++) {
+ for (let i = 0; i < count; i++) {
+ steer(brains[i], states[i], states, DT);
+ const x0 = states[i].x;
+ const z0 = states[i].z;
+ stepSkater(states[i], DT);
+ const t = trace[i];
+ t.distance += Math.hypot(states[i].x - x0, states[i].z - z0);
+ const v = speedOf(states[i]);
+ if (v < t.minSpeed) t.minSpeed = v;
+ if (v > t.maxSpeed) t.maxSpeed = v;
+ if (!insideRink(states[i].x, states[i].z, SKATE.radius)) t.offIce++;
+ }
+ // Count how often two bodies are actually overlapping. The proxies resolve
+ // this in the browser; here it measures whether the *steering* alone keeps
+ // them roughly apart.
+ for (let i = 0; i < count; i++) {
+ for (let j = i + 1; j < count; j++) {
+ const d = Math.hypot(states[i].x - states[j].x, states[i].z - states[j].z);
+ if (d < SKATE.radius * 2) {
+ trace[i].touches++;
+ trace[j].touches++;
+ }
+ }
+ }
+ }
+ return { states, brains, trace, steps, count };
+}
+
+section('the 3-on-3 lineup is legal, split by half, and faces centre ice');
+{
+ const spawns = spawnLineup(3, 2);
+ ok(spawns.length === 6, `six skaters on the ice (${spawns.length})`);
+ ok(spawns.filter((s) => s.team === 0).length === 3, 'three a side, home');
+ ok(spawns.filter((s) => s.team === 1).length === 3, 'three a side, away');
+
+ for (const sp of spawns) {
+ ok(insideRink(sp.x, sp.z, SKATE.radius + 1), `spawn (${sp.x.toFixed(1)}, ${sp.z.toFixed(1)}) is on the ice`);
+ // Facing should point back toward the middle of the rink.
+ near(sp.yaw, Math.atan2(-sp.x, -sp.z), 1e-9, 'spawn faces centre ice');
+ // Each team starts in its own half, the way a lineup does.
+ const ownHalf = sp.team === 0 ? sp.x < 0 : sp.x > 0;
+ ok(ownHalf, `team ${sp.team} lines up in its own half (x=${sp.x.toFixed(1)})`);
+ }
+
+ // Index order has to agree with the team field, because the match builds
+ // skaters and materials off the index.
+ for (let i = 0; i < spawns.length; i++) {
+ ok(spawns[i].team === Math.floor(i / 3), `index ${i} belongs to team ${Math.floor(i / 3)}`);
+ }
+
+ for (let i = 0; i < spawns.length; i++) {
+ for (let j = i + 1; j < spawns.length; j++) {
+ const d = Math.hypot(spawns[i].x - spawns[j].x, spawns[i].z - spawns[j].z);
+ ok(d > 2, `spawns ${i} and ${j} are not on top of each other (${d.toFixed(1)}m)`);
+ }
+ }
+
+ // Nobody starts inside the far team, and nobody starts in a corner.
+ for (const sp of spawns) {
+ ok(Math.abs(sp.x) < RINK.halfX - 4, `spawn is clear of the end boards (x=${sp.x.toFixed(1)})`);
+ }
+}
+
+section('a 3-on-3 skates a full minute without leaving the ice');
+{
+ const { trace, states, count } = simulate(3, 60);
+ ok(count === 6, 'six skaters simulated');
+ for (let i = 0; i < count; i++) {
+ ok(trace[i].offIce === 0, `skater ${i} never went through the boards`);
+ ok(Number.isFinite(states[i].x) && Number.isFinite(states[i].z), `skater ${i} stayed finite`);
+ ok(trace[i].distance > 120, `skater ${i} actually covered ground (${trace[i].distance.toFixed(0)}m in 60s)`);
+ ok(trace[i].maxSpeed > 4, `skater ${i} got up to a real speed (${trace[i].maxSpeed.toFixed(1)} m/s)`);
+ ok(trace[i].maxSpeed <= SKATE.speedCeiling, `skater ${i} never exceeded the ceiling`);
+ }
+}
+
+section('bots keep out of each other\'s way on their own');
+{
+ const { trace, steps, count } = simulate(3, 60);
+ for (let i = 0; i < count; i++) {
+ const overlapFraction = trace[i].touches / steps;
+ ok(
+ overlapFraction < 0.06,
+ `skater ${i} spends almost no time inside another body (${(overlapFraction * 100).toFixed(1)}%)`,
+ );
+ }
+}
+
+section('bots reach their waypoints rather than circling forever');
+{
+ const rand = rng(19);
+ const s = createSkaterState(0, { x: 0, z: 0, yaw: 0 });
+ const brain = createBrain(rand);
+ let arrivals = 0;
+ let last = null;
+ for (let n = 0; n < 60 * 120; n++) {
+ steer(brain, s, [s], DT);
+ if (brain.target !== last) {
+ if (last !== null) arrivals++;
+ last = brain.target;
+ }
+ stepSkater(s, DT);
+ }
+ ok(arrivals >= 5, `a lone bot got through several waypoints in a minute (${arrivals})`);
+}
+
+section('a full 5-on-5 still behaves');
+{
+ // Not a spike-1 requirement, but the cheapest possible check that the
+ // steering does not fall over the moment there is a full side on the ice.
+ const { trace, states, count } = simulate(5, 30, 3);
+ ok(count === 10, 'ten skaters simulated');
+ for (let i = 0; i < count; i++) {
+ ok(trace[i].offIce === 0, `skater ${i} of ten stayed on the ice`);
+ ok(Number.isFinite(states[i].x), `skater ${i} of ten stayed finite`);
+ }
+}
+
+section('the whole match is deterministic');
+{
+ const a = simulate(3, 20, 42);
+ const b = simulate(3, 20, 42);
+ for (let i = 0; i < a.count; i++) {
+ near(a.states[i].x, b.states[i].x, 0, `skater ${i} replays to the same x`);
+ near(a.states[i].z, b.states[i].z, 0, `skater ${i} replays to the same z`);
+ }
+}
+
+done('ai');
diff --git a/test/goalie.mjs b/test/goalie.mjs
new file mode 100644
index 0000000..b3ad08a
--- /dev/null
+++ b/test/goalie.mjs
@@ -0,0 +1,103 @@
+import * as THREE from 'three';
+import { createGoalie } from '../src/character/goalie.js';
+import { done, ok, section } from './harness.mjs';
+
+/**
+ * Goalie presentation: skeleton, gear, and stance selection.
+ *
+ * Save logic and angle play are covered by shootout.mjs — this file pins the
+ * things that used to be a capsule-and-box placeholder.
+ */
+
+const DT = 1 / 60;
+
+function make() {
+ const scene = new THREE.Group();
+ const goalie = createGoalie(null, scene, { end: 1, team: 1, seed: 42 });
+ return { scene, goalie };
+}
+
+section('goalie is a skinned skeleton, not a capsule');
+{
+ const { goalie } = make();
+ ok(goalie.skelData.list.length >= 20, `has a full bone list (${goalie.skelData.list.length})`);
+ ok(goalie.bodyMesh?.isSkinnedMesh, 'body is a SkinnedMesh');
+ ok(goalie.gear.padL.parent === goalie.skelData.bones.shinL, 'left pad is on the left shin');
+ ok(goalie.gear.padR.parent === goalie.skelData.bones.shinR, 'right pad is on the right shin');
+ ok(goalie.gear.trapper.parent === goalie.skelData.bones.handL, 'trapper is on the left hand');
+ ok(goalie.gear.blocker.parent === goalie.skelData.bones.handR, 'blocker is on the right hand');
+ ok(goalie.gear.mask.parent === goalie.skelData.bones.head, 'mask is on the head');
+ ok(goalie.gear.stick.parent === goalie.skelData.bones.handR, 'paddle is in the blocker hand');
+ goalie.destroy();
+}
+
+section('nothing produces NaN while tracking');
+{
+ const { goalie } = make();
+ let bad = false;
+ for (let i = 0; i < 180; i++) {
+ goalie.update(DT, {
+ x: 15 + i * 0.08,
+ y: 0.1 + 0.5 * Math.sin(i * 0.1),
+ z: Math.sin(i * 0.07) * 2,
+ });
+ for (const b of goalie.skelData.list) {
+ for (const e of b.matrixWorld.elements) {
+ if (!Number.isFinite(e)) bad = true;
+ }
+ }
+ }
+ ok(!bad, 'bone matrices stay finite');
+ goalie.destroy();
+}
+
+section('stances respond to the puck');
+{
+ const { goalie } = make();
+ // Far out: ready.
+ for (let i = 0; i < 60; i++) goalie.update(DT, { x: 10, y: 0.5, z: 0 });
+ ok(goalie.animator.state === 'ready', `idle crease is ready (${goalie.animator.state})`);
+
+ // Low and closing: butterfly.
+ for (let i = 0; i < 90; i++) {
+ goalie.update(DT, { x: 12 + i * 0.15, y: 0.1, z: 0.2 });
+ }
+ ok(
+ goalie.animator.state === 'butterfly',
+ `low attack draws a butterfly (${goalie.animator.state})`,
+ );
+
+ // High and close: reach.
+ for (let i = 0; i < 45; i++) {
+ goalie.update(DT, { x: goalie.pos.x + 2, y: 1.25, z: goalie.pos.z });
+ }
+ ok(goalie.animator.state === 'reach', `high puck draws a reach (${goalie.animator.state})`);
+ goalie.destroy();
+}
+
+section('goalie drops low in the butterfly');
+{
+ const { goalie } = make();
+ for (let i = 0; i < 40; i++) goalie.update(DT, { x: 18, y: 0.5, z: 0 });
+ const readyY = goalie.skelData.bones.root.position.y;
+ const foot = new THREE.Vector3();
+ goalie.skelData.bones.footL.getWorldPosition(foot);
+ ok(Math.abs(foot.y - 0.085) < 0.04, `ready feet are on the ice (y=${foot.y.toFixed(3)})`);
+
+ for (let i = 0; i < 80; i++) {
+ goalie.update(DT, { x: goalie.pos.x + 1.2, y: 0.08, z: 0 });
+ }
+ const flyY = goalie.skelData.bones.root.position.y;
+ ok(flyY < readyY - 0.1, `butterfly drops the hips (${readyY.toFixed(2)} → ${flyY.toFixed(2)})`);
+ goalie.skelData.bones.footL.getWorldPosition(foot);
+ ok(Math.abs(foot.y - 0.085) < 0.04, `butterfly feet stay on the ice (y=${foot.y.toFixed(3)})`);
+ // Pads flare wider than the ready stance.
+ const inv = new THREE.Matrix4().copy(goalie.mover.matrixWorld).invert();
+ const fL = foot.clone().applyMatrix4(inv);
+ goalie.skelData.bones.footR.getWorldPosition(foot);
+ const fR = foot.clone().applyMatrix4(inv);
+ ok(Math.abs(fL.x - fR.x) > 0.9, `butterfly opens the stance (width ${(fL.x - fR.x).toFixed(2)})`);
+ goalie.destroy();
+}
+
+done('goalie');
diff --git a/test/harness.mjs b/test/harness.mjs
new file mode 100644
index 0000000..2362d91
--- /dev/null
+++ b/test/harness.mjs
@@ -0,0 +1,28 @@
+/** The smallest test harness that gives a useful failure message. */
+
+let failures = 0;
+let checks = 0;
+
+export function ok(cond, msg) {
+ checks++;
+ if (!cond) {
+ failures++;
+ console.error(' FAIL ' + msg);
+ }
+}
+
+export function near(actual, expected, tol, msg) {
+ ok(Math.abs(actual - expected) <= tol, `${msg} (got ${actual}, want ${expected} ±${tol})`);
+}
+
+export function section(name) {
+ console.log('· ' + name);
+}
+
+export function done(name) {
+ if (failures) {
+ console.error(`\n${name}: ${failures} of ${checks} checks failed`);
+ process.exit(1);
+ }
+ console.log(`${name}: ${checks} checks passed`);
+}
diff --git a/test/hits.mjs b/test/hits.mjs
new file mode 100644
index 0000000..5f4e201
--- /dev/null
+++ b/test/hits.mjs
@@ -0,0 +1,449 @@
+import * as THREE from 'three';
+import { createPhysicsWorld, initPhysics } from '../src/physics/world.js';
+import { createMatch } from '../src/game/match.js';
+import { closestLimbs, describeHit } from '../src/game/hits.js';
+import { REGION } from '../src/character/skeleton.js';
+import { segSegDistance } from '../src/core/math.js';
+import { insideRink } from '../shared/rink.js';
+import { done, near, ok, section } from './harness.mjs';
+
+/**
+ * Body checks, end to end and headless.
+ *
+ * The thing under test is the handoff: while upright the proxy capsule owns
+ * position and the ragdoll is a kinematic passenger; a knockdown inverts that,
+ * and getting up inverts it back. That round trip is the part of the design
+ * with nowhere to hide, so most of this file is about proving it does not leak
+ * a disabled proxy, a stranded sim position, or a skater who never gets up.
+ */
+
+const DT = 1 / 60;
+
+await initPhysics();
+
+/** A match with everyone parked, so only the skaters under test move. */
+function arena(perTeam = 1) {
+ const physics = createPhysicsWorld();
+ const match = createMatch({ scene: new THREE.Group(), physics, perTeam, teams: 2 });
+ return { physics, match };
+}
+
+/**
+ * Drive skater 0 into skater 1 head on and run until something happens.
+ * Returns the hits that landed.
+ */
+function collide({ closing = 'full', seconds = 6, gap = 16 } = {}) {
+ const { physics, match } = arena(1);
+ const [a, b] = match.states;
+ const landed = [];
+ const seen = new Set();
+
+ a.x = -gap / 2; a.z = 0; a.yaw = Math.PI / 2; a.vx = 0; a.vz = 0;
+ b.x = gap / 2; b.z = 0; b.yaw = -Math.PI / 2; b.vx = 0; b.vz = 0;
+ match.skaters[0].proxy.teleport(a.x, a.z);
+ match.skaters[1].proxy.teleport(b.x, b.z);
+
+ // Drive both directly, so the AI's avoidance steering cannot politely
+ // sidestep the collision this test exists to cause. With cameraYaw 0 the
+ // stick's x maps straight to world +X.
+ match.setControl(0, { x: 1, y: 0, sprint: true, brake: false, cameraYaw: 0 });
+ match.setControl(1, closing === 'full'
+ ? { x: -1, y: 0, sprint: true, brake: false, cameraYaw: 0 }
+ : { x: 0, y: 0, sprint: false, brake: false, cameraYaw: 0 });
+
+ const steps = Math.round(seconds / DT);
+ for (let n = 0; n < steps; n++) {
+ match.update(DT);
+ for (const h of match.recentHits) {
+ const id = `${h.at}|${h.attacker}|${h.victim}`;
+ if (!seen.has(id)) {
+ seen.add(id);
+ landed.push(h);
+ }
+ }
+ }
+ return { physics, match, landed };
+}
+
+section('segment distance is correct');
+{
+ const A = new THREE.Vector3();
+ const B = new THREE.Vector3();
+ // Two parallel segments one metre apart.
+ let d = segSegDistance(
+ new THREE.Vector3(0, 0, 0), new THREE.Vector3(1, 0, 0),
+ new THREE.Vector3(0, 1, 0), new THREE.Vector3(1, 1, 0), A, B,
+ );
+ near(d, 1, 1e-9, 'parallel segments');
+
+ // Crossing segments touch.
+ d = segSegDistance(
+ new THREE.Vector3(-1, 0, 0), new THREE.Vector3(1, 0, 0),
+ new THREE.Vector3(0, -1, 0), new THREE.Vector3(0, 1, 0), A, B,
+ );
+ near(d, 0, 1e-9, 'crossing segments');
+
+ // Endpoint to endpoint, no overlap in parameter space.
+ d = segSegDistance(
+ new THREE.Vector3(0, 0, 0), new THREE.Vector3(1, 0, 0),
+ new THREE.Vector3(3, 0, 0), new THREE.Vector3(4, 0, 0), A, B,
+ );
+ near(d, 2, 1e-9, 'collinear, disjoint');
+ near(A.x, 1, 1e-9, 'closest point on the first segment is its end');
+ near(B.x, 3, 1e-9, 'closest point on the second is its start');
+
+ // Degenerate: both segments are points.
+ d = segSegDistance(
+ new THREE.Vector3(0, 0, 0), new THREE.Vector3(0, 0, 0),
+ new THREE.Vector3(3, 4, 0), new THREE.Vector3(3, 4, 0), A, B,
+ );
+ near(d, 5, 1e-9, 'two points');
+}
+
+section('the closest limb pair is found between two posed rigs');
+{
+ const { physics, match } = arena(1);
+ const [a, b] = match.skaters;
+ // Stand them shoulder to shoulder.
+ match.states[0].x = 0; match.states[0].z = 0;
+ match.states[1].x = 0.75; match.states[1].z = 0;
+ a.applyState(match.states[0], 0);
+ b.applyState(match.states[1], 0);
+ a.update(DT);
+ b.update(DT);
+
+ const pair = closestLimbs(a.ragdoll, b.ragdoll);
+ ok(pair, 'a pair was found');
+ ok(pair.attackerPart && pair.victimPart, 'both sides identified');
+ ok(pair.distance < 0.6, `and they are genuinely close (${pair.distance.toFixed(3)}m)`);
+ // Side by side, the nearest parts must be on the facing sides, i.e. arms or
+ // torso — never a foot to a head.
+ ok(
+ pair.attackerPart.name !== 'footL' && pair.attackerPart.name !== 'footR',
+ `a shoulder-to-shoulder stance does not resolve to a foot (${pair.attackerPart.name})`,
+ );
+ physics.destroy();
+}
+
+section('a full-speed head-on check lands and knocks someone down');
+{
+ const { physics, match, landed } = collide({ closing: 'full' });
+ ok(landed.length > 0, `a hit was registered (${landed.length})`);
+ const hit = landed[0];
+ ok(hit.speed > 4, `with real closing speed (${hit.speed.toFixed(1)} m/s)`);
+ ok(hit.outcome !== 'bump', `and it was more than a bump (${hit.outcome})`);
+ ok(hit.attacker !== hit.victim, 'attacker and victim are different skaters');
+ ok(typeof hit.by === 'string' && hit.by.length > 0, `it was delivered by something (${hit.by})`);
+ ok(typeof describeHit(hit) === 'string', `and describes itself: "${describeHit(hit)}"`);
+ physics.destroy();
+}
+
+section('a skater who is run over goes down, then gets back up');
+{
+ const { physics, match } = collide({ closing: 'stationary', seconds: 5 });
+ const downed = match.skaters.filter((s) => s.limp);
+ // Either someone is still down, or they already got up — both mean the path
+ // ran. Keep simulating until nobody is down, and check it terminates.
+ let steps = 0;
+ while (match.skaters.some((s) => s.limp) && steps < 60 / DT) {
+ match.update(DT);
+ steps++;
+ }
+ ok(steps < 60 / DT, `everyone got back up (took ${(steps * DT).toFixed(1)}s)`);
+ for (let i = 0; i < match.skaters.length; i++) {
+ const sk = match.skaters[i];
+ ok(!sk.limp, `skater ${i} is upright`);
+ ok(sk.proxy.enabled, `skater ${i}'s proxy is switched back on`);
+ ok(sk.ragdoll.mode === 'driven', `skater ${i}'s rig is back under animation`);
+ ok(insideRink(match.states[i].x, match.states[i].z, 0.3), `skater ${i} is on the ice`);
+ ok(Number.isFinite(match.states[i].x), `skater ${i}'s position is finite`);
+ }
+ physics.destroy();
+}
+
+section('a knockdown puts a skater on the ice, not in the air');
+{
+ // The failure this catches is specific and very visible: applying the whole
+ // impulse at the contact point, which sits well above the centre of mass,
+ // cartwheels the victim up over the hitter's head instead of driving them
+ // down and back.
+ const { physics, match } = collide({ closing: 'full', gap: 24, seconds: 3 });
+ const victim = match.skaters.find((s) => s.limp);
+ ok(victim, 'somebody went down');
+
+ const pelvis = new THREE.Vector3();
+ const head = new THREE.Vector3();
+ let peakPelvis = 0;
+ let peakHead = 0;
+ for (let n = 0; n < 2.5 / DT; n++) {
+ match.update(DT);
+ victim.ragdoll.parts.pelvis.bone.getWorldPosition(pelvis);
+ victim.ragdoll.parts.head.bone.getWorldPosition(head);
+ peakPelvis = Math.max(peakPelvis, pelvis.y);
+ peakHead = Math.max(peakHead, head.y);
+ }
+ // Standing hip height is ~1.0m and standing head height ~1.6m. Going above
+ // those while being knocked over means they were launched.
+ ok(peakPelvis < 1.35, `the hips never went above standing height (peak ${peakPelvis.toFixed(2)}m)`);
+ ok(peakHead < 2.0, `and neither did the head (peak ${peakHead.toFixed(2)}m)`);
+ physics.destroy();
+}
+
+section('a knockdown drives the victim away from the hit, not back into it');
+{
+ // Run until contact rather than for a fixed time: how long the run-up takes
+ // depends on the acceleration curve, and a test that silently ends before
+ // the collision proves nothing.
+ const { physics, match } = collide({ closing: 'stationary', gap: 22, seconds: 3 });
+ let waited = 0;
+ while (!match.skaters.some((s) => s.limp) && waited < 6) {
+ match.update(DT);
+ waited += DT;
+ }
+ const victimIndex = match.skaters.findIndex((s) => s.limp);
+ ok(victimIndex >= 0, `somebody went down (after ${waited.toFixed(1)}s of extra run-up)`);
+ const pelvis = new THREE.Vector3();
+ match.skaters[victimIndex].ragdoll.parts.pelvis.bone.getWorldPosition(pelvis);
+ const startX = pelvis.x;
+ for (let n = 0; n < 1.2 / DT; n++) match.update(DT);
+ match.skaters[victimIndex].ragdoll.parts.pelvis.bone.getWorldPosition(pelvis);
+ // The attacker was travelling +X, so the victim has to end up further +X.
+ ok(pelvis.x > startX, `the body carried on down the ice (${startX.toFixed(2)} → ${pelvis.x.toFixed(2)})`);
+ physics.destroy();
+}
+
+section('getting up does not teleport the body');
+{
+ // The bug this pins down: while limp the ragdoll writes the body's
+ // displacement into the *root bone*, because the mover stays parked where
+ // they fell. Moving the mover onto the pelvis without re-expressing that
+ // offset applies the displacement twice — the skater visibly flies out by
+ // however far they slid and the crossfade then drags them back.
+ //
+ // Measured on the rendered bones, not on the sim state, because the sim
+ // state was always right; it was the drawn pose that jumped.
+ const { physics, match } = arena(1);
+ const sk = match.skaters[0];
+ const st = match.states[0];
+ st.x = -4;
+ st.z = 3;
+ sk.proxy.teleport(st.x, st.z);
+ for (let n = 0; n < 20; n++) match.update(DT);
+
+ sk.goDown({ severity: 9, direction: new THREE.Vector3(1, 0, 0), victimPart: 'spine2' });
+ // Send them sliding so the fall position and the resting position differ by
+ // a long way — with them equal the bug cannot show.
+ sk.ragdoll.applyImpulse('spine2', new THREE.Vector3(300, 30, 90), null);
+
+ const sample = new THREE.Vector3();
+ const bones = ['pelvis', 'head', 'footL', 'handR'];
+ const before = new Map();
+ let slid = 0;
+
+ while (sk.limp) {
+ // Remember the last frame before the handoff.
+ for (const b of bones) {
+ sk.ragdoll.parts[b].bone.getWorldPosition(sample);
+ before.set(b, sample.clone());
+ }
+ sk.ragdoll.parts.pelvis.bone.getWorldPosition(sample);
+ slid = Math.hypot(sample.x - st.x, sample.z - st.z);
+ match.update(DT);
+ }
+
+ ok(slid > 0.5, `the body really did slide away from where it fell (${slid.toFixed(2)}m)`);
+
+ // First frame back under animation: every bone must be where it just was.
+ let worst = 0;
+ let worstBone = '';
+ for (const b of bones) {
+ sk.ragdoll.parts[b].bone.getWorldPosition(sample);
+ const moved = sample.distanceTo(before.get(b));
+ if (moved > worst) {
+ worst = moved;
+ worstBone = b;
+ }
+ }
+ ok(worst < 0.12, `no bone jumped across the handoff (worst ${worstBone} ${worst.toFixed(3)}m)`);
+
+ // And the whole get-up should be a pose change, not a journey.
+ sk.ragdoll.parts.pelvis.bone.getWorldPosition(sample);
+ const riseStart = sample.clone();
+ let drift = 0;
+ while (sk.rising > 0) {
+ match.update(DT);
+ sk.ragdoll.parts.pelvis.bone.getWorldPosition(sample);
+ drift = Math.max(drift, Math.hypot(sample.x - riseStart.x, sample.z - riseStart.z));
+ }
+ ok(drift < 0.6, `they stood up roughly where they lay (drifted ${drift.toFixed(2)}m)`);
+ physics.destroy();
+}
+
+section('a skater who gets up faces the way they were lying');
+{
+ const { physics, match } = arena(1);
+ const sk = match.skaters[0];
+ const st = match.states[0];
+ st.x = 0;
+ st.z = 0;
+ st.yaw = 0;
+ sk.proxy.teleport(0, 0);
+ for (let n = 0; n < 20; n++) match.update(DT);
+ sk.goDown(null);
+ sk.ragdoll.applyImpulse('spine2', new THREE.Vector3(0, 20, 260), null);
+ while (sk.limp) match.update(DT);
+
+ const pelvis = new THREE.Vector3();
+ const chest = new THREE.Vector3();
+ sk.ragdoll.parts.pelvis.bone.getWorldPosition(pelvis);
+ sk.ragdoll.parts.spine3.bone.getWorldPosition(chest);
+ const bodyYaw = Math.atan2(chest.x - pelvis.x, chest.z - pelvis.z);
+ const off = Math.abs(Math.atan2(Math.sin(st.yaw - bodyYaw), Math.cos(st.yaw - bodyYaw)));
+ ok(off < 0.9, `facing follows the sprawled body rather than a stale yaw (${off.toFixed(2)} rad off)`);
+ ok(Number.isFinite(st.yaw), 'and is a real number');
+ physics.destroy();
+}
+
+section('the sim follows the body across a knockdown');
+{
+ const { physics, match } = arena(1);
+ const sk = match.skaters[0];
+ const st = match.states[0];
+ st.x = -5; st.z = 2; st.vx = 0; st.vz = 0;
+ sk.proxy.teleport(st.x, st.z);
+ for (let n = 0; n < 20; n++) match.update(DT);
+
+ sk.goDown({ severity: 9, direction: new THREE.Vector3(1, 0, 0), victimPart: 'spine2' });
+ ok(sk.limp, 'they are down');
+ ok(!sk.proxy.enabled, 'the proxy switched off — no invisible bollard left behind');
+
+ // Shove the rig so it ends up somewhere other than where it fell.
+ sk.ragdoll.applyImpulse('spine2', new THREE.Vector3(260, 40, 0), null);
+ for (let n = 0; n < 90; n++) match.update(DT);
+
+ const pelvis = new THREE.Vector3();
+ sk.ragdoll.parts.pelvis.bone.getWorldPosition(pelvis);
+ while (sk.limp) match.update(DT);
+
+ ok(sk.proxy.enabled, 'the proxy came back');
+ const gap = Math.hypot(st.x - pelvis.x, st.z - pelvis.z);
+ ok(gap < 1.2, `the sim was moved to where the body actually ended up (${gap.toFixed(2)}m off)`);
+ ok(Math.hypot(st.vx, st.vz) < 2, 'and starts from rest rather than inheriting the slide');
+ physics.destroy();
+}
+
+section('a downed skater is not driven around by the sim');
+{
+ const { physics, match } = arena(1);
+ const sk = match.skaters[0];
+ const st = match.states[0];
+ st.x = 0; st.z = 0;
+ sk.proxy.teleport(0, 0);
+ for (let n = 0; n < 10; n++) match.update(DT);
+ sk.goDown(null);
+ const at = { x: st.x, z: st.z };
+ // Hold full sprint intent for a second while down.
+ for (let n = 0; n < 60; n++) {
+ st.ix = 1;
+ st.iz = 0;
+ st.sprint = true;
+ match.update(DT);
+ }
+ const moved = Math.hypot(st.x - at.x, st.z - at.z);
+ near(moved, 0, 1e-6, 'the frozen sim position did not skate off without the body');
+ physics.destroy();
+}
+
+section('ragdoll limbs join the collision world only while dynamic');
+{
+ const { physics, match } = arena(1);
+ const sk = match.skaters[0];
+ const api = physics.api;
+ const shape = sk.ragdoll.parts.spine2.shape;
+
+ const drivenMask = api.b3Shape_GetFilter(shape).maskBits;
+ sk.goDown(null);
+ const limpMask = api.b3Shape_GetFilter(shape).maskBits;
+ ok(limpMask !== drivenMask, 'the filter changed when the rig went dynamic');
+ ok(limpMask > drivenMask, 'and it got wider, not narrower');
+
+ while (sk.limp) match.update(DT);
+ const backMask = api.b3Shape_GetFilter(shape).maskBits;
+ near(Number(backMask), Number(drivenMask), 0, 'and went back on standing up');
+ physics.destroy();
+}
+
+section('a 3-on-3 with hits enabled stays sane');
+{
+ const { physics, match } = arena(3);
+ for (let n = 0; n < 90 / DT; n++) match.update(DT);
+ for (let i = 0; i < match.states.length; i++) {
+ const s = match.states[i];
+ ok(Number.isFinite(s.x) && Number.isFinite(s.z), `skater ${i} finite after 90s`);
+ ok(insideRink(s.x, s.z, 0.3), `skater ${i} still on the ice`);
+ }
+ ok(match.recentHits.length >= 0, 'the hit list did not blow up');
+ physics.destroy();
+}
+
+section('hit severity is graded, not binary');
+{
+ // Different run-ups must produce different outcomes, or "varied hits" is a
+ // lie. A short approach is a shove; a long one puts someone on the ice.
+ const outcomes = new Set();
+ const byShortRun = [];
+ const byLongRun = [];
+ for (const [gap, into] of [[2.5, byShortRun], [22, byLongRun]]) {
+ const { physics, landed } = collide({ closing: 'stationary', gap, seconds: 6 });
+ for (const h of landed) {
+ outcomes.add(h.outcome);
+ into.push(h);
+ }
+ physics.destroy();
+ }
+ ok(outcomes.size >= 2, `run-up length changes the outcome (${[...outcomes].join(', ')})`);
+ ok(byShortRun.length > 0 && byLongRun.length > 0, 'both approaches landed something');
+ ok(
+ byLongRun[0].severity > byShortRun[0].severity,
+ `a longer run-up hits harder (${byLongRun[0].severity.toFixed(1)} vs ${byShortRun[0].severity.toFixed(1)})`,
+ );
+}
+
+section('the kind of hit follows the pose, not a coin flip');
+{
+ // Two geometries that should produce genuinely different checks: running
+ // down a stationary skater leads with the shoulder, while a head-on between
+ // two skaters both crouched low at speed is a hip check.
+ const kinds = new Set();
+ const seen = [];
+ for (const closing of ['stationary', 'full']) {
+ const { physics, landed } = collide({ closing, gap: 22, seconds: 6 });
+ for (const h of landed) {
+ kinds.add(h.by);
+ seen.push(`${closing}: ${describeHit(h)}`);
+ }
+ physics.destroy();
+ }
+ ok(kinds.size >= 2, `more than one kind of hit is reachable (${[...kinds].join(', ')})`);
+ ok(kinds.has('shoulder') || kinds.has('hip'), `and they are real checks (${seen.join(' | ')})`);
+}
+
+section('nobody delivers a check with their head');
+{
+ // A skater at speed is pitched forward, which makes the head the leading
+ // part of the body geometrically. Without the delivering-part restriction
+ // almost every hit resolves to a headbutt.
+ const delivered = new Set();
+ for (const closing of ['stationary', 'full']) {
+ for (const gap of [5, 14, 22]) {
+ const { physics, landed } = collide({ closing, gap, seconds: 6 });
+ for (const h of landed) delivered.add(h.attackerPart);
+ physics.destroy();
+ }
+ }
+ ok(!delivered.has('head'), `no hit was credited to a head (${[...delivered].join(', ')})`);
+ ok(!delivered.has('neck'), 'nor to a neck');
+ ok(delivered.size > 0, 'and hits did land');
+}
+
+done('hits');
diff --git a/test/input.mjs b/test/input.mjs
new file mode 100644
index 0000000..ddc3731
--- /dev/null
+++ b/test/input.mjs
@@ -0,0 +1,361 @@
+import { PAD, createInput, stickToWorld } from '../src/game/input.js';
+import { createSkaterState, stepSkater } from '../shared/skaterSim.js';
+import { done, near, ok, section } from './harness.mjs';
+
+/**
+ * A fake window and a fake gamepad, so the pad layer can be tested without a
+ * browser or a pad. The Gamepad API is polled, not evented, which makes it
+ * unusually easy to stand in for.
+ */
+function fakePad(overrides = {}) {
+ const buttons = Array.from({ length: 17 }, () => ({ pressed: false, value: 0 }));
+ return {
+ index: 0,
+ id: 'Xbox Wireless Controller (STANDARD GAMEPAD)',
+ connected: true,
+ mapping: 'standard',
+ axes: [0, 0, 0, 0],
+ buttons,
+ ...overrides,
+ };
+}
+
+/**
+ * Node exposes `navigator` as a getter-only global, so it has to be replaced
+ * with defineProperty rather than assigned. Both globals are restored after
+ * each case so one test cannot leak a fake pad into the next.
+ */
+function stubGlobal(name, value) {
+ const had = Object.getOwnPropertyDescriptor(globalThis, name);
+ Object.defineProperty(globalThis, name, { value, configurable: true, writable: true });
+ return () => {
+ if (had) Object.defineProperty(globalThis, name, had);
+ else delete globalThis[name];
+ };
+}
+
+function harness() {
+ const listeners = new Map();
+ const fakeWindow = {
+ addEventListener: (t, fn) => listeners.set(t, fn),
+ removeEventListener: () => {},
+ };
+ const pad = fakePad();
+ const restoreNav = stubGlobal('navigator', { getGamepads: () => [pad] });
+ const restoreWin = stubGlobal('window', fakeWindow);
+ const input = createInput(fakeWindow);
+ return {
+ input,
+ pad,
+ listeners,
+ press: (i, value = 1) => { pad.buttons[i] = { pressed: value > 0.5, value }; },
+ release: (i) => { pad.buttons[i] = { pressed: false, value: 0 }; },
+ restore: () => {
+ restoreWin();
+ restoreNav();
+ },
+ };
+}
+
+/**
+ * Camera-relative steering.
+ *
+ * Worth its own file because the failure mode is silent and infuriating:
+ * a sign flip here means pushing the stick forward sends the skater backwards
+ * only when the camera happens to be on a particular side, which is very easy
+ * to mistake for a physics bug.
+ *
+ * The convention under test: the camera orbits at `cameraYaw`, sitting at
+ * +(sin, cos) from its target, so "away from the camera" is -(sin, cos).
+ */
+
+const DT = 1 / 120;
+
+/** Angle between two XZ directions, radians. */
+function angleBetween(ax, az, bx, bz) {
+ const dot = (ax * bx + az * bz) / (Math.hypot(ax, az) * Math.hypot(bx, bz));
+ return Math.acos(Math.max(-1, Math.min(1, dot)));
+}
+
+section('pushing forward always means away from the camera');
+{
+ for (const yaw of [0, 0.7, Math.PI / 2, 2.5, Math.PI, -1.2, -Math.PI / 2]) {
+ const w = stickToWorld({ x: 0, y: 1 }, yaw);
+ // The camera sits at +(sin, cos) * distance from its target, so away from
+ // it is the negative of that.
+ near(w.ix, -Math.sin(yaw), 1e-12, `yaw ${yaw.toFixed(2)}: forward is away from the camera (x)`);
+ near(w.iz, -Math.cos(yaw), 1e-12, `yaw ${yaw.toFixed(2)}: forward is away from the camera (z)`);
+ }
+}
+
+section('the four directions are square to each other');
+{
+ for (const yaw of [0, 1.1, -2.2, Math.PI]) {
+ const f = stickToWorld({ x: 0, y: 1 }, yaw);
+ const b = stickToWorld({ x: 0, y: -1 }, yaw);
+ const r = stickToWorld({ x: 1, y: 0 }, yaw);
+ const l = stickToWorld({ x: -1, y: 0 }, yaw);
+
+ near(angleBetween(f.ix, f.iz, r.ix, r.iz), Math.PI / 2, 1e-9, `yaw ${yaw.toFixed(1)}: right is 90° from forward`);
+ near(angleBetween(f.ix, f.iz, b.ix, b.iz), Math.PI, 1e-9, `yaw ${yaw.toFixed(1)}: back is opposite forward`);
+ near(angleBetween(r.ix, r.iz, l.ix, l.iz), Math.PI, 1e-9, `yaw ${yaw.toFixed(1)}: left is opposite right`);
+
+ // Right must be to the camera's right, not its left. Cross product of
+ // forward x right about +Y is negative for a correct right-handed frame.
+ const cross = f.ix * r.iz - f.iz * r.ix;
+ ok(cross > 0, `yaw ${yaw.toFixed(1)}: "right" is on the camera's right, not its left`);
+ }
+}
+
+section('magnitude survives the transform');
+{
+ for (const yaw of [0, 0.9, -1.7]) {
+ for (const stick of [{ x: 1, y: 0 }, { x: 0, y: 1 }, { x: 0.6, y: 0.8 }, { x: 0.3, y: -0.2 }]) {
+ const w = stickToWorld(stick, yaw);
+ near(
+ Math.hypot(w.ix, w.iz),
+ Math.hypot(stick.x, stick.y),
+ 1e-12,
+ `yaw ${yaw.toFixed(1)}: a rotation does not change stick magnitude`,
+ );
+ }
+ }
+}
+
+section('a centred stick produces no intent');
+{
+ for (const yaw of [0, 1.4, -2.9]) {
+ const w = stickToWorld({ x: 0, y: 0 }, yaw);
+ near(w.ix, 0, 1e-12, 'centred stick, no x');
+ near(w.iz, 0, 1e-12, 'centred stick, no z');
+ }
+}
+
+section('holding forward drives the skater away from the camera');
+{
+ // The end-to-end claim: stick + sim together move the body where the player
+ // expects, from any camera angle and any starting facing.
+ for (const cameraYaw of [0, 1.0, -2.0, Math.PI]) {
+ const s = createSkaterState(0, { x: 0, z: 0, yaw: 2.3 }); // facing anywhere
+ const w = stickToWorld({ x: 0, y: 1 }, cameraYaw);
+ for (let n = 0; n < 3 / DT; n++) {
+ s.ix = w.ix;
+ s.iz = w.iz;
+ s.sprint = true;
+ stepSkater(s, DT, { clampBoards: false });
+ }
+ const travelled = angleBetween(s.x, s.z, w.ix, w.iz);
+ ok(
+ travelled < 0.2,
+ `camera ${cameraYaw.toFixed(1)}: skater ended up where the stick pointed (${travelled.toFixed(3)} rad off)`,
+ );
+ ok(Math.hypot(s.x, s.z) > 8, 'and actually covered ground');
+ }
+}
+
+section('the skater turns to face the stick regardless of where they started');
+{
+ for (const startYaw of [0, 2.0, -2.0, Math.PI]) {
+ const s = createSkaterState(0, { x: 0, z: 0, yaw: startYaw });
+ const w = stickToWorld({ x: 0, y: 1 }, 0); // away from a camera at yaw 0
+ for (let n = 0; n < 2 / DT; n++) {
+ s.ix = w.ix;
+ s.iz = w.iz;
+ stepSkater(s, DT, { clampBoards: false });
+ }
+ const want = Math.atan2(w.ix, w.iz);
+ const off = Math.abs(Math.atan2(Math.sin(s.yaw - want), Math.cos(s.yaw - want)));
+ ok(off < 0.25, `from yaw ${startYaw.toFixed(1)}: came round to face the stick (${off.toFixed(3)} rad off)`);
+ }
+}
+
+section('the pad reads as an Xbox controller');
+{
+ const h = harness();
+ const s = h.input.read(1 / 60);
+ ok(h.input.connected, 'a connected pad is found even without a connect event');
+ ok(s.padId.includes('Xbox'), `and identifies itself (${s.padId})`);
+ near(s.x, 0, 1e-9, 'a resting stick is centred (x)');
+ near(s.y, 0, 1e-9, 'a resting stick is centred (y)');
+ ok(!s.sprint && !s.brake, 'and nothing is pressed');
+ h.restore();
+}
+
+section('sticks have a radial deadzone and correct signs');
+{
+ const h = harness();
+
+ h.pad.axes = [0.1, -0.1, 0, 0];
+ let s = h.input.read(1 / 60);
+ near(s.x, 0, 1e-9, 'a small drift is inside the deadzone');
+ near(s.y, 0, 1e-9, 'on both axes');
+
+ // Pad Y is positive *downward*, so pushing up must come out positive.
+ h.pad.axes = [0, -1, 0, 0];
+ s = h.input.read(1 / 60);
+ ok(s.y > 0.9, `pushing the stick up is positive y (${s.y.toFixed(2)})`);
+ near(s.x, 0, 1e-9, 'and no x');
+
+ h.pad.axes = [1, 0, 0, 0];
+ s = h.input.read(1 / 60);
+ ok(s.x > 0.9, `pushing right is positive x (${s.x.toFixed(2)})`);
+
+ // Full diagonal must not exceed unit length, or diagonals are faster.
+ h.pad.axes = [1, -1, 0, 0];
+ s = h.input.read(1 / 60);
+ ok(Math.hypot(s.x, s.y) <= 1.0001, `a full diagonal stays on the unit circle (${Math.hypot(s.x, s.y).toFixed(3)})`);
+
+ // The right stick is axes 2/3 and must not be confused with the left.
+ h.pad.axes = [0, 0, 0, -1];
+ s = h.input.read(1 / 60);
+ near(s.x, 0, 1e-9, 'the right stick does not move the skater');
+ ok(s.skill.y > 0.9, `and lands on the Skill Stick (${s.skill.y.toFixed(2)})`);
+ h.restore();
+}
+
+section('triggers are analog, not boolean');
+{
+ const h = harness();
+ h.press(PAD.RT, 0.3);
+ let s = h.input.read(1 / 60);
+ ok(s.hustle > 0.2 && s.hustle < 0.4, `a light pull is a light hustle (${s.hustle.toFixed(2)})`);
+ ok(!s.sprint, 'and does not trip the sprint stride');
+
+ h.press(PAD.RT, 1);
+ s = h.input.read(1 / 60);
+ near(s.hustle, 1, 1e-9, 'a full pull is full hustle');
+ ok(s.sprint, 'and does trip the sprint stride');
+
+ h.press(PAD.LT, 1);
+ s = h.input.read(1 / 60);
+ ok(s.brake, 'the left trigger stops');
+ ok(s.protect > 0.9, `and reports analog (${s.protect.toFixed(2)})`);
+ h.restore();
+}
+
+section('buttons report as actions, and only on the edge');
+{
+ const h = harness();
+ h.input.read(1 / 60);
+
+ h.press(PAD.A);
+ let s = h.input.read(1 / 60);
+ ok(s.pressed.pass, 'A is a pass');
+ ok(s.held.pass, 'and is held');
+
+ s = h.input.read(1 / 60);
+ ok(!s.pressed.pass, 'holding it does not re-fire the press');
+ ok(s.held.pass, 'but it is still held');
+
+ h.release(PAD.A);
+ h.press(PAD.B);
+ s = h.input.read(1 / 60);
+ ok(!s.held.pass, 'releasing clears held');
+ ok(s.pressed.poke, 'B is a poke check');
+
+ h.release(PAD.B);
+ h.press(PAD.LB);
+ s = h.input.read(1 / 60);
+ ok(s.pressed.switchPlayer, 'LB switches player');
+ h.restore();
+}
+
+section('the Skill Stick fires a shot on pull-back-and-push');
+{
+ const h = harness();
+ const dt = 1 / 60;
+ h.input.read(dt);
+
+ // Pull back and hold, which should charge but not fire.
+ h.pad.axes = [0, 0, 0, 1]; // pad Y down = stick pulled back
+ let s;
+ for (let i = 0; i < 20; i++) s = h.input.read(dt);
+ ok(s.shot === null, 'holding the stick back does not fire');
+ ok(s.charge > 0.4, `it winds up instead (${s.charge.toFixed(2)})`);
+
+ // Push forward: release.
+ h.pad.axes = [0, 0, 0, -1];
+ s = h.input.read(dt);
+ ok(s.shot, 'pushing forward releases the shot');
+ ok(s.shot.power > 0.5, `with real power after a long wind-up (${s.shot.power.toFixed(2)})`);
+ near(s.charge, 0, 1e-9, 'and the wind-up is spent');
+
+ s = h.input.read(dt);
+ ok(s.shot === null, 'the shot fires once, not every frame after');
+ h.restore();
+}
+
+section('a quick flick is a weaker shot than a full wind-up');
+{
+ function fire(windFrames) {
+ const h = harness();
+ const dt = 1 / 60;
+ h.input.read(dt);
+ h.pad.axes = [0, 0, 0, 1];
+ for (let i = 0; i < windFrames; i++) h.input.read(dt);
+ h.pad.axes = [0, 0, 0, -1];
+ const s = h.input.read(dt);
+ h.restore();
+ return s.shot;
+ }
+ const flick = fire(2);
+ const loaded = fire(40);
+ ok(flick, 'a flick still fires');
+ ok(loaded, 'and so does a full wind-up');
+ ok(loaded.power > flick.power, `holding longer hits harder (${loaded.power.toFixed(2)} vs ${flick.power.toFixed(2)})`);
+ ok(flick.power >= 0.25, `but a snap shot is never nothing (${flick.power.toFixed(2)})`);
+}
+
+section('an abandoned wind-up is forgotten, not banked');
+{
+ const h = harness();
+ const dt = 1 / 60;
+ h.input.read(dt);
+ h.pad.axes = [0, 0, 0, 1];
+ for (let i = 0; i < 8; i++) h.input.read(dt);
+ // Let go back to centre and wait it out.
+ h.pad.axes = [0, 0, 0, 0];
+ let s;
+ for (let i = 0; i < 150; i++) s = h.input.read(dt);
+ ok(s.shot === null, 'nothing fired from a wind-up left to rot');
+ near(s.charge, 0, 1e-9, 'and the charge decayed away');
+ h.restore();
+}
+
+section('the shot carries aim from the stick');
+{
+ const h = harness();
+ const dt = 1 / 60;
+ h.input.read(dt);
+ h.pad.axes = [0, 0, 0.8, 1]; // wound back, stick held to the right
+ for (let i = 0; i < 20; i++) h.input.read(dt);
+ h.pad.axes = [0, 0, 0.8, -1];
+ const s = h.input.read(dt);
+ ok(s.shot, 'the shot fired');
+ ok(s.shot.aim > 0.5, `and remembers it was aimed right (${s.shot.aim.toFixed(2)})`);
+ h.restore();
+}
+
+section('the shoot button works for anyone who never learns the Skill Stick');
+{
+ const h = harness();
+ h.input.read(1 / 60);
+ h.press(PAD.X);
+ const s = h.input.read(1 / 60);
+ ok(s.shot, 'X shoots');
+ ok(s.shot.power > 0 && s.shot.power <= 1, `at a sensible power (${s.shot.power.toFixed(2)})`);
+ h.restore();
+}
+
+section('rumble never throws, whatever the pad supports');
+{
+ const h = harness();
+ ok(h.input.rumble(1, 1, 100) === false, 'a pad without haptics reports no rumble rather than crashing');
+ h.pad.vibrationActuator = { playEffect: () => Promise.resolve('complete') };
+ ok(h.input.rumble(1, 1, 100) === true, 'and a pad with them reports success');
+ h.pad.vibrationActuator = { playEffect: () => { throw new Error('nope'); } };
+ ok(h.input.rumble(1, 1, 100) === false, 'a throwing actuator is swallowed');
+ h.restore();
+}
+
+done('input');
diff --git a/test/physics.mjs b/test/physics.mjs
new file mode 100644
index 0000000..17a7e71
--- /dev/null
+++ b/test/physics.mjs
@@ -0,0 +1,312 @@
+import * as THREE from 'three';
+import { createPhysicsWorld, initPhysics } from '../src/physics/world.js';
+import { createBodyProxy } from '../src/physics/bodyProxy.js';
+import { CAT } from '../src/physics/bridge.js';
+import { createSkater } from '../src/character/skater.js';
+import { spawnLineup } from '../shared/ai.js';
+import { SKATE, createSkaterState, speedOf, stepSkater } from '../shared/skaterSim.js';
+import { RINK, insideRink } from '../shared/rink.js';
+import { done, near, ok, section } from './harness.mjs';
+
+/**
+ * Box3D integration.
+ *
+ * The claim these tests exist to check is the one the spike rests on: that
+ * board contact and skater-on-skater contact are solved by the physics engine
+ * and come back into the sim as momentum, rather than being faked by a clamp.
+ * Everything else about the skating is covered headlessly in skaterSim.mjs.
+ */
+
+const DT = 1 / 120;
+
+await initPhysics();
+
+/** A world plus `n` skaters wired the way the match loop wires them. */
+function makeWorld(spawns) {
+ const physics = createPhysicsWorld();
+ const states = spawns.map((sp, i) => createSkaterState(i, sp));
+ const proxies = spawns.map((sp, i) => {
+ const p = createBodyProxy(physics, { index: i, position: sp });
+ p.teleport(sp.x, sp.z);
+ return p;
+ });
+ return { physics, states, proxies };
+}
+
+/** Step the match loop's inner cycle for `seconds`. */
+function run(w, seconds, drive) {
+ const steps = Math.round(seconds / DT);
+ for (let n = 0; n < steps; n++) {
+ for (let i = 0; i < w.states.length; i++) {
+ w.proxies[i].read(w.states[i]);
+ if (drive) drive(w.states[i], i, n * DT);
+ stepSkater(w.states[i], DT, { clampBoards: false });
+ w.proxies[i].write(w.states[i]);
+ }
+ w.physics.step(DT);
+ }
+}
+
+section('the world builds');
+{
+ const w = makeWorld([{ x: 0, z: 0, yaw: 0 }]);
+ ok(w.physics.boardBodies.length > 30, `the boards are a real ring (${w.physics.boardBodies.length} segments)`);
+ ok(w.proxies[0].mass > 60 && w.proxies[0].mass < 120, `a skater weighs something plausible (${w.proxies[0].mass.toFixed(0)} kg)`);
+ w.physics.destroy();
+}
+
+section('the proxy carries the skater and stays upright');
+{
+ const w = makeWorld([{ x: -20, z: 0, yaw: Math.PI / 2 }]);
+ run(w, 3, (s) => {
+ s.ix = 1;
+ s.iz = 0;
+ });
+ const t = w.physics.api.b3Body_GetTransform(w.proxies[0].body);
+ ok(t.p.x > -18, `the body actually moved down the ice (x=${t.p.x.toFixed(1)})`);
+ near(t.p.y, 0, 1e-3, 'and never left the ice');
+ near(t.q.v.x, 0, 1e-4, 'and never tipped over (x)');
+ near(t.q.v.z, 0, 1e-4, 'and never tipped over (z)');
+ near(w.states[0].x, t.p.x, 1e-6, 'the sim reads its position straight out of Box3D');
+ w.physics.destroy();
+}
+
+section('the boards stop a skater at full speed');
+{
+ // Straight at the end boards from centre ice, sprinting, for long enough to
+ // be well past them if nothing were there.
+ const w = makeWorld([{ x: 0, z: 0, yaw: Math.PI / 2 }]);
+ run(w, 12, (s) => {
+ s.ix = 1;
+ s.iz = 0;
+ s.sprint = true;
+ });
+ const s = w.states[0];
+ ok(insideRink(s.x, s.z, SKATE.radius * 0.9), `stopped by the end boards (x=${s.x.toFixed(2)} of ${RINK.halfX})`);
+ ok(s.x > RINK.halfX - 2, 'and got all the way to them');
+ w.physics.destroy();
+}
+
+section('the corners hold too');
+{
+ // The corners are the interesting case: they are a chain of short boxes, and
+ // a body driven into the seam between two of them is exactly how a skater
+ // escapes a rink.
+ for (const heading of [0.5, 1.0, 2.2, -0.8, -2.5]) {
+ const w = makeWorld([{ x: 0, z: 0, yaw: heading }]);
+ run(w, 14, (s) => {
+ s.ix = Math.sin(heading);
+ s.iz = Math.cos(heading);
+ s.sprint = true;
+ });
+ const s = w.states[0];
+ ok(
+ insideRink(s.x, s.z, SKATE.radius * 0.9),
+ `heading ${heading.toFixed(1)} stayed inside (${s.x.toFixed(1)}, ${s.z.toFixed(1)})`,
+ );
+ w.physics.destroy();
+ }
+}
+
+section('a board hit costs speed');
+{
+ // Started far enough out that four seconds of sprinting is a run-up, not a
+ // collision — the measurement below is the speed *arriving* at the boards.
+ const w = makeWorld([{ x: -8, z: 0, yaw: Math.PI / 2 }]);
+ run(w, 4, (s) => {
+ s.ix = 1;
+ s.iz = 0;
+ s.sprint = true;
+ });
+ const entry = speedOf(w.states[0]);
+ ok(w.states[0].x < RINK.halfX - 3, `still short of the boards after the run-up (x=${w.states[0].x.toFixed(1)})`);
+ ok(entry > 5, `carrying real speed into them (${entry.toFixed(1)} m/s)`);
+ run(w, 3, (s) => {
+ s.ix = 1;
+ s.iz = 0;
+ s.sprint = true;
+ });
+ // Still pushing into the wall, so speed should be near nothing, not bouncing
+ // around the rink.
+ ok(speedOf(w.states[0]) < 1.5, `pinned against the boards (${speedOf(w.states[0]).toFixed(2)} m/s)`);
+ w.physics.destroy();
+}
+
+section('two skaters cannot occupy the same ice');
+{
+ // The worst case the engine will ever see: both at full sprint, dead head
+ // on, both still pushing after contact for several seconds.
+ //
+ // They settle around 0.53 m apart rather than at two capsule radii (0.72 m).
+ // That is not a solver failure — raising the substep count does not move it
+ // by a millimetre — it is the equilibrium of two bodies whose velocity is
+ // *commanded* by the sim each step leaning on each other. The proxy radius
+ // is deliberately larger than the body it carries (torso half-width is about
+ // 0.22 m), so at that separation the two torsos still have ~10 cm of daylight
+ // between them and nothing visibly intersects.
+ //
+ // What would be a real failure is passing through, so that is checked too.
+ const w = makeWorld([
+ { x: -8, z: 0, yaw: Math.PI / 2 },
+ { x: 8, z: 0, yaw: -Math.PI / 2 },
+ ]);
+ const TORSO_HALF_WIDTH = 0.22;
+ let minGap = Infinity;
+ let crossed = false;
+ const steps = Math.round(6 / DT);
+ for (let n = 0; n < steps; n++) {
+ for (let i = 0; i < 2; i++) {
+ w.proxies[i].read(w.states[i]);
+ w.states[i].ix = i === 0 ? 1 : -1;
+ w.states[i].iz = 0;
+ w.states[i].sprint = true;
+ stepSkater(w.states[i], DT, { clampBoards: false });
+ w.proxies[i].write(w.states[i]);
+ }
+ w.physics.step(DT);
+ const gap = Math.hypot(w.states[0].x - w.states[1].x, w.states[0].z - w.states[1].z);
+ minGap = Math.min(minGap, gap);
+ if (w.states[0].x > w.states[1].x) crossed = true;
+ }
+ ok(!crossed, 'neither skater ever passed through the other');
+ ok(
+ minGap > TORSO_HALF_WIDTH * 2,
+ `torsos never intersected (closest ${minGap.toFixed(2)}m, two torso widths is ${(TORSO_HALF_WIDTH * 2).toFixed(2)}m)`,
+ );
+ ok(minGap < SKATE.radius * 2, 'and they did genuinely make contact');
+ w.physics.destroy();
+}
+
+section('a bump transfers momentum into the sim');
+{
+ // One skater flying, one standing still directly in the way.
+ const w = makeWorld([
+ { x: -12, z: 0, yaw: Math.PI / 2 },
+ { x: 4, z: 0, yaw: Math.PI / 2 },
+ ]);
+ run(w, 5, (s, i) => {
+ if (i === 0) {
+ s.ix = 1;
+ s.iz = 0;
+ s.sprint = true;
+ } else {
+ s.ix = 0;
+ s.iz = 0;
+ }
+ });
+ const victim = w.states[1];
+ ok(victim.x > 4.05, `the stationary skater was shoved down the ice (x ${victim.x.toFixed(2)} from 4.00)`);
+ ok(speedOf(victim) > 0.3, `and carried real speed away from it (${speedOf(victim).toFixed(2)} m/s)`);
+ ok(speedOf(victim) < SKATE.speedCeiling, 'without being launched');
+ w.physics.destroy();
+}
+
+section('a glancing hit knocks a skater off their line');
+{
+ // Passing shoulder to shoulder rather than head on.
+ const w = makeWorld([
+ { x: -10, z: 0.3, yaw: Math.PI / 2 },
+ { x: 10, z: -0.3, yaw: -Math.PI / 2 },
+ ]);
+ run(w, 6, (s, i) => {
+ s.ix = i === 0 ? 1 : -1;
+ s.iz = 0;
+ s.sprint = true;
+ });
+ ok(
+ Math.abs(w.states[0].z) > 0.4 || Math.abs(w.states[1].z) > 0.4,
+ `contact pushed someone off their line (z ${w.states[0].z.toFixed(2)} / ${w.states[1].z.toFixed(2)})`,
+ );
+ w.physics.destroy();
+}
+
+section('the ragdoll is built and follows the animated skeleton');
+{
+ // Nothing in spike 1 pushes the rig, but it has to be there and correct or
+ // the first hit in spike 2 will land on a rig that was never wired up.
+ const physics = createPhysicsWorld();
+ const scene = new THREE.Group();
+ const sk = createSkater({ seed: 5, scene, physics, index: 0, team: 0, position: { x: 3, z: -2 }, facing: 0.4 });
+
+ ok(sk.ragdoll, 'a skater has a ragdoll');
+ ok(sk.ragdoll.order.length === 18, `18 capsules (${sk.ragdoll.order.length})`);
+ ok(sk.ragdoll.joints.length === 17, `17 joints (${sk.ragdoll.joints.length})`);
+ ok(sk.ragdoll.mode === 'driven', 'and starts kinematic, chasing the animation');
+
+ const mass = sk.ragdoll.totalMass();
+ ok(mass > 70 && mass < 100, `the rig weighs a person (${mass.toFixed(0)} kg)`);
+
+ // Drive it the way the match loop does, then check the physics bodies ended
+ // up on the bones rather than at the origin.
+ const state = createSkaterState(0, { x: 3, z: -2, yaw: 0.4 });
+ for (let n = 0; n < 120; n++) {
+ state.ix = 1;
+ state.iz = 0;
+ stepSkater(state, DT, { clampBoards: false });
+ sk.applyState(state, 0);
+ sk.update(DT);
+ physics.step(DT, (fixedDt) => sk.ragdoll.syncFromSkeleton(fixedDt));
+ }
+
+ const api = physics.api;
+ const bone = new THREE.Vector3();
+ let worst = 0;
+ for (const part of sk.ragdoll.order) {
+ part.bone.getWorldPosition(bone);
+ const p = api.b3Body_GetPosition(part.body);
+ worst = Math.max(worst, Math.hypot(p.x - bone.x, p.y - bone.y, p.z - bone.z));
+ }
+ ok(worst < 0.05, `every capsule sits on its bone (worst gap ${worst.toFixed(4)}m)`);
+
+ // And the whole rig travelled with the skater rather than staying at spawn.
+ const pelvis = api.b3Body_GetPosition(sk.ragdoll.parts.pelvis.body);
+ ok(Math.abs(pelvis.x - state.x) < 0.4, `the rig moved with the skater (${pelvis.x.toFixed(2)} vs ${state.x.toFixed(2)})`);
+ ok(pelvis.y > 0.6 && pelvis.y < 1.1, `and its hips are at hip height (${pelvis.y.toFixed(2)}m)`);
+
+ sk.dispose();
+ physics.destroy();
+}
+
+section('a full 3-on-3 runs without anything escaping');
+{
+ // Six bodies, all sprinting at centre ice at once, for twenty-five seconds.
+ // This is the pile-up case: every proxy in contact with several others while
+ // the sim keeps commanding velocity into the middle of the heap.
+ const w = makeWorld(spawnLineup(3, 2));
+ ok(w.states.length === 6, 'six skaters on the ice');
+ run(w, 25, (s, i, t) => {
+ const dx = -s.x;
+ const dz = -s.z;
+ const len = Math.hypot(dx, dz) || 1;
+ s.ix = (dx / len) * Math.sin(t * 0.7 + i);
+ s.iz = (dz / len) * Math.cos(t * 0.5 + i);
+ s.sprint = true;
+ });
+ for (let i = 0; i < w.states.length; i++) {
+ const s = w.states[i];
+ ok(Number.isFinite(s.x) && Number.isFinite(s.z), `skater ${i} stayed finite`);
+ ok(insideRink(s.x, s.z, SKATE.radius * 0.9), `skater ${i} stayed on the ice`);
+ ok(speedOf(s) <= SKATE.speedCeiling, `skater ${i} never exceeded the speed ceiling`);
+ }
+ // Nobody ends up standing inside anybody, even after a sustained pile-up.
+ for (let i = 0; i < w.states.length; i++) {
+ for (let j = i + 1; j < w.states.length; j++) {
+ const d = Math.hypot(w.states[i].x - w.states[j].x, w.states[i].z - w.states[j].z);
+ ok(d > 0.44, `skaters ${i} and ${j} are not inside each other (${d.toFixed(2)}m)`);
+ }
+ }
+ w.physics.destroy();
+}
+
+section('every skater in a 3-on-3 gets its own collision layer');
+{
+ // Ragdoll categories are one bit per skater from bit 1 up, and the proxy
+ // layer sits at bit 15. Six a side would still fit; this checks the two do
+ // not collide at the roster sizes we actually intend to reach.
+ for (let i = 0; i < 10; i++) {
+ ok(CAT.skater(i) !== CAT.PROXY, `skater ${i}'s ragdoll bit is not the proxy bit`);
+ ok((CAT.skater(i) & CAT.RINK) === 0n, `skater ${i}'s ragdoll bit is not the rink bit`);
+ }
+}
+
+done('physics');
diff --git a/test/pose.mjs b/test/pose.mjs
new file mode 100644
index 0000000..ba35f81
--- /dev/null
+++ b/test/pose.mjs
@@ -0,0 +1,394 @@
+import * as THREE from 'three';
+import { buildSkeleton } from '../src/character/skeleton.js';
+import { buildAnimator } from '../src/anim/skateAnimator.js';
+import { buildStick } from '../src/character/stick.js';
+import { segDist } from '../src/core/math.js';
+import { done, ok, section } from './harness.mjs';
+
+/**
+ * Animator checks, run headlessly.
+ *
+ * Nothing here needs a GPU: the skeleton is three.js Bones and the animator is
+ * maths. That makes the pose the one part of the render path that can be
+ * regression-tested, which is worth doing because "the skater looks wrong" is
+ * otherwise only ever caught by a human squinting at a screenshot.
+ */
+
+const DT = 1 / 60;
+
+function rig() {
+ const skelData = buildSkeleton();
+ const mover = new THREE.Group();
+ // Same hierarchy as createSkater: skeleton rides on the mover so body yaw
+ // carries the bones. Leaving the root unparented made every yaw test a lie —
+ // the stick target orbited in world space while the hand sat still.
+ mover.add(skelData.rootBone);
+ const anim = buildAnimator(skelData, mover);
+ // The stick is part of the pose now — it hangs off the hand and the animator
+ // aims it, so a rig without one is not the rig the game runs.
+ const stick = buildStick(null, null, 0);
+ stick.attachTo(skelData.bones.handR);
+ anim.stick = stick;
+ return { skelData, mover, anim, stick };
+}
+
+/** Drive the animator for `seconds` under a fixed set of inputs. */
+function drive(r, seconds, inputs) {
+ const steps = Math.round(seconds / DT);
+ for (let i = 0; i < steps; i++) {
+ Object.assign(r.anim, inputs);
+ r.anim.update(DT);
+ }
+ return r;
+}
+
+const _v = new THREE.Vector3();
+/** World position of a bone, relative to the mover's own frame. */
+function bonePos(r, name) {
+ r.mover.updateMatrixWorld(true);
+ r.skelData.bones[name].getWorldPosition(_v);
+ return _v.clone().sub(r.mover.position);
+}
+
+/** How far a limb sticks out sideways, as an angle from straight down. */
+function spread(hip, hand) {
+ const dx = Math.abs(hand.x - hip.x);
+ const dy = hip.y - hand.y;
+ return Math.atan2(dx, Math.max(1e-6, dy));
+}
+
+const GLIDE = { moveSpeed: 6, bladeSpeed: 6, effort: 0, yawRate: 0, braking: false, originYaw: 0 };
+const STRIDE = { moveSpeed: 6, bladeSpeed: 6, effort: 1, yawRate: 0, braking: false, originYaw: 0 };
+const STAND = { moveSpeed: 0, bladeSpeed: 0, effort: 0, yawRate: 0, braking: false, originYaw: 0 };
+const CARVE = { moveSpeed: 7, bladeSpeed: 7, effort: 0.8, yawRate: 1.2, braking: false, originYaw: 0 };
+
+section('nothing produces NaN');
+{
+ for (const [name, inputs] of Object.entries({ GLIDE, STRIDE, STAND, CARVE })) {
+ const r = drive(rig(), 4, inputs);
+ let bad = 0;
+ for (const b of r.skelData.list) {
+ for (const e of b.matrixWorld.elements) if (!Number.isFinite(e)) bad++;
+ }
+ ok(bad === 0, `${name} leaves every bone matrix finite`);
+ }
+}
+
+section('the skater stands on the ice, not in it or above it');
+{
+ for (const [name, inputs] of Object.entries({ GLIDE, STRIDE, STAND, CARVE })) {
+ const r = drive(rig(), 4, inputs);
+ for (const side of ['L', 'R']) {
+ const foot = bonePos(r, `foot${side}`);
+ ok(foot.y > -0.02, `${name}: ${side} foot is not through the ice (y=${foot.y.toFixed(3)})`);
+ ok(foot.y < 0.35, `${name}: ${side} foot is not floating (y=${foot.y.toFixed(3)})`);
+ }
+ }
+}
+
+section('the skater is crouched, and more so under a stride');
+{
+ const glide = drive(rig(), 4, GLIDE);
+ const stride = drive(rig(), 4, STRIDE);
+ const hipG = bonePos(glide, 'pelvis').y;
+ const hipS = bonePos(stride, 'pelvis').y;
+ // Rest pelvis height is 1.0; a hockey stance sits well under that.
+ ok(hipG < 0.95, `gliding hips are below rest height (${hipG.toFixed(3)})`);
+ ok(hipS < hipG, `a stride sits deeper than a glide (${hipS.toFixed(3)} vs ${hipG.toFixed(3)})`);
+ ok(hipS > 0.6, `but not folded in half (${hipS.toFixed(3)})`);
+
+ const head = bonePos(stride, 'head');
+ ok(head.y > hipS + 0.35, `the head is still well above the hips (${head.y.toFixed(3)})`);
+}
+
+section('the torso is pitched forward, but not folded over');
+{
+ /** Angle of the pelvis→neck line from vertical, degrees. */
+ function torsoAngle(inputs) {
+ const r = drive(rig(), 4, inputs);
+ const hips = bonePos(r, 'pelvis');
+ const neck = bonePos(r, 'neck');
+ const up = neck.clone().sub(hips);
+ return Math.atan2(Math.hypot(up.x, up.z), up.y) * 57.3;
+ }
+ const stand = torsoAngle(STAND);
+ const stride = torsoAngle(STRIDE);
+ ok(stand > 3 && stand < 22, `a standing skater is slightly forward (${stand.toFixed(0)}°)`);
+ ok(stride > 25, `at speed they are properly over their skates (${stride.toFixed(0)}°)`);
+ ok(stride < 55, `but not bent double (${stride.toFixed(0)}°)`);
+ ok(stride > stand + 8, 'and more folded moving than standing');
+
+ // The head has to come back up, or they are skating looking at their boots.
+ // Measured off the head bone's own forward axis rather than off a bone
+ // offset: the head is a leaf, so there is no child position to read a
+ // direction from.
+ const r = drive(rig(), 4, STRIDE);
+ r.mover.updateMatrixWorld(true);
+ const gazeDir = new THREE.Vector3(0, 0, 1)
+ .applyQuaternion(r.skelData.bones.head.getWorldQuaternion(new THREE.Quaternion()));
+ const gaze = Math.asin(-gazeDir.y) * 57.3;
+ ok(gaze < stride - 8, `the eyes are up the ice, not on the boots (${gaze.toFixed(0)}° down vs a ${stride.toFixed(0)}° torso)`);
+ ok(gaze > -20, 'and not craned back at the roof');
+}
+
+section('arms hang by the body, not out in a T-pose');
+{
+ for (const [name, inputs] of Object.entries({ GLIDE, STRIDE, STAND })) {
+ const r = drive(rig(), 4, inputs);
+ for (const side of ['L', 'R']) {
+ const shoulder = bonePos(r, `upperArm${side}`);
+ const hand = bonePos(r, `hand${side}`);
+ const angle = spread(shoulder, hand);
+ // Wider than the old free-arm limit on purpose: these hands are holding
+ // a stick out in front, which is not the same silhouette as a skater
+ // swinging their arms.
+ ok(
+ angle < 1.15,
+ `${name}: ${side} arm is within 66° of the body (${(angle * 57.3).toFixed(0)}°)`,
+ );
+ ok(hand.y < shoulder.y, `${name}: ${side} hand is below the shoulder`);
+ // Hands carried in front, the way a skater carries them.
+ ok(hand.z > -0.15, `${name}: ${side} hand is not trailing behind the back (z=${hand.z.toFixed(2)})`);
+ }
+ }
+}
+
+section('the elbows are bent');
+{
+ const r = drive(rig(), 4, STRIDE);
+ for (const side of ['L', 'R']) {
+ const shoulder = bonePos(r, `upperArm${side}`);
+ const elbow = bonePos(r, `forearm${side}`);
+ const hand = bonePos(r, `hand${side}`);
+ const upper = elbow.clone().sub(shoulder).normalize();
+ const fore = hand.clone().sub(elbow).normalize();
+ const bend = Math.acos(Math.max(-1, Math.min(1, upper.dot(fore))));
+ ok(bend > 0.35, `${side} elbow is bent (${(bend * 57.3).toFixed(0)}°)`);
+ ok(bend < 2.2, `${side} elbow is not folded shut (${(bend * 57.3).toFixed(0)}°)`);
+ }
+}
+
+section('a stride moves the legs, a glide does not');
+{
+ function footTravel(inputs) {
+ const r = rig();
+ let minZ = Infinity;
+ let maxZ = -Infinity;
+ for (let i = 0; i < 240; i++) {
+ Object.assign(r.anim, inputs);
+ r.anim.update(DT);
+ const f = bonePos(r, 'footL');
+ minZ = Math.min(minZ, f.z);
+ maxZ = Math.max(maxZ, f.z);
+ }
+ return maxZ - minZ;
+ }
+ const strideTravel = footTravel(STRIDE);
+ const glideTravel = footTravel(GLIDE);
+ ok(strideTravel > 0.3, `a stride swings the blade fore and aft (${strideTravel.toFixed(2)}m)`);
+ ok(glideTravel < 0.08, `a glide holds it still (${glideTravel.toFixed(2)}m)`);
+}
+
+section('the skater banks into a turn');
+{
+ const straight = drive(rig(), 3, { ...CARVE, yawRate: 0 });
+ const right = drive(rig(), 3, { ...CARVE, yawRate: 1.4 });
+ const left = drive(rig(), 3, { ...CARVE, yawRate: -1.4 });
+
+ ok(Math.abs(straight.anim.bank) < 0.02, 'no bank on a straight line');
+ ok(right.anim.bank > 0.3, `a right-hand turn banks right (${right.anim.bank.toFixed(2)} rad)`);
+ ok(left.anim.bank < -0.3, `a left-hand turn banks left (${left.anim.bank.toFixed(2)} rad)`);
+
+ // The lean has to show up in the body, not just in the number.
+ const headR = bonePos(right, 'head');
+ const headS = bonePos(straight, 'head');
+ ok(headR.x > headS.x + 0.1, `the head leads into the turn (${headR.x.toFixed(2)} vs ${headS.x.toFixed(2)})`);
+}
+
+section('a hockey stop is a different pose');
+{
+ const skate = drive(rig(), 3, { ...STRIDE, braking: false });
+ const stop = drive(rig(), 3, { ...STRIDE, braking: true });
+ ok(stop.anim.state === 'stop', 'braking at speed enters the stop state');
+ ok(skate.anim.state === 'skate', 'and not braking does not');
+
+ // Blades across the travel: the toes should be turned well off the body's
+ // forward axis, which is what actually scrapes the ice.
+ const l = stop.skelData.bones.footL.getWorldQuaternion(new THREE.Quaternion());
+ const fwd = new THREE.Vector3(0, 0, 1).applyQuaternion(l);
+ const off = Math.abs(Math.atan2(fwd.x, fwd.z));
+ ok(off > 0.7, `the blades are thrown across the travel (${(off * 57.3).toFixed(0)}°)`);
+}
+
+section('a slow skater does not enter the stop state');
+{
+ const r = drive(rig(), 3, { ...STAND, braking: true });
+ ok(r.anim.state === 'skate', 'braking from a standstill is not a hockey stop');
+}
+
+section('feet stay under the body');
+{
+ for (const [name, inputs] of Object.entries({ GLIDE, STRIDE, CARVE })) {
+ const r = drive(rig(), 4, inputs);
+ for (const side of ['L', 'R']) {
+ const foot = bonePos(r, `foot${side}`);
+ ok(Math.abs(foot.x) < 0.75, `${name}: ${side} blade is not splayed out (x=${foot.x.toFixed(2)})`);
+ ok(Math.abs(foot.z) < 0.6, `${name}: ${side} blade is not stretched out (z=${foot.z.toFixed(2)})`);
+ }
+ }
+}
+
+section('the blade is on the ice, ahead of the skater');
+{
+ // The failure this pins: a socket rotation authored in hand space composes
+ // with whatever the arm is doing, so a grip tuned for one gait floats the
+ // blade half a metre up in another. Checked across every skating stance.
+ for (const [name, inputs] of Object.entries({ GLIDE, STRIDE, STAND, CARVE })) {
+ const r = drive(rig(), 4, { ...inputs, hasPuck: true });
+ const blade = new THREE.Vector3();
+ r.stick.bladeWorld(blade);
+ const inv = new THREE.Matrix4().copy(r.mover.matrixWorld).invert();
+ const local = blade.clone().applyMatrix4(inv);
+ ok(local.y > -0.02 && local.y < 0.16, `${name}: blade is on the ice (y=${local.y.toFixed(3)})`);
+ ok(local.z > 0.5, `${name}: and out in front (z=${local.z.toFixed(2)})`);
+ // Carry keeps the blade near the body midline, slightly forehand — not
+ // parked a metre off the hip.
+ ok(Math.abs(local.x) < 0.75, `${name}: not flung out sideways (x=${local.x.toFixed(2)})`);
+ }
+}
+
+section('the stick is held, not floating');
+{
+ // Puck carry must be two-handed: top hand on the butt, lower hand on the
+ // shaft. The old pose parked the stick on the hip and left the off-hand
+ // ~25 cm short — the failure the motion-reference carry frame calls out.
+ const r = drive(rig(), 4, { ...GLIDE, effort: 0.2, hasPuck: true });
+ r.mover.updateMatrixWorld(true);
+ const butt = new THREE.Vector3();
+ const heel = new THREE.Vector3();
+ r.stick.shaftSegment(butt, heel);
+ const handR = new THREE.Vector3();
+ r.skelData.bones.handR.getWorldPosition(handR);
+ ok(handR.distanceTo(butt) < 0.12, `the top hand is on the butt of the stick (${handR.distanceTo(butt).toFixed(3)}m)`);
+
+ const handL = new THREE.Vector3();
+ const closest = new THREE.Vector3();
+ r.skelData.bones.handL.getWorldPosition(handL);
+ const gap = segDist(handL, butt, heel, closest);
+ ok(gap < 0.08, `the lower hand is on the shaft (${gap.toFixed(3)}m)`);
+
+ // Stick sits in front of the body, not parked out on the hip.
+ const inv = new THREE.Matrix4().copy(r.mover.matrixWorld).invert();
+ const handLocal = handR.clone().applyMatrix4(inv);
+ ok(Math.abs(handLocal.x) < 0.28, `top hand is in front of the torso (x=${handLocal.x.toFixed(2)})`);
+ ok(handLocal.z > 0.25, `top hand is out in front (z=${handLocal.z.toFixed(2)})`);
+}
+
+section('the stick stays in the socket when the body turns');
+{
+ // Failure this pins: aiming with setFromUnitVectors in *world* space leaves a
+ // free twist around the shaft that does not cancel under parent yaw. The stick
+ // then rolls with every body turn instead of holding a fixed grip in the hand.
+ const r = rig();
+ // Settle derived quantities first so the spin only changes yaw.
+ drive(r, 2, { ...GLIDE, hasPuck: true, yawRate: 0 });
+ const local0 = new THREE.Quaternion();
+ const local = new THREE.Quaternion();
+ const handQ = new THREE.Quaternion();
+ const stickQ = new THREE.Quaternion();
+ let maxDelta = 0;
+
+ for (let i = 0; i < 48; i++) {
+ const yaw = (i / 48) * Math.PI * 2;
+ r.anim.setTransform(r.mover.position, yaw);
+ Object.assign(r.anim, { ...GLIDE, hasPuck: true, originYaw: yaw, yawRate: 0 });
+ r.anim.update(DT);
+ r.skelData.bones.handR.getWorldQuaternion(handQ);
+ r.stick.group.getWorldQuaternion(stickQ);
+ local.copy(handQ).invert().multiply(stickQ);
+ if (i === 0) local0.copy(local);
+ // 1 - |dot| is 0 for identical orientations (including double-cover).
+ maxDelta = Math.max(maxDelta, 1 - Math.abs(local0.dot(local)));
+ }
+ ok(maxDelta < 0.02, `stick local pose is stable across a full spin (delta ${maxDelta.toFixed(4)})`);
+}
+
+section('stick actions run and finish');
+{
+ for (const action of ['shoot', 'pass', 'poke']) {
+ const r = rig();
+ drive(r, 1, GLIDE);
+ r.anim.playAction(action, { power: 1 });
+ ok(r.anim.action === action, `${action} started`);
+ // Halfway through it must still be running.
+ for (let i = 0; i < 8; i++) {
+ Object.assign(r.anim, GLIDE);
+ r.anim.update(DT);
+ }
+ ok(r.anim.action === action, `${action} is still running mid-way`);
+ for (let i = 0; i < 60; i++) {
+ Object.assign(r.anim, GLIDE);
+ r.anim.update(DT);
+ }
+ ok(r.anim.action === null, `${action} finished and cleared`);
+ }
+}
+
+section('a wind-up lifts the blade off the ice and holds');
+{
+ const r = rig();
+ drive(r, 1, { ...GLIDE, hasPuck: true });
+ const flat = new THREE.Vector3();
+ r.stick.bladeWorld(flat);
+ const inv = new THREE.Matrix4().copy(r.mover.matrixWorld).invert();
+ const flatLocal = flat.clone().applyMatrix4(inv);
+
+ r.anim.action = 'windup';
+ r.anim.actionTime = 0;
+ for (let i = 0; i < 90; i++) {
+ Object.assign(r.anim, { ...GLIDE, hasPuck: true, charge: 1 });
+ r.anim.action = 'windup';
+ r.anim.update(DT);
+ }
+ const back = new THREE.Vector3();
+ r.stick.bladeWorld(back);
+ const backLocal = back.clone().applyMatrix4(inv);
+ // High and back behind the head — not hanging blade-down at hip height.
+ ok(backLocal.y > 1.1, `the blade is up high (y=${backLocal.y.toFixed(2)})`);
+ ok(backLocal.y > flatLocal.y + 0.8, `well above the carry (${flatLocal.y.toFixed(2)} → ${backLocal.y.toFixed(2)})`);
+ ok(backLocal.z < -0.15, `and back behind the body (z=${backLocal.z.toFixed(2)})`);
+ ok(r.anim.action === 'windup', 'and the wind-up is held, not played once');
+}
+
+section('Skill Stick right moves the blade to the skater\'s right');
+{
+ // Local +X is the skater's *left*. Skill Stick +X is pad-right. Getting the
+ // sign wrong mirrored every deke.
+ const right = drive(rig(), 3, { ...GLIDE, hasPuck: true, handling: { x: 1, y: 0 } });
+ const left = drive(rig(), 3, { ...GLIDE, hasPuck: true, handling: { x: -1, y: 0 } });
+ const inv = new THREE.Matrix4().copy(right.mover.matrixWorld).invert();
+ const br = new THREE.Vector3();
+ const bl = new THREE.Vector3();
+ right.stick.bladeWorld(br);
+ left.stick.bladeWorld(bl);
+ br.applyMatrix4(inv);
+ bl.applyMatrix4(new THREE.Matrix4().copy(left.mover.matrixWorld).invert());
+ // Skater's right is −X: stick-right must land more negative than stick-left.
+ ok(br.x < bl.x - 0.3, `stick-right is on the right (x ${br.x.toFixed(2)} vs ${bl.x.toFixed(2)})`);
+}
+
+section('hustling changes the grip');
+{
+ const settled = drive(rig(), 4, { ...GLIDE, effort: 0, moveSpeed: 1, hasPuck: true });
+ const flatOut = drive(rig(), 4, { ...STRIDE, hasPuck: false });
+ ok(settled.anim.hustleGrip < 0.35, `a settled skater keeps two hands on it (${settled.anim.hustleGrip.toFixed(2)})`);
+ ok(flatOut.anim.hustleGrip > 0.7, `a skater at full stride dangles it (${flatOut.anim.hustleGrip.toFixed(2)})`);
+
+ const a = new THREE.Vector3();
+ const b = new THREE.Vector3();
+ settled.stick.bladeWorld(a);
+ flatOut.stick.bladeWorld(b);
+ ok(b.z > a.z + 0.1, `and pushes the blade further out front (${a.z.toFixed(2)} → ${b.z.toFixed(2)})`);
+}
+
+done('pose');
diff --git a/test/puck.mjs b/test/puck.mjs
new file mode 100644
index 0000000..7be308b
--- /dev/null
+++ b/test/puck.mjs
@@ -0,0 +1,324 @@
+import * as THREE from 'three';
+import { createPhysicsWorld, initPhysics } from '../src/physics/world.js';
+import { PUCK, createPuck } from '../src/physics/puck.js';
+import { createMatch } from '../src/game/match.js';
+import { CARRY } from '../src/game/possession.js';
+import { RINK, insideRink } from '../shared/rink.js';
+import { done, near, ok, section } from './harness.mjs';
+
+/**
+ * Puck, stick and possession.
+ *
+ * The two things worth testing hard are the ones that are hard to see: that a
+ * 45 m/s shot does not tunnel through the boards (it moves ten times its own
+ * radius per step, so it will unless it is a bullet), and that possession
+ * behaves sanely at both ends of the magnetism dial — because that dial is a
+ * feel decision that has not been made yet, and the code has to survive
+ * wherever it lands.
+ */
+
+const DT = 1 / 60;
+await initPhysics();
+
+function arena(perTeam = 1) {
+ const physics = createPhysicsWorld();
+ const match = createMatch({ scene: new THREE.Group(), physics, perTeam, teams: 2 });
+ return { physics, match };
+}
+
+/** Park everyone far from the play so they cannot interfere. */
+function clearIce(match, keep = []) {
+ for (let i = 0; i < match.states.length; i++) {
+ if (keep.includes(i)) continue;
+ const x = -RINK.halfX * 0.8 + i * 3;
+ match.states[i].x = x;
+ match.states[i].z = -RINK.halfZ * 0.75;
+ match.states[i].vx = 0;
+ match.states[i].vz = 0;
+ match.skaters[i].proxy.teleport(x, -RINK.halfZ * 0.75);
+ match.setControl(i, { x: 0, y: 0, sprint: false, brake: false, cameraYaw: 0 });
+ }
+}
+
+section('the puck is a regulation puck');
+{
+ const physics = createPhysicsWorld();
+ const puck = createPuck(physics);
+ near(PUCK.radius * 2, 0.0762, 1e-4, 'three inches across');
+ near(PUCK.thickness, 0.0254, 1e-4, 'one inch thick');
+ near(puck.mass, 0.170, 0.005, `and 170 grams (got ${puck.mass.toFixed(3)} kg)`);
+ puck.destroy();
+ physics.destroy();
+}
+
+section('the puck settles flat on the ice and stays there');
+{
+ const physics = createPhysicsWorld();
+ const puck = createPuck(physics, { position: { x: 0, y: 1.5, z: 0 } });
+ for (let n = 0; n < 3 / DT; n++) physics.step(DT);
+ const p = puck.position();
+ ok(p.y > 0 && p.y < 0.05, `it lands on the surface (y=${p.y.toFixed(4)})`);
+ // Angular X and Z are locked, so it can never be standing on its edge.
+ const q = puck.rotation();
+ const up = new THREE.Vector3(0, 1, 0).applyQuaternion(q);
+ ok(up.y > 0.999, `and lies flat rather than rolling on its edge (up.y=${up.y.toFixed(4)})`);
+ puck.destroy();
+ physics.destroy();
+}
+
+section('a hard shot does not tunnel through the boards');
+{
+ // The headline risk. At 45 m/s the puck covers 0.37 m per 1/120 s step,
+ // roughly ten times its own radius, so without continuous collision it goes
+ // straight through the wall and is never seen again.
+ for (const speed of [20, 45, 55]) {
+ const physics = createPhysicsWorld();
+ const puck = createPuck(physics, { position: { x: 0, y: 0.02, z: 0 } });
+ puck.setVelocity(speed, 0, 0);
+ for (let n = 0; n < 4 / DT; n++) physics.step(DT);
+ const p = puck.position();
+ ok(
+ insideRink(p.x, p.z, 0),
+ `a ${speed} m/s shot stayed in the rink (ended at x=${p.x.toFixed(2)}, z=${p.z.toFixed(2)})`,
+ );
+ ok(Math.abs(p.y) < 1.5, `and did not go over the glass (y=${p.y.toFixed(2)})`);
+ puck.destroy();
+ physics.destroy();
+ }
+}
+
+section('a shot into the corner stays in the corner');
+{
+ // Corners are a chain of short board segments; the seams between them are
+ // where a fast small body escapes if anything is going to.
+ for (const angle of [0.6, 1.1, -0.7, 2.4]) {
+ const physics = createPhysicsWorld();
+ const puck = createPuck(physics, { position: { x: 0, y: 0.02, z: 0 } });
+ puck.setVelocity(Math.sin(angle) * 48, 0, Math.cos(angle) * 48);
+ for (let n = 0; n < 5 / DT; n++) physics.step(DT);
+ const p = puck.position();
+ ok(insideRink(p.x, p.z, 0), `a shot at ${angle.toFixed(1)} rad stayed inside`);
+ puck.destroy();
+ physics.destroy();
+ }
+}
+
+section('a dumped puck slides a long way but does stop');
+{
+ const physics = createPhysicsWorld();
+ const puck = createPuck(physics, { position: { x: -RINK.halfX * 0.9, y: 0.02, z: 0 } });
+ puck.setVelocity(14, 0, 0);
+ let travelled = 0;
+ const start = puck.position().x;
+ for (let n = 0; n < 6 / DT; n++) physics.step(DT);
+ travelled = Math.abs(puck.position().x - start);
+ ok(travelled > 15, `it carries down the ice (${travelled.toFixed(1)}m in 6s)`);
+ ok(puck.speed() < 14, `and does lose speed (${puck.speed().toFixed(1)} m/s left)`);
+ puck.destroy();
+ physics.destroy();
+}
+
+section('a skater picks up a loose puck');
+{
+ const { physics, match } = arena(1);
+ clearIce(match, [0]);
+ match.states[0].x = 0;
+ match.states[0].z = 0;
+ match.states[0].yaw = Math.PI / 2;
+ match.skaters[0].proxy.teleport(0, 0);
+ // Drop the puck right where skater 0's blade is.
+ match.puck.place(0.8, 0.02, 0.2);
+ for (let n = 0; n < 1 / DT; n++) match.update(DT);
+ ok(match.possession.carrier === 0, `skater 0 picked it up (carrier=${match.possession.carrier})`);
+ physics.destroy();
+}
+
+section('a carried puck stays with the skater');
+{
+ const { physics, match } = arena(1);
+ clearIce(match, [0]);
+ match.states[0].x = -20;
+ match.states[0].z = 0;
+ match.states[0].yaw = Math.PI / 2;
+ match.skaters[0].proxy.teleport(-20, 0);
+ match.puck.place(-20 + 0.8, 0.02, 0);
+ match.setControl(0, { x: 1, y: 0, sprint: true, brake: false, cameraYaw: 0 });
+ for (let n = 0; n < 0.5 / DT; n++) match.update(DT);
+ ok(match.possession.carrier === 0, 'possession established');
+
+ let maxGap = 0;
+ for (let n = 0; n < 2.5 / DT; n++) {
+ match.update(DT);
+ if (match.possession.carrier !== 0) break;
+ const p = match.puck.position();
+ maxGap = Math.max(maxGap, Math.hypot(p.x - match.states[0].x, p.z - match.states[0].z));
+ }
+ ok(match.possession.carrier === 0, 'and survived a full-speed rush');
+ ok(maxGap < CARRY.breakRadius + 1, `the puck stayed with the stick (worst ${maxGap.toFixed(2)}m)`);
+ ok(match.states[0].x > -12, `while actually covering ground (x=${match.states[0].x.toFixed(1)})`);
+ physics.destroy();
+}
+
+section('the dial does what it says at both ends');
+{
+ function carryWander(magnetism) {
+ const { physics, match } = arena(1);
+ clearIce(match, [0]);
+ match.states[0].x = -20;
+ match.states[0].z = 0;
+ match.states[0].yaw = Math.PI / 2;
+ match.skaters[0].proxy.teleport(-20, 0);
+ match.puck.place(-20 + 0.8, 0.02, 0);
+ match.possession.tuning.magnetism = magnetism;
+ match.setControl(0, { x: 1, y: 0, sprint: true, brake: false, cameraYaw: 0 });
+ for (let n = 0; n < 0.5 / DT; n++) match.update(DT);
+ const had = match.possession.carrier === 0;
+
+ let worst = 0;
+ let held = 0;
+ for (let n = 0; n < 2 / DT; n++) {
+ match.update(DT);
+ if (match.possession.carrier === 0) {
+ held++;
+ const p = match.puck.position();
+ const c = match.possession.carryPoint(new THREE.Vector3());
+ if (c) worst = Math.max(worst, p.distanceTo(c));
+ }
+ }
+ physics.destroy();
+ return { had, worst, held: held / (2 / DT) };
+ }
+
+ const glued = carryWander(1);
+ const loose = carryWander(0.15);
+ ok(glued.had && loose.had, 'both settings pick the puck up');
+ ok(
+ glued.worst < loose.worst,
+ `high magnetism keeps the puck tighter to the blade (${glued.worst.toFixed(3)}m vs ${loose.worst.toFixed(3)}m)`,
+ );
+ ok(glued.held > 0.9, `and holds possession through the rush (${(glued.held * 100).toFixed(0)}% of frames)`);
+}
+
+section('shooting sends the puck away and gives up possession');
+{
+ const { physics, match } = arena(1);
+ clearIce(match, [0]);
+ match.states[0].x = -10;
+ match.states[0].z = 0;
+ match.states[0].yaw = Math.PI / 2;
+ match.skaters[0].proxy.teleport(-10, 0);
+ match.puck.place(-10 + 0.8, 0.02, 0);
+ for (let n = 0; n < 0.5 / DT; n++) match.update(DT);
+ ok(match.possession.carrier === 0, 'carrying first');
+
+ const fired = match.possession.shoot(1, Math.PI / 2);
+ ok(fired, 'the shot fired');
+ ok(match.possession.carrier === null, 'and possession was given up');
+ ok(match.puck.speed() > 25, `the puck is moving like a shot (${match.puck.speed().toFixed(1)} m/s)`);
+
+ // And it must not be instantly re-captured by the shooter.
+ for (let n = 0; n < 0.1 / DT; n++) match.update(DT);
+ ok(match.possession.carrier !== 0, 'the shooter cannot immediately vacuum it back up');
+ physics.destroy();
+}
+
+section('a harder shot travels faster than a soft one');
+{
+ function fire(power) {
+ const { physics, match } = arena(1);
+ clearIce(match, [0]);
+ match.states[0].x = -10;
+ match.states[0].z = 0;
+ match.states[0].yaw = Math.PI / 2;
+ match.skaters[0].proxy.teleport(-10, 0);
+ match.puck.place(-10 + 0.8, 0.02, 0);
+ for (let n = 0; n < 0.5 / DT; n++) match.update(DT);
+ match.possession.shoot(power, Math.PI / 2);
+ const speed = match.puck.speed();
+ physics.destroy();
+ return speed;
+ }
+ const soft = fire(0.2);
+ const hard = fire(1);
+ ok(hard > soft * 2, `full power is far harder than a soft one (${hard.toFixed(1)} vs ${soft.toFixed(1)} m/s)`);
+ ok(hard < PUCK.maxSpeed, 'and stays under the ceiling');
+}
+
+section('a knockdown loses the puck');
+{
+ const { physics, match } = arena(1);
+ clearIce(match, [0]);
+ match.states[0].x = 0;
+ match.states[0].z = 0;
+ match.skaters[0].proxy.teleport(0, 0);
+ match.puck.place(0.8, 0.02, 0.2);
+ for (let n = 0; n < 1 / DT; n++) match.update(DT);
+ ok(match.possession.carrier === 0, 'carrying first');
+ match.skaters[0].goDown(null);
+ match.update(DT);
+ ok(match.possession.carrier === null, 'going down gives the puck up');
+ physics.destroy();
+}
+
+section('the Skill Stick moves the puck around the carrier');
+{
+ const { physics, match } = arena(1);
+ clearIce(match, [0]);
+ match.states[0].x = 0;
+ match.states[0].z = 0;
+ match.states[0].yaw = Math.PI / 2;
+ match.skaters[0].proxy.teleport(0, 0);
+ match.puck.place(0.8, 0.02, 0);
+ const control = { x: 0, y: 0, sprint: false, brake: false, cameraYaw: 0, skill: { x: 0, y: 0 }, pressed: {} };
+ match.setControl(0, control);
+ for (let n = 0; n < 1 / DT; n++) match.update(DT);
+ ok(match.possession.carrier === 0, 'carrying first');
+
+ control.skill.x = 1;
+ for (let n = 0; n < 0.6 / DT; n++) match.update(DT);
+ const right = match.puck.position().clone();
+ control.skill.x = -1;
+ for (let n = 0; n < 0.6 / DT; n++) match.update(DT);
+ const left = match.puck.position().clone();
+
+ // Skater faces +X (yaw = π/2). Mesh-right is local −X (handR side), which
+ // is world +Z at that yaw — not the sim's up×forward "right", which is
+ // mirrored from the skeleton. Stick-right must follow the mesh.
+ ok(
+ right.z > left.z + 0.25,
+ `stick-right moves the puck to the skater's right (${right.z.toFixed(2)} vs ${left.z.toFixed(2)})`,
+ );
+ physics.destroy();
+}
+
+section('a 3-on-3 with a puck stays sane and produces contact');
+{
+ // This is the payoff: give six skaters one thing to want and they converge,
+ // which is what finally exercises the hit system under normal play instead
+ // of only in staged collisions.
+ const { physics, match } = arena(3);
+ let hits = 0;
+ const seen = new Set();
+ for (let n = 0; n < 60 / DT; n++) {
+ match.update(DT);
+ for (const h of match.recentHits) {
+ const id = `${h.at}|${h.attacker}|${h.victim}`;
+ if (!seen.has(id)) {
+ seen.add(id);
+ hits++;
+ }
+ }
+ }
+ for (let i = 0; i < match.states.length; i++) {
+ const s = match.states[i];
+ ok(Number.isFinite(s.x) && Number.isFinite(s.z), `skater ${i} finite after 60s`);
+ ok(insideRink(s.x, s.z, 0.3), `skater ${i} still on the ice`);
+ }
+ const p = match.puck.position();
+ ok(Number.isFinite(p.x) && Number.isFinite(p.z), 'the puck is finite');
+ ok(insideRink(p.x, p.z, 0), `the puck is still on the ice (${p.x.toFixed(1)}, ${p.z.toFixed(1)})`);
+ ok(hits > 0, `chasing a puck produced contact without staging it (${hits} hits in 60s)`);
+ physics.destroy();
+}
+
+done('puck');
diff --git a/test/rink.mjs b/test/rink.mjs
new file mode 100644
index 0000000..84de54d
--- /dev/null
+++ b/test/rink.mjs
@@ -0,0 +1,77 @@
+import { RINK, clampToRink, insideRink, randomIcePoint, rinkOutline, rinkPenetration } from '../shared/rink.js';
+import { done, near, ok, section } from './harness.mjs';
+
+section('penetration on the straights');
+{
+ ok(insideRink(0, 0), 'centre ice is on the ice');
+ ok(!insideRink(RINK.halfX + 1, 0), 'past the end boards is not');
+ ok(!insideRink(0, RINK.halfZ + 1), 'past the side boards is not');
+
+ const p = rinkPenetration(0, RINK.halfZ + 0.5);
+ near(p.dist, 0.5, 1e-9, 'side board penetration');
+ near(p.nz, -1, 1e-9, 'side board normal points back to centre');
+}
+
+section('penetration in the corners');
+{
+ // The corner arc centre, pushed out along the diagonal by exactly the radius,
+ // has to land on the boards.
+ const cx = RINK.halfX - RINK.cornerR;
+ const cz = RINK.halfZ - RINK.cornerR;
+ const d = RINK.cornerR / Math.SQRT2;
+ const p = rinkPenetration(cx + d, cz + d);
+ near(p.dist, 0, 1e-9, 'diagonal from the corner centre lands on the boards');
+ near(Math.hypot(p.nx, p.nz), 1, 1e-9, 'corner normal is unit length');
+ ok(p.nx < 0 && p.nz < 0, 'corner normal points inward');
+
+ // A point in the corner quadrant but inside the arc is on the ice, even
+ // though it is outside neither straight wall — this is the case a plain
+ // rectangle test gets wrong.
+ ok(insideRink(cx + 1, cz + 1), 'inside the corner arc is on the ice');
+ ok(!insideRink(RINK.halfX - 0.5, RINK.halfZ - 0.5), 'the clipped corner is off the ice');
+}
+
+section('radius is respected');
+{
+ ok(!insideRink(0, RINK.halfZ - 0.2, 0.36), 'a body wider than its gap does not fit');
+ ok(insideRink(0, RINK.halfZ - 1, 0.36), 'the same body fits with room to spare');
+}
+
+section('clamping kills inward velocity');
+{
+ const s = { x: 0, z: RINK.halfZ + 0.2, vx: 1, vz: 3 };
+ const hit = clampToRink(s, 0.36, 0);
+ ok(hit, 'a body past the boards reports a hit');
+ ok(insideRink(s.x, s.z, 0.36), 'and is put back on the ice');
+ near(s.vz, 0, 1e-9, 'velocity into the boards is removed');
+ near(s.vx, 1, 1e-9, 'velocity along them is kept');
+
+ const bouncy = { x: 0, z: RINK.halfZ + 0.2, vx: 0, vz: 4 };
+ clampToRink(bouncy, 0.36, 0.5);
+ near(bouncy.vz, -2, 1e-9, 'restitution reverses half the closing speed');
+
+ const clear = { x: 0, z: 0, vx: 5, vz: 0 };
+ ok(!clampToRink(clear, 0.36), 'centre ice is not clamped');
+ near(clear.vx, 5, 1e-9, 'and keeps its speed');
+}
+
+section('outline follows the boards');
+{
+ const outline = rinkOutline(8);
+ ok(outline.length === 36, 'four arcs of nine points');
+ let maxOff = 0;
+ for (const p of outline) maxOff = Math.max(maxOff, Math.abs(rinkPenetration(p.x, p.z).dist));
+ near(maxOff, 0, 1e-9, 'every outline point sits exactly on the boards');
+}
+
+section('random points land on the ice');
+{
+ let n = 0;
+ const rand = () => ((n = (n * 1103515245 + 12345) % 2147483648) / 2147483648);
+ for (let i = 0; i < 500; i++) {
+ const p = randomIcePoint(rand, 3);
+ ok(insideRink(p.x, p.z, 3), `waypoint ${i} is 3m clear of the boards`);
+ }
+}
+
+done('rink');
diff --git a/test/shootout.mjs b/test/shootout.mjs
new file mode 100644
index 0000000..2e8f5c7
--- /dev/null
+++ b/test/shootout.mjs
@@ -0,0 +1,237 @@
+import * as THREE from 'three';
+import { createPhysicsWorld, initPhysics } from '../src/physics/world.js';
+import { createMatch } from '../src/game/match.js';
+import { createShootout } from '../src/game/shootout.js';
+import { NET, goalLineX, goalieSpot, isGoal } from '../shared/net.js';
+import { PUCK } from '../src/physics/puck.js';
+import { done, near, ok, section } from './harness.mjs';
+
+/**
+ * The shootout: net, goalie, and the loop that turns them into a result.
+ *
+ * The thing worth testing hard is that it *terminates*. A shootout that can
+ * hang — a puck asleep in a corner, a goalie who never lets go of it, an
+ * attempt with no way to end — is worse than one that scores wrongly, because
+ * nothing tells you it has happened.
+ */
+
+const DT = 1 / 60;
+await initPhysics();
+
+function arena() {
+ const physics = createPhysicsWorld();
+ const match = createMatch({ scene: new THREE.Group(), physics, perTeam: 3, teams: 2 });
+ const shootout = createShootout({ scene: new THREE.Group(), physics, match });
+ match.addSubstepSync((dt) => {
+ shootout.goalies[1].syncPhysics(dt);
+ shootout.goalies[-1].syncPhysics(dt);
+ });
+ shootout.reset();
+ return { physics, match, shootout };
+}
+
+function run(w, seconds) {
+ for (let n = 0; n < seconds / DT; n++) {
+ w.match.update(DT);
+ w.shootout.update(DT);
+ }
+}
+
+section('goal detection follows the rule');
+{
+ const end = 1;
+ const line = goalLineX(end);
+ const r = PUCK.radius;
+
+ ok(!isGoal({ x: line, y: 0.02, z: 0 }, end, r), 'a puck on the line is not a goal');
+ ok(!isGoal({ x: line + r * 0.5, y: 0.02, z: 0 }, end, r), 'nor one only half across');
+ ok(isGoal({ x: line + r * 2, y: 0.02, z: 0 }, end, r), 'fully across and between the posts is');
+ ok(!isGoal({ x: line + r * 2, y: 0.02, z: NET.width }, end, r), 'wide of the post is not');
+ ok(!isGoal({ x: line + r * 2, y: NET.height + 0.2, z: 0 }, end, r), 'over the bar is not');
+ ok(!isGoal({ x: line + NET.depth + 0.5, y: 0.02, z: 0 }, end, r), 'behind the net is not');
+ // And the same at the other end, where every sign flips.
+ const l2 = goalLineX(-1);
+ ok(isGoal({ x: l2 - r * 2, y: 0.02, z: 0 }, -1, r), 'the far end scores too');
+ ok(!isGoal({ x: l2 + r * 2, y: 0.02, z: 0 }, -1, r), 'and not from in front of it');
+}
+
+section('the goalie plays the angle');
+{
+ const end = 1;
+ const line = goalLineX(end);
+ const spot = { x: 0, z: 0 };
+
+ goalieSpot({ x: 0, z: 0 }, end, 0.6, spot);
+ near(spot.z, 0, 1e-9, 'a puck dead centre puts them dead centre');
+ ok(spot.x < line && spot.x > line - 1, `and out in front of the line (${spot.x.toFixed(2)})`);
+
+ // Puck to one side: the goalie shifts the same way, but less.
+ goalieSpot({ x: line - 8, z: 4 }, end, 0.6, spot);
+ ok(spot.z > 0, 'a puck to the left moves them left');
+ ok(spot.z < 4, 'but they do not chase it out there');
+ ok(Math.abs(spot.z) <= NET.width / 2 + 0.25, `and never past the post (${spot.z.toFixed(2)})`);
+
+ // Extreme angle: still covering the post, never abandoning the net.
+ goalieSpot({ x: line, z: 12 }, end, 0.6, spot);
+ ok(Math.abs(spot.z) <= NET.width / 2 + 0.25, 'even from the goal line corner');
+}
+
+section('a shootout sets itself up');
+{
+ const w = arena();
+ const so = w.shootout.state;
+ ok(so.phase === 'ready', 'it starts in the ready phase');
+ ok(so.score[0] === 0 && so.score[1] === 0, 'nil-nil');
+ ok(w.match.possession.carrier === null, 'nobody starts holding the puck');
+ const s = w.match.states[so.shooter];
+ const puck = w.match.puck.position();
+ near(Math.hypot(puck.x, puck.z), 0, 0.3, 'the puck is on the dot at centre ice');
+ ok(Math.hypot(s.x - puck.x, s.z - puck.z) > 3, `and the shooter starts back from it (${Math.hypot(s.x - puck.x, s.z - puck.z).toFixed(1)}m)`);
+ w.physics.destroy();
+}
+
+section('the shooter skates onto the puck rather than spawning on it');
+{
+ const w = arena();
+ ok(w.match.possession.carrier === null, 'loose at the start');
+ // Give them time to get released and reach it.
+ let gained = false;
+ for (let n = 0; n < 6 / DT; n++) {
+ w.match.update(DT);
+ w.shootout.update(DT);
+ if (w.match.possession.carrier === w.shootout.state.shooter) {
+ gained = true;
+ break;
+ }
+ }
+ ok(gained, 'the shooter picked the puck up on the way through');
+ w.physics.destroy();
+}
+
+section('losing the handle does not end the attempt');
+{
+ const w = arena();
+ // Run to live, then knock the puck away from whoever has it.
+ for (let n = 0; n < 5 / DT; n++) {
+ w.match.update(DT);
+ w.shootout.update(DT);
+ if (w.shootout.state.phase === 'live' && w.match.possession.carrier !== null) break;
+ }
+ ok(w.shootout.state.phase === 'live', 'the attempt is live');
+ const before = w.shootout.state.attempts.slice();
+ w.match.possession.release('test', 0.2);
+ w.match.puck.setVelocity(0, 0, 0);
+ // Sit on a dead loose puck for well over the old two-second dead timeout.
+ for (let n = 0; n < 3 / DT; n++) {
+ w.match.update(DT);
+ w.shootout.update(DT);
+ }
+ ok(
+ w.shootout.state.attempts[0] === before[0] && w.shootout.state.attempts[1] === before[1],
+ 'a dead loose puck did not end the attempt',
+ );
+ w.physics.destroy();
+}
+
+section('the AI takes attempts and they all resolve');
+{
+ const w = arena();
+ const results = [];
+ let lastRound = null;
+ for (let n = 0; n < 120 / DT; n++) {
+ w.match.update(DT);
+ w.shootout.update(DT);
+ const last = w.shootout.state.last;
+ if (last && last !== lastRound) {
+ results.push(last);
+ lastRound = last;
+ }
+ }
+ ok(results.length >= 4, `several attempts completed in two minutes (${results.length})`);
+ for (const r of results) {
+ ok(r.result === 'goal' || r.result === 'save', `every attempt resolved (${r.result} ${r.detail})`);
+ }
+ const so = w.shootout.state;
+ ok(so.attempts[0] > 0 && so.attempts[1] > 0, 'both teams got to shoot');
+ ok(Math.abs(so.attempts[0] - so.attempts[1]) <= 1, 'and the sides alternate');
+ w.physics.destroy();
+}
+
+section('bots actually shoot');
+{
+ // This is the gap that made the whole shooting layer human-only: a bot with
+ // the puck used to carry it forever.
+ const w = arena();
+ let shots = 0;
+ const seen = new Set();
+ for (let n = 0; n < 90 / DT; n++) {
+ w.match.update(DT);
+ w.shootout.update(DT);
+ for (const p of w.match.recentPlays) {
+ const id = `${p.at}|${p.type}|${p.skater}`;
+ if (!seen.has(id)) {
+ seen.add(id);
+ if (p.type === 'shot') shots++;
+ }
+ }
+ }
+ ok(shots > 0, `bots put shots on net without a human driving (${shots})`);
+ w.physics.destroy();
+}
+
+section('the goalie makes saves and the shooter sometimes scores');
+{
+ // Over enough attempts both outcomes have to be reachable, or the goalie is
+ // either a wall or a turnstile and neither is a game.
+ const w = arena();
+ let goals = 0;
+ let saves = 0;
+ let lastRound = null;
+ for (let n = 0; n < 240 / DT; n++) {
+ w.match.update(DT);
+ w.shootout.update(DT);
+ const last = w.shootout.state.last;
+ if (last && last !== lastRound) {
+ lastRound = last;
+ if (last.result === 'goal') goals++;
+ else saves++;
+ }
+ }
+ ok(goals + saves >= 8, `plenty of attempts to judge on (${goals + saves})`);
+ ok(saves > 0, `the goalie stops some (${saves} saves)`);
+ // Not asserted the other way round: a goalie who is currently unbeatable is
+ // a tuning problem, and the number is reported so it can be tuned.
+ console.log(` ${goals} goals / ${saves} saves`);
+ w.physics.destroy();
+}
+
+section('nothing escapes and nothing hangs');
+{
+ const w = arena();
+ run(w, 120);
+ const p = w.match.puck.position();
+ ok(Number.isFinite(p.x) && Number.isFinite(p.z), 'the puck is finite');
+ ok(Math.abs(p.x) < 40 && Math.abs(p.z) < 20, `and still in the building (${p.x.toFixed(1)}, ${p.z.toFixed(1)})`);
+ for (let i = 0; i < w.match.states.length; i++) {
+ ok(Number.isFinite(w.match.states[i].x), `skater ${i} is finite`);
+ }
+ ok(w.shootout.state.round > 1, `rounds advanced (round ${w.shootout.state.round})`);
+ w.physics.destroy();
+}
+
+section('the goalie is not knocked around by the puck');
+{
+ const w = arena();
+ run(w, 3);
+ const g = w.shootout.goalies[1];
+ const before = { x: g.pos.x, z: g.pos.z };
+ // Fire a puck straight into them at full pace.
+ w.match.puck.place(goalLineX(1) - 4, 0.3, 0);
+ w.match.puck.setVelocity(45, 0, 0);
+ run(w, 0.6);
+ // They may have shuffled to track it, but not been shoved into the net.
+ ok(Math.abs(g.pos.x - before.x) < 1.2, `the goalie held their ground (${(g.pos.x - before.x).toFixed(2)}m)`);
+ w.physics.destroy();
+}
+
+done('shootout');
diff --git a/test/skaterSim.mjs b/test/skaterSim.mjs
new file mode 100644
index 0000000..3bc923e
--- /dev/null
+++ b/test/skaterSim.mjs
@@ -0,0 +1,246 @@
+import { SKATE, applyIntent, createSkaterState, speedOf, stepSkater } from '../shared/skaterSim.js';
+import { RINK, insideRink } from '../shared/rink.js';
+import { done, near, ok, section } from './harness.mjs';
+
+const DT = 1 / 120;
+
+/**
+ * Run the sim for `seconds`, optionally editing the state each step.
+ *
+ * Board clamping is off by default so a test about acceleration is not
+ * secretly a test about the end boards. The containment section turns it back
+ * on, which is the only place it is the subject.
+ */
+function run(s, seconds, edit = null, opts = { clampBoards: false }) {
+ const steps = Math.round(seconds / DT);
+ for (let i = 0; i < steps; i++) {
+ if (edit) edit(s, i * DT);
+ stepSkater(s, DT, opts);
+ }
+ return s;
+}
+
+/** Down the ice: the rink's long axis is +X, which is yaw = PI/2. */
+const START = { x: 0, z: 0, yaw: Math.PI / 2 };
+const forward = (s) => {
+ s.ix = 1;
+ s.iz = 0;
+};
+const coast = (s) => {
+ s.ix = 0;
+ s.iz = 0;
+};
+
+section('the stride reaches a speed and holds it');
+{
+ const s = createSkaterState(0, START);
+ run(s, 8, forward);
+ const cruise = speedOf(s);
+ ok(cruise > 4.5, `cruise settles above 4.5 m/s (got ${cruise.toFixed(2)})`);
+ ok(cruise <= SKATE.cruiseSpeed, `and never exceeds the cruise ceiling (${cruise.toFixed(2)})`);
+
+ // Another four seconds must not keep adding speed.
+ const before = speedOf(s);
+ run(s, 4, forward);
+ near(speedOf(s), before, 0.05, 'top speed is stable, not creeping');
+}
+
+section('acceleration takes time — you cannot jump to top speed');
+{
+ const s = createSkaterState(0, START);
+ run(s, 0.5, forward);
+ const half = speedOf(s);
+ ok(half > 0.8, `half a second of pushing gets you moving (${half.toFixed(2)} m/s)`);
+ ok(half < 4, 'but nowhere near cruise');
+}
+
+section('sprinting is meaningfully faster');
+{
+ const cruiser = createSkaterState(0, START);
+ run(cruiser, 8, forward);
+ const sprinter = createSkaterState(1, START);
+ run(sprinter, 8, (s) => {
+ forward(s);
+ s.sprint = true;
+ });
+ ok(
+ speedOf(sprinter) > speedOf(cruiser) + 1.5,
+ `sprint beats cruise by more than 1.5 m/s (${speedOf(sprinter).toFixed(2)} vs ${speedOf(cruiser).toFixed(2)})`,
+ );
+ ok(speedOf(sprinter) <= SKATE.sprintSpeed, 'and stays under the sprint ceiling');
+}
+
+section('a glide keeps its momentum');
+{
+ const s = createSkaterState(0, START);
+ run(s, 8, forward);
+ const entry = speedOf(s);
+ run(s, 3, coast);
+ const after = speedOf(s);
+ ok(after > entry * 0.6, `three seconds of glide keeps most of the speed (${after.toFixed(2)} of ${entry.toFixed(2)})`);
+ ok(after < entry, 'but not all of it');
+}
+
+section('braking is much faster than gliding');
+{
+ const glide = createSkaterState(0, START);
+ run(glide, 8, forward);
+ const brake = createSkaterState(1, START);
+ run(brake, 8, forward);
+ near(speedOf(glide), speedOf(brake), 0.01, 'both start from the same speed');
+
+ run(glide, 1, coast);
+ run(brake, 1, (s) => {
+ coast(s);
+ s.brake = true;
+ });
+ ok(speedOf(brake) < 0.6, `a hockey stop is done inside a second (${speedOf(brake).toFixed(2)} m/s left)`);
+ ok(speedOf(glide) > speedOf(brake) * 4, 'a glide over the same second is nowhere near stopped');
+}
+
+section('the blade kills sideways drift');
+{
+ const s = createSkaterState(0, START);
+ // Thrown across the blade at 4 m/s: body pointing +X, momentum along +Z.
+ s.vx = 0;
+ s.vz = 4;
+ run(s, 1.5, coast);
+ const velYaw = Math.atan2(s.vx, s.vz);
+ const offBlade = Math.abs(Math.abs(velYaw) - Math.PI / 2);
+ ok(offBlade < 0.25, `momentum ends up along the blade, not across it (${offBlade.toFixed(3)} rad off)`);
+}
+
+section('a carve redirects momentum instead of destroying it');
+{
+ const s = createSkaterState(0, START);
+ run(s, 6, forward);
+ const entry = speedOf(s);
+ ok(Math.abs(Math.atan2(s.vx, s.vz) - Math.PI / 2) < 0.05, 'travelling straight down the ice first');
+
+ // Ninety degrees of turn: the stick swings from +X to -Z.
+ run(s, 1.2, (st) => {
+ st.ix = 0;
+ st.iz = -1;
+ });
+
+ const velYaw = Math.atan2(s.vx, s.vz);
+ ok(velYaw > 2.2, `the velocity vector followed the turn round (${velYaw.toFixed(2)} rad, want ~PI)`);
+ ok(speedOf(s) > entry * 0.4, `and kept real speed through it (${speedOf(s).toFixed(2)} of ${entry.toFixed(2)})`);
+ ok(speedOf(s) < entry, 'a hard carve is not free');
+}
+
+section('momentum resists an instant reversal');
+{
+ const s = createSkaterState(0, START);
+ run(s, 6, forward);
+ const entryX = s.vx;
+ ok(entryX > 3, 'moving down the ice to begin with');
+
+ // A tenth of a second of "go back the other way" must not flip the velocity.
+ run(s, 0.1, (st) => {
+ st.ix = -1;
+ st.iz = 0;
+ });
+ ok(s.vx > 0, 'still travelling the original way a tenth of a second later');
+ ok(s.vx < entryX, 'but already losing speed to the edges');
+}
+
+section('a turn on the spot costs nothing');
+{
+ const s = createSkaterState(0, { x: 0, z: 0, yaw: 0 });
+ run(s, 0.6, (st) => {
+ st.ix = 1;
+ st.iz = 0;
+ });
+ ok(Math.abs(s.yaw - Math.PI / 2) < 0.35, `a standing skater can pivot (yaw ${s.yaw.toFixed(2)})`);
+}
+
+section('turning is harder at speed than at rest');
+{
+ const slow = createSkaterState(0, { x: 0, z: 0, yaw: 0 });
+ run(slow, 0.3, (st) => {
+ st.ix = 1;
+ st.iz = 0;
+ });
+ const fast = createSkaterState(1, { x: 0, z: 0, yaw: 0 });
+ run(fast, 6, (st) => {
+ st.ix = 0;
+ st.iz = 1;
+ st.sprint = true;
+ });
+ const before = fast.yaw;
+ run(fast, 0.3, (st) => {
+ st.ix = 1;
+ st.iz = 0;
+ st.sprint = true;
+ });
+ ok(
+ Math.abs(fast.yaw - before) < Math.abs(slow.yaw),
+ `a flying skater turns slower than a standing one (${(fast.yaw - before).toFixed(3)} vs ${slow.yaw.toFixed(3)} rad)`,
+ );
+}
+
+section('nothing leaves the rink');
+{
+ // Point skaters at the boards from centre ice and hold it for ten seconds.
+ for (let i = 0; i < 16; i++) {
+ const a = (i / 16) * Math.PI * 2;
+ const s = createSkaterState(i, { x: 0, z: 0, yaw: a });
+ run(s, 10, (st) => {
+ st.ix = Math.sin(a);
+ st.iz = Math.cos(a);
+ st.sprint = true;
+ }, { clampBoards: true });
+ ok(insideRink(s.x, s.z, SKATE.radius), `skater driving at heading ${a.toFixed(2)} stayed on the ice`);
+ ok(Number.isFinite(s.x) && Number.isFinite(s.z), 'and its position stayed finite');
+ }
+}
+
+section('the sim is deterministic');
+{
+ const drive = (st, t) => {
+ st.ix = Math.sin(t * 1.3);
+ st.iz = Math.cos(t * 0.7);
+ st.sprint = t > 3;
+ };
+ const a = createSkaterState(0, { x: 4, z: -6, yaw: 1 });
+ const b = createSkaterState(0, { x: 4, z: -6, yaw: 1 });
+ run(a, 12, drive, { clampBoards: true });
+ run(b, 12, drive, { clampBoards: true });
+ near(a.x, b.x, 0, 'same inputs, same x');
+ near(a.z, b.z, 0, 'same inputs, same z');
+ near(a.yaw, b.yaw, 0, 'same inputs, same yaw');
+}
+
+section('intent from a controller is clamped before the sim sees it');
+{
+ // This is the seam a gamepad or a network message will come in through, so
+ // it has to survive garbage without the sim ever seeing it.
+ const s = createSkaterState(0, START);
+
+ applyIntent(s, { ix: 1, iz: 1 });
+ near(Math.hypot(s.ix, s.iz), 1, 1e-9, 'a diagonal stick is normalised, not sqrt(2) fast');
+
+ applyIntent(s, { ix: 0.3, iz: -0.4 });
+ near(s.ix, 0.3, 1e-9, 'a stick inside the deadzone circle is left alone (x)');
+ near(s.iz, -0.4, 1e-9, 'a stick inside the deadzone circle is left alone (z)');
+
+ applyIntent(s, { ix: 40, iz: -40 });
+ ok(Math.hypot(s.ix, s.iz) <= 1 + 1e-9, 'an out-of-range stick is clamped');
+
+ applyIntent(s, { ix: NaN, iz: undefined, sprint: 'yes', brake: 0 });
+ ok(s.ix === 0 && s.iz === 0, 'NaN and undefined become a centred stick');
+ ok(s.sprint === true && s.brake === false, 'and the flags come through as booleans');
+
+ // The clamped state must still step without producing garbage.
+ stepSkater(s, DT);
+ ok(Number.isFinite(s.x) && Number.isFinite(s.vx), 'and the sim steps cleanly afterwards');
+}
+
+section('rink dimensions are the ones we think they are');
+{
+ near(RINK.halfX * 2, 60.96, 0.01, 'the rink is 200 feet long');
+ near(RINK.halfZ * 2, 25.9, 0.02, 'and 85 feet wide');
+}
+
+done('skaterSim');
diff --git a/tools/capture.mjs b/tools/capture.mjs
new file mode 100644
index 0000000..904c1fb
--- /dev/null
+++ b/tools/capture.mjs
@@ -0,0 +1,172 @@
+import { spawn } from 'node:child_process';
+import fs from 'node:fs';
+import path from 'node:path';
+import process from 'node:process';
+import puppeteer from 'puppeteer-core';
+
+/**
+ * Boot the app in a headless browser, let it skate for a while, and report
+ * back what happened: console errors, frame rate, and where everyone ended up.
+ *
+ * The point is not the screenshots — it is that a spike whose whole success
+ * criterion is "does this look and run right" needs an answer that does not
+ * depend on someone having the tab open.
+ *
+ * node tools/capture.mjs [seconds]
+ */
+
+const ROOT = path.resolve(import.meta.dirname, '..');
+const OUT = path.join(ROOT, 'shots');
+const PORT = 4181;
+const URL = process.env.TILT_URL ?? `http://127.0.0.1:${PORT}/`;
+const SECONDS = Number(process.argv[2] ?? 12);
+
+function findChrome() {
+ const cache = path.join(process.env.HOME, '.cache/puppeteer/chrome');
+ if (fs.existsSync(cache)) {
+ const builds = fs.readdirSync(cache).sort().reverse();
+ for (const b of builds) {
+ const exe = path.join(cache, b, 'chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing');
+ if (fs.existsSync(exe)) return exe;
+ }
+ }
+ const system = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
+ if (fs.existsSync(system)) return system;
+ throw new Error('no Chrome found — set CHROME_PATH');
+}
+
+async function waitForServer(url, timeoutMs = 30000) {
+ const deadline = Date.now() + timeoutMs;
+ for (;;) {
+ try {
+ const res = await fetch(url);
+ if (res.ok) return;
+ } catch {
+ // Vite is still starting.
+ }
+ if (Date.now() > deadline) throw new Error('vite did not come up at ' + url);
+ await new Promise((r) => setTimeout(r, 250));
+ }
+}
+
+let server = null;
+let browser = null;
+
+try {
+ fs.mkdirSync(OUT, { recursive: true });
+
+ if (!process.env.TILT_URL) {
+ server = spawn(
+ path.join(ROOT, 'node_modules/.bin/vite'),
+ ['--host', '127.0.0.1', '--port', String(PORT), '--strictPort'],
+ { cwd: ROOT, stdio: ['ignore', 'pipe', 'pipe'] },
+ );
+ server.stderr.on('data', (d) => process.stderr.write('[vite] ' + d));
+ }
+ await waitForServer(URL);
+
+ browser = await puppeteer.launch({
+ executablePath: process.env.CHROME_PATH ?? findChrome(),
+ headless: true,
+ args: ['--enable-unsafe-swiftshader', '--use-gl=angle', '--use-angle=swiftshader', '--no-sandbox'],
+ });
+
+ const page = await browser.newPage();
+ // deviceScaleFactor 2, not 1: the target is a retina Mac, and running this at
+ // 1 hid a canvas-sizing bug that made the element twice the window on the
+ // machine anyone actually looks at it on.
+ await page.setViewport({ width: 1280, height: 720, deviceScaleFactor: 2 });
+
+ const errors = [];
+ page.on('console', (msg) => {
+ if (msg.type() === 'error') errors.push(msg.text());
+ });
+ page.on('pageerror', (err) => errors.push(String(err?.stack ?? err)));
+
+ await page.goto(URL, { waitUntil: 'domcontentloaded' });
+
+ // The boot overlay is removed once physics is up and the first frame ran.
+ await page.waitForFunction(() => !document.getElementById('boot'), { timeout: 45000 });
+
+ // The canvas must fill the window exactly, at whatever pixel ratio. Checked
+ // at two window sizes so a resize path that only works on first load fails
+ // here rather than in someone's browser.
+ for (const [w, h] of [[1280, 720], [900, 1000]]) {
+ await page.setViewport({ width: w, height: h, deviceScaleFactor: 2 });
+ await new Promise((r) => setTimeout(r, 300));
+ const fit = await page.evaluate(() => {
+ const c = document.getElementById('stage');
+ const r = c.getBoundingClientRect();
+ return {
+ css: [Math.round(r.width), Math.round(r.height)],
+ win: [window.innerWidth, window.innerHeight],
+ buffer: [c.width, c.height],
+ dpr: window.devicePixelRatio,
+ };
+ });
+ const fits = fit.css[0] === fit.win[0] && fit.css[1] === fit.win[1];
+ console.log(`viewport ${w}x${h} @${fit.dpr}x: canvas ${fit.css.join('x')} css, `
+ + `${fit.buffer.join('x')} buffer — ${fits ? 'fills the window' : 'DOES NOT FIT'}`);
+ if (!fits) {
+ errors.push(`canvas ${fit.css.join('x')} does not fill window ${fit.win.join('x')}`);
+ }
+ }
+ await page.setViewport({ width: 1280, height: 720, deviceScaleFactor: 2 });
+ await new Promise((r) => setTimeout(r, 300));
+
+ // The starting lineup, before anyone has moved: both teams in their own half.
+ await page.evaluate(() => {
+ window.tilt.match.reset();
+ Object.assign(window.tilt.cam.state, { mode: 'broadcast', distance: 46, pitch: 0.85 });
+ });
+ await new Promise((r) => setTimeout(r, 400));
+ await page.screenshot({ path: path.join(OUT, 'lineup.png') });
+ await page.evaluate(() => {
+ window.tilt.match.reset();
+ Object.assign(window.tilt.cam.state, { distance: 34, pitch: 0.62 });
+ });
+
+ // Let them skate. Software rasterisation is slow, so this is wall-clock time
+ // rather than a frame count — the sim is dt-driven and does not care.
+ await new Promise((r) => setTimeout(r, SECONDS * 1000));
+
+ const hud = await page.$eval('#hud', (el) => el.textContent);
+ await page.screenshot({ path: path.join(OUT, 'broadcast.png') });
+
+ /** Frame a shot through the debug handle and wait for the camera to settle. */
+ async function shot(name, camState, settleMs = 2500) {
+ await page.evaluate((s) => Object.assign(window.tilt.cam.state, s), camState);
+ await new Promise((r) => setTimeout(r, settleMs));
+ await page.screenshot({ path: path.join(OUT, name + '.png') });
+ }
+
+ // Follow-cam: the only view that shows whether the stride and the direction
+ // of travel actually agree.
+ await shot('follow', { mode: 'follow', followIndex: 0, distance: 9, pitch: 0.28 });
+ // Close enough to judge the stance, the arm carry and the blade angle.
+ await shot('closeup', { mode: 'follow', followIndex: 0, distance: 3.6, pitch: 0.16 });
+ // From the side, where a lean into a turn actually reads.
+ await shot('side', { mode: 'follow', followIndex: 1, distance: 5.5, pitch: 0.1 });
+
+ // Fastest skater's numbers, so the shot can be read against real motion.
+ const detail = await page.evaluate(() => window.tilt.match.states.map((s) => ({
+ speed: +Math.hypot(s.vx, s.vz).toFixed(2),
+ effort: +s.effort.toFixed(2),
+ gait: +window.tilt.match.skaters[s.id].animator.gait.toFixed(2),
+ bank: +window.tilt.match.skaters[s.id].animator.bank.toFixed(2),
+ state: window.tilt.match.skaters[s.id].animator.state,
+ })));
+
+ console.log('HUD: ' + hud.replace(/\n/g, ' | '));
+ console.log('skaters: ' + JSON.stringify(detail));
+ console.log(`shots → ${path.relative(ROOT, OUT)}/`);
+ if (errors.length) {
+ console.error('\nbrowser errors:\n' + errors.join('\n'));
+ process.exitCode = 1;
+ } else {
+ console.log('no console errors');
+ }
+} finally {
+ await browser?.close();
+ server?.kill('SIGTERM');
+}
diff --git a/tools/hitprobe.mjs b/tools/hitprobe.mjs
new file mode 100644
index 0000000..5ba1fec
--- /dev/null
+++ b/tools/hitprobe.mjs
@@ -0,0 +1,78 @@
+import * as THREE from 'three';
+import { createPhysicsWorld, initPhysics } from '../src/physics/world.js';
+import { createMatch } from '../src/game/match.js';
+import { describeHit } from '../src/game/hits.js';
+
+/**
+ * Fire skaters at each other from various run-ups and angles and print what
+ * comes out. A tuning aid, not a test: the numbers below are the ones you stare
+ * at when deciding what should count as a bump, a stagger and a knockdown.
+ *
+ * node tools/hitprobe.mjs
+ */
+
+const DT = 1 / 60;
+await initPhysics();
+
+/**
+ * @param {'stationary'|'full'} mode is the victim skating into it too
+ * @param {number} gap metres between them at the start
+ * @param {number} offsetZ lateral offset — 0 is dead centre
+ */
+function probe(mode, gap, offsetZ = 0) {
+ const physics = createPhysicsWorld();
+ const match = createMatch({ scene: new THREE.Group(), physics, perTeam: 1, teams: 2 });
+ const [a, b] = match.states;
+ a.x = -gap / 2; a.z = 0; a.yaw = Math.PI / 2;
+ b.x = gap / 2; b.z = offsetZ; b.yaw = mode === 'full' ? -Math.PI / 2 : Math.PI / 2;
+ match.skaters[0].proxy.teleport(a.x, a.z);
+ match.skaters[1].proxy.teleport(b.x, b.z);
+ match.setControl(0, { x: 1, y: 0, sprint: true, brake: false, cameraYaw: 0 });
+ match.setControl(1, mode === 'full'
+ ? { x: -1, y: 0, sprint: true, brake: false, cameraYaw: 0 }
+ : { x: 0, y: 0, sprint: false, brake: false, cameraYaw: 0 });
+
+ const seen = new Set();
+ const out = [];
+ for (let n = 0; n < 6 / DT; n++) {
+ match.update(DT);
+ for (const h of match.recentHits) {
+ const id = `${h.at}|${h.attacker}`;
+ if (!seen.has(id)) {
+ seen.add(id);
+ out.push(h);
+ }
+ }
+ }
+ physics.destroy();
+ return out;
+}
+
+const rows = [
+ ['stationary', 2.5, 0],
+ ['stationary', 5, 0],
+ ['stationary', 10, 0],
+ ['stationary', 22, 0],
+ ['stationary', 22, 0.45],
+ ['stationary', 22, -0.45],
+ ['full', 10, 0],
+ ['full', 24, 0],
+ ['full', 24, 0.5],
+];
+
+console.log('mode gap offZ | outcome m/s sev limbs description');
+console.log('-'.repeat(96));
+for (const [mode, gap, off] of rows) {
+ const hits = probe(mode, gap, off);
+ if (!hits.length) {
+ console.log(`${mode.padEnd(11)} ${String(gap).padStart(4)} ${String(off).padStart(5)} | (no hit)`);
+ continue;
+ }
+ for (const h of hits) {
+ console.log(
+ `${mode.padEnd(11)} ${String(gap).padStart(4)} ${String(off).padStart(5)} | `
+ + `${h.outcome.padEnd(10)} ${h.speed.toFixed(1).padStart(4)} ${h.severity.toFixed(1).padStart(5)} `
+ + `${(h.attackerPart + '→' + h.victimPart).padEnd(24)} ${describeHit(h)}`,
+ );
+ }
+}
diff --git a/tools/img2mesh.mjs b/tools/img2mesh.mjs
new file mode 100644
index 0000000..5028abc
--- /dev/null
+++ b/tools/img2mesh.mjs
@@ -0,0 +1,234 @@
+import { spawn } from 'node:child_process';
+import fs from 'node:fs';
+import path from 'node:path';
+import process from 'node:process';
+import puppeteer from 'puppeteer-core';
+
+/**
+ * img2mesh harness — capture a shot sheet of the player and goalie for
+ * equipment / animation iteration.
+ *
+ * npm run img2mesh
+ * npm run img2mesh -- --subject goalie --poses ready,butterfly --views front,side
+ * npm run img2mesh -- --list
+ *
+ * Writes PNGs + manifest.json under shots/img2mesh/. Pair each PNG with a
+ * reference (drop into shots/img2mesh/ref/) and re-run after code changes.
+ */
+
+const ROOT = path.resolve(import.meta.dirname, '..');
+const OUT = path.join(ROOT, 'shots', 'img2mesh');
+const PORT = 4182;
+const BASE = process.env.TILT_URL ?? `http://127.0.0.1:${PORT}/`;
+const STUDIO = new URL('character.html', BASE).href;
+
+function parseArgs(argv) {
+ const out = {
+ subjects: ['player', 'goalie'],
+ poses: null,
+ views: null,
+ list: false,
+ settle: 50,
+ };
+ for (let i = 0; i < argv.length; i++) {
+ const a = argv[i];
+ if (a === '--list') out.list = true;
+ else if (a === '--subject' || a === '--subjects') {
+ out.subjects = argv[++i].split(',').map((s) => s.trim()).filter(Boolean);
+ } else if (a === '--poses') {
+ out.poses = argv[++i].split(',').map((s) => s.trim()).filter(Boolean);
+ } else if (a === '--views') {
+ out.views = argv[++i].split(',').map((s) => s.trim()).filter(Boolean);
+ } else if (a === '--settle') {
+ out.settle = Number(argv[++i]) || 50;
+ } else if (a === '--help' || a === '-h') {
+ console.log(`img2mesh — character shot sheet
+
+Usage:
+ node tools/img2mesh.mjs [options]
+
+Options:
+ --subject player|goalie|player,goalie (default: both)
+ --poses carry,windup,butterfly,... (default: all for subject)
+ --views front,side,threequarter,... (default: front,3/4,side,closeup,gear)
+ --settle N frames of settle before shot (via API)
+ --list print catalogs and exit
+ --help
+`);
+ process.exit(0);
+ }
+ }
+ return out;
+}
+
+function findChrome() {
+ if (process.env.CHROME_PATH && fs.existsSync(process.env.CHROME_PATH)) {
+ return process.env.CHROME_PATH;
+ }
+ const cache = path.join(process.env.HOME, '.cache/puppeteer/chrome');
+ if (fs.existsSync(cache)) {
+ const builds = fs.readdirSync(cache).sort().reverse();
+ for (const b of builds) {
+ const exe = path.join(
+ cache,
+ b,
+ 'chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing',
+ );
+ if (fs.existsSync(exe)) return exe;
+ }
+ }
+ const system = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
+ if (fs.existsSync(system)) return system;
+ throw new Error('no Chrome found — set CHROME_PATH');
+}
+
+async function waitForServer(url, timeoutMs = 30000) {
+ const deadline = Date.now() + timeoutMs;
+ for (;;) {
+ try {
+ const res = await fetch(url);
+ if (res.ok || res.status === 404) return; // 404 on / is fine; studio is /character.html
+ } catch {
+ // still booting
+ }
+ if (Date.now() > deadline) throw new Error('vite did not come up at ' + url);
+ await new Promise((r) => setTimeout(r, 200));
+ }
+}
+
+const args = parseArgs(process.argv.slice(2));
+let server = null;
+let browser = null;
+
+try {
+ fs.mkdirSync(OUT, { recursive: true });
+ fs.mkdirSync(path.join(OUT, 'ref'), { recursive: true });
+
+ if (!process.env.TILT_URL) {
+ server = spawn(
+ path.join(ROOT, 'node_modules/.bin/vite'),
+ ['--host', '127.0.0.1', '--port', String(PORT), '--strictPort'],
+ { cwd: ROOT, stdio: ['ignore', 'pipe', 'pipe'] },
+ );
+ server.stderr.on('data', (d) => process.stderr.write('[vite] ' + d));
+ }
+ await waitForServer(BASE);
+
+ browser = await puppeteer.launch({
+ executablePath: findChrome(),
+ headless: true,
+ args: [
+ '--enable-unsafe-swiftshader',
+ '--use-gl=angle',
+ '--use-angle=swiftshader',
+ '--no-sandbox',
+ ],
+ });
+
+ const page = await browser.newPage();
+ await page.setViewport({ width: 1280, height: 720, deviceScaleFactor: 2 });
+
+ const errors = [];
+ page.on('pageerror', (err) => errors.push(String(err?.stack ?? err)));
+ page.on('console', (msg) => {
+ if (msg.type() === 'error') errors.push(msg.text());
+ });
+
+ await page.goto(STUDIO, { waitUntil: 'domcontentloaded' });
+ await page.waitForFunction(() => window.img2mesh?.captureShot, { timeout: 45000 });
+ // Boot overlay gone.
+ await page.waitForFunction(() => !document.getElementById('boot'), { timeout: 10000 }).catch(() => {});
+
+ if (args.list) {
+ const catalogs = await page.evaluate(() => ({
+ playerPoses: window.img2mesh.catalogs.playerPoses(),
+ goaliePoses: window.img2mesh.catalogs.goaliePoses(),
+ views: window.img2mesh.catalogs.views(),
+ }));
+ console.log(JSON.stringify(catalogs, null, 2));
+ process.exit(0);
+ }
+
+ const sheet = await page.evaluate((opts) => {
+ return window.img2mesh.shotSheet(opts);
+ }, {
+ subjects: args.subjects,
+ poses: args.poses,
+ views: args.views ?? ['front', 'threequarter', 'side', 'closeup', 'gear'],
+ });
+
+ console.log(`img2mesh: ${sheet.length} shots → ${path.relative(ROOT, OUT)}/`);
+
+ const manifest = {
+ createdAt: new Date().toISOString(),
+ subjects: args.subjects,
+ shots: [],
+ };
+
+ for (let i = 0; i < sheet.length; i++) {
+ const spec = sheet[i];
+ const meta = await page.evaluate(async (s) => {
+ return window.img2mesh.captureShot(s);
+ }, { ...spec, settleMs: args.settle });
+
+ const filePath = path.join(OUT, spec.file);
+ await page.screenshot({ path: filePath, type: 'png' });
+
+ const entry = {
+ ...spec,
+ path: path.relative(ROOT, filePath),
+ measures: meta.measures,
+ };
+ manifest.shots.push(entry);
+
+ const m = meta.measures.player || meta.measures.goalie;
+ const foot = m ? ` feetY=${m.footLY.toFixed(2)}` : '';
+ console.log(
+ `[${String(i + 1).padStart(3)}/${sheet.length}] ${spec.file}`
+ + ` anim=${m?.anim ?? '—'}${foot}`,
+ );
+ }
+
+ const manifestPath = path.join(OUT, 'manifest.json');
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
+ console.log(`manifest → ${path.relative(ROOT, manifestPath)}`);
+
+ // Index HTML for quick visual review in a browser.
+ const indexPath = path.join(OUT, 'index.html');
+ const cards = manifest.shots.map((s) => {
+ const m = s.measures.player || s.measures.goalie || {};
+ return `
+
+ ${s.subject} · ${s.pose} · ${s.view}
+ anim=${m.anim ?? '—'} feetY=${m.footLY?.toFixed?.(2) ?? '—'} hands=${m.handLY?.toFixed?.(2) ?? '—'}/${m.handRY?.toFixed?.(2) ?? '—'}
+
+`;
+ }).join('\n');
+ fs.writeFileSync(indexPath, `
+img2mesh sheet
+
+
+