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
+8 -11
View File
@@ -698,23 +698,20 @@ export function buildGoalieGear(mats, skelData, phys) {
);
// Shoulder floaters — parented to the upper arms, aligned to the A-pose axis
// so they actually sit on the arm instead of hovering beside it.
// so they actually sit on the arm instead of hovering beside it. Keep these
// compact, like the skater's deltoid caps: the sleeve supplies the dressed
// silhouette, while a long rigid arm pad tears through the skinned cloth in
// butterfly and reach poses.
function makeFloater(side) {
const g = new THREE.Group();
g.name = `floater${side}`;
const cap = loft([
S(V(0, 0.05, 0.01), 0.068, 0.065, 3, PAL.base),
S(V(0, -0.02, 0.012), 0.084, 0.078, 4),
S(V(0, -0.08, 0.01), 0.079, 0.072, 4),
S(V(0, -0.145, 0.008), 0.068, 0.061, 3),
S(V(0, 0.04, 0.008), 0.058, 0.054, 3, PAL.base),
S(V(0, -0.025, 0.01), 0.068, 0.064, 4),
S(V(0, -0.09, 0.008), 0.062, 0.058, 4),
S(V(0, -0.14, 0.006), 0.05, 0.046, 3),
], { radial: 16, sub: 4 });
g.add(mesh(cap, mats.painted, `floater${side}Cap`));
const arm = loft([
S(V(0, -0.16, 0.006), 0.064, 0.059, 3, PAL.base),
S(V(0, -0.26, 0.004), 0.058, 0.053, 3),
S(V(0, -0.315, 0.002), 0.046, 0.042, 3, PAL.trim),
], { radial: 14, sub: 4 });
g.add(mesh(arm, mats.painted, `floater${side}Arm`));
alignTo(g, ARM_DIR[side]);
pieces.push(g);
return g;
+95 -13
View File
@@ -1,7 +1,7 @@
import * as THREE from 'three';
import { createSkater } from '../character/skater.js';
import { createBrain, spawnLineup, steer } from '../../shared/ai.js';
import { applyIntent, createSkaterState, stepSkater } from '../../shared/skaterSim.js';
import { applyIntent, createSkaterState, stepSkater, SKATE } from '../../shared/skaterSim.js';
import { stickToWorld } from './input.js';
import { createHitResolver } from './hits.js';
import { PUCK, createPuck } from '../physics/puck.js';
@@ -9,6 +9,7 @@ import { NET, goalLineX } from '../../shared/net.js';
import { createPossession } from './possession.js';
import { makeRng } from '../core/rng.js';
import { clamp, wrapAngle } from '../../shared/scalar.js';
import { FACEOFF_DOTS, insideRink, nearestFaceoffDot, rinkPenetration } from '../../shared/rink.js';
/**
* The match loop.
@@ -248,6 +249,41 @@ export function createMatch({ scene, physics, perTeam = 3, teams = 2, seed = 202
chaser: new Array(teams).fill(null),
};
/**
* One-way board hop: outside → ice only. Boards still block leaving.
* @returns {boolean}
*/
function reenterSkaters() {
let any = false;
for (let i = 0; i < count; i++) {
const s = states[i];
// Limp bodies ride the ragdoll; get-up re-homes the proxy to the pelvis.
if (skaters[i].limp) continue;
const pen = rinkPenetration(s.x, s.z, SKATE.radius * 0.85);
if (pen.dist <= 0.02) continue;
any = true;
const inset = pen.dist + 0.08;
s.x += pen.nx * inset;
s.z += pen.nz * inset;
// Kill velocity going further out of the rink.
const outward = s.vx * -pen.nx + s.vz * -pen.nz;
if (outward > 0) {
s.vx += pen.nx * outward;
s.vz += pen.nz * outward;
}
// Hop inward so they clear the wall instead of grinding it.
s.vx += pen.nx * 1.2;
s.vz += pen.nz * 1.2;
const hopX = s.vx;
const hopZ = s.vz;
skaters[i].proxy?.teleport(s.x, s.z);
s.vx = hopX;
s.vz = hopZ;
skaters[i].proxy?.write(s);
}
return any;
}
/** @param {number} dt */
function update(dt) {
const pp = puck.position();
@@ -373,6 +409,10 @@ export function createMatch({ scene, physics, perTeam = 3, teams = 2, seed = 202
puck.setVelocity(v.x * k, v.y * k, v.z * k);
}
// Skaters who end up outside (over the boards, tunnel, get-up glitch) hop
// back onto the ice. One-way only: boards still block leaving the normal way.
reenterSkaters();
// ---- 3. hits, knockdowns and getting up --------------------------------
hits.tick(dt);
for (let i = 0; i < count; i++) {
@@ -481,24 +521,66 @@ export function createMatch({ scene, physics, perTeam = 3, teams = 2, seed = 202
return states.filter((s) => s.team === index);
},
/** Drop everyone back on their spawn, momentum cleared. */
reset() {
for (let i = 0; i < count; i++) {
const spawn = spawns[i];
// Anyone lying on the ice has to be stood up before being placed, or
// their proxy stays disabled and they spawn as a corpse.
if (skaters[i].limp) skaters[i].getUp(states[i]);
Object.assign(states[i], { x: spawn.x, z: spawn.z, vx: 0, vz: 0, yaw: spawn.yaw });
skaters[i].proxy?.teleport(spawn.x, spawn.z);
brains[i].target = null;
/**
* Drop everyone for a faceoff at a given dot (default: centre ice).
* Accepts a faceoff-dot object or raw `{x,z}`.
*/
faceoffAt(spot = FACEOFF_DOTS[0]) {
const fx = spot?.x ?? 0;
const fz = spot?.z ?? 0;
// Team 0 stands on the X side of the puck, team 1 on +X — each faces in.
const byTeam = [[], []];
for (let i = 0; i < count; i++) byTeam[states[i].team]?.push(i);
for (let t = 0; t < teams; t++) {
const side = t === 0 ? -1 : 1;
const ids = byTeam[t] ?? [];
for (let k = 0; k < ids.length; k++) {
const i = ids[k];
// First skater is the draw; the rest fan back and wide.
const along = k === 0 ? 1.05 : 2.4 + (k - 1) * 1.1;
const lateral = k === 0 ? 0 : ((k % 2 === 1 ? 1 : -1) * (0.9 + Math.floor((k - 1) / 2) * 1.2));
let x = fx + side * along;
let z = fz + lateral;
// Keep the lineup on the ice if the dot is near the boards.
const pen = rinkPenetration(x, z, SKATE.radius + 0.15);
if (pen.dist > 0) {
x += pen.nx * (pen.dist + 0.05);
z += pen.nz * (pen.dist + 0.05);
}
if (skaters[i].limp) skaters[i].getUp(states[i]);
Object.assign(states[i], {
x, z, vx: 0, vz: 0,
yaw: side > 0 ? -Math.PI / 2 : Math.PI / 2,
ix: 0, iz: 0, sprint: false, brake: false,
});
skaters[i].proxy?.teleport(x, z);
brains[i].target = null;
}
}
recentHits.length = 0;
recentPlays.length = 0;
// Faceoff: puck at centre ice, dead.
possession.reset();
puck.place(0, 0.05, 0);
puck.place(fx, PUCK.thickness / 2 + 0.01, fz);
},
/** Centre-ice faceoff — the default restart. */
reset() {
this.faceoffAt(FACEOFF_DOTS[0]);
},
reenterSkaters,
/** True when a skater is clearly outside the playing surface. */
skaterOutOfBounds(i) {
const s = states[i];
if (!s) return false;
return !insideRink(s.x, s.z, -0.15);
},
nearestFaceoffDot,
destroy() {
hits.destroy();
puck.destroy();
+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();
},
};
}
+327 -100
View File
@@ -7,18 +7,24 @@ import { createMatch } from './game/match.js';
import { createInput } from './game/input.js';
import { describeHit } from './game/hits.js';
import { createShootout } from './game/shootout.js';
import { createScrimmage } from './game/scrimmage.js';
import { RINK } from '../shared/rink.js';
/**
* Spike 1 boot: three AI skaters on a rink.
* Shell: renderer, lights, main menu, and the active mode loop.
*
* Everything gameplay-shaped lives in `game/match.js`; this file is the shell —
* renderer, lights, resize, the frame loop and a small debug HUD.
* Modes:
* 1v1 — shootout (one shooter vs goalie, alternating teams)
* 3v3 — six skaters + nets + goalies, open play with scoring
*
* Everything gameplay-shaped lives in `game/`; this file wires the shell and
* tears modes down cleanly so Esc can return to the menu.
*/
const canvas = document.getElementById('stage');
const boot = document.getElementById('boot');
const hud = document.getElementById('hud');
const menuEl = document.getElementById('menu');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: 'high-performance' });
renderer.shadowMap.enabled = true;
@@ -82,62 +88,221 @@ resize();
const stats = { fps: 0, steps: 0, top: 0 };
const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
async function boot3() {
/** @typedef {'1v1' | '3v3'} GameMode */
const RUMBLE = {
knockdown: [1.0, 0.7, 260],
stagger: [0.55, 0.35, 150],
bump: [0.22, 0.12, 70],
};
function parseModeFromUrl() {
const raw = new URLSearchParams(location.search).get('mode');
if (raw === '1v1' || raw === '1on1' || raw === 'shootout') return '1v1';
if (raw === '3v3' || raw === '3on3' || raw === 'scrimmage') return '3v3';
return null;
}
async function bootApp() {
await initPhysics();
const physics = createPhysicsWorld();
buildRink(scene);
const match = createMatch({ scene, physics, perTeam: 3, teams: 2 });
const puckView = buildPuckMesh(scene, PUCK);
// The shootout owns the nets and the goalies, and drives its own kinematic
// bodies inside the physics substep.
const shootout = createShootout({ scene, physics, match });
match.addSubstepSync((fixedDt) => {
shootout.goalies[1].syncPhysics(fixedDt);
shootout.goalies[-1].syncPhysics(fixedDt);
});
shootout.reset();
const input = createInput(window);
// One live input object, refreshed each frame and read by the match.
const stick = input.state;
// Rumble on contact the player is part of. Strength tracks the outcome, so
// the pad tells you whether you laid someone out or just brushed them, and
// taking one buzzes harder than giving one.
const RUMBLE = {
knockdown: [1.0, 0.7, 260],
stagger: [0.55, 0.35, 150],
bump: [0.22, 0.12, 70],
};
/** @type {GameMode | null} */
let mode = null;
/** @type {ReturnType<typeof createMatch> | null} */
let match = null;
/** @type {ReturnType<typeof createShootout> | null} */
let shootout = null;
/** @type {ReturnType<typeof createScrimmage> | null} */
let scrimmage = null;
/** @type {ReturnType<typeof buildPuckMesh> | null} */
let puckView = null;
/** @type {(() => void) | null} */
let removeSubstepSync = null;
let playerDriving = false;
let lastHitSeen = -1;
function clearMode() {
if (removeSubstepSync) {
removeSubstepSync();
removeSubstepSync = null;
}
if (shootout) {
shootout.destroy();
shootout = null;
}
if (scrimmage) {
scrimmage.destroy();
scrimmage = null;
}
if (puckView) {
// Ring is parented under the mesh.
scene.remove(puckView.mesh);
puckView.mesh.geometry?.dispose?.();
puckView.mesh.material?.dispose?.();
puckView.ring.geometry?.dispose?.();
puckView.ring.material?.dispose?.();
puckView = null;
}
if (match) {
match.destroy();
match = null;
}
mode = null;
playerDriving = false;
lastHitSeen = -1;
hud.textContent = '';
Object.assign(cam.state, {
mode: 'broadcast',
followIndex: 0,
distance: 34,
pitch: 0.62,
});
}
/**
* Take control of a skater, or give them back.
*
* Taking control snaps the camera onto whoever you just grabbed — driving a
* skater you cannot see is the kind of thing that reads as a broken build.
*/
let playerShooting = false;
/**
* Take the shooter, or hand them back. In a shootout there is only one
* skater worth driving, and which one it is changes every attempt — so
* control follows the shooter rather than being pinned to an index.
* 1v1: control follows whoever is shooting this attempt.
* 3v3: control follows the camera's follow target (or Home 1).
*/
function toggleControl() {
playerShooting = !playerShooting;
shootout.setShooterControl(playerShooting ? stick : null);
if (playerShooting) {
cam.state.mode = 'follow';
cam.state.followIndex = shootout.state.shooter;
cam.state.distance = 9;
cam.state.pitch = 0.3;
if (!match) return;
playerDriving = !playerDriving;
if (mode === '1v1' && shootout) {
shootout.setShooterControl(playerDriving ? stick : null);
if (playerDriving) {
cam.state.mode = 'follow';
cam.state.followIndex = shootout.state.shooter;
cam.state.distance = 9;
cam.state.pitch = 0.3;
}
return;
}
// 3v3 scrimmage
if (!playerDriving) {
if (match.playerIndex !== null) match.setControl(match.playerIndex, null);
return;
}
const idx = cam.state.mode === 'follow'
? cam.state.followIndex
: 0;
match.setControl(idx, stick);
cam.state.mode = 'follow';
cam.state.followIndex = idx;
cam.state.distance = 9;
cam.state.pitch = 0.3;
}
function showMenu() {
clearMode();
menuEl.hidden = false;
menuEl.querySelector('button.choice')?.focus();
}
/**
* Start a mode. Safe to call again — tears down whatever was running.
* @param {GameMode} next
*/
function startMode(next) {
if (next !== '1v1' && next !== '3v3') {
throw new Error(`unknown mode: ${next}`);
}
clearMode();
menuEl.hidden = true;
mode = next;
match = createMatch({ scene, physics, perTeam: 3, teams: 2 });
puckView = buildPuckMesh(scene, PUCK);
if (next === '1v1') {
// The shootout owns the nets and the goalies, and drives its own
// kinematic bodies inside the physics substep.
shootout = createShootout({ scene, physics, match });
removeSubstepSync = match.addSubstepSync((fixedDt) => {
shootout.goalies[1].syncPhysics(fixedDt);
shootout.goalies[-1].syncPhysics(fixedDt);
});
shootout.reset();
playerDriving = false;
} else {
// Full ice: nets + goalies at both ends, continuous play with scoring.
scrimmage = createScrimmage({ scene, physics, match });
removeSubstepSync = match.addSubstepSync((fixedDt) => {
scrimmage.goalies[1].syncPhysics(fixedDt);
scrimmage.goalies[-1].syncPhysics(fixedDt);
});
scrimmage.reset();
// Scrimmage opens under AI; press P to jump in.
playerDriving = false;
}
Object.assign(cam.state, {
mode: 'broadcast',
distance: next === '1v1' ? 34 : 40,
pitch: next === '1v1' ? 0.62 : 0.72,
followIndex: 0,
});
}
// ---- menu wiring --------------------------------------------------------
menuEl.addEventListener('click', (e) => {
const btn = e.target.closest('button.choice');
if (!btn) return;
startMode(/** @type {GameMode} */ (btn.dataset.mode));
});
window.addEventListener('keydown', (e) => {
if (e.key === 'c' || e.key === 'C') cam.cycleMode(match.skaters.length);
if (e.key === 'r' || e.key === 'R') shootout.reset();
// Menu: 1 / 2 / Enter on focused choice.
if (!menuEl.hidden) {
if (e.key === '1') {
e.preventDefault();
startMode('1v1');
} else if (e.key === '2') {
e.preventDefault();
startMode('3v3');
} else if (e.key === 'Enter') {
const active = document.activeElement;
if (active?.dataset?.mode) {
e.preventDefault();
startMode(/** @type {GameMode} */ (active.dataset.mode));
}
}
return;
}
// In-game.
if (e.key === 'Escape') {
e.preventDefault();
showMenu();
return;
}
if (!match) return;
if (e.key === 'c' || e.key === 'C') {
cam.cycleMode(match.skaters.length);
// If the player is driving in 3v3, keep control on whoever the camera
// is following so C can swap bodies.
if (mode === '3v3' && playerDriving && cam.state.mode === 'follow') {
const prev = match.playerIndex;
if (prev !== null && prev !== cam.state.followIndex) {
match.setControl(prev, null);
match.setControl(cam.state.followIndex, stick);
}
}
}
if (e.key === 'r' || e.key === 'R') {
if (mode === '1v1' && shootout) shootout.reset();
else if (mode === '3v3' && scrimmage) scrimmage.reset();
else match.reset();
}
if (e.key === 'p' || e.key === 'P' || e.code === 'Tab') {
e.preventDefault();
toggleControl();
@@ -153,10 +318,29 @@ async function boot3() {
// Debug handle. The capture tool drives the camera through this to frame
// repeatable shots, and it is the fastest way to poke at a skater from the
// console while tuning.
window.tilt = { match, shootout, cam, physics, scene, renderer, stats, input, toggleControl };
window.tilt = {
get match() { return match; },
get shootout() { return shootout; },
get scrimmage() { return scrimmage; },
get mode() { return mode; },
cam,
physics,
scene,
renderer,
stats,
input,
startMode,
showMenu,
toggleControl,
};
boot.remove();
// Deep link / capture: ?mode=1v1|3v3 skips the menu.
const auto = parseModeFromUrl();
if (auto) startMode(auto);
else showMenu();
let last = performance.now();
let fpsAccum = 0;
let fpsFrames = 0;
@@ -167,61 +351,70 @@ async function boot3() {
const dt = Math.min(0.05, (now - last) / 1000);
last = now;
// The camera yaw rides along with the stick so the match can turn a
// screen-space push into a world direction. Sampled before the update so
// input and simulation are one frame consistent.
input.read(dt);
stick.cameraYaw = cam.state.yaw;
match.update(dt);
shootout.update(dt);
// Follow whoever is shooting, so the camera never has to be told.
if (cam.state.mode === 'follow') cam.state.followIndex = shootout.state.shooter;
if (match) {
match.update(dt);
if (shootout) shootout.update(dt);
if (scrimmage) scrimmage.update(dt);
// Haptics for anything the player was part of.
const newest = match.recentHits[0];
if (newest && newest.at !== lastHitSeen) {
lastHitSeen = newest.at;
const me = match.playerIndex;
if (me !== null && (newest.attacker === me || newest.victim === me)) {
const [strong, weak, ms] = RUMBLE[newest.outcome] ?? RUMBLE.bump;
// Taking a hit shakes harder than landing one.
const k = newest.victim === me ? 1 : 0.7;
input.rumble(strong * k, weak * k, ms);
// Follow whoever is shooting, so the camera never has to be told.
if (mode === '1v1' && shootout && cam.state.mode === 'follow') {
cam.state.followIndex = shootout.state.shooter;
}
// Haptics for anything the player was part of.
const newest = match.recentHits[0];
if (newest && newest.at !== lastHitSeen) {
lastHitSeen = newest.at;
const me = match.playerIndex;
if (me !== null && (newest.attacker === me || newest.victim === me)) {
const [strong, weak, ms] = RUMBLE[newest.outcome] ?? RUMBLE.bump;
// Taking a hit shakes harder than landing one.
const k = newest.victim === me ? 1 : 0.7;
input.rumble(strong * k, weak * k, ms);
}
}
if (puckView) {
puckView.mesh.position.copy(match.puck.position());
puckView.mesh.quaternion.copy(match.puck.rotation());
puckView.ring.visible = match.possession.loose;
}
cam.update(dt, match.states);
fpsAccum += dt;
fpsFrames++;
if (fpsAccum >= 0.5) {
stats.fps = Math.round(fpsFrames / fpsAccum);
stats.steps = physics.stepCount;
stats.top = match.states.reduce((m, s) => Math.max(m, Math.hypot(s.vx, s.vz)), 0);
fpsAccum = 0;
fpsFrames = 0;
}
drawHud();
} else {
// Idle rink under the menu — slow orbit so the ice is not a still photo.
cam.state.yaw += dt * 0.08;
cam.update(dt, []);
hud.textContent = '';
}
puckView.mesh.position.copy(match.puck.position());
puckView.mesh.quaternion.copy(match.puck.rotation());
puckView.ring.visible = match.possession.loose;
cam.update(dt, match.states);
renderer.render(scene, cam.camera);
fpsAccum += dt;
fpsFrames++;
if (fpsAccum >= 0.5) {
stats.fps = Math.round(fpsFrames / fpsAccum);
stats.steps = physics.stepCount;
stats.top = match.states.reduce((m, s) => Math.max(m, Math.hypot(s.vx, s.vz)), 0);
fpsAccum = 0;
fpsFrames = 0;
}
// Drawn every frame, not on the half-second tick: the hustle and shot
// meters are feedback, and feedback at 2 Hz is worse than none.
drawHud();
requestAnimationFrame(frame);
}
const bar = (v) => '▮'.repeat(Math.round(clamp01(v) * 8)).padEnd(8, '▯');
function drawHud() {
if (!match) return;
const watching = cam.state.mode === 'follow'
? match.states[cam.state.followIndex]?.name ?? 'broadcast'
: 'broadcast';
const player = match.playerIndex !== null ? match.states[match.playerIndex] : null;
const down = match.skaters.filter((s) => s.limp).length;
const feed = match.recentHits
.filter((h) => h.outcome !== 'bump')
.slice(0, 3)
@@ -232,44 +425,78 @@ async function boot3() {
? `pad: ${(stick.padId ?? '').slice(0, 30) || 'connected'}`
: 'pad: none — keyboard';
const so = shootout.state;
const teamName = (t) => (t === 0 ? 'HOME' : 'AWAY');
const scoreLine = `${teamName(0)} ${so.score[0]}${so.score[1]} ${teamName(1)}`
+ ` round ${so.round}`;
const phaseLine = so.phase === 'ready'
? `${teamName(so.shootingTeam)} to shoot…`
: so.phase === 'result'
? (so.last?.result === 'goal'
? `GOAL — ${teamName(so.last.team)}`
: `SAVE${so.last?.detail ? ` (${so.last.detail})` : ''}`)
: `${teamName(so.shootingTeam)} shooting · ${Math.max(0, so.clock).toFixed(1)}s`;
const carrier = match.possession.carrier;
const puckLine = carrier === null
? `puck: loose ${match.puck.speed().toFixed(1)} m/s`
: `puck: ${match.states[carrier].name}${carrier === match.playerIndex ? ' ← YOU' : ''}`;
const mag = match.possession.tuning.magnetism;
hud.textContent = `${scoreLine}`
const controlsHint = player
? (input.connected
? '\nL-stick skate · RT hustle · LT stop · R-stick Skill Stick\nA pass · X shoot · B poke'
: '\nWASD skate · Shift hustle · Space stop · arrows Skill Stick\nJ pass · K shoot · L poke')
+ `\nhustle ${bar(stick.hustle)} wind-up ${bar(stick.charge)}`
: '';
if (mode === '1v1' && shootout) {
const so = shootout.state;
const teamName = (t) => (t === 0 ? 'HOME' : 'AWAY');
const scoreLine = `${teamName(0)} ${so.score[0]}${so.score[1]} ${teamName(1)}`
+ ` round ${so.round}`;
const phaseLine = so.phase === 'ready'
? `${teamName(so.shootingTeam)} to shoot…`
: so.phase === 'result'
? (so.last?.result === 'goal'
? `GOAL — ${teamName(so.last.team)}`
: `SAVE${so.last?.detail ? ` (${so.last.detail})` : ''}`)
: `${teamName(so.shootingTeam)} shooting · ${Math.max(0, so.clock).toFixed(1)}s`;
hud.textContent = `1-on-1 shootout`
+ `\n${scoreLine}`
+ `\n${phaseLine}`
+ `\n`
+ `\n${stats.fps} fps · ${puckLine}`
+ `\n${pad}`
+ `\n[P] ${playerDriving ? 'let the AI shoot' : 'take the shooter'} [C] camera [R] restart [Esc] menu`
+ `\nmagnetism ${bar(mag)} ${mag.toFixed(2)} [ ] to tune`
+ controlsHint
+ (feed ? `\n\nhits:\n${feed}` : '');
return;
}
// 3v3 with nets and goalies
const sc = scrimmage?.state;
const scoreLine = sc
? `HOME ${sc.score[0]}${sc.score[1]} AWAY`
: 'HOME 0 — 0 AWAY';
let phaseLine = 'live';
if (sc?.phase === 'goal') {
const who = sc.last?.team === 0 ? 'HOME' : 'AWAY';
phaseLine = `GOAL — ${who}`;
} else if (sc?.phase === 'cover') {
phaseLine = `covered (${sc.last?.detail ?? 'goalie'})`;
} else if (sc?.phase === 'oob') {
phaseLine = `whistle — puck ${sc.last?.detail || 'out'} · faceoff ${sc.last?.faceoff?.id ?? ''}`;
} else if (sc?.phase === 'skater_oob') {
phaseLine = `whistle — ${sc.last?.detail || 'skater'} over boards · faceoff ${sc.last?.faceoff?.id ?? ''}`;
}
hud.textContent = `3-on-3 · cam ${watching}`
+ `\n${scoreLine}`
+ `\n${phaseLine}`
+ `\n`
+ `\n${stats.fps} fps · ${puckLine}`
+ `\n${pad}`
+ `\n[P] ${playerShooting ? 'let the AI shoot' : 'take the shooter'} [C] camera [R] restart`
+ `\n[P] ${playerDriving ? 'hand back to AI' : 'take control'} [C] camera [R] faceoff [Esc] menu`
+ `\nmagnetism ${bar(mag)} ${mag.toFixed(2)} [ ] to tune`
+ (player
? (input.connected
? '\nL-stick skate · RT hustle · LT stop · R-stick Skill Stick\nA pass · X shoot · B poke'
: '\nWASD skate · Shift hustle · Space stop · arrows Skill Stick\nJ pass · K shoot · L poke')
+ `\nhustle ${bar(stick.hustle)} wind-up ${bar(stick.charge)}`
: '')
+ controlsHint
+ (feed ? `\n\nhits:\n${feed}` : '');
}
requestAnimationFrame(frame);
}
boot3().catch((err) => {
bootApp().catch((err) => {
console.error(err);
boot.textContent = 'FAILED TO START — ' + (err?.message ?? err);
});