Initial commit
This commit is contained in:
@@ -0,0 +1,313 @@
|
||||
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;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user