import * as THREE from 'three'; import { PUCK } from '../physics/puck.js'; import { clamp, lerp } from '../../shared/scalar.js'; /** * Who has the puck, and what "having it" means. * * This is the one genuinely undecided piece of the game, so it is built as a * dial rather than as an answer. `magnetism` runs 0..1 between the two models: * * 0 Pure physics. The puck is always a free rigid body and the only thing * that moves it is the blade collider pushing it. Authentic, and skittery * to the point of being unplayable — you lose it to contacts you never * intended and can never quite line up a shot. * * 1 Hard attach. The puck is placed at the carry point every frame. Totally * controllable, looks glued, and kills the scrambles that are the reason * to build a physics-driven hockey game at all. * * In between, the puck's velocity is blended toward whatever would carry it to * the stick, so it *mostly* follows but can be jostled off the blade by a hit, * a poke or a body in the way. Where that dial should sit is a feel question, * so it is tunable at runtime (`[` and `]` in the browser) rather than baked. * * Everything else here follows from that: capture is a proximity test, release * is either deliberate (shot, pass) or forced (hit, poke, the puck getting too * far from the blade). */ export const CARRY = { /** Default dial position. Tuned by hand; see the note above. */ magnetism: 0.72, /** A loose puck this close to the blade gets picked up. */ captureRadius: 0.55, /** * Possession breaks if the puck gets this far from the blade. * * Has to be generous relative to how far the blade sits in front of the body * (~1.35 m). At 1.15 m a shooter accelerating from a standstill outran their * own puck every time — twelve of nineteen shootout attempts ended with the * puck sitting on the ice at centre and nobody ever taking a shot. */ breakRadius: 2.0, /** How hard the puck is pulled onto the carry point, 1/s. */ stiffness: 20, /** Seconds after losing it before the same skater can re-capture. */ reclaimDelay: 0.35, /** Seconds after a shot or pass before anyone can capture. */ looseDelay: 0.18, /** How far the Skill Stick can push the puck fore/aft and side to side. */ reachFwd: 0.34, reachSide: 0.42, /** Shot speed at full power, m/s. ~45 is a real slapshot. */ shotSpeed: 45, /** Passes are firm but not shots. */ passSpeed: 18, /** A shot lifts slightly; a pass stays flat. */ shotLift: 0.1, /** How far a poke check reaches, blade to puck. */ pokeRadius: 1.25, /** How hard a poke or a check knocks the puck away, m/s. */ pokeSpeed: 5.5, /** How far the puck is stepped clear of the blade on release, metres. */ releaseGap: 0.4, }; const _carryWorld = new THREE.Vector3(); const _toTarget = new THREE.Vector3(); const _desired = new THREE.Vector3(); const _puckPos = new THREE.Vector3(); const _puckVel = new THREE.Vector3(); const _dir = new THREE.Vector3(); /** * @param {object} opts * @param {object} opts.puck from createPuck * @param {object[]} opts.skaters * @param {object[]} opts.states */ export function createPossession({ puck, skaters, states, onEvent = null }) { /** Index of the carrier, or null. */ let carrier = null; /** Per-skater cooldown before they may capture again. */ const cooldown = new Array(skaters.length).fill(0); /** Global cooldown after a deliberate release. */ let looseFor = 0; const tuning = { ...CARRY }; /** Skill Stick offset applied to the carry point, -1..1 each. */ const handling = { x: 0, y: 0 }; /** * Where the puck should sit for skater `i`, in world space. * * Read off the actual blade rather than computed from a fixed offset. That * inversion is the point of socketing the stick to the hand: the arms decide * where the blade is, and the puck goes where the blade is. Stickhandling is * then an arm pose rather than a number added to a carry point, and the puck * cannot end up somewhere the stick is not. */ function bladePoint(i, out) { const sk = skaters[i]; if (!sk?.stick) return out.set(0, 0, 0); sk.stick.bladeWorld(out); // The puck rides on the ice at the blade's XZ, not at the blade's centre — // the blade has height and a lie angle, and a puck floating at its middle // reads as hovering. out.y = PUCK.thickness / 2; return out; } function emit(type, payload) { if (onEvent) onEvent({ type, ...payload }); } /** Hand the puck to nobody, optionally locking capture for a moment. */ function release(reason, delay = tuning.reclaimDelay) { if (carrier === null) return; const was = carrier; cooldown[was] = delay; carrier = null; looseFor = Math.max(looseFor, tuning.looseDelay); emit('lost', { skater: was, reason }); } function capture(index) { if (carrier === index) return; if (carrier !== null) { const was = carrier; cooldown[was] = tuning.reclaimDelay; emit('stolen', { skater: index, from: was }); } else { emit('gained', { skater: index }); } carrier = index; cooldown[index] = 0; } /** * Poke check: reach in and knock the puck off whoever has it. * * Range is measured blade-to-puck, so it depends on where the poker's stick * actually is. Without this — and without contact dislodging the puck — a * carrier is untouchable, and a minute of play is one skater holding the puck * for the entire minute while five others follow them around. */ function poke(byIndex) { if (carrier === null || carrier === byIndex) return false; if (skaters[byIndex]?.limp) return false; bladePoint(byIndex, _carryWorld); _puckPos.copy(puck.position()); if (_puckPos.distanceTo(_carryWorld) > tuning.pokeRadius) return false; // Knock it away from the carrier, roughly along the poke. _dir.subVectors(_puckPos, _carryWorld).setY(0); if (_dir.lengthSq() < 1e-6) _dir.set(1, 0, 0); _dir.normalize().multiplyScalar(tuning.pokeSpeed); puck.setVelocity(_dir.x, 0, _dir.z); release('poked', tuning.reclaimDelay); emit('poke', { skater: byIndex, from: carrier }); return true; } /** * Contact dislodges the puck. Called when a check lands on the carrier — * a stagger is enough, it does not need a knockdown. */ function jar(severity = 1) { if (carrier === null) return false; _puckPos.copy(puck.position()); _dir.set(Math.random() - 0.5, 0, Math.random() - 0.5); if (_dir.lengthSq() < 1e-6) _dir.set(1, 0, 0); _dir.normalize().multiplyScalar(tuning.pokeSpeed * clamp(severity, 0.4, 1.6)); puck.setVelocity(_dir.x, 0, _dir.z); release('jarred loose', tuning.reclaimDelay); return true; } /** Fire the puck. `power` 0..1, `aimYaw` world radians. */ function shoot(power, aimYaw, { pass = false } = {}) { if (carrier === null) return null; const from = carrier; const speed = (pass ? tuning.passSpeed : tuning.shotSpeed) * clamp(power, 0.15, 1); _dir.set(Math.sin(aimYaw), 0, Math.cos(aimYaw)); const state = states[from]; // Step the puck off the blade before releasing it. // // It is sitting *exactly* on the blade — that is what carrying it means — // and the follow-through animation immediately sweeps that kinematic // collider through the same point at speed. Shots were being smashed // sideways by the shooter's own stick: measured, they stopped six metres // short of the net or flew twelve metres wide, and nothing ever scored. _puckPos.copy(puck.position()); puck.place( _puckPos.x + _dir.x * tuning.releaseGap, PUCK.thickness / 2, _puckPos.z + _dir.z * tuning.releaseGap, { keepMotion: true }, ); // A shot inherits the shooter's momentum. Skating into it is worth speed, // which is the whole reason a one-timer off the rush is dangerous. puck.setVelocity( _dir.x * speed + state.vx * 0.4, pass ? 0 : speed * tuning.shotLift, _dir.z * speed + state.vz * 0.4, ); release(pass ? 'pass' : 'shot', tuning.reclaimDelay); emit(pass ? 'pass' : 'shot', { skater: from, power, speed, aimYaw }); return { from, speed, power }; } return { tuning, handling, get carrier() { return carrier; }, get loose() { return carrier === null; }, shoot, poke, jar, release, capture, bladePoint, /** Where the puck is being carried, in world space. Null if loose. */ carryPoint(out) { if (carrier === null) return null; return bladePoint(carrier, out); }, /** * Advance possession by `dt`. * * Called once per rendered frame rather than per physics substep: capture * and release are gameplay decisions, and running them at 120 Hz just makes * the cooldowns six times as fiddly for no gain in fidelity. */ update(dt) { for (let i = 0; i < cooldown.length; i++) cooldown[i] = Math.max(0, cooldown[i] - dt); looseFor = Math.max(0, looseFor - dt); puck.position(); // refresh the cached vector _puckPos.copy(puck.position()); _puckVel.copy(puck.velocity()); // ---- forced release --------------------------------------------------- if (carrier !== null) { const holder = skaters[carrier]; if (holder.limp) { release('knocked down', 0.8); } else { this.carryPoint(_carryWorld); const gap = _puckPos.distanceTo(_carryWorld); if (gap > tuning.breakRadius) release('lost the handle'); } } // ---- capture ---------------------------------------------------------- if (carrier === null && looseFor <= 0) { let best = null; let bestGap = tuning.captureRadius; for (let i = 0; i < skaters.length; i++) { if (skaters[i].limp || cooldown[i] > 0) continue; bladePoint(i, _carryWorld); const gap = _puckPos.distanceTo(_carryWorld); if (gap < bestGap) { bestGap = gap; best = i; } } if (best !== null) capture(best); } // ---- carry ------------------------------------------------------------ if (carrier === null) return; this.carryPoint(_carryWorld); _toTarget.subVectors(_carryWorld, _puckPos); const state = states[carrier]; // The velocity that would put the puck on the carry point, given that the // carry point is itself moving with the skater. _desired.set( state.vx + _toTarget.x * tuning.stiffness, _toTarget.y * tuning.stiffness, state.vz + _toTarget.z * tuning.stiffness, ); const m = clamp(tuning.magnetism, 0, 1); puck.setVelocity( lerp(_puckVel.x, _desired.x, m), lerp(_puckVel.y, _desired.y, m), lerp(_puckVel.z, _desired.z, m), ); // There was a second "fumble" test here, a function of stiffness and // magnetism, meant to catch a puck the magnetism was papering over. It // was redundant with `breakRadius` and, after stiffness went up, fired // *tighter* than it — at 1.16 m against a 1.7 m break — so it silently // stripped the puck off every shooter accelerating out of centre ice. // Twenty of twenty-four shootout attempts ended with nobody shooting. // One distance test is enough, and it is the one above. }, /** Clear everything — faceoffs and resets. */ reset() { carrier = null; looseFor = 0; cooldown.fill(0); handling.x = 0; handling.y = 0; }, }; }