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 +
+
+
IMG2MESH…
+ + + diff --git a/index.html b/index.html new file mode 100644 index 0000000..5763556 --- /dev/null +++ b/index.html @@ -0,0 +1,33 @@ + + + + + +tilt — spike 1 + + + + + +
+
TILT…
+ + + 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.file} +
${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 + + +

IMG2MESH · ${manifest.shots.length} shots · ${manifest.createdAt}

+
+${cards} +
+`); + console.log(`gallery → ${path.relative(ROOT, indexPath)}`); + + 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/vite.config.js b/vite.config.js new file mode 100644 index 0000000..b6fd93e --- /dev/null +++ b/vite.config.js @@ -0,0 +1,15 @@ +import { defineConfig } from 'vite'; + +export default defineConfig({ + server: { port: 5174, open: true }, + build: { + target: 'es2022', + // The body/skin generators are one deterministic chunk; splitting them buys + // nothing and costs a round trip before anything can render. + chunkSizeWarningLimit: 2500, + }, + // box3d.js ships an Emscripten bundle that resolves its .wasm via + // import.meta.url. Pre-bundling rewrites that URL and breaks the lookup, so + // leave it alone. + optimizeDeps: { exclude: ['box3d.js'] }, +});