From afd764e5e7bb665b5e75343a527673de0a7284ff Mon Sep 17 00:00:00 2001 From: ryanfitzpatrickio Date: Mon, 3 Aug 2026 19:20:00 -0500 Subject: [PATCH] animations --- .idea/tilt.iml | 6 +- README.md | 11 +- ROADMAP.md | 3 +- package.json | 2 +- shared/skaterSim.js | 149 ++++++++++++-- src/anim/poses/skate.js | 9 +- src/anim/poses/stickwork.js | 70 +++++++ src/anim/skateAnimator.js | 106 +++++++++- src/character/skater.js | 5 +- src/character/skaterGear.js | 223 ++++++++++++++------- src/character/stick.js | 7 + src/game/input.js | 119 ++++++++++-- src/game/match.js | 96 +++++++-- src/game/possession.js | 375 +++++++++++++++++++++++++++++++++++- src/game/shotAim.js | 153 +++++++++++++++ src/main.js | 51 ++++- src/render/camera.js | 329 ++++++++++++++++++++++++++----- src/render/markers.js | 195 +++++++++++++++++++ test/input.mjs | 60 +++++- test/puck.mjs | 52 +++++ test/shotAim.mjs | 133 +++++++++++++ test/skaterSim.mjs | 107 ++++++++++ 22 files changed, 2049 insertions(+), 212 deletions(-) create mode 100644 src/game/shotAim.js create mode 100644 src/render/markers.js create mode 100644 test/shotAim.mjs diff --git a/.idea/tilt.iml b/.idea/tilt.iml index c956989..19c5245 100644 --- a/.idea/tilt.iml +++ b/.idea/tilt.iml @@ -1,7 +1,11 @@ - + + + + + diff --git a/README.md b/README.md index d5fb94b..718edfb 100644 --- a/README.md +++ b/README.md @@ -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 })`, diff --git a/ROADMAP.md b/ROADMAP.md index 4699867..09b123f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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. diff --git a/package.json b/package.json index 0bed4e4..f946155 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/shared/skaterSim.js b/shared/skaterSim.js index 5dcd11f..4430c8b 100644 --- a/shared/skaterSim.js +++ b/shared/skaterSim.js @@ -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; diff --git a/src/anim/poses/skate.js b/src/anim/poses/skate.js index 646ef67..7869227 100644 --- a/src/anim/poses/skate.js +++ b/src/anim/poses/skate.js @@ -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. diff --git a/src/anim/poses/stickwork.js b/src/anim/poses/stickwork.js index f84d003..c6674b1 100644 --- a/src/anim/poses/stickwork.js +++ b/src/anim/poses/stickwork.js @@ -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. diff --git a/src/anim/skateAnimator.js b/src/anim/skateAnimator.js index 5964508..5ebe14b 100644 --- a/src/anim/skateAnimator.js +++ b/src/anim/skateAnimator.js @@ -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 diff --git a/src/character/skater.js b/src/character/skater.js index af03665..4ea25b8 100644 --- a/src/character/skater.js +++ b/src/character/skater.js @@ -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. */ diff --git a/src/character/skaterGear.js b/src/character/skaterGear.js index a7ffa40..40d0857 100644 --- a/src/character/skaterGear.js +++ b/src/character/skaterGear.js @@ -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, diff --git a/src/character/stick.js b/src/character/stick.js index 2e6793c..88ae1ae 100644 --- a/src/character/stick.js +++ b/src/character/stick.js @@ -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. */ diff --git a/src/game/input.js b/src/game/input.js index a2c9aa2..3b525f2 100644 --- a/src/game/input.js +++ b/src/game/input.js @@ -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; diff --git a/src/game/match.js b/src/game/match.js index 88b2d95..9319202 100644 --- a/src/game/match.js +++ b/src/game/match.js @@ -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); diff --git a/src/game/possession.js b/src/game/possession.js index 63a03c7..f0c4e7f 100644 --- a/src/game/possession.js +++ b/src/game/possession.js @@ -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; }, diff --git a/src/game/shotAim.js b/src/game/shotAim.js new file mode 100644 index 0000000..151f0ba --- /dev/null +++ b/src/game/shotAim.js @@ -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, +}); diff --git a/src/main.js b/src/main.js index ac9a60f..da7a665 100644 --- a/src/main.js +++ b/src/main.js @@ -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 | null} */ let puckView = null; + /** @type {ReturnType | 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) { diff --git a/src/render/camera.js b/src/render/camera.js index 5dde97c..b6a707b 100644 --- a/src/render/camera.js +++ b/src/render/camera.js @@ -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); }, }; } diff --git a/src/render/markers.js b/src/render/markers.js new file mode 100644 index 0000000..d5a66c9 --- /dev/null +++ b/src/render/markers.js @@ -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 }; diff --git a/test/input.mjs b/test/input.mjs index ddc3731..8696c1e 100644 --- a/test/input.mjs +++ b/test/input.mjs @@ -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(); } diff --git a/test/puck.mjs b/test/puck.mjs index 7be308b..b398a25 100644 --- a/test/puck.mjs +++ b/test/puck.mjs @@ -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); diff --git a/test/shotAim.mjs b/test/shotAim.mjs new file mode 100644 index 0000000..2cc177b --- /dev/null +++ b/test/shotAim.mjs @@ -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'); diff --git a/test/skaterSim.mjs b/test/skaterSim.mjs index 91add3c..3d6974d 100644 --- a/test/skaterSim.mjs +++ b/test/skaterSim.mjs @@ -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.