animations

This commit is contained in:
ryanfitzpatrickio
2026-08-03 19:20:00 -05:00
parent fb1ebeed05
commit afd764e5e7
22 changed files with 2049 additions and 212 deletions
+5 -1
View File
@@ -1,7 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/.freemocap-venv/lib/python3.12/site-packages" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/.freemocap-venv/lib/python3.12/site-packages/jedi/inference/compiled" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/.freemocap-venv/lib/python3.12/site-packages/pandas/tests/extension" isTestSource="false" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
+7 -4
View File
@@ -55,10 +55,11 @@ 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) |
| **left stick** | **WASD** | skate (camera-relative); during a wind-up, aims across the net |
| **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 |
| **LT** | Space | contain / backskate — hold facing, moderated speed, stick reverse = retreat |
| **right stick** | arrows | Skill Stick: stickhandle, pull back to wind up, push to release; waggle while RB held |
| **RB** | E | stick spread — blade out on the ice, RS left/right to swat / intercept |
| **A** | J | pass to the nearest teammate |
| **X** | K | shoot |
| **B** | L | poke check |
@@ -96,7 +97,9 @@ check that a change did not break the render path.
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
- Shooting with wind-up power off the Skill Stick (placement from the movement
stick + how you skate during the wind-up — EA-style net aim), passing, poke
checks, and
contact knocking the puck off whoever is carrying it.
Roster size is a parameter, not a constant: `createMatch({ perTeam, teams })`,
+2 -1
View File
@@ -149,7 +149,8 @@ Hours to days each.
spread. Needs hands on a pad, not test output.
### Controls / possession
- `LT` puck protection — the analog value is computed and discarded.
- `LT` puck protection when carrying — LT is now **backskating**; protection
can layer on top of the analog later without stealing the backskate feel.
- `RB` deke modifier.
- Bot stickhandling (`possession.handling` is human-only; bots leave it at 0).
- Forehand / backhand blade roll.
+1 -1
View File
@@ -8,7 +8,7 @@
"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/animationClip.mjs && node test/freemocapImport.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",
"test": "node test/skaterSim.mjs && node test/rink.mjs && node test/ai.mjs && node test/pose.mjs && node test/animationClip.mjs && node test/freemocapImport.mjs && node test/input.mjs && node test/shotAim.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",
+132 -17
View File
@@ -24,6 +24,13 @@ import { normalizePlayer } from './player.js';
* 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.
*
* **LT / L2 contain & backskate.** EA NHL style: holding LT does *not* spin
* you around. Facing stays put (eyes on the play) and the stick drives
* movement relative to that facing — push back to retreat heels-first, push
* forward for a careful crawl, or leave the stick centred to bleed speed and
* stay neutral instead of flying up-ice. Reverse-stick without LT is still a
* hockey stop; LT is the controlled/defensive gear.
*/
export const SKATE = Object.freeze({
@@ -35,6 +42,27 @@ export const SKATE = Object.freeze({
accel: 7.2,
/** Extra push while sprinting. */
sprintAccel: 9.0,
/**
* LT contain speeds (m/s). Forward stays a crawl so you don't fly the zone;
* reverse and lateral are quick so you can open up and gap-control.
*/
containForwardSpeed: 3.2,
containForwardSprint: 4.0,
containBackSpeed: 5.8,
containBackSprint: 6.8,
containLateralSpeed: 5.4,
containLateralSprint: 6.4,
/** Accel under LT (m/s²). Reverse / lateral / direction flips are snappy. */
containForwardAccel: 5.5,
containBackAccel: 16.0,
containLateralAccel: 15.0,
/** Extra accel when flipping fore↔aft or side under LT (offense ↔ defense). */
containPivotAccel: 20.0,
/**
* Soft decel when LT is held and the stick is centred (or nearly). Bleeds a
* rush without a full snowplow so you can sit neutral in the gap.
*/
containIdleDecel: 3.2,
/** 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)². */
@@ -49,6 +77,11 @@ export const SKATE = Object.freeze({
turnRate: 5.2,
/** Body yaw rate at top speed — you cannot pivot on a rail. */
turnRateFast: 1.9,
/**
* Facing turn rate scale while LT is held. Zero: stick steers *travel* only,
* chest holds so you do not spin when retreating.
*/
containTurnScale: 0,
/** Proxy capsule radius, also used for board clamping. */
radius: 0.36,
/** Never let a collision fling anyone faster than this. */
@@ -83,6 +116,11 @@ export function createSkaterState(id, spawn = {}, opts = {}) {
sprint: false,
/** Held brake — a hockey stop, independent of which way the stick points. */
brake: false,
/**
* LT / L2 contain & backskate. Facing holds; stick drives travel relative
* to facing (including reverse) at a moderated speed.
*/
backskate: false,
// ---- read-only outputs the animator and the AI read -------------------
/** Signed speed along the blade. Negative means gliding backwards. */
@@ -108,6 +146,7 @@ export function applyIntent(s, msg) {
s.iz = iz;
s.sprint = !!msg.sprint;
s.brake = !!msg.brake;
s.backskate = !!msg.backskate;
}
/** Unit forward for a yaw, in three.js's convention (+Z is forward at yaw 0). */
@@ -123,15 +162,17 @@ export const forwardZ = (yaw) => Math.cos(yaw);
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);
const backskating = !!s.backskate;
// ---- 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) {
// Normal skating: body turns toward the stick.
// LT contain: facing *holds* — stick steers travel relative to the chest so
// a reverse push is heels-first without spinning around.
if (intentLen > 0.05 && !(backskating && SKATE.containTurnScale <= 0)) {
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;
let rate = SKATE.turnRate + (SKATE.turnRateFast - SKATE.turnRate) * t;
if (backskating) rate *= SKATE.containTurnScale;
s.yaw = lerpAngle(s.yaw, intentYaw, Math.min(1, rate * dt));
}
s.yaw = wrapAngle(s.yaw);
@@ -140,17 +181,22 @@ export function stepSkater(s, dt, { clampBoards = true } = {}) {
// 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°.
//
// Under LT we ease the grip a little so reverse and lateral shuffles are not
// immediately crushed onto a pure blade line.
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 edge = backskating ? SKATE.edgeGrip * 0.55 : SKATE.edgeGrip;
const grip = 1 - Math.exp(-edge * 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 scrub = backskating ? SKATE.carveScrub * 0.45 : SKATE.carveScrub;
const kept = 1 - clamp(scrub * Math.abs(turnBy), 0, 0.6);
const speed = speed0 * kept;
s.vx = Math.sin(newVelYaw) * speed;
s.vz = Math.cos(newVelYaw) * speed;
@@ -165,21 +211,86 @@ export function stepSkater(s, dt, { clampBoards = true } = {}) {
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.
// Stick relative to facing: +1 = stick ahead, 1 = stick behind the heels.
const align = intentLen > 0.05 ? (s.ix * fx + s.iz * fz) / intentLen : 0;
const side = intentLen > 0.05 ? (s.ix * rx + s.iz * rz) / intentLen : 0;
let effort = 0;
if (s.brake || (intentLen > 0.05 && align < -0.35 && vf > 0.4)) {
// Explicit brake, or reverse-stick hockey stop *while not* holding LT.
// With LT down, reverse stick means *skate* reverse — eyes stay on the play.
const reverseStop = !backskating
&& intentLen > 0.05
&& align < -0.35
&& vf > 0.4;
if (s.brake || reverseStop) {
// 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 (backskating) {
// Contain gear: facing holds; stick drives travel relative to the chest.
// Forward is a crawl (don't fly the zone). Reverse and left/right are
// snappy so a D-man can open up, gap, and jump back into the play.
const fwdMax = s.sprint ? SKATE.containForwardSprint : SKATE.containForwardSpeed;
const backMax = s.sprint ? SKATE.containBackSprint : SKATE.containBackSpeed;
const latMax = s.sprint ? SKATE.containLateralSprint : SKATE.containLateralSpeed;
if (intentLen > 0.08) {
// Desired blade speed: stick fore → crawl, stick aft → reverse.
const alongMax = align >= 0 ? fwdMax : backMax;
const desiredVf = align * intentLen * alongMax;
const gap = desiredVf - vf;
// Near target ease off; when flipping direction, stay full-send.
const flipping = Math.abs(vf) > 0.4
&& Math.abs(desiredVf) > 0.2
&& Math.sign(desiredVf) !== Math.sign(vf);
const headroom = flipping
? 1
: (Math.abs(gap) > 1e-4
? clamp(Math.abs(gap) / Math.max(0.3, alongMax), 0, 1)
: 0);
let base;
if (flipping) base = SKATE.containPivotAccel;
else if (align < -0.08 || desiredVf < vf && desiredVf < 0) base = SKATE.containBackAccel;
else base = SKATE.containForwardAccel;
vf += Math.sign(gap || 1) * base * headroom * dt;
if (vf > fwdMax) vf = fwdMax;
if (vf < -backMax) vf = -backMax;
// Lateral shuffle: full side speed, high accel, snappy side-to-side cuts.
const desiredVr = side * intentLen * latMax;
const latGap = desiredVr - vr;
const latFlip = Math.abs(vr) > 0.35
&& Math.abs(desiredVr) > 0.15
&& Math.sign(desiredVr) !== Math.sign(vr);
const latHead = latFlip
? 1
: (Math.abs(latGap) > 1e-4
? clamp(Math.abs(latGap) / Math.max(0.25, latMax), 0, 1)
: 0);
const latBase = latFlip ? SKATE.containPivotAccel : SKATE.containLateralAccel;
vr += Math.sign(latGap || 1) * latBase * latHead * dt;
if (vr > latMax) vr = latMax;
if (vr < -latMax) vr = -latMax;
// Effort reads higher on reverse / cut so the legs keep moving.
const cut = Math.max(Math.abs(align), Math.abs(side));
effort = clamp(intentLen * (0.45 + 0.55 * cut), 0, 1);
} else {
// Stick centred under LT: sit in the gap. Bleed speed without snowplowing.
const decel = SKATE.containIdleDecel * dt;
if (Math.abs(vf) <= decel) vf = 0;
else vf -= Math.sign(vf) * decel;
if (Math.abs(vr) <= decel) vr = 0;
else vr -= Math.sign(vr) * decel;
effort = speed0 > 0.8 ? 0.35 : 0.08;
}
} 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.
// Forward 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 maxSpeed = s.sprint ? SKATE.sprintSpeed : SKATE.cruiseSpeed;
const base = s.sprint ? SKATE.sprintAccel : SKATE.accel;
const headroom = clamp(1 - vf / maxSpeed, 0, 1);
vf += base * align * intentLen * headroom * dt;
@@ -195,9 +306,13 @@ export function stepSkater(s, dt, { clampBoards = true } = {}) {
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);
// Lateral: normally killed hard. Under LT a shuffle is intentional — barely
// scrub so left/right accel actually sticks between steps.
if (backskating) {
vr *= Math.exp(-SKATE.edgeGrip * 0.12 * dt);
} else {
vr *= Math.exp(-SKATE.edgeGrip * 2 * dt);
}
s.vx = fx * vf + rx * vr;
s.vz = fz * vf + rz * vr;
+6 -3
View File
@@ -51,7 +51,7 @@ export const SKATE_POSE = {
* Foot targets are not written here; they are world-space and belong to the
* stepper.
*/
export function poseSkate(P, { gait, speed, bank, phase, t }) {
export function poseSkate(P, { gait, speed, bank, phase, t, backskating = false }) {
const K = SKATE_POSE;
const fast = clamp(speed / 7, 0, 1);
const s1 = Math.sin(phase * Math.PI * 2);
@@ -60,8 +60,11 @@ export function poseSkate(P, { gait, speed, bank, phase, t }) {
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;
// Backskating is more upright — eyes on the rush, weight over the heels —
// not the deep forward lean of a full-speed stride.
const pitchScale = backskating ? 0.45 : 1;
const pitch = lerp(K.leanIdle, K.leanFast, fast) * pitchScale;
const twist = K.hipTwist * gait * (backskating ? 0.7 : 1);
// 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.
+70
View File
@@ -285,6 +285,76 @@ export function posePass(P, { phase = 0, aim = 0 }) {
E(P.q.handL, -0.1, 0, -0.12);
}
/**
* Stick spread (RB hold): dominant arm drives the blade on a *frontal* cone —
* centre is out in front of the chest, Skill Stick X (`waggle` 1..1) sweeps
* left↔right up to ±90°. Never behind the hips. Non-dominant arm is off the
* shaft, out to the side in a ready stance. Slight crouch.
*
* Authored for a right shot (top hand R). Left shots mirror.
*/
export function poseSpread(P, { waggle = 0, phase = 1 } = {}) {
const w = clamp(phase, 0, 1);
// Skill Stick +X = screen-right. Match the stick target's sign flip so the
// arm and blade travel the same way (see skateAnimator SPREAD_ARC).
const a = -clamp(waggle, -1, 1);
const ang = a * (Math.PI / 2);
const fwd = Math.cos(ang); // 1 centre, 0 at full left/right
const lat = Math.sin(ang); // body-right after the stick-sign flip
// Bone space: +Y twist that opens the right arm across the body is opposite
// of body-right, so lateral into the arm uses lat.
const side = -lat;
// Sit into it — eyes up the ice; torso opens with the lateral sweep only.
E(P.q.spine1, 0.1 * w, -0.04 * w + side * 0.12, 0);
E(P.q.spine2, 0.12 * w, -0.05 * w + side * 0.14, 0);
E(P.q.spine3, 0.08 * w, -0.04 * w + side * 0.12, 0);
E(P.q.neck, -0.08 * w, 0.06 * w - side * 0.14, 0);
E(P.q.head, -0.06 * w, 0.06 * w - side * 0.16, 0);
// Dominant (right) arm: extended low. Centre = straight out in front;
// full stick swings the arm across the body front to each side (not
// forward/back along the midline).
E(P.q.clavicleR, 0.04 * w, -0.06 * w + side * 0.22, 0.05 * w);
E(
P.q.upperArmR,
// Pitch: reach low toward the ice; a bit more extended when out front.
lerp(-0.42, -0.28, w) - fwd * 0.12,
// Yaw: centre ≈ forward of the shoulder; lat swings ±90° in front.
lerp(0.42, 0.12, w) + side * 1.05,
lerp(0.68, 0.0, w) + Math.abs(lat) * 0.1,
);
E(
P.q.forearmR,
lerp(-1.35, -0.2, w) - fwd * 0.08,
lerp(0.02, 0.04, w) + side * 0.25,
lerp(0.32, 0.0, w),
);
E(
P.q.handR,
lerp(-0.12, -0.2, w),
lerp(0.12, 0.0, w) + side * 0.22,
lerp(0.04, 0.06, w),
);
// Non-dominant (left) arm: detached, out to the side and slightly forward —
// ready stance, does not follow the waggle.
E(P.q.clavicleL, 0.05 * w, 0.12 * w, -0.06);
E(
P.q.upperArmL,
lerp(-0.38, -0.26, w),
lerp(0.1, 0.68, w), // open wide to the left
lerp(-0.48, 0.55, w), // rolled out, clear of the shaft
);
E(
P.q.forearmL,
lerp(-1.32, -0.92, w),
-0.05,
lerp(0.18, -0.14, w),
);
E(P.q.handL, -0.05, 0.04, 0.02);
}
/**
* 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.
+99 -7
View File
@@ -6,7 +6,7 @@ import { poseSkate, poseStop } from './poses/skate.js';
import {
STICK_ARMS, STICK_BONES, STICK_SPINE,
mirrorStickwork,
poseCarry, posePass, posePoke,
poseCarry, posePass, posePoke, poseSpread,
} from './poses/stickwork.js';
import { frameSpan, sampleTiltStick } from './clip.js';
import { shot1 } from './clips/shot1.js';
@@ -36,6 +36,21 @@ import { STICK } from '../character/stick.js';
/** How far the Skill Stick can push the blade around the carrier, metres. */
const STICK_REACH = { side: 0.5, fwd: 0.34 };
/**
* Stick-spread frontal cone on the ice.
*
* Built from the skater's *facing* (forward / right), not a local-axis guess:
* waggle 0 → straight ahead
* waggle +1 → 90° to the skater's right
* waggle 1 → 90° to the skater's left
* Always in the front half-plane (never behind the hips).
*/
const SPREAD_ARC = {
/** Blade distance from body centre on the ice, metres. */
reach: 1.20,
/** Half-arc at full stick, radians (π/2 = 90° each way). */
maxAngle: Math.PI / 2,
};
/** Foot joint height above the ice — boot plus blade. */
const FOOT_SOLE = 0.085;
@@ -108,6 +123,8 @@ export function buildAnimator(skelData, mover) {
/** Rate the velocity vector is turning, rad/s. Drives the bank. */
yawRate: 0,
braking: false,
/** LT backskate / reverse blade — more upright, smaller forward lean. */
backskating: false,
// ---- derived, smoothed --------------------------------------------------
/** Stride amplitude, 0 (pure glide) .. 1 (digging in). */
@@ -123,6 +140,12 @@ export function buildAnimator(skelData, mover) {
// ---- stickwork ---------------------------------------------------------
/** True while this skater has the puck. Decides the resting grip. */
hasPuck: false,
/**
* Proximity grab: world-space point to IK the blade toward while fishing
* for a loose puck. `weight` 0..1. Null when not grabbing.
* @type {{ x: number, y: number, z: number, weight: number } | null}
*/
grabTarget: null,
/** 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. */
@@ -130,13 +153,15 @@ export function buildAnimator(skelData, mover) {
/** 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.
* Current stick action: null, 'windup', 'spread', 'shoot', 'pass' or 'poke'.
* Wind-up and spread are held; the others run once and blend out.
*/
action: null,
actionTime: 0,
actionPower: 1,
actionAim: 0,
/** Skill Stick X while stick-spreading, 1..1. */
spreadWaggle: 0,
/** A released held wind-up starts shot1 at its midpoint, not frame zero. */
actionFromWindup: false,
/** Eased 0..1 between the settled grip and the one-handed dangle. */
@@ -338,6 +363,17 @@ export function buildAnimator(skelData, mover) {
return w;
}
if (anim.action === 'spread') {
// Held stick-spread. Full replace of the arms once blended in.
const w = Math.min(1, anim.actionTime / ACTION_BLEND);
poseSpread(overlay, {
waggle: anim.spreadWaggle,
phase: Math.min(1, anim.actionTime / 0.14),
});
if (anim.shotSign < 0) mirrorStickwork(overlay);
return w;
}
const duration = ACTION_TIME[anim.action] ?? 0.3;
const t = anim.actionTime / duration;
if (t >= 1) {
@@ -369,6 +405,11 @@ export function buildAnimator(skelData, mover) {
/** 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 === 'spread') {
// Blend to full extension quickly, then hold. Waggle is applied after
// as a target offset so the blade sweeps on the ice.
return ['carry', 'spread', Math.min(1, anim.actionTime / 0.14)];
}
if (anim.action === 'shoot') {
// Three beats matching poseShot: loaded → blade square on the ice →
// follow-through. Contact is short and early so the bottom of the blade
@@ -398,7 +439,13 @@ export function buildAnimator(skelData, mover) {
bank: anim.bank,
phase: anim.stridePhase,
t,
backskating: anim.backskating,
});
// Stick-spread sits a little lower — reach out, weight over the skates.
if (anim.action === 'spread') {
const crouch = Math.min(1, anim.actionTime / 0.14) * 0.07;
P.rootOffset.y -= crouch;
}
for (const side of ['L', 'R']) {
const p = (anim.stridePhase + (side === 'R' ? 0.5 : 0)) % 1;
const toe = strideLocal(side, p, anim.gait, _localFoot);
@@ -643,9 +690,14 @@ export function buildAnimator(skelData, mover) {
// Overwriting it was what stood everybody upright the moment they picked up
// a stick.
for (const n of STICK_BONES) overlay.q[n].identity();
// While fishing for a loose puck, lean the carry a little more extended so
// the stick IK out to the puck does not fight a tucked rest pose.
const grabReach = anim.grabTarget
? clamp(anim.grabTarget.weight, 0, 1) * 0.85
: 0;
poseCarry(overlay, {
hustle: anim.hustleGrip,
reach: anim.handling.y,
hustle: Math.max(anim.hustleGrip, grabReach * 0.45),
reach: anim.handling.y + grabReach * 0.55,
lateral: anim.handling.x,
});
// Authored for a right shot; left shots run the mirrored pose set.
@@ -724,7 +776,45 @@ export function buildAnimator(skelData, mover) {
_stickTarget.x -= anim.handling.x * STICK_REACH.side;
_stickTarget.z += anim.handling.y * STICK_REACH.fwd;
}
_stickTarget.applyMatrix4(mover.matrixWorld);
// Stick-spread: frontal left↔right cone in *world* space from the
// skater's facing. Using forward/right of originYaw (same basis as the
// feet) so the arc cannot get re-interpreted as local X = "front".
if (anim.action === 'spread') {
// Skill Stick +X is screen-right; body-right is the opposite sign on
// the forward/right basis used here (same flip as stickhandling).
const ang = -clamp(anim.spreadWaggle, -1, 1) * SPREAD_ARC.maxAngle;
// forward weight = cos(ang), lateral weight = sin(ang) on body-right.
const fwdW = Math.cos(ang);
const latW = Math.sin(ang);
const r = SPREAD_ARC.reach;
const yaw = anim.originYaw;
const fx = Math.sin(yaw);
const fz = Math.cos(yaw);
const rx = Math.cos(yaw);
const rz = -Math.sin(yaw);
_stickTarget.set(
anim.origin.x + (fx * fwdW + rx * latW) * r,
0.03,
anim.origin.z + (fz * fwdW + rz * latW) * r,
);
} else {
_stickTarget.applyMatrix4(mover.matrixWorld);
// Proximity grab: blend the blade aim toward the loose puck so the
// stick reaches out to snag it. Skipped while spreading / shooting.
const grab = anim.grabTarget;
if (
grab
&& !anim.hasPuck
&& anim.action !== 'shoot'
&& anim.action !== 'pass'
&& anim.action !== 'poke'
) {
const gw = clamp(grab.weight, 0, 1);
_stickTarget.x = lerp(_stickTarget.x, grab.x, gw);
_stickTarget.y = lerp(_stickTarget.y, grab.y ?? 0.03, gw);
_stickTarget.z = lerp(_stickTarget.z, grab.z, gw);
}
}
B[`hand${top}`].getWorldPosition(_handPos);
B[`hand${top}`].getWorldQuaternion(_handQuat);
_handQuat.invert();
@@ -740,7 +830,9 @@ export function buildAnimator(skelData, mover) {
// applying it on top of a blend from a limp pose yanks the lower hand
// onto the shaft mid-rise — measured as a ~0.9 m jump on handR for a
// left shot, where the lower hand *is* the right.
const twoHanded = (1 - anim.hustleGrip) * (anim.action === 'poke' ? 0.15 : 1)
// One-handed: poke and stick-spread (dominant hand only).
const oneHanded = anim.action === 'poke' || anim.action === 'spread';
const twoHanded = (1 - anim.hustleGrip) * (oneHanded ? 0.08 : 1)
* (anim.blend >= 1 ? 1 : 0);
if (twoHanded > 0.05 && !referenceHandsAligned) {
// Preferred lower-hand grip is a bit down the shaft (hands apart, the
+4 -1
View File
@@ -169,7 +169,10 @@ export function createSkater({
animator.bladeSpeed = s.bladeSpeed;
animator.effort = s.effort;
animator.yawRate = yawRate;
animator.braking = !!s.brake;
// Hockey-stop pose only for a true snowplow. Backskating uses the skate
// state with reverse blade speed (heels-first strides).
animator.braking = !!s.brake && !s.backskate;
animator.backskating = !!s.backskate || s.bladeSpeed < -0.35;
},
/** Advance animation, the reaction envelope, and the get-up timer. */
+151 -72
View File
@@ -1,4 +1,5 @@
import * as THREE from 'three';
import { clamp } from '../core/math.js';
import { PART } from './body.js';
import { JERSEY_COVERAGE, jerseyParts, skinnedFrom } from './jersey.js';
import { carvedShell, loft, mergeBars, tint, tube } from './gearMesh.js';
@@ -50,21 +51,24 @@ 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,
riseY: 0.098,
pushZ: 0.002,
/** Half-widths of the shell ellipse (scaled by headF at build). */
rx: 0.118,
ry: 0.142,
rz: 0.132,
/**
* 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.
* Polar angle the shell starts at. Lower = deeper bowl so the brim sits
* under the ear line instead of reading as a beanie on the crown.
*/
phi0: 0.36,
wall: 0.009,
/** Brow line: everything in front of and below this is open face. */
browY: 0.03,
earY: -0.022,
phi0: 0.28,
wall: 0.011,
/** Face port: open below the brow, full cage covers it. */
browY: 0.018,
portW: 0.078,
earY: -0.018,
/** Wire cage bar radius. */
barR: 0.0042,
},
/** Blade bottom, in foot-bone-local metres. Feet plant at y ≈ 0.09. */
bladeY: -0.09,
@@ -414,10 +418,14 @@ export function buildSkaterGear(mats, skelData, phys) {
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.
// White hard-shell player helmet with a full wire cage — not a team-coloured
// beanie. Shape cues from a modern CCM/Bauer shell: flatter crown, defined
// brow, ear pockets, chin cup + strap, grid cage over the open face.
const H = KIT.helmet;
const skull = new THREE.Vector3(0, H.riseY, H.pushZ);
const WHITE = new THREE.Color(0xf4f6f8);
const WHITE_DIM = new THREE.Color(0xd8dde3);
const LINER = new THREE.Color(0x2a2e36);
function helmetSurface(theta, v, out) {
const phi = H.phi0 + (Math.PI - H.phi0) * v;
@@ -427,55 +435,65 @@ export function buildSkaterGear(mats, skelData, phys) {
const sx = Math.sin(theta);
const front = Math.max(0, f);
const back = Math.max(0, -f);
const side = Math.abs(sx);
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;
// Flatten the crown slightly so it reads as a helmet, not a ball.
const crownFlat = 1 - 0.14 * Math.max(0, 1 - v * 1.8) ** 2;
let rx = H.rx * headF * crownFlat;
let ry = H.ry * headF;
let rz = H.rz * headF * crownFlat;
// Occipital bowl + ear flair.
rz *= 1 + 0.12 * back * v;
rx *= 1 + 0.08 * side * (1 - v) * 0.6;
// Forehead flat / brow shelf.
rz *= 1 - 0.10 * front * front * clamp(1 - v * 1.4, 0, 1);
const x = rx * sp * sx;
const y = -H.ry * headF * cp;
const y = -ry * 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);
const lip = Math.exp(-(((v - 0.1) / 0.11) ** 2)) * front ** 2;
z += 0.012 * lip * headF;
// Subtle centre ridge along the crown (vents live here visually).
const ridge = Math.exp(-(sx * sx) / 0.08) * Math.max(0, 1 - v * 1.5) * 0.006 * headF;
return out.set(skull.x + x, skull.y + y + ridge, skull.z + z);
}
/** Open face below the brow, plus a port over each ear. */
/** Open face below the brow + ear ports under the cups. */
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) {
// Full face opening for the cage — wider and deeper than the old half-cap.
if (dz > 0.022 * headF && dy < H.browY && ax < H.portW * headF) return true;
// Ear ports.
if (
ax > 0.09 * headF
&& dy < H.earY + 0.03
&& dy > H.earY - 0.048
&& Math.abs(dz + 0.01) < 0.042
) {
return true;
}
return false;
};
const helmetColor = (p, kind) => {
if (kind === 'inner') return PAL.pad;
if (kind === 'inner') return LINER;
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;
// Dark rubber brim / chin-strap mounts along the lower edge.
if (dy < -0.055 * headF) return LINER;
// Soft grey vents strip on the crown midline.
if (Math.abs(p.x) < 0.016 * headF && dy > 0.04 * headF) return WHITE_DIM;
return WHITE;
};
const helmet = new THREE.Group();
helmet.name = 'helmet';
helmet.add(mesh(
carvedShell({
rows: 26,
cols: 36,
rows: 32,
cols: 44,
thickness: H.wall,
center: skull,
surface: helmetSurface,
@@ -486,49 +504,104 @@ export function buildSkaterGear(mats, skelData, phys) {
'helmetShell',
));
// Ear cups over the ports, on their own straps.
// Ear cups — white shells with dark rims over the ports.
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) });
S(V(s * 0.092 * headF, skull.y + H.earY, skull.z - 0.012), 0.032, 0.03, 3.2, WHITE),
S(V(s * 0.108 * headF, skull.y + H.earY, skull.z - 0.012), 0.034, 0.032, 3.2, WHITE),
S(V(s * 0.116 * headF, skull.y + H.earY, skull.z - 0.012), 0.026, 0.024, 3.5, WHITE_DIM),
], { radial: 14, sub: 3, ref: new THREE.Vector3(0, 1, 0) });
helmet.add(mesh(cup, mats.hard, 'helmetEar'));
}
// Chin strap under the jaw.
// Chin cup + strap under the jaw.
{
const cup = loft([
S(V(0, skull.y - 0.128 * headF, skull.z + 0.048 * headF), 0.038, 0.018, 3.5, WHITE),
S(V(0, skull.y - 0.142 * headF, skull.z + 0.056 * headF), 0.042, 0.02, 3.5, WHITE),
S(V(0, skull.y - 0.152 * headF, skull.z + 0.05 * headF), 0.034, 0.016, 4, WHITE_DIM),
], { radial: 12, sub: 3 });
helmet.add(mesh(cup, mats.hard, 'helmetChinCup'));
}
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 }),
V(-0.11 * headF, skull.y + H.earY - 0.01, skull.z - 0.008),
V(-0.072 * headF, skull.y - 0.118 * headF, skull.z + 0.028 * headF),
V(0, skull.y - 0.14 * headF, skull.z + 0.052 * headF),
V(0.072 * headF, skull.y - 0.118 * headF, skull.z + 0.028 * headF),
V(0.11 * headF, skull.y + H.earY - 0.01, skull.z - 0.008),
], 0.0055, { radial: 6 }),
mats.strap,
'helmetStrap',
));
// Half visor: eye level only. Run it down over the whole face and the player
// reads as a welder.
// Full wire cage over the face port — horizontal + vertical grid + rim.
{
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,
));
const CAGE_W = H.portW * 1.22 * headF;
const CAGE_H = 0.078 * headF;
const portCY = skull.y + H.browY - CAGE_H * 0.55;
const _probe = new THREE.Vector3();
// Sample shell front at brow so the cage sits on the opening, not through it.
helmetSurface(0, 0.12, _probe);
const baseZ = _probe.z - skull.z + 0.004;
const bulge = 0.038 * headF;
const cageAt = (x, dy) => {
const k = 1 - (x / CAGE_W) ** 2 - (dy / CAGE_H) ** 2;
const z = skull.z + baseZ + bulge * Math.sqrt(Math.max(0, k));
return V(x, portCY + dy, z);
};
const bars = [];
// Horizontal bars (4).
for (let i = 0; i < 4; i++) {
const dy = -CAGE_H * 0.72 + (CAGE_H * 1.44 * i) / 3;
const span = CAGE_W * Math.sqrt(Math.max(0, 1 - (dy / CAGE_H) ** 2));
if (span < 0.02) continue;
const pts = [];
for (let j = 0; j <= 10; j++) {
const x = -span + (2 * span * j) / 10;
pts.push(cageAt(clamp(x, -span * 0.995, span * 0.995), dy));
}
bars.push(tube(pts, H.barR, { radial: 5 }));
}
// 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'));
// Vertical bars (3).
for (const fx of [-0.55, 0, 0.55]) {
const x = CAGE_W * fx;
const span = CAGE_H * Math.sqrt(Math.max(0, 1 - (x / Math.max(CAGE_W, 1e-4)) ** 2));
if (span < 0.018) continue;
const pts = [];
for (let j = 0; j <= 10; j++) {
const dy = -span + (2 * span * j) / 10;
pts.push(cageAt(x, clamp(dy, -span * 0.995, span * 0.995)));
}
bars.push(tube(pts, H.barR, { radial: 5 }));
}
// Perimeter rim, slightly thicker, anchored into the shell.
{
const ring = [];
for (let i = 0; i < 28; i++) {
const a = (i / 28) * Math.PI * 2;
const p = cageAt(CAGE_W * 1.04 * Math.cos(a), CAGE_H * 1.04 * Math.sin(a));
p.z -= 0.003;
ring.push(p);
}
bars.push(tube(ring, H.barR * 1.35, { radial: 6, closed: true, segments: 56 }));
}
helmet.add(mesh(mergeBars(bars), mats.cage, 'helmetCage'));
}
// Tiny crown vent ridges (read as vents from a distance).
for (const sx of [-0.028, 0.028]) {
helmet.add(mesh(
tube([
V(sx * headF, skull.y + 0.1 * headF, skull.z - 0.04 * headF),
V(sx * headF, skull.y + 0.118 * headF, skull.z + 0.01 * headF),
V(sx * headF, skull.y + 0.1 * headF, skull.z + 0.05 * headF),
], 0.0035, { radial: 5 }),
mats.hard,
'helmetVent',
));
}
pieces.push(helmet);
@@ -583,8 +656,14 @@ export function buildSkaterGearMaterials(teamJersey, teamAccent = 0xf0e6d2) {
hard: new THREE.MeshStandardMaterial({
color: 0xffffff,
vertexColors: true,
roughness: 0.38,
metalness: 0.06,
roughness: 0.32,
metalness: 0.08,
}),
/** Wire face cage — dark steel, not painted. */
cage: new THREE.MeshStandardMaterial({
color: 0x1a1c22,
roughness: 0.35,
metalness: 0.65,
}),
steel: new THREE.MeshStandardMaterial({
color: 0xc8ccd4,
+7
View File
@@ -86,6 +86,13 @@ export const GRIP = {
follow: { target: [0.28, 0.72, 0.88], roll: 0.42 },
/** Poke: thrust out flat, as far ahead as the arm reaches. */
poke: { target: [-0.18, 0.03, 1.42], roll: 0.05 },
/**
* Stick spread (RB hold): blade on the ice out in front at centre.
* The live aim is a world-space frontal cone from the skater's facing
* (see skateAnimator SPREAD_ARC) — this GRIP is only the blend-in rest.
* +Z is forward, +X is left.
*/
spread: { target: [0.0, 0.03, 1.20], roll: 0.02 },
};
/** Small fixed offset of the butt from the hand bone. */
+106 -13
View File
@@ -70,6 +70,10 @@ 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.
*
* Placement is not primarily the Skill Stick. EA-style aim uses the *movement*
* stick (and how you skate during the wind-up) to open the net; the right stick
* is power + release, with only a light lateral fine-tune. See `shotAim.js`.
*/
const SHOT = {
/** Right stick Y below this counts as winding up. */
@@ -82,6 +86,11 @@ const SHOT = {
timeout: 1.6,
/** Floor so a quick snap still does something. */
minPower: 0.25,
/**
* How fast the move-stick sample tracks during wind-up (1/s). Higher follows
* cuts immediately; lower averages "how you moved into the shot."
*/
moveTrack: 9,
};
const rising = () => ({
@@ -116,8 +125,19 @@ export function createInput(target = window) {
// 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 };
/**
* Wind-up state for the Skill Stick.
* `moveX` / `moveY` are a smoothed sample of the left stick while charged —
* that is "how you moved during the wind-up," the primary aim input.
*/
const wind = {
active: false,
t: 0,
depth: 0,
skillAim: 0,
moveX: 0,
moveY: 0,
};
const state = {
// ---- the movement contract the match consumes --------------------------
@@ -125,6 +145,13 @@ export function createInput(target = window) {
y: 0,
sprint: false,
brake: false,
/** LT / L2 — EA-style backskating (see skaterSim). */
backskate: false,
/**
* RB hold — stick spread / swat. Dominant hand fully extended, blade on
* the ice; Skill Stick X waggles the blade for intercepts.
*/
spread: false,
cameraYaw: 0,
// ---- richer view for everything else -----------------------------------
@@ -141,12 +168,19 @@ export function createInput(target = window) {
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.
* `{ power, move: {x,y}, skillAim, aim }` —
* move left stick sample over the wind-up (primary aim)
* skillAim Skill Stick X at release (light fine-tune)
* aim combined 1..1 hint for UI / anim before match resolves world yaw
*/
shot: null,
/** How wound up the shot is right now, 0..1. Drives the wind-up pose. */
charge: 0,
/**
* Live aim hint while winding up (1..1, + = right). Match uses the full
* move sample for world aim; this is for pose lean and HUD.
*/
aimHint: 0,
source: 'none',
padId: null,
};
@@ -181,6 +215,22 @@ export function createInput(target = window) {
return true;
}
/** Pack a shot event from the current wind + stick samples. */
function packShot(power) {
const moveX = clamp(wind.moveX, -1, 1);
const moveY = clamp(wind.moveY, -1, 1);
const skillAim = clamp(state.skill.x, -1, 1);
// Combined hint: move is primary, skill is a light nudge (matches match).
const aim = clamp(moveX * 0.85 + skillAim * 0.25, -1, 1);
return {
power,
move: { x: moveX, y: moveY },
skillAim,
/** @deprecated prefer move + skillAim; kept as the combined anim hint */
aim,
};
}
/**
* Advance the shot gesture. Returns a shot on the frame of release.
*
@@ -195,16 +245,27 @@ export function createInput(target = window) {
wind.active = true;
wind.t = 0;
wind.depth = Math.abs(y);
wind.aim = state.skill.x;
wind.skillAim = state.skill.x;
// Seed move sample from where the stick already is — a cut into the
// wind-up should count, not wait a beat to register.
wind.moveX = state.move.x;
wind.moveY = state.move.y;
}
state.charge = 0;
state.aimHint = 0;
return null;
}
wind.t += dt;
wind.depth = Math.max(wind.depth, Math.abs(Math.min(0, y)));
wind.aim = state.skill.x;
wind.skillAim = state.skill.x;
// Track left stick through the wind-up. Exponential follow so a late cut
// still lands, but a brief twitch does not fully rewrite the sample.
const k = 1 - Math.exp(-SHOT.moveTrack * dt);
wind.moveX += (state.move.x - wind.moveX) * k;
wind.moveY += (state.move.y - wind.moveY) * k;
state.charge = clamp(wind.t / SHOT.fullWind, 0, 1) * wind.depth;
state.aimHint = clamp(wind.moveX * 0.85 + wind.skillAim * 0.25, -1, 1);
if (y > SHOT.releaseAt) {
const power = clamp(
@@ -212,14 +273,24 @@ export function createInput(target = window) {
0,
1,
);
// One last hard sample of where the sticks are on the release frame —
// that is the moment the player commits.
wind.moveX = state.move.x !== 0 || state.move.y !== 0
? wind.moveX * 0.35 + state.move.x * 0.65
: wind.moveX;
wind.moveY = state.move.x !== 0 || state.move.y !== 0
? wind.moveY * 0.35 + state.move.y * 0.65
: wind.moveY;
wind.active = false;
state.charge = 0;
return { power, aim: clamp(state.skill.x, -1, 1) };
state.aimHint = 0;
return packShot(power);
}
// Held back forever without releasing: drop it rather than firing later.
if (wind.t > SHOT.timeout) {
wind.active = false;
state.charge = 0;
state.aimHint = 0;
}
return null;
}
@@ -323,15 +394,37 @@ export function createInput(target = window) {
// 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;
// LT is backskating (EA NHL). Keyboard Space matches. A dedicated brake
// is not on the pad; reverse-stick while *not* holding LT is still a
// hockey stop inside the sim. `brake` stays on the contract for AI /
// tests that want an explicit snowplow.
state.backskate = state.protect > 0.28;
state.brake = false;
// RB held = stick spread. Keyboard E matches the deke binding.
state.spread = !!state.held.deke;
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) };
// While spreading, Skill Stick is the waggle — cancel any wind-up so a
// lateral push does not bank a slapshot.
if (state.spread) {
wind.active = false;
wind.t = 0;
state.charge = 0;
state.aimHint = 0;
state.shot = null;
} else {
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. Move stick at
// the press is the aim sample (no wind-up history to average).
if (!state.shot && state.pressed.shoot) {
wind.moveX = state.move.x;
wind.moveY = state.move.y;
state.shot = packShot(0.6);
}
// Live aim hint when not winding.
if (!wind.active && !state.shot) state.aimHint = 0;
}
return state;
+75 -21
View File
@@ -3,6 +3,7 @@ import { createSkater } from '../character/skater.js';
import { createBrain, spawnLineup, steer } from '../../shared/ai.js';
import { applyIntent, createSkaterState, stepSkater, SKATE } from '../../shared/skaterSim.js';
import { stickToWorld } from './input.js';
import { resolveShotAim } from './shotAim.js';
import { createHitResolver } from './hits.js';
import { PUCK, createPuck } from '../physics/puck.js';
import { NET, goalLineX } from '../../shared/net.js';
@@ -43,6 +44,8 @@ export function createMatch({ scene, physics, perTeam = 3, teams = 2, seed = 202
const skaters = [];
/** Previous velocity heading per skater, for the animator's bank. */
const prevVelYaw = [];
/** Previous Skill Stick X while stick-spreading, for swat waggle rate. */
const prevSpreadWaggle = [];
for (let i = 0; i < count; i++) {
const spawn = spawns[i];
@@ -59,6 +62,7 @@ export function createMatch({ scene, physics, perTeam = 3, teams = 2, seed = 202
states.push(s);
brains.push(createBrain(rng.f, {}));
prevVelYaw.push(spawn.yaw);
prevSpreadWaggle.push(0);
skaters.push(createSkater({
seed: seed + i * 977,
scene,
@@ -125,24 +129,35 @@ export function createMatch({ scene, physics, perTeam = 3, teams = 2, seed = 202
/**
* 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.
* Aim is EA-style: the movement stick (and how you skated during the wind-up)
* places the shot across the net; the Skill Stick is power + release with a
* light lateral fine-tune. See `resolveShotAim`. A pass looks for the nearest
* teammate ahead instead.
*/
function handleShooting(i, control) {
// Stick-spread owns the Skill Stick; no shot / pass while RB is held.
if (control.spread) return;
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);
const move = control.shot.move ?? control.move ?? { x: 0, y: 0 };
const resolved = resolveShotAim({
x: state.x,
z: state.z,
yaw: state.yaw,
vx: state.vx,
vz: state.vz,
team: state.team,
moveX: move.x,
moveY: move.y,
cameraYaw: control.cameraYaw ?? 0,
skillAim: control.shot.skillAim ?? 0,
});
possession.shoot(control.shot.power, resolved.aimYaw);
skaters[i].animator.playAction('shoot', {
power: control.shot.power,
aim: stickAim,
aim: resolved.placement,
});
return;
}
@@ -334,8 +349,9 @@ export function createMatch({ scene, physics, perTeam = 3, teams = 2, seed = 202
}
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) {
// The Skill Stick moves the puck, and only for whoever is carrying it
// and not stick-spreading (RB owns the skill stick as a waggle then).
if (possession.carrier === i && control.skill && !control.spread) {
possession.handling.x = control.skill.x;
possession.handling.y = control.skill.y;
}
@@ -346,16 +362,40 @@ export function createMatch({ scene, physics, perTeam = 3, teams = 2, seed = 202
iz: _worldIntent.iz,
sprint: control.sprint,
brake: control.brake,
backskate: control.backskate,
});
// 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);
if (control.spread) {
// Stick-spread: held pose + continuous swat check while the blade
// is out. Waggle rate powers active sweeps.
const waggle = control.skill?.x ?? 0;
const waggleRate = (waggle - prevSpreadWaggle[i]) / Math.max(1e-4, dt);
prevSpreadWaggle[i] = waggle;
const anim = skaters[i].animator;
if (anim.action !== 'spread' && anim.action !== 'shoot' && anim.action !== 'pass') {
anim.action = 'spread';
anim.actionTime = 0;
}
if (anim.action === 'spread') {
anim.spreadWaggle = waggle;
possession.swat(i, { waggle, waggleRate });
}
} else {
prevSpreadWaggle[i] = 0;
if (skaters[i].animator.action === 'spread') {
skaters[i].animator.action = null;
skaters[i].animator.spreadWaggle = 0;
}
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);
@@ -454,6 +494,8 @@ export function createMatch({ scene, physics, perTeam = 3, teams = 2, seed = 202
anim.hasPuck = possession.carrier === i;
const ctrl = controls.get(i);
anim.charge = anim.hasPuck ? (ctrl?.charge ?? 0) : 0;
// Stick reaches for a loose puck in vicinity (proximity grab IK).
anim.grabTarget = anim.hasPuck ? null : possession.grabAim(i);
if (anim.hasPuck) {
anim.handling.x = possession.handling.x;
anim.handling.y = possession.handling.y;
@@ -462,11 +504,23 @@ export function createMatch({ scene, physics, perTeam = 3, teams = 2, seed = 202
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) {
// actionAim follows the live move-stick aim hint so the body opens toward
// the corner you are skating into, not only the Skill Stick X.
// Stick-spread (RB) wins over wind-up — it already owns the skill stick.
if (anim.action === 'spread') {
// Held by the control loop above; keep waggle fresh if control dropped
// mid-frame edge cases leave it set.
if (ctrl?.spread) anim.spreadWaggle = ctrl.skill?.x ?? anim.spreadWaggle;
} else 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;
anim.actionAim = ctrl?.aimHint ?? 0;
} else if (anim.action === 'windup') {
if (!anim.hasPuck || anim.charge <= 0.05 || ctrl?.spread) {
anim.action = null;
} else {
anim.actionAim = ctrl?.aimHint ?? anim.actionAim;
}
}
skaters[i].applyState(s, yawRate);
+366 -9
View File
@@ -30,8 +30,40 @@ import { clamp, lerp } from '../../shared/scalar.js';
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. */
/** A loose puck this close to the blade gets picked up for free. */
captureRadius: 0.55,
/**
* Vicinity for a proximity grab attempt (body to puck). Outside this the
* stick does not reach; inside, intercept quality grows an effective capture
* radius and the stick IKs out toward the puck.
*/
grabRadius: 2.05,
/**
* Extra blade reach (metres) at full intercept quality — lining up a moving
* puck lets you snag it farther from the rest blade pose.
*/
grabReachBonus: 0.95,
/**
* How fast grab progress fills at quality 1, 1/s. A still puck at the feet
* is nearly instant; a hard pass needs a clean line and a beat of contact.
*/
grabRate: 2.4,
/** Soft suction of a loose puck onto the blade while grabbing, 1/s. */
grabPull: 7.5,
/**
* Puck speed (m/s) where proximity grab rate is about half. Faster biscuits
* are much harder to vacuum up without a clean intercept.
*/
grabSpeedHalf: 7.5,
/** Below this speed, blade-on-puck is still a free snag. */
grabAutoSpeed: 2.8,
/** Skaters within this of the puck count as contesting the scramble. */
grabCrowdRadius: 2.35,
/**
* Each extra contestant multiplies grab rate by 1/(1 + k*(n-1)).
* Two bodies ~0.70×, three ~0.54×, four ~0.44×.
*/
grabCrowdK: 0.42,
/**
* Possession breaks if the puck gets this far from the blade.
*
@@ -60,6 +92,17 @@ export const CARRY = {
pokeRadius: 1.25,
/** How hard a poke or a check knocks the puck away, m/s. */
pokeSpeed: 5.5,
/**
* Stick-spread (RB) reach — longer than a poke because the blade is fully
* extended on the ice. Blade-to-puck metres.
*/
swatRadius: 1.7,
/** Base knock speed from a passive spread contact, m/s. */
swatSpeed: 6.5,
/** Extra knock from an active Skill Stick waggle (scaled by |waggleRate|). */
swatWaggleBoost: 9.0,
/** Minimum time between swat hits from the same skater, seconds. */
swatCooldown: 0.1,
/** How far the puck is stepped clear of the blade on release, metres. */
releaseGap: 0.4,
};
@@ -71,6 +114,74 @@ const _puckPos = new THREE.Vector3();
const _puckVel = new THREE.Vector3();
const _dir = new THREE.Vector3();
/**
* How well skater `s` is set to pick up a loose puck at `puckPos` with `puckVel`.
* 0..1 — distance, face/skate alignment, and intercept path on moving pucks.
* Raw puck speed is *not* folded in here; see `speedGrabFactor` so a lined-up
* intercept can still work on a hard pass while pure proximity does not.
*/
export function interceptQuality(s, puckPos, puckVel, grabRadius) {
const dx = puckPos.x - s.x;
const dz = puckPos.z - s.z;
const dist = Math.hypot(dx, dz);
if (dist > grabRadius || dist < 1e-6) return 0;
const toX = dx / dist;
const toZ = dz / dist;
const near = 1 - dist / grabRadius;
const fx = Math.sin(s.yaw);
const fz = Math.cos(s.yaw);
const face = fx * toX + fz * toZ; // facing the puck
const skateSpd = Math.hypot(s.vx, s.vz);
const skateAlign = skateSpd > 0.45
? (s.vx * toX + s.vz * toZ) / skateSpd
: face;
const puckSpd = Math.hypot(puckVel.x, puckVel.z);
const rvx = puckVel.x - s.vx;
const rvz = puckVel.z - s.vz;
// Positive when the gap is closing.
const closing = -(rvx * toX + rvz * toZ);
// Moving puck: is its path aimed through us? Low cross = on a collision line.
let path = 0;
if (puckSpd > 1.2) {
const cross = Math.abs(toX * puckVel.z - toZ * puckVel.x) / puckSpd;
const onLine = clamp(1 - cross * 1.35, 0, 1);
const approach = clamp(closing / 9, 0, 1);
path = onLine * (0.35 + 0.65 * approach);
}
// Still / soft puck: proximity + face. Hard puck: almost entirely path + line-up.
const soft = clamp(1 - puckSpd / 10, 0, 1);
const lineUp = clamp(skateAlign, 0, 1) * 0.28 + clamp(face, 0, 1) * 0.14;
const softScore = near * 0.4 + lineUp + clamp(closing / 14, 0, 0.1);
const hardScore = path * 0.72 + lineUp * 0.55 + near * 0.12 + clamp(closing / 10, 0, 0.15);
return clamp(lerp(hardScore, softScore, soft), 0, 1);
}
/**
* Multiplier on proximity grab rate from puck speed alone.
* Still ≈ 1, half around `halfSpeed` m/s, very low on slapshots.
*/
export function speedGrabFactor(puckSpd, halfSpeed = 7.5) {
const s = Math.max(0, puckSpd);
return clamp(1 / (1 + (s / Math.max(0.5, halfSpeed)) ** 1.45), 0.06, 1);
}
/**
* Multiplier when `n` skaters are contesting the same loose puck.
* One body → 1; each extra body cuts everyone's odds.
*/
export function crowdGrabFactor(contestants, k = 0.42) {
const n = Math.max(1, contestants | 0);
if (n <= 1) return 1;
return 1 / (1 + (n - 1) * k);
}
/**
* @param {object} opts
* @param {object} opts.puck from createPuck
@@ -85,6 +196,16 @@ export function createPossession({ puck, skaters, states, onEvent = null }) {
/** Global cooldown after a deliberate release. */
let looseFor = 0;
const tuning = { ...CARRY };
/** Per-skater cooldown so a continuous spread does not re-hit every frame. */
const swatCool = new Array(skaters.length).fill(0);
/** Accumulated grab progress 0..1 while fishing for a loose puck. */
const grabProgress = new Array(skaters.length).fill(0);
/**
* Live grab aims for the animator — world blade targets while reaching for
* a loose puck. `null` when not grabbing.
* @type {({ x: number, y: number, z: number, weight: number, quality: number } | null)[]}
*/
const grabAim = new Array(skaters.length).fill(null);
/** Skill Stick offset applied to the carry point, -1..1 each. */
const handling = { x: 0, y: 0 };
@@ -161,6 +282,92 @@ export function createPossession({ puck, skaters, states, onEvent = null }) {
return true;
}
/**
* Stick-spread swat / intercept (RB hold).
*
* The blade is already fully extended on the ice; this tests blade-to-puck
* and knocks anything in range — a loose puck on a pass, a shot, or the
* biscuit on someone else's stick. Active Skill Stick waggle adds power and
* sets the sweep direction.
*
* @param {number} byIndex
* @param {{ waggle?: number, waggleRate?: number }} [opts]
* waggle 1..1 Skill Stick X; waggleRate ≈ stick X velocity (1/s)
*/
function swat(byIndex, { waggle = 0, waggleRate = 0 } = {}) {
if (skaters[byIndex]?.limp) return false;
if (swatCool[byIndex] > 0) return false;
bladePoint(byIndex, _carryWorld);
_puckPos.copy(puck.position());
if (_puckPos.distanceTo(_carryWorld) > tuning.swatRadius) return false;
// Don't swat your own carry — the blade is out wide, not on the puck.
if (carrier === byIndex) return false;
const state = states[byIndex];
const fx = Math.sin(state.yaw);
const fz = Math.cos(state.yaw);
const rx = Math.cos(state.yaw);
const rz = -Math.sin(state.yaw);
// Sweep direction: Skill Stick rate if actively waggling, else stick offset,
// else a mild shove away from the body along the blade-to-puck line.
const wag = clamp(waggleRate, -8, 8);
let sx;
let sz;
if (Math.abs(wag) > 0.6) {
// Active waggle: same sign flip as the spread arc (Skill Stick +X is
// screen-right; body-right basis needs the invert to match the blade).
const sign = -(Math.sign(wag) || 1);
sx = rx * sign;
sz = rz * sign;
} else if (Math.abs(waggle) > 0.2) {
const sign = -(Math.sign(waggle) || 1);
sx = rx * sign * 0.7 + fx * 0.3;
sz = rz * sign * 0.7 + fz * 0.3;
} else {
_dir.subVectors(_puckPos, _carryWorld).setY(0);
if (_dir.lengthSq() < 1e-6) {
sx = fx;
sz = fz;
} else {
_dir.normalize();
sx = _dir.x;
sz = _dir.z;
}
}
const len = Math.hypot(sx, sz) || 1;
sx /= len;
sz /= len;
const power = clamp(
0.45 + Math.abs(wag) * 0.12 + Math.abs(waggle) * 0.25,
0.45,
1.8,
);
const knock = (tuning.swatSpeed + Math.abs(wag) * 0.35 * tuning.swatWaggleBoost) * power;
// Steal if someone else has it; otherwise just redirect a loose puck.
const stolenFrom = (carrier !== null && carrier !== byIndex) ? carrier : null;
if (stolenFrom !== null) release('swatted', tuning.reclaimDelay);
// Fast incoming pucks get killed first, then redirected — that is the
// "stop a hard pass / shot" feel, not a free rebound rocket.
_puckVel.copy(puck.velocity());
const incoming = Math.hypot(_puckVel.x, _puckVel.z);
const damp = incoming > 10 ? 0.18 : incoming > 5 ? 0.35 : 0.55;
puck.setVelocity(
_puckVel.x * damp + sx * knock + state.vx * 0.25,
0,
_puckVel.z * damp + sz * knock + state.vz * 0.25,
);
swatCool[byIndex] = tuning.swatCooldown;
emit('swat', { skater: byIndex, power, knock, waggle, from: stolenFrom });
return true;
}
/**
* Contact dislodges the puck. Called when a check lands on the carrier —
* a stagger is enough, it does not need a knockdown.
@@ -217,10 +424,15 @@ export function createPossession({ puck, skaters, states, onEvent = null }) {
get loose() { return carrier === null; },
shoot,
poke,
swat,
jar,
release,
capture,
bladePoint,
/** World-space stick aim while reaching for a loose puck, or null. */
grabAim(i) {
return grabAim[i] ?? null;
},
/** Where the puck is being carried, in world space. Null if loose. */
carryPoint(out) {
@@ -237,6 +449,7 @@ export function createPossession({ puck, skaters, states, onEvent = null }) {
*/
update(dt) {
for (let i = 0; i < cooldown.length; i++) cooldown[i] = Math.max(0, cooldown[i] - dt);
for (let i = 0; i < swatCool.length; i++) swatCool[i] = Math.max(0, swatCool[i] - dt);
looseFor = Math.max(0, looseFor - dt);
puck.position(); // refresh the cached vector
@@ -255,20 +468,161 @@ export function createPossession({ puck, skaters, states, onEvent = null }) {
}
}
// ---- capture ----------------------------------------------------------
// Clear grab aims every frame; capture re-fills who is fishing.
for (let i = 0; i < grabAim.length; i++) grabAim[i] = null;
// ---- capture / proximity grab -----------------------------------------
// Not "closest body wins." Each skater races grab progress weighted by
// intercept quality, puck speed (faster → harder), and how many others
// are contesting the same biscuit.
if (carrier === null && looseFor <= 0) {
let best = null;
let bestGap = tuning.captureRadius;
const puckSpd = Math.hypot(_puckVel.x, _puckVel.z);
const speedF = speedGrabFactor(puckSpd, tuning.grabSpeedHalf);
// Who is in the scramble?
const contesting = [];
for (let i = 0; i < skaters.length; i++) {
if (skaters[i].limp || cooldown[i] > 0) continue;
const s = states[i];
const bodyDist = Math.hypot(_puckPos.x - s.x, _puckPos.z - s.z);
if (bodyDist <= tuning.grabCrowdRadius) contesting.push(i);
}
const crowdF = crowdGrabFactor(contesting.length, tuning.grabCrowdK);
let best = null;
let bestScore = -1;
let pullI = -1;
let pullScore = 0;
for (let i = 0; i < skaters.length; i++) {
if (skaters[i].limp || cooldown[i] > 0) {
grabProgress[i] = 0;
continue;
}
const s = states[i];
const bodyDist = Math.hypot(_puckPos.x - s.x, _puckPos.z - s.z);
bladePoint(i, _carryWorld);
const gap = _puckPos.distanceTo(_carryWorld);
if (gap < bestGap) {
bestGap = gap;
best = i;
const bladeGap = Math.hypot(
_puckPos.x - _carryWorld.x,
_puckPos.z - _carryWorld.z,
);
if (bodyDist > tuning.grabRadius && bladeGap > tuning.grabRadius) {
grabProgress[i] = Math.max(0, grabProgress[i] - dt * 2.5);
continue;
}
const quality = interceptQuality(s, _puckPos, _puckVel, tuning.grabRadius);
// Effective claim strength — speed and crowd punish pure proximity.
const claim = quality * speedF * crowdF;
const reach = tuning.captureRadius
+ tuning.grabReachBonus * quality * speedF;
const inReach = bladeGap <= reach || bodyDist <= tuning.grabRadius * 0.85;
// Blade already on a *slow* puck: free snag. On a rocket, still have
// to win the progress race — tickling a 30 m/s biscuit is not enough.
const bladeOn = bladeGap <= tuning.captureRadius;
const freeSnag = bladeOn && puckSpd <= tuning.grabAutoSpeed;
if (freeSnag) {
const score = 2 + claim + (1 - bladeGap / tuning.captureRadius);
if (score > bestScore) {
bestScore = score;
best = i;
}
grabProgress[i] = 1;
grabAim[i] = {
x: _puckPos.x,
y: PUCK.thickness / 2,
z: _puckPos.z,
weight: 1,
quality: 1,
};
continue;
}
if (!inReach || quality < 0.08) {
grabProgress[i] = Math.max(0, grabProgress[i] - dt * 1.8);
continue;
}
// Stick IK — still reach for hard pucks so the grab reads, even when
// claim rate is low.
const weight = clamp(
quality * 0.5
+ (1 - bladeGap / Math.max(reach, 0.01)) * 0.45
+ speedF * 0.1,
0.18,
1,
);
grabAim[i] = {
x: _puckPos.x,
y: PUCK.thickness / 2,
z: _puckPos.z,
weight,
quality: claim,
};
// Progress rate: claim strength, plus a small bonus for blade contact
// on a moving puck (you're on it, but speed still gates the snag).
const bladeBonus = bladeOn ? 0.35 * speedF : 0;
grabProgress[i] = clamp(
grabProgress[i] + (claim + bladeBonus) * tuning.grabRate * dt,
0,
1.2,
);
if (claim > pullScore) {
pullScore = claim;
pullI = i;
}
// First to finish the race with a real claim — not closest body.
if (grabProgress[i] >= 1 && claim > 0.12) {
const score = claim + grabProgress[i] * 0.05
+ (bladeOn ? 0.15 * speedF : 0);
if (score > bestScore) {
bestScore = score;
best = i;
}
}
}
if (best !== null) capture(best);
// Soft suction only when the puck is slow enough to settle and someone
// has a clear claim — no vacuuming slapshots through a crowd.
// Pull is a capped speed *toward* the blade (unit direction), never a
// raw gap×gain that could fling the puck across the rink.
if (pullI >= 0 && pullScore > 0.22 && best === null && speedF > 0.4 && crowdF > 0.55) {
bladePoint(pullI, _carryWorld);
_toTarget.subVectors(_carryWorld, _puckPos);
_toTarget.y = 0;
const gap = _toTarget.length();
if (gap > 0.05 && gap < tuning.grabRadius) {
const s = states[pullI];
const pullSpd = clamp(tuning.grabPull * pullScore * speedF * 0.55, 0, 6.5);
_toTarget.multiplyScalar(1 / gap);
_desired.set(
s.vx * 0.5 + _toTarget.x * pullSpd,
0,
s.vz * 0.5 + _toTarget.z * pullSpd,
);
const blend = clamp(pullScore * speedF * dt * 1.2, 0, 0.28);
puck.setVelocity(
lerp(_puckVel.x, _desired.x, blend),
lerp(_puckVel.y, 0, blend),
lerp(_puckVel.z, _desired.z, blend),
);
_puckVel.copy(puck.velocity());
}
}
if (best !== null) {
capture(best);
for (let i = 0; i < grabProgress.length; i++) grabProgress[i] = 0;
}
} else {
for (let i = 0; i < grabProgress.length; i++) grabProgress[i] = 0;
}
// ---- carry ------------------------------------------------------------
@@ -306,6 +660,9 @@ export function createPossession({ puck, skaters, states, onEvent = null }) {
carrier = null;
looseFor = 0;
cooldown.fill(0);
swatCool.fill(0);
grabProgress.fill(0);
for (let i = 0; i < grabAim.length; i++) grabAim[i] = null;
handling.x = 0;
handling.y = 0;
},
+153
View File
@@ -0,0 +1,153 @@
import { NET, goalLineX } from '../../shared/net.js';
import { clamp, lerpAngle, wrapAngle } from '../../shared/scalar.js';
import { stickToWorld } from './input.js';
/**
* Player shot aim — EA NHL style.
*
* The Skill Stick winds up and releases power. Placement comes from how you
* skate into the shot: left stick steers the body during the wind-up, and that
* same stick acts as a soft aimer across the net when you are attacking.
*
* Layers, strongest first:
* 1. Body facing, blended toward skate velocity (you shoot where you are going)
* 2. Movement stick world direction (primary placement while winding up)
* 3. Net-relative corner pick when roughly facing the goal mouth
* 4. Skill Stick lateral as a light fine-tune only
*
* Pure function so headless tests can assert placement without a match loop.
*/
/** How hard velocity yaws the shot off facing. */
const VEL_BLEND_MAX = 0.48;
/** Stick magnitude below this is treated as neutral. */
const STICK_DEAD = 0.12;
/** How hard the move stick pulls world aim when fully held. */
const MOVE_PULL = 0.72;
/** Skill Stick max offset, radians (~10°). Was 0.6 (~34°) when it was primary. */
const SKILL_RAD = 0.18;
/** Blend toward a point across the mouth when the stick opens a corner. */
const NET_BLEND = 0.62;
/** How far past the posts a full stick can still aim (misses are allowed). */
const NET_OVERSHOOT = 1.15;
const _world = { ix: 0, iz: 0 };
/**
* Attack end for a skater: home (team 0) shoots +X, away shoots X.
* @param {number} team
*/
export function attackEndForTeam(team) {
return team === 0 ? 1 : -1;
}
/**
* @param {object} opts
* @param {number} opts.x
* @param {number} opts.z
* @param {number} opts.yaw
* @param {number} opts.vx
* @param {number} opts.vz
* @param {number} [opts.team]
* @param {number} [opts.moveX] movement stick, screen space (1..1)
* @param {number} [opts.moveY]
* @param {number} [opts.cameraYaw]
* @param {number} [opts.skillAim] Skill Stick X at release (1..1)
* @param {number} [opts.goalX] override attack goal line X
* @param {number} [opts.goalZ] goal centre Z (default 0)
* @param {number} [opts.netHalfWidth]
* @returns {{ aimYaw: number, placement: number, targetZ: number | null }}
* `placement` is body-relative aim for anim (1..1, + = skater's right).
* `targetZ` is the net-mouth target when net aim engaged, else null.
*/
export function resolveShotAim({
x,
z,
yaw,
vx,
vz,
team = 0,
moveX = 0,
moveY = 0,
cameraYaw = 0,
skillAim = 0,
goalX = null,
goalZ = 0,
netHalfWidth = NET.width / 2,
} = {}) {
// ---- 1. body + skate velocity -------------------------------------------
const speed = Math.hypot(vx, vz);
let aimYaw = yaw;
if (speed > 0.55) {
const velYaw = Math.atan2(vx, vz);
const blend = clamp((speed - 0.55) / 5.5, 0, VEL_BLEND_MAX);
aimYaw = lerpAngle(yaw, velYaw, blend);
}
// ---- 2. movement stick world direction ----------------------------------
stickToWorld({ x: moveX, y: moveY }, cameraYaw, _world);
const stickMag = Math.hypot(_world.ix, _world.iz);
if (stickMag > STICK_DEAD) {
const stickYaw = Math.atan2(_world.ix, _world.iz);
// Stronger pull as the stick leaves centre — a full push commits the shot.
const pull = MOVE_PULL * clamp((stickMag - STICK_DEAD) / (1 - STICK_DEAD), 0, 1);
aimYaw = lerpAngle(aimYaw, stickYaw, pull);
}
// ---- 3. net-relative aimer ----------------------------------------------
// When roughly attacking the mouth, left/right on the move stick opens posts
// rather than only rotating in world space. That is the "stick aims the net"
// feel: carve toward the far post on the wind-up, release, and the puck goes
// there.
const end = goalX != null ? Math.sign(goalX) || attackEndForTeam(team) : attackEndForTeam(team);
const mouthX = goalX != null ? goalX : goalLineX(end);
const toMouth = Math.atan2(mouthX - x, goalZ - z);
const facingGoal = Math.abs(wrapAngle(toMouth - yaw)) < 1.15;
// Only engage inside a reasonable shooting range so open-ice dumps still
// follow skate direction rather than magnetizing to a distant net.
const range = Math.hypot(mouthX - x, goalZ - z);
let targetZ = null;
if (facingGoal && range > 1.2 && range < 22 && stickMag > STICK_DEAD) {
// Lateral in skater space: how far is the stick pointing to the body right.
const fx = Math.sin(yaw);
const fz = Math.cos(yaw);
const rx = fz;
const rz = -fx;
const lateral = clamp(
(_world.ix * rx + _world.iz * rz) / Math.max(stickMag, 1e-6),
-1,
1,
);
// Full stick reaches just past the post so you can miss wide on purpose.
targetZ = lateral * netHalfWidth * NET_OVERSHOOT;
const netYaw = Math.atan2(mouthX - x, targetZ - z);
// Stronger net pull when the stick is mostly lateral (opening a corner)
// rather than purely forward (driving the middle).
const corner = Math.abs(lateral);
aimYaw = lerpAngle(aimYaw, netYaw, NET_BLEND * (0.45 + 0.55 * corner));
}
// ---- 4. Skill Stick fine-tune -------------------------------------------
// Forward is (sin yaw, cos yaw); body right is (cos yaw, sin yaw). Positive
// yaw therefore turns toward the skater's right, so Skill Stick +X (push
// right) adds to yaw.
aimYaw += clamp(skillAim, -1, 1) * SKILL_RAD;
// Body-relative placement for the wind-up / shot pose (1..1).
// + means "to the skater's right" (Skill Stick +X), which is how
// `poseWindup` / `poseShot` read it.
const placement = clamp(wrapAngle(aimYaw - yaw) / 0.75, -1, 1);
return { aimYaw, placement, targetZ };
}
export const SHOT_AIM = Object.freeze({
VEL_BLEND_MAX,
STICK_DEAD,
MOVE_PULL,
SKILL_RAD,
NET_BLEND,
NET_OVERSHOOT,
});
+44 -7
View File
@@ -3,6 +3,7 @@ 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 { createMarkers } from './render/markers.js';
import { createMatch } from './game/match.js';
import { createInput } from './game/input.js';
import { describeHit } from './game/hits.js';
@@ -121,6 +122,8 @@ async function bootApp() {
let scrimmage = null;
/** @type {ReturnType<typeof buildPuckMesh> | null} */
let puckView = null;
/** @type {ReturnType<typeof createMarkers> | null} */
let markers = null;
/** @type {(() => void) | null} */
let removeSubstepSync = null;
@@ -140,6 +143,10 @@ async function bootApp() {
scrimmage.destroy();
scrimmage = null;
}
if (markers) {
markers.destroy();
markers = null;
}
if (puckView) {
// Ring is parented under the mesh.
scene.remove(puckView.mesh);
@@ -180,8 +187,8 @@ async function bootApp() {
if (playerDriving) {
cam.state.mode = 'follow';
cam.state.followIndex = shootout.state.shooter;
cam.state.distance = 9;
cam.state.pitch = 0.3;
// Dreamfall-style chase seeds distance/pitch itself on first follow frame.
if (cam.chase) cam.chase.seeded = false;
}
return;
}
@@ -197,8 +204,7 @@ async function bootApp() {
match.setControl(idx, stick);
cam.state.mode = 'follow';
cam.state.followIndex = idx;
cam.state.distance = 9;
cam.state.pitch = 0.3;
if (cam.chase) cam.chase.seeded = false;
}
function showMenu() {
@@ -221,6 +227,10 @@ async function bootApp() {
match = createMatch({ scene, physics, perTeam: 3, teams: 2 });
puckView = buildPuckMesh(scene, PUCK);
// The old yellow puck disc ring is redundant once the arrow is up; keep the
// mesh but hide the ring so we do not stack two markers on the same puck.
puckView.ring.visible = false;
markers = createMarkers(scene);
if (next === '1v1') {
// The shootout owns the nets and the goalies, and drives its own
@@ -380,7 +390,28 @@ async function bootApp() {
if (puckView) {
puckView.mesh.position.copy(match.puck.position());
puckView.mesh.quaternion.copy(match.puck.rotation());
puckView.ring.visible = match.possession.loose;
puckView.ring.visible = false;
}
if (markers) {
const carrierIdx = match.possession.carrier;
const playerIdx = match.playerIndex;
const playerHasPuck = playerIdx !== null && carrierIdx === playerIdx;
const playerState = playerIdx !== null ? match.states[playerIdx] : null;
const carrierState = carrierIdx !== null ? match.states[carrierIdx] : null;
const carrierIsTeammate = !!(
playerState
&& carrierState
&& !playerHasPuck
&& playerState.team === carrierState.team
);
markers.update(dt, {
puck: match.puck.position(),
player: playerState,
carrier: carrierState,
playerHasPuck,
carrierIsTeammate,
});
}
cam.update(dt, match.states);
@@ -400,6 +431,7 @@ async function bootApp() {
cam.state.yaw += dt * 0.08;
cam.update(dt, []);
hud.textContent = '';
if (markers) markers.update(dt, {});
}
renderer.render(scene, cam.camera);
@@ -433,9 +465,14 @@ async function bootApp() {
const controlsHint = 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')
? '\nL-stick skate + aim · RT hustle · LT contain · RB stick-spread · R-stick wind-up/waggle\nA pass · X shoot · B poke'
: '\nWASD skate + aim · Shift hustle · Space contain · E stick-spread · arrows wind-up/waggle\nJ pass · K shoot · L poke')
+ `\nhustle ${bar(stick.hustle)} wind-up ${bar(stick.charge)}`
+ (stick.backskate ? ' LT' : '')
+ (stick.spread ? ' SPREAD' : '')
+ (stick.charge > 0.05
? ` aim ${bar((stick.aimHint + 1) * 0.5)}`
: '')
: '';
if (mode === '1v1' && shootout) {
+278 -51
View File
@@ -3,23 +3,106 @@ import { RINK } from '../../shared/rink.js';
import { clamp, wrapAngle } from '../../shared/scalar.js';
/**
* Broadcast camera.
* Broadcast + third-person chase 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.
* Follow mode is ported from Dreamfall's rally chase cam
* (`dreamfall/src/game/systems/CameraSystem.js` → `updateVehicleChaseCamera`):
*
* Drag orbits, wheel zooms, and the target is smoothed rather than snapped so
* a bot changing direction does not whip the camera.
* - Heading-locked yaw (not stick-coupled)
* - Velocity-predictive look (leads into turns, angle-clamped)
* - Exp springs `1 - exp(-rate·dt)` — frame-rate independent, no overshoot
* - Planar tracking stiffens with speed so lag stays ~constant (lag ≈ v/λ)
* - Height filtered separately (no bob from every foot plant)
* - Smoothed look target + max yaw rate as a whip backstop
*
* Broadcast mode is unchanged: lazy centroid pan for the wide shot.
*/
/** Chase tuning — Dreamfall comfort defaults, scaled for a skating body. */
const CHASE = Object.freeze({
/** Base distance behind the skater, metres. */
distance: 8.5,
/** Extra metres at full skate speed. */
speedDistanceBoost: 1.6,
/** Orbit pitch (radians) — mild high angle over the shoulder. */
pitch: 0.28,
/** Eye / look height on the body, metres. */
lookHeight: 1.15,
/** Base look-ahead along travel/heading, metres. */
lookAhead: 3.2,
/** Extra look-ahead at speed. */
speedLookAheadBoost: 2.4,
/** Yaw spring (1/s) toward desired look heading. */
yawSmoothing: 4.6,
/** Camera position spring (1/s). Soft at idle; raised with speed. */
positionSmoothing: 7.0,
/** Planar target spring base (1/s). */
targetSmoothing: 12,
/** Hard ceiling on planar tracking spring. */
maxTrackingSmoothing: 40,
/** Hard ceiling on camera position spring. */
maxPositionSmoothing: 28,
/** Target lag budget (m) — trackSpeed / this raises the spring rate. */
maxChaseLag: 1.1,
/** Snap if planar lag exceeds this (teleport / mode switch). */
maxTargetLag: 10,
/** Height spring (1/s) — softer than planar. */
heightSmoothing: 4.0,
/** Look-point spring (1/s). */
lookTargetSmoothing: 9,
/** Blend of travel direction into look yaw at full speed. */
velocityLookBlend: 0.32,
/** Max yaw bias from heading toward velocity, radians (~18°). */
velocityLookMaxAngle: 0.32,
/** Speed (m/s) that counts as "full" for ease curves. */
fullSpeed: 7.4,
/** Low-pass on measured skate speed (1/s). */
speedSmoothing: 4.5,
/** Cap view yaw change, rad/s — backstop against single-frame whips. */
maxYawRate: 1.8,
/** Blend live XZ into smoothed target at speed (never Y). */
speedLiveAnchor: 0.35,
/** Min horizontal speed before velocity influences look yaw. */
velocityLookMinSpeed: 0.7,
});
function lerpAngle(from, to, alpha) {
return from + wrapAngle(to - from) * alpha;
}
function smoothstep01(t) {
const c = clamp(t, 0, 1);
return c * c * (3 - 2 * c);
}
/**
* Desired look yaw: heading blended toward travel, clamped so a spin cannot
* whip the camera. Same idea as Dreamfall `_computeVelocityLookYaw`.
*/
function velocityLookYaw(headingYaw, vx, vz, tuning) {
const speed = Math.hypot(vx, vz);
if (speed < tuning.velocityLookMinSpeed || tuning.velocityLookBlend <= 0) {
return headingYaw;
}
const travelYaw = Math.atan2(vx, vz);
// If mostly reversing, keep heading (don't flip the chase 180° every cut).
const fx = Math.sin(headingYaw);
const fz = Math.cos(headingYaw);
const along = (vx * fx + vz * fz) / speed;
if (along < -0.25) return headingYaw;
let delta = wrapAngle(travelYaw - headingYaw);
delta = clamp(delta, -tuning.velocityLookMaxAngle, tuning.velocityLookMaxAngle);
const w = clamp(speed / tuning.fullSpeed, 0, 1) * tuning.velocityLookBlend;
return wrapAngle(headingYaw + delta * w);
}
export function createCamera(canvas, aspect) {
const camera = new THREE.PerspectiveCamera(52, aspect, 0.1, 400);
const state = {
mode: 'broadcast',
/** Orbit angles, radians. */
/** Orbit yaw: where the camera sits relative to the target (broadcast / drag). */
yaw: 0,
pitch: 0.62,
distance: 34,
@@ -28,8 +111,28 @@ export function createCamera(canvas, aspect) {
followIndex: null,
};
// ---- chase internals (Dreamfall-style) ----------------------------------
const chase = {
/** Smoothed heading the camera is aligned to (skater face / travel). */
lookYaw: 0,
/** Planar / height-smoothed anchor under the skater. */
anchor: new THREE.Vector3(),
hasAnchor: false,
height: 0,
hasHeight: false,
/** Smoothed look-at point. */
lookAt: new THREE.Vector3(),
hasLookAt: false,
/** Filtered skate speed. */
speed: 0,
/** True after first follow frame so we don't ease from origin. */
seeded: false,
};
const _want = new THREE.Vector3();
const _offset = new THREE.Vector3();
const _desiredPos = new THREE.Vector3();
const _look = new THREE.Vector3();
let dragging = false;
let lastX = 0;
@@ -62,12 +165,165 @@ export function createCamera(canvas, aspect) {
canvas.addEventListener('pointercancel', endDrag);
canvas.addEventListener('wheel', (e) => {
e.preventDefault();
state.distance = clamp(state.distance * (1 + e.deltaY * 0.0012), 6, 90);
if (state.mode === 'follow') {
state.distance = clamp(state.distance * (1 + e.deltaY * 0.0012), 4.5, 18);
} else {
state.distance = clamp(state.distance * (1 + e.deltaY * 0.0012), 6, 90);
}
}, { passive: false });
function seedChase(s) {
chase.lookYaw = s.yaw;
chase.anchor.set(s.x, 0, s.z);
chase.hasAnchor = true;
chase.height = CHASE.lookHeight;
chase.hasHeight = true;
chase.lookAt.set(s.x, CHASE.lookHeight, s.z);
chase.hasLookAt = true;
chase.speed = Math.hypot(s.vx ?? 0, s.vz ?? 0);
chase.seeded = true;
state.yaw = wrapAngle(s.yaw + Math.PI);
state.pitch = CHASE.pitch;
state.distance = CHASE.distance;
state.target.set(s.x, CHASE.lookHeight, s.z);
}
/**
* Third-person chase — Dreamfall rally model, skater-sized.
* @param {number} dt
* @param {{ x:number, z:number, yaw:number, vx?:number, vz?:number }} s
*/
function updateFollow(dt, s) {
if (!chase.seeded) seedChase(s);
const vx = s.vx ?? 0;
const vz = s.vz ?? 0;
const rawSpeed = Math.hypot(vx, vz);
// ---- filtered speed ---------------------------------------------------
const speedA = 1 - Math.exp(-CHASE.speedSmoothing * dt);
chase.speed += (rawSpeed - chase.speed) * speedA;
const speedRatio = clamp(chase.speed / CHASE.fullSpeed, 0, 1);
const speedEase = smoothstep01(speedRatio);
// ---- heading + velocity look yaw --------------------------------------
const desiredLook = velocityLookYaw(s.yaw, vx, vz, CHASE);
if (!dragging) {
// Exp yaw spring + rate cap (Dreamfall _applyYawSmoothing).
let next = lerpAngle(chase.lookYaw, desiredLook, 1 - Math.exp(-CHASE.yawSmoothing * dt));
let d = wrapAngle(next - chase.lookYaw);
const maxStep = CHASE.maxYawRate * dt;
if (d > maxStep) d = maxStep;
if (d < -maxStep) d = -maxStep;
chase.lookYaw = wrapAngle(chase.lookYaw + d);
}
// Camera sits *behind* the look heading; orbit yaw is what stickToWorld uses.
if (!dragging) {
state.yaw = wrapAngle(chase.lookYaw + Math.PI);
}
state.pitch = CHASE.pitch;
// ---- planar target + height -------------------------------------------
// Stiffness scales with speed so lag ≈ v/λ stays near maxChaseLag.
const targetRate = Math.min(
CHASE.maxTrackingSmoothing,
Math.max(CHASE.targetSmoothing, chase.speed / Math.max(CHASE.maxChaseLag, 1e-3)),
);
const posRate = Math.min(
CHASE.maxPositionSmoothing,
Math.max(CHASE.positionSmoothing, chase.speed / Math.max(CHASE.maxChaseLag * 1.15, 1e-3)),
);
_want.set(s.x, 0, s.z);
if (!chase.hasAnchor || chase.anchor.distanceTo(_want) > CHASE.maxTargetLag) {
chase.anchor.copy(_want);
chase.hasAnchor = true;
} else {
chase.anchor.lerp(_want, 1 - Math.exp(-targetRate * dt));
}
// Soft live XZ anchor at speed — residual lag does not read as pull-out.
const live = speedEase * CHASE.speedLiveAnchor;
const ax = chase.anchor.x + (s.x - chase.anchor.x) * live;
const az = chase.anchor.z + (s.z - chase.anchor.z) * live;
if (!chase.hasHeight) {
chase.height = CHASE.lookHeight;
chase.hasHeight = true;
} else {
chase.height += (CHASE.lookHeight - chase.height)
* (1 - Math.exp(-CHASE.heightSmoothing * dt));
}
state.target.set(ax, chase.height, az);
// ---- camera position --------------------------------------------------
const distance = (state.distance || CHASE.distance) + speedEase * CHASE.speedDistanceBoost;
// state.distance is user zoom; base framing uses CHASE.distance when first
// entering follow — wheel multiplies state.distance in place.
const dist = clamp(distance, 4.5, 20);
const cp = Math.cos(state.pitch);
// Sit along orbit yaw (behind look heading when not dragging).
_offset.set(
Math.sin(state.yaw) * cp,
Math.sin(state.pitch),
Math.cos(state.yaw) * cp,
).multiplyScalar(dist);
_desiredPos.copy(state.target).add(_offset);
camera.position.lerp(_desiredPos, 1 - Math.exp(-posRate * dt));
// ---- look-ahead -------------------------------------------------------
// Along smoothed look heading (face + velocity lead), not raw chassis.
const lookAhead = CHASE.lookAhead + speedEase * CHASE.speedLookAheadBoost;
const lx = Math.sin(chase.lookYaw);
const lz = Math.cos(chase.lookYaw);
_look.set(
ax + lx * lookAhead,
chase.height,
az + lz * lookAhead,
);
if (!chase.hasLookAt || chase.lookAt.distanceTo(_look) > 16) {
chase.lookAt.copy(_look);
chase.hasLookAt = true;
} else {
chase.lookAt.lerp(_look, 1 - Math.exp(-CHASE.lookTargetSmoothing * dt));
}
camera.lookAt(chase.lookAt);
}
function updateBroadcast(dt, skaters) {
chase.seeded = false;
const chaseRate = 2.4;
_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, chaseRate * 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);
}
return {
camera,
state,
/** Exposed for tests / tuning probes. */
chase,
CHASE,
resize(w, h) {
camera.aspect = w / h;
@@ -79,62 +335,33 @@ export function createCamera(canvas, aspect) {
if (state.mode === 'broadcast') {
state.mode = 'follow';
state.followIndex = 0;
chase.seeded = false;
state.distance = CHASE.distance;
state.pitch = CHASE.pitch;
} else if (state.followIndex + 1 < count) {
state.followIndex += 1;
chase.seeded = false;
state.distance = CHASE.distance;
state.pitch = CHASE.pitch;
} else {
state.mode = 'broadcast';
state.followIndex = null;
chase.seeded = false;
state.distance = 34;
state.pitch = 0.62;
}
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
* @param {{x:number,z:number,yaw:number,vx?:number,vz?: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));
}
updateFollow(dt, skaters[state.followIndex]);
} 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);
updateBroadcast(dt, skaters);
}
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);
},
};
}
+195
View File
@@ -0,0 +1,195 @@
import * as THREE from 'three';
/**
* Play readability markers: puck arrow + ground rings for the controlled
* skater and the puck carrier.
*
* - Red down-arrow floats just above the puck and draws over everything
* (depthTest off) so it stays readable from the broadcast camera.
* - Controlled player: blue ice ring, turns green while they have the puck.
* - Teammate with puck: green ice ring. Opponent with puck: red.
*/
const BLUE = 0x3b9eff;
const GREEN = 0x3dff7a;
const RED = 0xff2a2a;
const ARROW = {
/** Height of the cone tip above the puck centre, metres. */
tipAbove: 0.42,
/** Bob amplitude / frequency so the marker still reads when the puck is still. */
bob: 0.04,
bobHz: 2.4,
coneR: 0.11,
coneH: 0.26,
};
const RING = {
inner: 0.52,
outer: 0.68,
y: 0.025,
opacity: 0.9,
};
function overlayMaterial(color, opacity = 1) {
return new THREE.MeshBasicMaterial({
color,
transparent: opacity < 1,
opacity,
depthTest: false,
depthWrite: false,
toneMapped: false,
});
}
function groundRingMaterial(color) {
return new THREE.MeshBasicMaterial({
color,
transparent: true,
opacity: RING.opacity,
depthWrite: false,
side: THREE.DoubleSide,
toneMapped: false,
// Sit on top of ice markings without z-fighting.
polygonOffset: true,
polygonOffsetFactor: -2,
polygonOffsetUnits: -2,
});
}
function makeArrow() {
const group = new THREE.Group();
group.name = 'puckArrow';
group.renderOrder = 1000;
// Cone tip points +Y by default; flip so it points at the puck.
const cone = new THREE.Mesh(
new THREE.ConeGeometry(ARROW.coneR, ARROW.coneH, 5),
overlayMaterial(RED),
);
cone.rotation.x = Math.PI;
cone.position.y = 0;
cone.renderOrder = 1000;
// Frustum culling off: depth-disabled overlays can get culled incorrectly
// when the bounding sphere is wrong relative to the camera.
cone.frustumCulled = false;
group.add(cone);
// Thin stem above the head so it reads as an arrow, not a floating diamond.
const stem = new THREE.Mesh(
new THREE.CylinderGeometry(ARROW.coneR * 0.28, ARROW.coneR * 0.28, 0.14, 6),
overlayMaterial(RED),
);
stem.position.y = ARROW.coneH * 0.5 + 0.07;
stem.renderOrder = 1000;
stem.frustumCulled = false;
group.add(stem);
group.frustumCulled = false;
return group;
}
function makeGroundRing(color) {
const mesh = new THREE.Mesh(
new THREE.RingGeometry(RING.inner, RING.outer, 48),
groundRingMaterial(color),
);
mesh.rotation.x = -Math.PI / 2;
mesh.position.y = RING.y;
mesh.renderOrder = 3;
mesh.visible = false;
mesh.name = 'groundRing';
return mesh;
}
/**
* @param {THREE.Scene} scene
*/
export function createMarkers(scene) {
const arrow = makeArrow();
const playerRing = makeGroundRing(BLUE);
const carrierRing = makeGroundRing(RED);
playerRing.name = 'playerRing';
carrierRing.name = 'carrierRing';
scene.add(arrow);
scene.add(playerRing);
scene.add(carrierRing);
let t = 0;
const _puck = new THREE.Vector3();
return {
/**
* @param {number} dt
* @param {object} opts
* @param {{ x: number, y?: number, z: number } | THREE.Vector3 | null} opts.puck
* @param {{ x: number, z: number } | null} opts.player controlled skater feet
* @param {{ x: number, z: number } | null} opts.carrier whoever has the puck
* @param {boolean} opts.playerHasPuck
* @param {boolean} opts.carrierIsTeammate true when carrier shares the player's team
*/
update(dt, {
puck = null,
player = null,
carrier = null,
playerHasPuck = false,
carrierIsTeammate = false,
} = {}) {
t += dt;
if (puck) {
_puck.set(puck.x, puck.y ?? 0.05, puck.z);
const bob = Math.sin(t * Math.PI * 2 * ARROW.bobHz) * ARROW.bob;
arrow.position.set(
_puck.x,
_puck.y + ARROW.tipAbove + bob,
_puck.z,
);
// Keep the arrow upright in world space (puck can tumble).
arrow.quaternion.identity();
arrow.visible = true;
} else {
arrow.visible = false;
}
// Controlled player: blue, or green while carrying.
if (player) {
playerRing.visible = true;
playerRing.position.x = player.x;
playerRing.position.z = player.z;
playerRing.material.color.setHex(playerHasPuck ? GREEN : BLUE);
} else {
playerRing.visible = false;
}
// Someone else has the puck: green for teammates, red for opponents.
// When the controlled player has it, only the player ring (now green) shows.
if (carrier && !playerHasPuck) {
carrierRing.visible = true;
carrierRing.position.x = carrier.x;
carrierRing.position.z = carrier.z;
carrierRing.material.color.setHex(carrierIsTeammate ? GREEN : RED);
} else {
carrierRing.visible = false;
}
},
destroy() {
scene.remove(arrow);
scene.remove(playerRing);
scene.remove(carrierRing);
for (const obj of [arrow, playerRing, carrierRing]) {
obj.traverse((child) => {
child.geometry?.dispose?.();
if (child.material) {
if (Array.isArray(child.material)) child.material.forEach((m) => m.dispose());
else child.material.dispose();
}
});
}
},
};
}
export { BLUE as MARKER_BLUE, GREEN as MARKER_GREEN, RED as MARKER_RED };
+56 -4
View File
@@ -228,7 +228,8 @@ section('triggers are analog, not boolean');
h.press(PAD.LT, 1);
s = h.input.read(1 / 60);
ok(s.brake, 'the left trigger stops');
ok(s.backskate, 'the left trigger is backskating (EA LT)');
ok(!s.brake, 'and is not a hockey stop');
ok(s.protect > 0.9, `and reports analog (${s.protect.toFixed(2)})`);
h.restore();
}
@@ -260,6 +261,29 @@ section('buttons report as actions, and only on the edge');
h.restore();
}
section('RB holds stick-spread and blocks the Skill Stick wind-up');
{
const h = harness();
const dt = 1 / 60;
h.input.read(dt);
h.press(PAD.RB);
let s = h.input.read(dt);
ok(s.spread, 'RB is stick-spread');
ok(s.held.deke, 'and is held on the deke binding');
// Skill Stick wound back while spreading must not charge a shot.
h.pad.axes = [0, 0, 0, 1];
for (let i = 0; i < 20; i++) s = h.input.read(dt);
near(s.charge, 0, 1e-9, 'no wind-up charge while spreading');
ok(s.shot === null, 'and no shot fires from the skill stick');
h.release(PAD.RB);
s = h.input.read(dt);
ok(!s.spread, 'releasing RB ends the spread');
h.restore();
}
section('the Skill Stick fires a shot on pull-back-and-push');
{
const h = harness();
@@ -322,17 +346,42 @@ section('an abandoned wind-up is forgotten, not banked');
h.restore();
}
section('the shot carries aim from the stick');
section('the shot carries aim from how you moved during the wind-up');
{
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
// Wind up while holding left stick right — that is the aim sample.
// Right stick is pulled back (pad Y +) with no lateral skill aim.
h.pad.axes = [0.85, 0, 0, 1];
let s;
for (let i = 0; i < 20; i++) s = h.input.read(dt);
ok(s.charge > 0.2, 'winding up');
ok(s.aimHint > 0.4, `live aim hint follows the move stick (${s.aimHint.toFixed(2)})`);
// Release: still holding left-right, skill stick forward.
h.pad.axes = [0.85, 0, 0, -1];
s = h.input.read(dt);
ok(s.shot, 'the shot fired');
ok(s.shot.move.x > 0.5, `move sample is right (${s.shot.move.x.toFixed(2)})`);
ok(Math.abs(s.shot.skillAim) < 0.2, `skill lateral is not the aim (${s.shot.skillAim.toFixed(2)})`);
ok(s.shot.aim > 0.4, `combined aim hint is right (${s.shot.aim.toFixed(2)})`);
h.restore();
}
section('Skill Stick lateral is recorded as a fine-tune, not primary aim');
{
const h = harness();
const dt = 1 / 60;
h.input.read(dt);
// No move stick; Skill Stick wound back and held right.
h.pad.axes = [0, 0, 0.8, 1];
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)})`);
ok(s.shot.skillAim > 0.5, `skillAim remembers the right push (${s.shot.skillAim.toFixed(2)})`);
near(s.shot.move.x, 0, 0.15, 'move sample stayed near centre');
h.restore();
}
@@ -340,10 +389,13 @@ section('the shoot button works for anyone who never learns the Skill Stick');
{
const h = harness();
h.input.read(1 / 60);
// Aim with left stick, fire with X.
h.pad.axes = [-0.9, 0, 0, 0];
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)})`);
ok(s.shot.move.x < -0.5, `and samples the move stick for aim (${s.shot.move.x.toFixed(2)})`);
h.restore();
}
+52
View File
@@ -118,6 +118,58 @@ section('a dumped puck slides a long way but does stop');
physics.destroy();
}
section('proximity grab rewards lining up a moving puck');
{
const {
interceptQuality, speedGrabFactor, crowdGrabFactor, CARRY: C,
} = await import('../src/game/possession.js');
const s = { x: 0, z: 0, yaw: Math.PI / 2, vx: 4, vz: 0 };
// Puck ahead on the line (within grab bubble), coming toward the skater.
const onLine = interceptQuality(
s,
{ x: 1.5, y: 0.02, z: 0 },
{ x: -12, y: 0, z: 0 },
C.grabRadius,
);
// Same distance, puck flying wide past the skater.
const wide = interceptQuality(
s,
{ x: 1.5, y: 0.02, z: 1.4 },
{ x: -12, y: 0, z: 0 },
C.grabRadius,
);
ok(onLine > 0.35, `lined-up hard puck is grabable (${onLine.toFixed(2)})`);
ok(onLine > wide + 0.08, `and beats a wide miss (${onLine.toFixed(2)} vs ${wide.toFixed(2)})`);
// Still puck at the feet — easy.
const soft = interceptQuality(
{ x: 0, z: 0, yaw: 0, vx: 0, vz: 0 },
{ x: 0.4, y: 0.02, z: 0.3 },
{ x: 0, y: 0, z: 0 },
C.grabRadius,
);
ok(soft > 0.4, `soft puck nearby is easy (${soft.toFixed(2)})`);
}
section('fast pucks and crowds lower proximity grab odds');
{
const { speedGrabFactor, crowdGrabFactor, CARRY: C } = await import('../src/game/possession.js');
const still = speedGrabFactor(0, C.grabSpeedHalf);
const pass = speedGrabFactor(12, C.grabSpeedHalf);
const slap = speedGrabFactor(35, C.grabSpeedHalf);
ok(still > 0.9, `still puck is free to grab (${still.toFixed(2)})`);
ok(pass < still * 0.55, `a firm pass is harder (${pass.toFixed(2)} vs ${still.toFixed(2)})`);
ok(slap < pass, `a rocket is harder still (${slap.toFixed(2)})`);
ok(slap < 0.2, `slapshot proximity is weak (${slap.toFixed(2)})`);
near(crowdGrabFactor(1, C.grabCrowdK), 1, 1e-9, 'one body: full claim');
ok(crowdGrabFactor(2, C.grabCrowdK) < 0.75, `two bodies cut odds (${crowdGrabFactor(2, C.grabCrowdK).toFixed(2)})`);
ok(
crowdGrabFactor(4, C.grabCrowdK) < crowdGrabFactor(2, C.grabCrowdK),
'more bodies cut further',
);
}
section('a skater picks up a loose puck');
{
const { physics, match } = arena(1);
+133
View File
@@ -0,0 +1,133 @@
import { goalLineX } from '../shared/net.js';
import { resolveShotAim, SHOT_AIM } from '../src/game/shotAim.js';
import { done, near, ok, section } from './harness.mjs';
/** Shortest-arc wrap to (−π, π]. */
function wrap(a) {
let x = a;
while (x > Math.PI) x -= Math.PI * 2;
while (x <= -Math.PI) x += Math.PI * 2;
return x;
}
/**
* EA-style shot aim: movement stick + wind-up skating places the puck;
* Skill Stick is a light fine-tune.
*/
section('a neutral shot goes where the body points');
{
const r = resolveShotAim({
x: 0, z: 0, yaw: Math.PI / 2, vx: 0, vz: 0,
moveX: 0, moveY: 0, skillAim: 0,
});
near(r.aimYaw, Math.PI / 2, 1e-9, 'facing +X → aim +X');
near(r.placement, 0, 1e-6, 'no body lean');
ok(r.targetZ === null, 'no net target without a move stick');
}
section('skate velocity yaws the shot off pure facing');
{
// Facing +X, but skating hard toward +Z (body left when facing +X:
// forward = (1,0), left = (0? wait: right = (cos, -sin) of π/2 = (0,-1) = Z;
// so +Z is body left).
const r = resolveShotAim({
x: 0, z: 0, yaw: Math.PI / 2, vx: 0, vz: 5,
moveX: 0, moveY: 0, skillAim: 0,
});
// Velocity heading is 0; aim should pull off π/2 toward 0.
ok(r.aimYaw < Math.PI / 2 - 0.05, `velocity pulls aim (${r.aimYaw.toFixed(3)})`);
ok(r.aimYaw > 0, 'but not all the way to pure velocity');
}
section('move stick opens net corners when facing the goal');
{
// Attacker on the slot, facing the +X net. Camera behind them looking +X:
// look dir = (+1, 0) = (sin θ, cos θ) ⇒ θ = −π/2.
const cameraYaw = -Math.PI / 2;
const right = resolveShotAim({
x: 15, z: 0, yaw: Math.PI / 2, vx: 2, vz: 0,
moveX: 1, moveY: 0.15, cameraYaw, skillAim: 0,
goalX: goalLineX(1),
});
const left = resolveShotAim({
x: 15, z: 0, yaw: Math.PI / 2, vx: 2, vz: 0,
moveX: -1, moveY: 0.15, cameraYaw, skillAim: 0,
goalX: goalLineX(1),
});
const middle = resolveShotAim({
x: 15, z: 0, yaw: Math.PI / 2, vx: 2, vz: 0,
moveX: 0, moveY: 1, cameraYaw, skillAim: 0,
goalX: goalLineX(1),
});
ok(right.targetZ != null, 'right stick engages net aim');
ok(left.targetZ != null, 'left stick engages net aim');
ok(
right.targetZ * left.targetZ < 0,
`opposite sticks open opposite posts (${right.targetZ?.toFixed(2)} vs ${left.targetZ?.toFixed(2)})`,
);
ok(Math.abs(right.targetZ) > 0.4, `opens a real corner (${right.targetZ?.toFixed(2)} m)`);
if (middle.targetZ != null) {
ok(
Math.abs(middle.targetZ) < Math.abs(right.targetZ),
'forward stick is closer to the middle than a full cut',
);
}
ok(Math.abs(wrap(right.aimYaw - left.aimYaw)) > 0.08, 'left and right aim yaws diverge');
}
section('Skill Stick is only a light fine-tune');
{
const base = resolveShotAim({
x: 12, z: 0, yaw: Math.PI / 2, vx: 0, vz: 0,
moveX: 0, moveY: 0, skillAim: 0,
});
const tuned = resolveShotAim({
x: 12, z: 0, yaw: Math.PI / 2, vx: 0, vz: 0,
moveX: 0, moveY: 0, skillAim: 1,
});
const delta = Math.abs(wrap(tuned.aimYaw - base.aimYaw));
near(delta, SHOT_AIM.SKILL_RAD, 1e-9, `full skillAim is ${SHOT_AIM.SKILL_RAD} rad`);
ok(delta < 0.25, 'far smaller than the old 0.6 rad primary aim');
}
section('far from the net, stick does not magnetize to the mouth');
{
// Centre ice, range past the net-aim window — still free to dump wide.
const r = resolveShotAim({
x: 0, z: 0, yaw: Math.PI / 2, vx: 0, vz: 0,
moveX: 1, moveY: 0, cameraYaw: -Math.PI / 2, skillAim: 0,
goalX: goalLineX(1),
});
ok(r.targetZ === null, 'outside shooting range: no net corner lock');
}
section('placement sign matches Skill Stick right for the animator');
{
// Skill Stick +X only → placement must be positive (poseWindup / poseShot).
const r = resolveShotAim({
x: 0, z: 0, yaw: 0, vx: 0, vz: 0,
moveX: 0, moveY: 0, skillAim: 1,
});
ok(r.placement > 0.1, `skill right → positive placement (${r.placement.toFixed(3)})`);
near(r.aimYaw, SHOT_AIM.SKILL_RAD, 1e-9, 'and yaw adds by the fine-tune amount');
}
section('move stick right of body yields positive placement');
{
// Facing +Z (yaw 0). Body right is +X. Camera yaw 0: screen right is +X world.
// Stick right → world +X → aim turns right of facing → positive placement.
const r = resolveShotAim({
x: 0, z: 0, yaw: 0, vx: 0, vz: 0,
moveX: 1, moveY: 0, cameraYaw: 0, skillAim: 0,
// No net: pure world stick pull (out of range / not forced)
goalX: goalLineX(1),
});
// At centre ice net aim is off; stick right still pulls aimYaw toward +X (π/2).
ok(r.aimYaw > 0.1, `stick right yaws positive (${r.aimYaw.toFixed(3)})`);
ok(r.placement > 0.1, `and placement is positive for the pose (${r.placement.toFixed(3)})`);
}
done('shotAim');
+107
View File
@@ -183,6 +183,113 @@ section('turning is harder at speed than at rest');
);
}
section('LT does not spin the body around');
{
// Facing +X, stick pulled toward X under LT — should retreat heels-first
// without yaw flipping to face the stick.
const s = createSkaterState(0, { x: 0, z: 0, yaw: Math.PI / 2 });
const startYaw = s.yaw;
run(s, 2.5, (st) => {
st.ix = -1;
st.iz = 0;
st.backskate = true;
});
const yawDrift = Math.abs(wrap(s.yaw - startYaw));
ok(yawDrift < 0.45, `facing holds under LT (yaw drift ${yawDrift.toFixed(2)} rad)`);
ok(s.bladeSpeed < -1.2, `stick back drives reverse (${s.bladeSpeed.toFixed(2)} m/s)`);
ok(s.x < -1.5, `and the body retreats (x ${s.x.toFixed(2)})`);
}
section('LT with stick forward is a crawl, not a rush');
{
const free = createSkaterState(0, START);
run(free, 5, forward);
const contain = createSkaterState(1, START);
run(contain, 5, (st) => {
forward(st);
st.backskate = true;
});
ok(
speedOf(free) > speedOf(contain) + 1.2,
`contain is much slower than free skate (${speedOf(contain).toFixed(2)} vs ${speedOf(free).toFixed(2)})`,
);
ok(speedOf(contain) <= SKATE.containForwardSpeed + 0.25, 'under the forward-contain ceiling');
ok(contain.bladeSpeed > 0, 'still facing the stick way, not flipped reverse');
}
section('LT reverse and lateral accelerate quickly for gap control');
{
// Reverse from a standstill under LT should get moving fast.
const back = createSkaterState(0, { x: 0, z: 0, yaw: Math.PI / 2 });
run(back, 0.45, (st) => {
st.ix = -1;
st.iz = 0;
st.backskate = true;
});
ok(back.bladeSpeed < -2.5, `quick reverse accel (${back.bladeSpeed.toFixed(2)} m/s in 0.45s)`);
ok(Math.abs(back.x) > 0.6, 'and covers ground retreating');
// Lateral shuffle under LT.
const side = createSkaterState(1, { x: 0, z: 0, yaw: Math.PI / 2 });
run(side, 0.45, (st) => {
st.ix = 0;
st.iz = 1; // body right when facing +X
st.backskate = true;
});
ok(Math.abs(side.z) > 0.7, `quick lateral under LT (z ${side.z.toFixed(2)})`);
ok(Math.abs(side.x) < Math.abs(side.z), 'mostly sideways relative to facing (+X)');
// Flip reverse → forward under LT (offense ↔ defense switch).
const flip = createSkaterState(2, { x: 0, z: 0, yaw: Math.PI / 2 });
run(flip, 0.8, (st) => {
st.ix = -1;
st.iz = 0;
st.backskate = true;
});
const rev = flip.bladeSpeed;
ok(rev < -2, 'was reverse');
run(flip, 0.35, (st) => {
st.ix = 1;
st.iz = 0;
st.backskate = true;
});
ok(flip.bladeSpeed > rev + 2.5, `snaps out of reverse toward forward (${flip.bladeSpeed.toFixed(2)} from ${rev.toFixed(2)})`);
}
section('LT stick neutral bleeds a rush toward neutral');
{
const s = createSkaterState(0, START);
run(s, 5, forward);
const entry = speedOf(s);
ok(entry > 3.5, 'at cruise');
// Hold LT, stick centred — sit in the gap instead of flying.
run(s, 1.2, (st) => {
st.ix = 0;
st.iz = 0;
st.backskate = true;
});
ok(speedOf(s) < entry * 0.55, `bled speed under LT (${speedOf(s).toFixed(2)} of ${entry.toFixed(2)})`);
ok(speedOf(s) > 0.15, 'but not a hard snowplow to zero');
const yawStill = Math.abs(wrap(s.yaw - Math.PI / 2));
ok(yawStill < 0.3, 'and did not spin to face the other way');
}
section('applyIntent accepts backskate');
{
const s = createSkaterState(0, START);
applyIntent(s, { ix: 0, iz: 1, backskate: 1 });
ok(s.backskate === true, 'backskate flag is boolean true');
applyIntent(s, { ix: 0, iz: 1, backskate: 0 });
ok(s.backskate === false, 'and clears');
}
function wrap(a) {
let x = a;
while (x > Math.PI) x -= Math.PI * 2;
while (x <= -Math.PI) x += Math.PI * 2;
return x;
}
section('nothing leaves the rink');
{
// Point skaters at the boards from centre ice and hold it for ten seconds.