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.
This commit is contained in:
ryanfitzpatrickio
2026-08-03 10:41:13 -05:00
parent 7d28facd68
commit f9aa382f30
18 changed files with 3121 additions and 130 deletions
+188
View File
@@ -0,0 +1,188 @@
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();
},
};
}