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:
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user