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