Initial commit

This commit is contained in:
ryanfitzpatrickio
2026-08-03 06:43:21 -05:00
commit 7ee3e9d02f
63 changed files with 15792 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/
shots/
.DS_Store
+10
View File
@@ -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
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/tilt.iml" filepath="$PROJECT_DIR$/.idea/tilt.iml" />
</modules>
</component>
</project>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
+501
View File
@@ -0,0 +1,501 @@
# tilt
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 2030 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.
+190
View File
@@ -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.
+76
View File
@@ -0,0 +1,76 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no,viewport-fit=cover">
<title>tilt — img2mesh</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><text y='13' font-size='13'>&#127919;</text></svg>">
<style>
html, body { margin:0; padding:0; height:100%; overflow:hidden; background:#0a0e14;
font-family:'SF Mono', ui-monospace, Menlo, monospace;
touch-action:none; overscroll-behavior:none; -webkit-user-select:none; user-select:none;
-webkit-tap-highlight-color:transparent; }
#stage { position:absolute; inset:0; width:100%; height:100%; display:block; touch-action:none; }
#hud { position:absolute; left:14px; top:12px; color:#8fb4d4; font-size:12px;
line-height:1.55; white-space:pre; pointer-events:none; text-shadow:0 1px 2px #000; }
#boot { position:absolute; inset:0; display:flex; align-items:center; justify-content:center;
color:#6ea8dc; letter-spacing:4px; font-size:13px; background:#0a0e14; z-index:10; }
#panel { position:absolute; right:12px; top:12px; width:220px; color:#b8d4ea; font-size:11px;
line-height:1.45; background:rgba(8,12,18,0.72); border:1px solid #1e3348; border-radius:8px;
padding:10px 12px; pointer-events:auto; backdrop-filter:blur(6px); }
#panel h1 { margin:0 0 8px; font-size:11px; letter-spacing:2px; color:#6ea8dc; font-weight:600; }
#panel label { display:block; margin:6px 0 2px; color:#6a8aa8; }
#panel select, #panel button {
width:100%; box-sizing:border-box; background:#0f1822; color:#d4e8f8;
border:1px solid #2a4a66; border-radius:4px; padding:5px 7px; font:inherit; margin-bottom:4px;
}
#panel button { cursor:pointer; }
#panel button:hover { border-color:#4a8ab8; }
#panel .row { display:flex; gap:6px; }
#panel .row button { flex:1; }
#panel kbd { color:#8fb4d4; }
</style>
</head>
<body>
<canvas id="stage"></canvas>
<div id="hud"></div>
<div id="panel">
<h1>IMG2MESH</h1>
<label>subject</label>
<select id="subject">
<option value="player">player</option>
<option value="goalie">goalie</option>
<option value="both">both</option>
</select>
<label>pose</label>
<select id="pose"></select>
<label>view</label>
<select id="view">
<option value="front">front</option>
<option value="threequarter">3/4</option>
<option value="side">side</option>
<option value="back">back</option>
<option value="top">top</option>
<option value="closeup">closeup</option>
<option value="gear">gear detail</option>
</select>
<div class="row" style="margin-top:8px">
<button id="prevPose" type="button">◀ pose</button>
<button id="nextPose" type="button">pose ▶</button>
</div>
<div class="row">
<button id="prevView" type="button">◀ view</button>
<button id="nextView" type="button">view ▶</button>
</div>
<button id="cycle" type="button" style="margin-top:6px">cycle all shots</button>
<div style="margin-top:10px;color:#6a8aa8">
drag orbit · wheel zoom<br>
<kbd>1</kbd> player <kbd>2</kbd> goalie <kbd>3</kbd> both<br>
<kbd>[</kbd><kbd>]</kbd> pose · <kbd>,</kbd><kbd>.</kbd> view<br>
<kbd>g</kbd> gear bones · <kbd>b</kbd> bones
</div>
</div>
<div id="boot">IMG2MESH&hellip;</div>
<script type="module" src="/src/studio/img2mesh.js"></script>
</body>
</html>
+33
View File
@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no,viewport-fit=cover">
<title>tilt — spike 1</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><text y='13' font-size='13'>&#127954;</text></svg>">
<style>
html, body { margin:0; padding:0; height:100%; overflow:hidden; background:#0a0e14;
font-family:'SF Mono', ui-monospace, Menlo, monospace;
/* A thumb on the canvas orbits the camera — it must never scroll or zoom
the page, or fire pull-to-refresh instead. */
touch-action:none; overscroll-behavior:none; -webkit-user-select:none; user-select:none;
-webkit-tap-highlight-color:transparent; }
/* width/height are not redundant with inset:0. A canvas is a replaced
element, so `width:auto` resolves to its intrinsic (drawing buffer) size
rather than stretching between left and right — which at DPR 2 makes the
element twice the window. three.js sets these inline too; this is the
safety net if that ever regresses. */
#stage { position:absolute; inset:0; width:100%; height:100%; display:block; touch-action:none; }
#hud { position:absolute; left:14px; top:12px; color:#8fb4d4; font-size:12px;
line-height:1.5; white-space:pre; pointer-events:none; text-shadow:0 1px 2px #000; }
#boot { position:absolute; inset:0; display:flex; align-items:center; justify-content:center;
color:#6ea8dc; letter-spacing:5px; font-size:13px; background:#0a0e14; z-index:10; }
</style>
</head>
<body>
<canvas id="stage"></canvas>
<div id="hud"></div>
<div id="boot">TILT&hellip;</div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+1259
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -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"
}
}
+199
View File
@@ -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;
}
+165
View File
@@ -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,
};
}
+102
View File
@@ -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;
}
+159
View File
@@ -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 };
}
+29
View File
@@ -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);
}
+215
View File
@@ -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);
+292
View File
@@ -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.020.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;
}
+173
View File
@@ -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;
}
+135
View File
@@ -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);
}
+223
View File
@@ -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);
}
+674
View File
@@ -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;
}
+245
View File
@@ -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;
}
+382
View File
@@ -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);
}
+289
View File
@@ -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 };
+678
View File
@@ -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,
}),
};
}
+334
View File
@@ -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;
}
+693
View File
@@ -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 }),
};
}
+114
View File
@@ -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);
}
}
+213
View File
@@ -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);
}
+296
View File
@@ -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();
},
};
}
+179
View File
@@ -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();
}
}
});
}
+27
View File
@@ -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),
};
}
+281
View File
@@ -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();
},
};
}
+389
View File
@@ -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 };
+508
View File
@@ -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();
},
};
}
+313
View File
@@ -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;
},
};
}
+237
View File
@@ -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 };
+275
View File
@@ -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);
});
+143
View File
@@ -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);
},
};
}
+129
View File
@@ -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),
};
}
+124
View File
@@ -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;
}
+146
View File
@@ -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);
},
};
}
+676
View File
@@ -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 26 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,
};
}
+189
View File
@@ -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);
},
};
}
+140
View File
@@ -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);
},
};
}
+144
View File
@@ -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,
}),
};
}
+216
View File
@@ -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 };
}
+760
View File
@@ -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<typeof createSkater> | null} */
let player = null;
/** @type {ReturnType<typeof createGoalie> | 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);
+162
View File
@@ -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');
+103
View File
@@ -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');
+28
View File
@@ -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`);
}
+449
View File
@@ -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');
+361
View File
@@ -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');
+312
View File
@@ -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');
+394
View File
@@ -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');
+324
View File
@@ -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');
+77
View File
@@ -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');
+237
View File
@@ -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');
+246
View File
@@ -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');
+172
View File
@@ -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');
}
+78
View File
@@ -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)}`,
);
}
}
+234
View File
@@ -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 `<figure>
<a href="${s.file}"><img src="${s.file}" alt="${s.file}" loading="lazy"></a>
<figcaption><strong>${s.subject}</strong> · ${s.pose} · ${s.view}<br>
anim=${m.anim ?? '—'} feetY=${m.footLY?.toFixed?.(2) ?? '—'} hands=${m.handLY?.toFixed?.(2) ?? '—'}/${m.handRY?.toFixed?.(2) ?? '—'}
</figcaption>
</figure>`;
}).join('\n');
fs.writeFileSync(indexPath, `<!DOCTYPE html>
<html lang="en"><head><meta charset="utf-8"><title>img2mesh sheet</title>
<style>
body { margin:0; background:#0a0e14; color:#9ec0dc; font:12px/1.4 ui-monospace, Menlo, monospace; }
h1 { margin:16px; font-size:14px; letter-spacing:2px; color:#6ea8dc; }
main { display:grid; grid-template-columns:repeat(auto-fill,minmax(280px,1fr)); gap:12px; padding:0 16px 24px; }
figure { margin:0; background:#121a24; border:1px solid #1e3348; border-radius:8px; overflow:hidden; }
img { display:block; width:100%; height:auto; background:#0c1018; }
figcaption { padding:8px 10px; }
</style></head>
<body>
<h1>IMG2MESH · ${manifest.shots.length} shots · ${manifest.createdAt}</h1>
<main>
${cards}
</main>
</body></html>`);
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');
}
+15
View File
@@ -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'] },
});