Files
tilt/src/game/scrimmage.js
T
ryanfitzpatrickio f9aa382f30 Merge goalie-tester worktree: modes, 3v3 goalies, OOB faceoffs, CF deploy.
Bring main menu (1v1/3v3), scrimmage with nets and goalies, dead-puck
whistles at the nearest faceoff circle, skater board re-entry, and
Cloudflare Workers/Pages deploy config. Keep main jersey gear stack and
compact goalie floaters.
2026-08-03 10:41:13 -05:00

189 lines
5.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
},
};
}