314 lines
12 KiB
JavaScript
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;
|
|
},
|
|
};
|
|
}
|