Initial commit
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
shots/
|
||||
.DS_Store
|
||||
Generated
+10
@@ -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
|
||||
Generated
+8
@@ -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>
|
||||
Generated
+8
@@ -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>
|
||||
@@ -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 20–30 fps, but that is SwiftShader
|
||||
software rasterisation in a headless browser, not a real GPU.)
|
||||
|
||||
## Tests
|
||||
|
||||
1171 checks, all headless, `npm test`:
|
||||
|
||||
- **skaterSim** — top speed, acceleration curve, glide decay, braking, the
|
||||
carve preserving momentum, resistance to instant reversal, determinism, and
|
||||
the `applyIntent` seam surviving garbage input.
|
||||
- **rink** — containment maths including the corner arcs, which a plain
|
||||
rectangle test gets wrong.
|
||||
- **ai** — a 3-on-3 for a simulated minute: nobody leaves the ice, nobody
|
||||
stands inside anybody, waypoints actually get reached, and the lineup puts
|
||||
each team in its own half with index order matching team order. Also a
|
||||
5-on-5, as the cheapest check that steering does not fall over with a full
|
||||
side on the ice.
|
||||
- **pose** — the animator, driven headlessly: no NaN, blades on the ice, torso
|
||||
angle, arm carry, elbow bend, bank into a turn, the stop pose. This is the
|
||||
only way "the skater looks wrong" gets caught by anything but a human
|
||||
squinting at a screenshot.
|
||||
- **input** — camera-relative steering. Its own file because the failure is
|
||||
silent and infuriating: a sign flip means the stick works from one camera
|
||||
angle and inverts from another, which reads as a physics bug. Checks that
|
||||
forward is always away from the camera, that the four directions stay square,
|
||||
that "right" is the camera's right and not its left, and end to end that
|
||||
holding forward from any camera angle and any starting facing puts the skater
|
||||
where the stick pointed.
|
||||
- **physics** — the Box3D claims: boards hold at full speed including in the
|
||||
corner seams, contact costs speed, two skaters cannot occupy the same ice, a
|
||||
bump transfers momentum, the ragdoll follows the skeleton, and a six-body
|
||||
pile-up at centre ice resolves without anyone escaping or interpenetrating.
|
||||
|
||||
- **hits** — the handoff, mostly. A knockdown must disable the proxy, leave the
|
||||
frozen sim position where it is rather than skating a disabled capsule around
|
||||
the rink, move the sim to wherever the body actually slid to on the way up,
|
||||
and restore the collision filter. Plus the things that were wrong when first
|
||||
looked at: nobody delivers a check with their head, a knockdown never lifts
|
||||
the hips above standing height, the victim carries on down the ice rather
|
||||
than bouncing back, a longer run-up hits harder, and more than one kind of
|
||||
check is reachable.
|
||||
|
||||
The render path is covered separately by `npm run capture`, which boots the app
|
||||
headless **at DPR 2** and asserts the canvas fills the window at two sizes. That
|
||||
check exists because it didn't: running captures at DPR 1 hid a canvas-sizing
|
||||
bug that made the element twice the window on any retina display.
|
||||
|
||||
## Not built yet
|
||||
|
||||
Full roadmap, ordered by difficulty: **[ROADMAP.md](ROADMAP.md)**.
|
||||
|
||||
Nets, goalies, scoring, offside/icing, penalties, faceoffs, gear textures,
|
||||
netplay.
|
||||
|
||||
## Where the next spike plugs in
|
||||
|
||||
**Nets and scoring.** It is the shortest path from "physics demo you can play"
|
||||
to "game you can win". Two static goal frames with a trigger volume behind the
|
||||
line, a whistle, and a faceoff reset — `match.reset()` already puts the puck at
|
||||
centre ice and stands everybody up. Everything needed to detect a goal exists;
|
||||
the puck is a real body with a real position.
|
||||
|
||||
Then, roughly in order of how much they would improve the thing:
|
||||
|
||||
- **Goalies** — a seventh skater per side with a different brain and a bigger
|
||||
collider. No new systems.
|
||||
- **Arm IK onto the stick.** The stick is positioned from the carry point and
|
||||
the arms do not yet reach for it. `solveGrabArm` in the Ludus animator is
|
||||
exactly this problem, already solved, and can be ported.
|
||||
- **Penalties.** Hits already carry `headshot`, `blindside`, the delivering part
|
||||
and the struck region, so boarding, charging and elbowing have the data they
|
||||
need without any new detection.
|
||||
- **Positional AI.** The brains know four states — carrying, chasing,
|
||||
supporting, defending — and pick between them off one nearest-to-puck test.
|
||||
Real forechecking and zone coverage is the next big behavioural step.
|
||||
- **Netplay.** `shared/` is still pure, deterministic and three.js-free, and
|
||||
`applyIntent` is a clamped entry point that never trusts what it is given.
|
||||
|
||||
### Three soft spots worth knowing
|
||||
|
||||
**The lower hand does not quite reach the shaft.** The left arm is 0.55 m and
|
||||
the natural two-handed grip point is ~1 m from the left shoulder on this
|
||||
skeleton, so the IK grips the nearest *reachable* point and still ends about
|
||||
0.25 m short. It reads as reaching for the stick rather than holding it. Fixing
|
||||
it properly means either a longer reach from a shoulder/spine contribution or
|
||||
accepting a higher grip; both are pose work, not architecture.
|
||||
|
||||
**Staggers are still visually unverified.** Knockdowns were tested hard — peak
|
||||
hip height, direction of travel, the full proxy/ragdoll handoff, and that
|
||||
nothing jumps on the way back up. The stagger path (physics deflecting the pose
|
||||
while animation shows through) is only asserted to *enter* the right state. It
|
||||
is far more common in play than a knockdown.
|
||||
|
||||
**Possession changes may be too frequent.** Around sixteen a minute in a 3-on-3
|
||||
with no zones, no goalies and no reason to hold position is plausible but
|
||||
untuned. It will want revisiting once there is a net to protect.
|
||||
+190
@@ -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.
|
||||
@@ -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'>🎯</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…</div>
|
||||
<script type="module" src="/src/studio/img2mesh.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+33
@@ -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'>🏒</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…</div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1259
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -0,0 +1,292 @@
|
||||
import * as THREE from 'three';
|
||||
import { E, clamp, smooth } from '../core/math.js';
|
||||
import {
|
||||
FOOT_Y,
|
||||
GOALIE_BONES,
|
||||
GOALIE_LEGS,
|
||||
GOALIE_UPPER,
|
||||
poseButterfly,
|
||||
poseReach,
|
||||
poseReady,
|
||||
poseShuffle,
|
||||
} from './poses/goalie.js';
|
||||
|
||||
/**
|
||||
* Goalie animator.
|
||||
*
|
||||
* Upper body is pose-authored; legs are two-bone IK onto mover-local foot
|
||||
* targets so the pads stay on the ice. The paddle stick keeps its authored
|
||||
* grip rotation (re-aiming it every frame is what made it thrash).
|
||||
*/
|
||||
|
||||
export function buildGoalieAnimator(skelData, mover) {
|
||||
const B = skelData.bones;
|
||||
const LEN = {
|
||||
thigh: B.shinL.position.length(),
|
||||
shin: B.footL.position.length(),
|
||||
};
|
||||
const restThighDir = {
|
||||
L: B.shinL.position.clone().normalize(),
|
||||
R: B.shinR.position.clone().normalize(),
|
||||
};
|
||||
const restShinDir = {
|
||||
L: B.footL.position.clone().normalize(),
|
||||
R: B.footR.position.clone().normalize(),
|
||||
};
|
||||
|
||||
function newPose() {
|
||||
const p = {
|
||||
q: {},
|
||||
rootOffset: new THREE.Vector3(),
|
||||
rootQuat: new THREE.Quaternion(),
|
||||
feet: {
|
||||
L: { x: 0.28, z: 0.05, yaw: 0.15 },
|
||||
R: { x: -0.28, z: 0.05, yaw: -0.15 },
|
||||
},
|
||||
};
|
||||
for (const n of GOALIE_BONES) p.q[n] = new THREE.Quaternion();
|
||||
return p;
|
||||
}
|
||||
|
||||
const cur = newPose();
|
||||
const frozen = newPose();
|
||||
|
||||
const anim = {
|
||||
state: 'ready',
|
||||
blend: 1,
|
||||
BLEND_TIME: 0.16,
|
||||
transitionTime: 0.16,
|
||||
time: 0,
|
||||
stateTime: 0,
|
||||
speed: 1,
|
||||
|
||||
origin: new THREE.Vector3(),
|
||||
originYaw: 0,
|
||||
|
||||
moveSpeed: 0,
|
||||
lateralVel: 0,
|
||||
puckHeight: 0.05,
|
||||
puckDist: 8,
|
||||
threatened: 0,
|
||||
|
||||
/** Goalie paddle group, parented to handR. Grip is adjusted per stance. */
|
||||
stick: null,
|
||||
};
|
||||
|
||||
// Hand-local stick grips, tuned against the equipment reference:
|
||||
// ready = paddle on ice in the five-hole, shaft up into the blocker hand;
|
||||
// butterfly = same idea, flatter, so it does not spear the surface.
|
||||
// Searched: nearly down-forward puts the paddle on the ice in the five-hole
|
||||
// (minY ≈ 0.02–0.05) without spearing through.
|
||||
const STICK_READY_E = new THREE.Euler(1.55, 0.3, 0.05, 'XYZ');
|
||||
const STICK_FLY_E = new THREE.Euler(1.65, 0.2, 0.0, 'XYZ');
|
||||
const STICK_READY_POS = new THREE.Vector3(0.04, -0.02, 0.04);
|
||||
const STICK_FLY_POS = new THREE.Vector3(0.05, 0.02, 0.05);
|
||||
const _stickEuler = new THREE.Euler();
|
||||
const _stickPos = new THREE.Vector3();
|
||||
|
||||
function applyMover() {
|
||||
mover.position.copy(anim.origin);
|
||||
mover.rotation.set(0, anim.originYaw, 0);
|
||||
}
|
||||
|
||||
anim.setTransform = function setTransform(position, yaw) {
|
||||
anim.origin.copy(position);
|
||||
anim.originYaw = yaw;
|
||||
};
|
||||
|
||||
function snapshot() {
|
||||
for (const n of GOALIE_BONES) frozen.q[n].copy(B[n].quaternion);
|
||||
frozen.rootOffset.copy(B.root.position);
|
||||
frozen.rootQuat.copy(B.root.quaternion);
|
||||
if (cur.feet) {
|
||||
frozen.feet.L = { ...cur.feet.L };
|
||||
frozen.feet.R = { ...cur.feet.R };
|
||||
}
|
||||
}
|
||||
|
||||
anim.setState = function setState(name, blendTime = null) {
|
||||
if (name === anim.state) return;
|
||||
snapshot();
|
||||
anim.state = name;
|
||||
anim.stateTime = 0;
|
||||
anim.blend = 0;
|
||||
anim.transitionTime = blendTime ?? anim.BLEND_TIME;
|
||||
};
|
||||
|
||||
function chooseState() {
|
||||
const low = anim.puckHeight < 0.38;
|
||||
const high = anim.puckHeight > 0.75;
|
||||
const close = anim.puckDist < 8;
|
||||
const veryClose = anim.puckDist < 3.5;
|
||||
const sliding = Math.abs(anim.lateralVel) > 1.2 || anim.moveSpeed > 1.6;
|
||||
|
||||
if (low && (veryClose || (close && anim.threatened > 0.3))) return 'butterfly';
|
||||
if (high && close && anim.threatened > 0.25) return 'reach';
|
||||
if (sliding) return 'shuffle';
|
||||
return 'ready';
|
||||
}
|
||||
|
||||
// ---- two-bone leg IK (same pattern as the skater) ------------------------
|
||||
const _H = new THREE.Vector3();
|
||||
const _d = new THREE.Vector3();
|
||||
const _pole = new THREE.Vector3();
|
||||
const _e2 = new THREE.Vector3();
|
||||
const _knee = new THREE.Vector3();
|
||||
const _dir = new THREE.Vector3();
|
||||
const _f = new THREE.Vector3();
|
||||
const _r = new THREE.Vector3();
|
||||
const _qP = new THREE.Quaternion();
|
||||
const _q1 = new THREE.Quaternion();
|
||||
const _q2 = new THREE.Quaternion();
|
||||
const _qF = new THREE.Quaternion();
|
||||
const _qInv = new THREE.Quaternion();
|
||||
const _worldFoot = new THREE.Vector3();
|
||||
|
||||
const fwdOf = (yaw, out) => out.set(Math.sin(yaw), 0, Math.cos(yaw));
|
||||
const rightOf = (yaw, out) => out.set(Math.cos(yaw), 0, -Math.sin(yaw));
|
||||
|
||||
function solveLeg(side, localX, localZ, toeYaw) {
|
||||
const thigh = B['thigh' + side];
|
||||
const shin = B['shin' + side];
|
||||
const foot = B['foot' + side];
|
||||
|
||||
// Local foot → world via the mover (already at originYaw).
|
||||
_worldFoot.set(localX, FOOT_Y, localZ).applyMatrix4(mover.matrixWorld);
|
||||
_worldFoot.y = FOOT_Y;
|
||||
|
||||
thigh.getWorldPosition(_H);
|
||||
_d.subVectors(_worldFoot, _H);
|
||||
let d = _d.length();
|
||||
const a = LEN.thigh;
|
||||
const b = LEN.shin;
|
||||
d = clamp(d, 0.12, a + b - 0.003);
|
||||
_d.normalize();
|
||||
const cosA = clamp((a * a + d * d - b * b) / (2 * a * d), -1, 1);
|
||||
const sinA = Math.sqrt(Math.max(0, 1 - cosA * cosA));
|
||||
|
||||
fwdOf(anim.originYaw, _f);
|
||||
rightOf(anim.originYaw, _r);
|
||||
// Knee pole: forward and outward so butterfly pads open, not knock-knees.
|
||||
_pole.copy(_f).addScaledVector(_r, side === 'L' ? 0.55 : -0.55);
|
||||
_pole.y -= 0.15;
|
||||
_e2.copy(_pole).addScaledVector(_d, -_pole.dot(_d));
|
||||
if (_e2.lengthSq() < 1e-8) _e2.copy(_f);
|
||||
_e2.normalize();
|
||||
_knee.copy(_H).addScaledVector(_d, a * cosA).addScaledVector(_e2, a * sinA);
|
||||
|
||||
_dir.subVectors(_knee, _H).normalize();
|
||||
_q1.setFromUnitVectors(restThighDir[side], _dir);
|
||||
thigh.parent.getWorldQuaternion(_qP);
|
||||
_qInv.copy(_qP).invert();
|
||||
thigh.quaternion.copy(_qInv).multiply(_q1);
|
||||
|
||||
_dir.subVectors(_worldFoot, _knee).normalize();
|
||||
_q2.setFromUnitVectors(restShinDir[side], _dir);
|
||||
_qInv.copy(_q1).invert();
|
||||
shin.quaternion.copy(_qInv).multiply(_q2);
|
||||
|
||||
const worldYaw = anim.originYaw + toeYaw;
|
||||
E(_qF, 0, worldYaw, 0, 'YXZ');
|
||||
_qInv.copy(_q2).invert();
|
||||
foot.quaternion.copy(_qInv).multiply(_qF);
|
||||
B['toe' + side].quaternion.identity();
|
||||
}
|
||||
|
||||
anim.update = function update(dt) {
|
||||
dt *= anim.speed;
|
||||
anim.time += dt;
|
||||
anim.stateTime += dt;
|
||||
anim.blend = Math.min(1, anim.blend + dt / anim.transitionTime);
|
||||
|
||||
anim.setState(chooseState());
|
||||
applyMover();
|
||||
mover.updateMatrixWorld(true);
|
||||
|
||||
const lean = clamp(anim.lateralVel / 3.5, -1, 1);
|
||||
const t = anim.time;
|
||||
|
||||
for (const n of GOALIE_BONES) cur.q[n].identity();
|
||||
cur.rootOffset.set(0, 0, 0);
|
||||
cur.rootQuat.identity();
|
||||
|
||||
if (anim.state === 'butterfly') {
|
||||
poseButterfly(cur, { lean, t });
|
||||
} else if (anim.state === 'shuffle') {
|
||||
poseShuffle(cur, {
|
||||
dir: anim.lateralVel >= 0 ? 1 : -1,
|
||||
effort: clamp(anim.moveSpeed / 3.5, 0.3, 1),
|
||||
t,
|
||||
});
|
||||
} else if (anim.state === 'reach') {
|
||||
const side = lean > 0.25 ? 1 : -1;
|
||||
poseReach(cur, {
|
||||
side,
|
||||
up: clamp((anim.puckHeight - 0.6) / 0.8, 0.4, 1),
|
||||
lean,
|
||||
t,
|
||||
});
|
||||
} else {
|
||||
poseReady(cur, { lean: lean * 0.5, t });
|
||||
}
|
||||
|
||||
const w = smooth(anim.blend);
|
||||
for (const n of GOALIE_UPPER) {
|
||||
B[n].quaternion.slerpQuaternions(frozen.q[n], cur.q[n], w);
|
||||
}
|
||||
// Legs identity mid-blend then IK — slerping free leg eulers fights the IK.
|
||||
for (const n of GOALIE_LEGS) B[n].quaternion.identity();
|
||||
B.root.position.lerpVectors(frozen.rootOffset, cur.rootOffset, w);
|
||||
B.root.quaternion.slerpQuaternions(frozen.rootQuat, cur.rootQuat, w);
|
||||
|
||||
mover.updateMatrixWorld(true);
|
||||
|
||||
// Blend foot targets in mover-local space, then IK.
|
||||
const fL = {
|
||||
x: lerp(frozen.feet.L.x, cur.feet.L.x, w),
|
||||
z: lerp(frozen.feet.L.z, cur.feet.L.z, w),
|
||||
yaw: lerp(frozen.feet.L.yaw, cur.feet.L.yaw, w),
|
||||
};
|
||||
const fR = {
|
||||
x: lerp(frozen.feet.R.x, cur.feet.R.x, w),
|
||||
z: lerp(frozen.feet.R.z, cur.feet.R.z, w),
|
||||
yaw: lerp(frozen.feet.R.yaw, cur.feet.R.yaw, w),
|
||||
};
|
||||
solveLeg('L', fL.x, fL.z, fL.yaw);
|
||||
solveLeg('R', fR.x, fR.z, fR.yaw);
|
||||
|
||||
// Stick grip: blend ready → butterfly so the paddle stays near the ice.
|
||||
if (anim.stick) {
|
||||
const k = anim.state === 'butterfly' ? Math.min(1, anim.stateTime / 0.14) : 0;
|
||||
_stickEuler.set(
|
||||
STICK_READY_E.x + (STICK_FLY_E.x - STICK_READY_E.x) * k,
|
||||
STICK_READY_E.y + (STICK_FLY_E.y - STICK_READY_E.y) * k,
|
||||
STICK_READY_E.z + (STICK_FLY_E.z - STICK_READY_E.z) * k,
|
||||
'XYZ',
|
||||
);
|
||||
anim.stick.quaternion.setFromEuler(_stickEuler);
|
||||
_stickPos.lerpVectors(STICK_READY_POS, STICK_FLY_POS, k);
|
||||
anim.stick.position.copy(_stickPos);
|
||||
}
|
||||
|
||||
mover.updateMatrixWorld(true);
|
||||
};
|
||||
|
||||
function lerp(a, b, t) {
|
||||
return a + (b - a) * t;
|
||||
}
|
||||
|
||||
// Seed frozen from a ready pose so the first frame has real foot targets.
|
||||
poseReady(frozen, { lean: 0, t: 0 });
|
||||
poseReady(cur, { lean: 0, t: 0 });
|
||||
for (const n of GOALIE_UPPER) B[n].quaternion.copy(frozen.q[n]);
|
||||
for (const n of GOALIE_LEGS) B[n].quaternion.identity();
|
||||
B.root.position.copy(frozen.rootOffset);
|
||||
B.root.quaternion.copy(frozen.rootQuat);
|
||||
applyMover();
|
||||
mover.updateMatrixWorld(true);
|
||||
solveLeg('L', frozen.feet.L.x, frozen.feet.L.z, frozen.feet.L.yaw);
|
||||
solveLeg('R', frozen.feet.R.x, frozen.feet.R.z, frozen.feet.R.yaw);
|
||||
|
||||
return anim;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 }),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,676 @@
|
||||
import * as THREE from 'three';
|
||||
import { BONE_RADIUS, BONE_REGION, SEG_CHILD } from '../character/skeleton.js';
|
||||
import { CAT, IDENTITY_QUAT, KIND, makeTag, quat, ragdollFilter, transform, vec3 } from './bridge.js';
|
||||
// Reaction curve: a blow bites almost instantly, then bleeds off over the
|
||||
// recovery window. Anything slower on the attack reads as the skater choosing
|
||||
// to flinch rather than being moved by the hit.
|
||||
// Reach full physics weight fast so the flinch is visible on the first frames
|
||||
// after the impulse (was 55 ms — most of a light hit was over before peak).
|
||||
export const REACTION_ATTACK = 0.04;
|
||||
|
||||
/**
|
||||
* Physical body built from the animation skeleton.
|
||||
*
|
||||
* Each part's collider is authored in *bone-local* space — capsule from the
|
||||
* bone origin to its child's local offset — and the rigid body is placed at
|
||||
* the bone's world transform. That sidesteps any axis-alignment math: the
|
||||
* capsule matches the bone exactly by construction, whatever direction the
|
||||
* bone happens to point.
|
||||
*
|
||||
* Two modes:
|
||||
* 'driven' bodies are kinematic and chase the animated skeleton. This is
|
||||
* everything spike 1 uses — the rig is here so that hits later have
|
||||
* something to push, not because anything pushes it yet.
|
||||
* 'limp' bodies go dynamic and the joints take over. Bone velocity at the
|
||||
* moment of transition is carried across, so a skater taken off
|
||||
* their feet mid-stride keeps the momentum of that stride.
|
||||
*
|
||||
* Carried over from Ludus with the collision filters retargeted (see
|
||||
* bridge.js) and nothing else changed: it is the same 18 capsules and 17
|
||||
* joints, and the reaction/limp paths are known-good.
|
||||
*/
|
||||
|
||||
const HINGE_FRAME = { v: { x: 0, y: Math.SQRT1_2, z: 0 }, s: Math.SQRT1_2 }; // local Z -> local X
|
||||
|
||||
// Body density by tissue type. Box3D derives mass and inertia from the shapes,
|
||||
// so these are the only mass numbers we author — but see CALIBRATION below.
|
||||
const DENSITY = { bone: 1350, limb: 1050, torso: 1010, head: 1090 };
|
||||
|
||||
// Adjacent bone capsules deliberately overlap so the rig has no gaps at the
|
||||
// joints, which means summing their volumes counts the overlaps twice and lands
|
||||
// around 175 kg of "flesh" for a normal build. Rather than fudge the densities
|
||||
// (and lose the physical relationship between tissue types), the whole rig is
|
||||
// scaled once at build time to hit a plausible total. Re-setting the shape
|
||||
// density and letting Box3D recompute keeps each body's inertia tensor
|
||||
// consistent with its new mass; scaling the tensor by hand would not.
|
||||
const TARGET_BODY_MASS = 86; // kg, before pads and stick
|
||||
|
||||
/**
|
||||
* Parts, parent-first. `hinge` marks a joint that should only bend one way
|
||||
* (elbows, knees); everything else is a cone-limited ball joint.
|
||||
*/
|
||||
const PARTS = [
|
||||
{ name: 'pelvis', bone: 'pelvis', parent: null, density: DENSITY.torso, radiusScale: 1.15 },
|
||||
{ name: 'spine1', bone: 'spine1', parent: 'pelvis', density: DENSITY.torso, cone: 0.34, twist: 0.5 },
|
||||
{ name: 'spine2', bone: 'spine2', parent: 'spine1', density: DENSITY.torso, cone: 0.34, twist: 0.5 },
|
||||
{ name: 'spine3', bone: 'spine3', parent: 'spine2', density: DENSITY.torso, cone: 0.3, twist: 0.4 },
|
||||
{ name: 'neck', bone: 'neck', parent: 'spine3', density: DENSITY.head, cone: 0.5, twist: 0.7 },
|
||||
{ name: 'head', bone: 'head', parent: 'neck', density: DENSITY.head, cone: 0.55, twist: 0.8, radiusScale: 1.0 },
|
||||
|
||||
{ name: 'upperArmL', bone: 'upperArmL', parent: 'spine3', density: DENSITY.limb, cone: 1.5, twist: 1.1 },
|
||||
{ name: 'forearmL', bone: 'forearmL', parent: 'upperArmL', density: DENSITY.limb, hinge: [-0.12, 2.5] },
|
||||
{ name: 'handL', bone: 'handL', parent: 'forearmL', density: DENSITY.limb, cone: 0.7, twist: 0.6 },
|
||||
{ name: 'upperArmR', bone: 'upperArmR', parent: 'spine3', density: DENSITY.limb, cone: 1.5, twist: 1.1 },
|
||||
{ name: 'forearmR', bone: 'forearmR', parent: 'upperArmR', density: DENSITY.limb, hinge: [-0.12, 2.5] },
|
||||
{ name: 'handR', bone: 'handR', parent: 'forearmR', density: DENSITY.limb, cone: 0.7, twist: 0.6 },
|
||||
|
||||
// Knee hinge is about bone-local +X (HINGE_FRAME maps joint Z → X). With the
|
||||
// rest limb along −Y, *positive* angle swings the foot back (−Z) — flexion.
|
||||
// Negative angle is hyperextension (foot forward). The old limits were
|
||||
// inverted ([-2.4, -0.12]), so limp legs only bent the wrong way.
|
||||
// Residual +0.12 rad of flex stops a perfectly straight column from standing
|
||||
// forever under gravity, and blocks reverse bend.
|
||||
{ name: 'thighL', bone: 'thighL', parent: 'pelvis', density: DENSITY.limb, cone: 1.15, twist: 0.5 },
|
||||
{ name: 'shinL', bone: 'shinL', parent: 'thighL', density: DENSITY.limb, hinge: [0.12, 2.4] },
|
||||
{ name: 'footL', bone: 'footL', parent: 'shinL', density: DENSITY.bone, cone: 0.5, twist: 0.3 },
|
||||
{ name: 'thighR', bone: 'thighR', parent: 'pelvis', density: DENSITY.limb, cone: 1.15, twist: 0.5 },
|
||||
{ name: 'shinR', bone: 'shinR', parent: 'thighR', density: DENSITY.limb, hinge: [0.12, 2.4] },
|
||||
{ name: 'footR', bone: 'footR', parent: 'shinR', density: DENSITY.bone, cone: 0.5, twist: 0.3 },
|
||||
];
|
||||
|
||||
const _wp = new THREE.Vector3();
|
||||
const _wq = new THREE.Quaternion();
|
||||
const _ws = new THREE.Vector3();
|
||||
const _prevP = new THREE.Vector3();
|
||||
const _prevQ = new THREE.Quaternion();
|
||||
const _pq = new THREE.Quaternion();
|
||||
const _pqi = new THREE.Quaternion();
|
||||
const _dq = new THREE.Quaternion();
|
||||
const _axis = new THREE.Vector3();
|
||||
const _zAxis = new THREE.Vector3(0, 0, 1);
|
||||
|
||||
/**
|
||||
* Adjacency (by part name) for self-collision filtering.
|
||||
* Adjacent capsules deliberately overlap at joints; they must never generate
|
||||
* contacts. Parts two links away still often rest inside each other in bind
|
||||
* pose (spine1↔spine3), so we cull graph distance ≤ 2 as well.
|
||||
*/
|
||||
function partDistance(a, b) {
|
||||
if (a === b) return 0;
|
||||
// BFS on the undirected tree. PARTS is small (18), so this is free.
|
||||
const adj = new Map();
|
||||
for (const p of PARTS) {
|
||||
if (!adj.has(p.name)) adj.set(p.name, []);
|
||||
if (p.parent) {
|
||||
adj.get(p.name).push(p.parent);
|
||||
if (!adj.has(p.parent)) adj.set(p.parent, []);
|
||||
adj.get(p.parent).push(p.name);
|
||||
}
|
||||
}
|
||||
const q = [[a, 0]];
|
||||
const seen = new Set([a]);
|
||||
while (q.length) {
|
||||
const [n, d] = q.shift();
|
||||
if (n === b) return d;
|
||||
for (const m of adj.get(n) ?? []) {
|
||||
if (seen.has(m)) continue;
|
||||
seen.add(m);
|
||||
q.push([m, d + 1]);
|
||||
}
|
||||
}
|
||||
return 99;
|
||||
}
|
||||
|
||||
/**
|
||||
* Precomputed "too close to collide" pairs keyed by part name.
|
||||
* Distance ≤ 1 = joint neighbours (capsules deliberately overlap).
|
||||
* Distance 2 on the *spine* only — limb forks (thighL↔thighR = 2 via pelvis)
|
||||
* must still collide so a limp body can tangle.
|
||||
*/
|
||||
const NO_COLLIDE = new Set();
|
||||
{
|
||||
const names = PARTS.map((p) => p.name);
|
||||
const spine = new Set(['pelvis', 'spine1', 'spine2', 'spine3', 'neck', 'head']);
|
||||
for (let i = 0; i < names.length; i++) {
|
||||
for (let j = i + 1; j < names.length; j++) {
|
||||
const d = partDistance(names[i], names[j]);
|
||||
const bothSpine = spine.has(names[i]) && spine.has(names[j]);
|
||||
if (d <= 1 || (d <= 2 && bothSpine)) {
|
||||
NO_COLLIDE.add(`${names[i]}|${names[j]}`);
|
||||
NO_COLLIDE.add(`${names[j]}|${names[i]}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** True when two body part *names* on the same rig may generate contacts. */
|
||||
export function ragdollPartsCollide(nameA, nameB) {
|
||||
if (nameA === nameB) return false;
|
||||
return !NO_COLLIDE.has(`${nameA}|${nameB}`);
|
||||
}
|
||||
|
||||
/** Slot indices match the order PARTS is walked when building the ragdoll. */
|
||||
const SLOT_NAMES = PARTS.map((p) => p.name);
|
||||
|
||||
/** True when two body *slots* on the same rig may generate contacts. */
|
||||
export function slotsShouldCollide(slotA, slotB) {
|
||||
const a = SLOT_NAMES[slotA];
|
||||
const b = SLOT_NAMES[slotB];
|
||||
if (a == null || b == null) return true;
|
||||
return ragdollPartsCollide(a, b);
|
||||
}
|
||||
|
||||
export function createRagdoll(physics, skelData, { skaterIndex = 0 } = {}) {
|
||||
const { api, world } = physics;
|
||||
const bones = skelData.bones;
|
||||
skelData.rootBone.updateMatrixWorld(true);
|
||||
|
||||
const filter = ragdollFilter(skaterIndex);
|
||||
// Two masks, swapped by setMode. `driven` keeps limbs out of the proxy layer
|
||||
// so an animated arm cannot shove anybody; `limp` lets a falling body hit
|
||||
// people. The rig's own proxy is disabled while it is down, so nothing here
|
||||
// has to special-case self.
|
||||
const drivenMask = filter.mask & ~CAT.PROXY;
|
||||
const limpMask = filter.mask | CAT.PROXY;
|
||||
const _filter = { categoryBits: filter.category, maskBits: drivenMask, groupIndex: 0 };
|
||||
const parts = {};
|
||||
const order = [];
|
||||
|
||||
for (const def of PARTS) {
|
||||
const bone = bones[def.bone];
|
||||
if (!bone) continue;
|
||||
const childName = SEG_CHILD[def.bone];
|
||||
const child = childName ? bones[childName] : null;
|
||||
|
||||
// Capsule endpoints in bone-local space.
|
||||
const c1 = new THREE.Vector3(0, 0, 0);
|
||||
const c2 = child
|
||||
? child.position.clone()
|
||||
: def.bone === 'head'
|
||||
? new THREE.Vector3(0, 0.15, 0.012)
|
||||
: def.bone.startsWith('hand')
|
||||
? new THREE.Vector3(def.bone.endsWith('L') ? 0.045 : -0.045, -0.095, 0.008)
|
||||
: new THREE.Vector3(0, -0.012, 0.085);
|
||||
|
||||
const radius = BONE_RADIUS[def.bone] * (def.radiusScale ?? 0.72);
|
||||
// A degenerate capsule (endpoints closer than the radius) is just a sphere
|
||||
// and confuses the solver; nudge it out along its own axis instead.
|
||||
if (c2.length() < radius * 0.5) c2.setLength(radius * 0.5 + 1e-3);
|
||||
|
||||
bone.matrixWorld.decompose(_wp, _wq, _ws);
|
||||
|
||||
const bd = api.b3DefaultBodyDef();
|
||||
// Created dynamic so Box3D computes mass and inertia from the shapes, then
|
||||
// switched to kinematic below. A kinematic body reports zero mass, so this
|
||||
// is the only moment the real figure is available.
|
||||
bd.type = api.b3BodyType.b3_dynamicBody;
|
||||
bd.position = vec3(_wp);
|
||||
bd.rotation = quat(_wq);
|
||||
bd.enableSleep = false;
|
||||
const body = api.b3CreateBody(world, bd);
|
||||
|
||||
const sd = api.b3DefaultShapeDef();
|
||||
sd.density = def.density;
|
||||
sd.enableHitEvents = true;
|
||||
sd.enableContactEvents = true;
|
||||
// Custom filter rejects adjacent limbs of the same skater (see world.js).
|
||||
sd.enableCustomFiltering = true;
|
||||
sd.baseMaterial.friction = 0.75;
|
||||
sd.baseMaterial.restitution = 0.05;
|
||||
sd.baseMaterial.userMaterialId = makeTag(KIND.BODY, skaterIndex, order.length);
|
||||
// Self bit is included: distant limbs collide when limp. Adjacent pairs
|
||||
// are culled by the world custom filter (and joints keep collideConnected off).
|
||||
sd.filter.categoryBits = filter.category;
|
||||
sd.filter.maskBits = drivenMask;
|
||||
sd.filter.groupIndex = 0;
|
||||
const shape = api.b3CreateCapsuleShape(body, sd, {
|
||||
center1: vec3(c1),
|
||||
center2: vec3(c2),
|
||||
radius,
|
||||
});
|
||||
api.b3Body_EnableHitEvents(body, true);
|
||||
const mass = api.b3Body_GetMass(body);
|
||||
|
||||
const part = {
|
||||
name: def.name,
|
||||
def,
|
||||
bone,
|
||||
body,
|
||||
shape,
|
||||
radius,
|
||||
mass,
|
||||
// Capsule endpoints in bone-local space, kept so the segment can be
|
||||
// rebuilt in world space for limb-level hit queries without asking
|
||||
// Box3D to hand the shape back every frame.
|
||||
localA: c1.clone(),
|
||||
localB: c2.clone(),
|
||||
region: BONE_REGION[def.bone],
|
||||
index: order.length,
|
||||
prevPos: _wp.clone(),
|
||||
prevQuat: _wq.clone(),
|
||||
linVel: new THREE.Vector3(),
|
||||
angVel: new THREE.Vector3(),
|
||||
disabled: false,
|
||||
};
|
||||
parts[def.name] = part;
|
||||
order.push(part);
|
||||
}
|
||||
|
||||
// ---- mass calibration ---------------------------------------------------
|
||||
// Runs while the bodies are still dynamic: a kinematic body has no mass to
|
||||
// recompute, so calibrating after the switch would silently do nothing.
|
||||
{
|
||||
let raw = 0;
|
||||
for (const part of order) raw += part.mass;
|
||||
if (raw > 1e-6) {
|
||||
const k = TARGET_BODY_MASS / raw;
|
||||
for (const part of order) {
|
||||
api.b3Shape_SetDensity(part.shape, part.def.density * k, false);
|
||||
api.b3Body_ApplyMassFromShapes(part.body);
|
||||
part.mass = api.b3Body_GetMass(part.body);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const part of order) api.b3Body_SetType(part.body, api.b3BodyType.b3_kinematicBody);
|
||||
|
||||
// ---- joints -------------------------------------------------------------
|
||||
const joints = [];
|
||||
for (const def of PARTS) {
|
||||
if (!def.parent) continue;
|
||||
const a = parts[def.parent];
|
||||
const b = parts[def.name];
|
||||
if (!a || !b) continue;
|
||||
|
||||
// The anchor is the child bone's origin: (0,0,0) in the child's frame, and
|
||||
// the child's local offset in the parent's frame.
|
||||
const localA = b.bone.position.clone();
|
||||
const localB = new THREE.Vector3(0, 0, 0);
|
||||
|
||||
let jointId;
|
||||
if (def.hinge) {
|
||||
const jd = api.b3DefaultRevoluteJointDef();
|
||||
jd.base.bodyIdA = a.body;
|
||||
jd.base.bodyIdB = b.body;
|
||||
jd.base.localFrameA = { p: vec3(localA), q: HINGE_FRAME };
|
||||
jd.base.localFrameB = { p: vec3(localB), q: HINGE_FRAME };
|
||||
// Stiffer limit solver on hinges so a heavy impact cannot soft-blow past
|
||||
// the hyperextension stop (knees) or the elbow lock.
|
||||
jd.base.constraintHertz = 90;
|
||||
jd.base.constraintDampingRatio = 3;
|
||||
jd.enableLimit = true;
|
||||
jd.lowerAngle = def.hinge[0];
|
||||
jd.upperAngle = def.hinge[1];
|
||||
// Springs start off — see setJointStiffness.
|
||||
jd.enableSpring = false;
|
||||
jd.hertz = 0;
|
||||
jd.dampingRatio = 0.7;
|
||||
jointId = api.b3CreateRevoluteJoint(world, jd);
|
||||
} else {
|
||||
// Cone axis is frame Z, so point Z down the limb.
|
||||
_axis.copy(localA).normalize();
|
||||
const frameQ = localA.lengthSq() > 1e-9
|
||||
? quat(_dq.setFromUnitVectors(_zAxis, _axis))
|
||||
: IDENTITY_QUAT;
|
||||
const jd = api.b3DefaultSphericalJointDef();
|
||||
jd.base.bodyIdA = a.body;
|
||||
jd.base.bodyIdB = b.body;
|
||||
jd.base.localFrameA = { p: vec3(localA), q: frameQ };
|
||||
jd.base.localFrameB = { p: vec3(localB), q: frameQ };
|
||||
jd.enableConeLimit = true;
|
||||
jd.coneAngle = def.cone ?? 0.6;
|
||||
jd.enableTwistLimit = true;
|
||||
jd.lowerTwistAngle = -(def.twist ?? 0.5);
|
||||
jd.upperTwistAngle = def.twist ?? 0.5;
|
||||
// Springs start off. A spring pulls each joint toward its neutral (bind
|
||||
// pose) rotation, and at any usable stiffness that turns the rig into a
|
||||
// self-supporting mannequin: it balances on straight legs and never
|
||||
// collapses. Stiffness is applied deliberately via setJointStiffness for
|
||||
// the partial "spring-damper blend" reaction, and left at zero for a real
|
||||
// collapse.
|
||||
jd.enableSpring = false;
|
||||
jd.hertz = 0;
|
||||
jd.dampingRatio = 0.65;
|
||||
jointId = api.b3CreateSphericalJoint(world, jd);
|
||||
}
|
||||
joints.push({ id: jointId, a: a.name, b: b.name, def, hinge: !!def.hinge });
|
||||
}
|
||||
|
||||
let mode = 'driven';
|
||||
let stiffness = 0;
|
||||
|
||||
/**
|
||||
* Joint stiffness — the spring-damper blend.
|
||||
*
|
||||
* `hertz` 0 gives a fully limp rig that collapses under its own weight; the
|
||||
* useful range for a reaction that recovers its pose is roughly 2–6 Hz. High
|
||||
* values make the rig self-supporting, which is right for a stumble and wrong
|
||||
* for a death.
|
||||
*/
|
||||
function setJointStiffness(hertz, dampingRatio = 0.65) {
|
||||
stiffness = hertz;
|
||||
const on = hertz > 0.01;
|
||||
for (const j of joints) {
|
||||
if (j.severed) continue;
|
||||
if (j.hinge) {
|
||||
api.b3RevoluteJoint_EnableSpring(j.id, on);
|
||||
if (on) {
|
||||
api.b3RevoluteJoint_SetSpringHertz(j.id, hertz);
|
||||
api.b3RevoluteJoint_SetSpringDampingRatio(j.id, dampingRatio);
|
||||
}
|
||||
} else {
|
||||
api.b3SphericalJoint_EnableSpring(j.id, on);
|
||||
if (on) {
|
||||
api.b3SphericalJoint_SetSpringHertz(j.id, hertz);
|
||||
api.b3SphericalJoint_SetSpringDampingRatio(j.id, dampingRatio);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Push the animated skeleton into the physics bodies (driven mode). */
|
||||
function syncFromSkeleton(dt) {
|
||||
for (const part of order) {
|
||||
part.bone.matrixWorld.decompose(_wp, _wq, _ws);
|
||||
api.b3Body_SetTargetTransform(part.body, transform(_wp, _wq), dt, true);
|
||||
}
|
||||
}
|
||||
|
||||
// Sanity ceilings for the handoff. A limb tip in a hard stride runs well under
|
||||
// these; anything above is a sampling artefact, and letting it through
|
||||
// launches the whole rig into the air the instant it goes limp.
|
||||
const MAX_LIN = 12; // m/s
|
||||
const MAX_ANG = 30; // rad/s
|
||||
|
||||
/**
|
||||
* Sample bone velocities, once per rendered frame.
|
||||
*
|
||||
* This deliberately does *not* live in syncFromSkeleton. That runs once per
|
||||
* fixed substep while the skeleton only moves once per rendered frame, so a
|
||||
* delta measured there gets divided by the substep duration rather than the
|
||||
* frame duration — inflating velocity by the substep count and leaving the
|
||||
* stored value dependent on which substep happened to run last.
|
||||
*/
|
||||
function sampleVelocities(frameDt) {
|
||||
const inv = frameDt > 1e-5 ? 1 / frameDt : 0;
|
||||
for (const part of order) {
|
||||
part.bone.matrixWorld.decompose(_wp, _wq, _ws);
|
||||
|
||||
part.linVel.subVectors(_wp, part.prevPos).multiplyScalar(inv);
|
||||
if (part.linVel.lengthSq() > MAX_LIN * MAX_LIN) part.linVel.setLength(MAX_LIN);
|
||||
|
||||
_prevQ.copy(part.prevQuat).invert();
|
||||
_dq.copy(_wq).multiply(_prevQ);
|
||||
if (_dq.w < 0) _dq.set(-_dq.x, -_dq.y, -_dq.z, -_dq.w); // shortest arc
|
||||
const angle = 2 * Math.acos(Math.min(1, _dq.w));
|
||||
if (angle > 1e-5) {
|
||||
const s = Math.sqrt(Math.max(1e-12, 1 - _dq.w * _dq.w));
|
||||
part.angVel.set(_dq.x / s, _dq.y / s, _dq.z / s).multiplyScalar(angle * inv);
|
||||
if (part.angVel.lengthSq() > MAX_ANG * MAX_ANG) part.angVel.setLength(MAX_ANG);
|
||||
} else part.angVel.set(0, 0, 0);
|
||||
|
||||
part.prevPos.copy(_wp);
|
||||
part.prevQuat.copy(_wq);
|
||||
}
|
||||
}
|
||||
|
||||
// Bone name -> the world quaternion its body currently reports.
|
||||
const bodyWorldQ = new Map();
|
||||
// Accumulated world quaternion per bone during the write-back walk.
|
||||
const accumQ = new Map();
|
||||
const _mq = new THREE.Quaternion();
|
||||
|
||||
/**
|
||||
* Read the physics bodies back onto the skeleton (limp mode).
|
||||
*
|
||||
* Two passes, because a bone's local rotation depends on its parent's *new*
|
||||
* world rotation. Reading `parent.matrixWorld` mid-walk would use last
|
||||
* frame's value and skew every limb down the chain.
|
||||
*
|
||||
* The walk also has to handle bones with no body of their own (root,
|
||||
* clavicles, toes): they keep their current local rotation and simply pass
|
||||
* the accumulated world rotation through. That matters because upperArm's
|
||||
* *bone* parent is the clavicle while its *joint* parent is spine3.
|
||||
*/
|
||||
const _moverQinv = new THREE.Quaternion();
|
||||
const _physWorld = new THREE.Quaternion();
|
||||
const _animWorld = new THREE.Quaternion();
|
||||
const _localTarget = new THREE.Quaternion();
|
||||
const _rootTarget = new THREE.Vector3();
|
||||
|
||||
/**
|
||||
* Write the physics pose onto the skeleton, blended against the pose the
|
||||
* animator just produced.
|
||||
*
|
||||
* `weight` 1 is a full ragdoll; anything between is the spring-damper
|
||||
* blend — the body is deflected by the blow but the animation still shows
|
||||
* through, and as the weight decays the skater recovers their stance.
|
||||
*
|
||||
* Blending happens in *world* space per bone and is converted back to a local
|
||||
* rotation afterwards. Slerping local rotations instead would compound down
|
||||
* the chain: a half-weight shoulder followed by a half-weight elbow does not
|
||||
* put the hand halfway between the two poses.
|
||||
*/
|
||||
function blendToSkeleton(moverMatrixInverse, weight = 1, { includeRoot = true } = {}) {
|
||||
if (weight <= 0.0005) return;
|
||||
const w = Math.min(1, weight);
|
||||
|
||||
bodyWorldQ.clear();
|
||||
accumQ.clear();
|
||||
for (const part of order) {
|
||||
const t = api.b3Body_GetTransform(part.body);
|
||||
bodyWorldQ.set(part.bone.name, _mq.set(t.q.v.x, t.q.v.y, t.q.v.z, t.q.s).clone());
|
||||
}
|
||||
|
||||
// The mover may be rotated, so body world rotations have to be brought into
|
||||
// the mover's frame before they become bone locals.
|
||||
_moverQinv.identity();
|
||||
if (moverMatrixInverse) _moverQinv.setFromRotationMatrix(moverMatrixInverse);
|
||||
|
||||
const walk = (bone, parentWorld) => {
|
||||
const phys = bodyWorldQ.get(bone.name);
|
||||
// The animated world rotation this bone would have had, given the already
|
||||
// blended parent above it.
|
||||
_animWorld.copy(parentWorld).multiply(bone.quaternion);
|
||||
let world;
|
||||
if (phys) {
|
||||
_physWorld.copy(_moverQinv).multiply(phys);
|
||||
world = _animWorld.clone().slerp(_physWorld, w);
|
||||
_pqi.copy(parentWorld).invert();
|
||||
_localTarget.copy(_pqi).multiply(world);
|
||||
bone.quaternion.copy(_localTarget);
|
||||
} else {
|
||||
world = _animWorld.clone();
|
||||
}
|
||||
accumQ.set(bone.name, world);
|
||||
for (const child of bone.children) if (child.isBone) walk(child, world);
|
||||
};
|
||||
|
||||
const rootBone = skelData.bones.root;
|
||||
const animRootQ = rootBone.quaternion.clone();
|
||||
rootBone.quaternion.identity();
|
||||
walk(rootBone, new THREE.Quaternion());
|
||||
if (w < 1) rootBone.quaternion.slerpQuaternions(animRootQ, rootBone.quaternion, w);
|
||||
|
||||
// The pelvis carries the rig's position; every other bone is rotation-only,
|
||||
// so the hierarchy keeps the limbs attached to it. Partial reactions leave
|
||||
// the root alone — displacing it slides the skater across the ice, which
|
||||
// reads as teleporting rather than as being hit.
|
||||
const pelvis = parts.pelvis;
|
||||
if (includeRoot && pelvis) {
|
||||
const t = api.b3Body_GetTransform(pelvis.body);
|
||||
_wp.set(t.p.x, t.p.y, t.p.z);
|
||||
if (moverMatrixInverse) _wp.applyMatrix4(moverMatrixInverse);
|
||||
_rootTarget.copy(_wp).sub(pelvis.bone.position);
|
||||
rootBone.position.lerp(_rootTarget, w);
|
||||
}
|
||||
}
|
||||
|
||||
/** Full ragdoll write-back. */
|
||||
function syncToSkeleton(moverMatrixInverse) {
|
||||
blendToSkeleton(moverMatrixInverse, 1, { includeRoot: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Snap the physics bodies onto the current skeleton pose.
|
||||
*
|
||||
* Needed when handing control back to animation: the bodies are wherever the
|
||||
* simulation left them, and driving a kinematic body toward a distant target
|
||||
* makes Box3D derive a huge velocity, which would fling anything it touches.
|
||||
*/
|
||||
function snapToSkeleton() {
|
||||
for (const part of order) {
|
||||
part.bone.matrixWorld.decompose(_wp, _wq, _ws);
|
||||
api.b3Body_SetTransform(part.body, vec3(_wp), quat(_wq));
|
||||
api.b3Body_SetLinearVelocity(part.body, { x: 0, y: 0, z: 0 });
|
||||
api.b3Body_SetAngularVelocity(part.body, { x: 0, y: 0, z: 0 });
|
||||
part.prevPos.copy(_wp);
|
||||
part.prevQuat.copy(_wq);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Modes:
|
||||
* 'driven' kinematic, chases the animation exactly
|
||||
* 'reacting' dynamic with stiff joints — deflects under a blow and is
|
||||
* expected to be blended back toward the animated pose
|
||||
* 'limp' dynamic and slack; gravity wins
|
||||
*/
|
||||
function setMode(next) {
|
||||
if (next === mode) return;
|
||||
const dynamic = next === 'limp' || next === 'reacting';
|
||||
if (!dynamic) snapToSkeleton();
|
||||
// Limbs only join the collision world while the rig is dynamic.
|
||||
//
|
||||
// A kinematic limb cannot be pushed, but it *can* push: a driven skater's
|
||||
// arm swinging through its stride would shove other skaters' proxy capsules
|
||||
// around, so an idle bystander could be checked by someone's elbow. Once
|
||||
// the rig goes dynamic that is exactly what we want — a falling body should
|
||||
// take people's legs out — so the mask is widened here rather than being
|
||||
// fixed once at build time.
|
||||
const mask = dynamic ? limpMask : drivenMask;
|
||||
for (const part of order) {
|
||||
if (part.filterMask !== mask) {
|
||||
_filter.categoryBits = filter.category;
|
||||
_filter.maskBits = mask;
|
||||
_filter.groupIndex = 0;
|
||||
api.b3Shape_SetFilter(part.shape, _filter, true);
|
||||
part.filterMask = mask;
|
||||
}
|
||||
api.b3Body_SetType(part.body, dynamic ? api.b3BodyType.b3_dynamicBody : api.b3BodyType.b3_kinematicBody);
|
||||
if (dynamic) {
|
||||
// Carry the animated motion across so the reaction continues the motion.
|
||||
api.b3Body_SetLinearVelocity(part.body, vec3(part.linVel));
|
||||
api.b3Body_SetAngularVelocity(part.body, vec3(part.angVel));
|
||||
if (next === 'reacting') {
|
||||
// Damping holds the flinch together without killing the impulse.
|
||||
// (1.6/2.2 made light hits die in place; recover via blend weight instead.)
|
||||
api.b3Body_SetLinearDamping(part.body, 0.85);
|
||||
api.b3Body_SetAngularDamping(part.body, 1.15);
|
||||
} else {
|
||||
api.b3Body_SetLinearDamping(part.body, 0.1);
|
||||
api.b3Body_SetAngularDamping(part.body, 0.25);
|
||||
}
|
||||
}
|
||||
api.b3Body_SetAwake(part.body, true);
|
||||
}
|
||||
mode = next;
|
||||
}
|
||||
|
||||
/** Apply a world-space impulse at a world point to one part. */
|
||||
function applyImpulse(partName, impulse, worldPoint) {
|
||||
const part = parts[partName];
|
||||
if (!part) return;
|
||||
api.b3Body_ApplyLinearImpulse(
|
||||
part.body,
|
||||
vec3(impulse),
|
||||
worldPoint ? vec3(worldPoint) : api.b3Body_GetPosition(part.body),
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
function applyTorqueImpulse(partName, torque) {
|
||||
const part = parts[partName];
|
||||
if (!part) return;
|
||||
api.b3Body_ApplyAngularImpulse(part.body, vec3(torque), true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Total mass of the rig, for stagger thresholds. Uses the figures captured at
|
||||
* build time rather than querying the bodies, which report zero while kinematic.
|
||||
*/
|
||||
function totalMass() {
|
||||
let m = 0;
|
||||
for (const part of order) m += part.mass;
|
||||
return m;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sever a joint: the limb below it becomes independent debris still made of
|
||||
* the same bodies, so it keeps colliding and can be sent flying.
|
||||
*/
|
||||
function severJoint(childPartName) {
|
||||
const j = joints.find((x) => x.b === childPartName);
|
||||
if (!j || j.severed) return false;
|
||||
api.b3DestroyJoint(j.id, true);
|
||||
j.severed = true;
|
||||
const part = parts[childPartName];
|
||||
if (part) part.disabled = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
function partForRegion(region) {
|
||||
return order.filter((p) => p.region === region);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write each capsule's segment into world space.
|
||||
*
|
||||
* Read off the bone matrices rather than off the Box3D bodies, so the answer
|
||||
* is correct in both modes: while driven the bodies chase the bones a substep
|
||||
* behind, and a hit resolved against last substep's pose picks the wrong limb
|
||||
* at speed. Reuses one array of scratch vectors — the caller must not hold on
|
||||
* to what it gets back.
|
||||
*/
|
||||
const _segments = order.map(() => ({
|
||||
part: null, a: new THREE.Vector3(), b: new THREE.Vector3(), radius: 0,
|
||||
}));
|
||||
function worldSegments() {
|
||||
for (let i = 0; i < order.length; i++) {
|
||||
const part = order[i];
|
||||
const seg = _segments[i];
|
||||
part.bone.updateWorldMatrix(true, false);
|
||||
seg.part = part;
|
||||
seg.a.copy(part.localA).applyMatrix4(part.bone.matrixWorld);
|
||||
seg.b.copy(part.localB).applyMatrix4(part.bone.matrixWorld);
|
||||
seg.radius = part.radius;
|
||||
}
|
||||
return _segments;
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
for (const j of joints) if (!j.severed) api.b3DestroyJoint(j.id, false);
|
||||
for (const part of order) api.b3DestroyBody(part.body);
|
||||
}
|
||||
|
||||
return {
|
||||
parts,
|
||||
order,
|
||||
joints,
|
||||
get mode() { return mode; },
|
||||
get stiffness() { return stiffness; },
|
||||
setMode,
|
||||
setJointStiffness,
|
||||
sampleVelocities,
|
||||
syncFromSkeleton,
|
||||
syncToSkeleton,
|
||||
blendToSkeleton,
|
||||
snapToSkeleton,
|
||||
applyImpulse,
|
||||
applyTorqueImpulse,
|
||||
severJoint,
|
||||
partForRegion,
|
||||
worldSegments,
|
||||
totalMass,
|
||||
destroy,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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');
|
||||
@@ -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
@@ -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
@@ -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');
|
||||
@@ -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
@@ -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
@@ -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');
|
||||
@@ -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');
|
||||
@@ -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');
|
||||
@@ -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');
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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'] },
|
||||
});
|
||||
Reference in New Issue
Block a user