Files
tilt/src/game/possession.js
T
2026-08-03 06:43:21 -05:00

314 lines
12 KiB
JavaScript

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;
},
};
}