import * as THREE from 'three'; import { createGoalie } from '../character/goalie.js'; import { buildNetMesh, createNet } from '../physics/net.js'; import { isGoal } from '../../shared/net.js'; import { FACEOFF_DOTS, nearestFaceoffDot, puckPlayable } from '../../shared/rink.js'; import { PUCK } from '../physics/puck.js'; /** * 3-on-3 with nets and goalies. * * Continuous play: goals, goalie covers, and dead pucks (out of bounds / * unplayable) all whistle and restart at a faceoff. Goals restart at centre; * OOB restarts at the nearest faceoff circle. */ export const SCRIMMAGE = { /** Hold the scoreboard message before the faceoff, seconds. */ resultTime: 2.0, /** * Goalie covers the puck and freezes play when it is this slow, m/s. * Matches the shootout idea: a sealed catch ends the rush. */ coverSpeed: 2.8, /** * How long a skater can sit clearly outside the boards before we whistle * (they normally hop back in on their own). */ skaterOobGrace: 0.55, }; /** * @param {{ scene: import('three').Scene, physics: object, match: object }} opts */ export function createScrimmage({ scene, physics, match }) { const { puck, possession, states, skaters } = match; const nets = [createNet(physics, 1), createNet(physics, -1)]; const netMeshes = [buildNetMesh(scene, 1), buildNetMesh(scene, -1)]; // end +1 (+X) is defended by team 1; end −1 by team 0. const goalies = { 1: createGoalie(physics, scene, { end: 1, index: 40, team: 1 }), '-1': createGoalie(physics, scene, { end: -1, index: 41, team: 0 }), }; const state = { /** 'live' | 'goal' | 'cover' | 'oob' | 'skater_oob' */ phase: 'live', score: [0, 0], /** Last stoppage, for the HUD. */ last: null, clock: 0, /** Where the next faceoff drops after this stoppage. */ nextFaceoff: FACEOFF_DOTS[0], }; /** Per-skater time spent outside the ice. */ const oobAge = new Float64Array(states.length); const _puckPos = new THREE.Vector3(); function faceoff() { const spot = state.nextFaceoff ?? FACEOFF_DOTS[0]; match.faceoffAt(spot); goalies[1].reset(); goalies[-1].reset(); state.phase = 'live'; state.clock = 0; oobAge.fill(0); } /** * @param {'goal'|'cover'|'oob'|'skater_oob'} kind * @param {string} detail * @param {{ x: number, z: number } | null} faceoffSpot */ function stoppage(kind, detail = '', faceoffSpot = null) { let team = null; if (kind === 'goal') { // Goal at +X end is team 0; at −X is team 1. team = detail === '+x' ? 0 : 1; state.score[team]++; state.nextFaceoff = FACEOFF_DOTS[0]; } else if (faceoffSpot) { state.nextFaceoff = nearestFaceoffDot(faceoffSpot.x, faceoffSpot.z); } else { state.nextFaceoff = FACEOFF_DOTS[0]; } state.phase = kind; state.clock = SCRIMMAGE.resultTime; state.last = { kind, detail, team, score: [...state.score], faceoff: state.nextFaceoff, }; // Kill puck motion so it does not rattle around during the hold. puck.setVelocity(0, 0, 0); possession.reset(); } function update(dt) { _puckPos.copy(puck.position()); goalies[1].update(dt, _puckPos); goalies[-1].update(dt, _puckPos); if (state.phase !== 'live') { state.clock -= dt; if (state.clock <= 0) faceoff(); return; } // Goals first — a covered puck that also crossed still counts as a goal. if (isGoal(_puckPos, 1, PUCK.radius)) { stoppage('goal', '+x'); return; } if (isGoal(_puckPos, -1, PUCK.radius)) { stoppage('goal', '-x'); return; } // Dead puck: out of bounds or unplayable → whistle, nearest circle. const play = puckPlayable(_puckPos.x, _puckPos.y, _puckPos.z, PUCK.radius); if (!play.ok) { stoppage('oob', play.reason, { x: _puckPos.x, z: _puckPos.z }); return; } // Either goalie freezes a slow puck in the body — whistle, faceoff. const speed = puck.speed(); if (speed < SCRIMMAGE.coverSpeed) { if (goalies[1].covers(_puckPos)) { stoppage('cover', 'away goalie', { x: _puckPos.x, z: _puckPos.z }); return; } if (goalies[-1].covers(_puckPos)) { stoppage('cover', 'home goalie', { x: _puckPos.x, z: _puckPos.z }); return; } } // Skaters hop back over the boards themselves (match.reenterSkaters). If // someone is still clearly outside after a short grace — wrong side of the // glass and stuck — whistle and draw at the nearest circle. for (let i = 0; i < states.length; i++) { if (skaters[i].limp) { oobAge[i] = 0; continue; } if (match.skaterOutOfBounds(i)) { oobAge[i] += dt; if (oobAge[i] >= SCRIMMAGE.skaterOobGrace) { stoppage('skater_oob', states[i].name, { x: states[i].x, z: states[i].z }); return; } } else { oobAge[i] = 0; } } } return { state, goalies, nets, netMeshes, update, faceoff, /** Full reset of score + ice (centre faceoff). */ reset() { state.score = [0, 0]; state.last = null; state.nextFaceoff = FACEOFF_DOTS[0]; faceoff(); }, destroy() { for (const n of nets) n.destroy(); for (const m of netMeshes) scene.remove(m); goalies[1].destroy(); goalies[-1].destroy(); }, }; }