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.
189 lines
5.3 KiB
JavaScript
189 lines
5.3 KiB
JavaScript
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();
|
||
},
|
||
};
|
||
}
|