Files
tilt/test/puck.mjs
T
2026-08-03 06:43:21 -05:00

325 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import * as THREE from 'three';
import { createPhysicsWorld, initPhysics } from '../src/physics/world.js';
import { PUCK, createPuck } from '../src/physics/puck.js';
import { createMatch } from '../src/game/match.js';
import { CARRY } from '../src/game/possession.js';
import { RINK, insideRink } from '../shared/rink.js';
import { done, near, ok, section } from './harness.mjs';
/**
* Puck, stick and possession.
*
* The two things worth testing hard are the ones that are hard to see: that a
* 45 m/s shot does not tunnel through the boards (it moves ten times its own
* radius per step, so it will unless it is a bullet), and that possession
* behaves sanely at both ends of the magnetism dial — because that dial is a
* feel decision that has not been made yet, and the code has to survive
* wherever it lands.
*/
const DT = 1 / 60;
await initPhysics();
function arena(perTeam = 1) {
const physics = createPhysicsWorld();
const match = createMatch({ scene: new THREE.Group(), physics, perTeam, teams: 2 });
return { physics, match };
}
/** Park everyone far from the play so they cannot interfere. */
function clearIce(match, keep = []) {
for (let i = 0; i < match.states.length; i++) {
if (keep.includes(i)) continue;
const x = -RINK.halfX * 0.8 + i * 3;
match.states[i].x = x;
match.states[i].z = -RINK.halfZ * 0.75;
match.states[i].vx = 0;
match.states[i].vz = 0;
match.skaters[i].proxy.teleport(x, -RINK.halfZ * 0.75);
match.setControl(i, { x: 0, y: 0, sprint: false, brake: false, cameraYaw: 0 });
}
}
section('the puck is a regulation puck');
{
const physics = createPhysicsWorld();
const puck = createPuck(physics);
near(PUCK.radius * 2, 0.0762, 1e-4, 'three inches across');
near(PUCK.thickness, 0.0254, 1e-4, 'one inch thick');
near(puck.mass, 0.170, 0.005, `and 170 grams (got ${puck.mass.toFixed(3)} kg)`);
puck.destroy();
physics.destroy();
}
section('the puck settles flat on the ice and stays there');
{
const physics = createPhysicsWorld();
const puck = createPuck(physics, { position: { x: 0, y: 1.5, z: 0 } });
for (let n = 0; n < 3 / DT; n++) physics.step(DT);
const p = puck.position();
ok(p.y > 0 && p.y < 0.05, `it lands on the surface (y=${p.y.toFixed(4)})`);
// Angular X and Z are locked, so it can never be standing on its edge.
const q = puck.rotation();
const up = new THREE.Vector3(0, 1, 0).applyQuaternion(q);
ok(up.y > 0.999, `and lies flat rather than rolling on its edge (up.y=${up.y.toFixed(4)})`);
puck.destroy();
physics.destroy();
}
section('a hard shot does not tunnel through the boards');
{
// The headline risk. At 45 m/s the puck covers 0.37 m per 1/120 s step,
// roughly ten times its own radius, so without continuous collision it goes
// straight through the wall and is never seen again.
for (const speed of [20, 45, 55]) {
const physics = createPhysicsWorld();
const puck = createPuck(physics, { position: { x: 0, y: 0.02, z: 0 } });
puck.setVelocity(speed, 0, 0);
for (let n = 0; n < 4 / DT; n++) physics.step(DT);
const p = puck.position();
ok(
insideRink(p.x, p.z, 0),
`a ${speed} m/s shot stayed in the rink (ended at x=${p.x.toFixed(2)}, z=${p.z.toFixed(2)})`,
);
ok(Math.abs(p.y) < 1.5, `and did not go over the glass (y=${p.y.toFixed(2)})`);
puck.destroy();
physics.destroy();
}
}
section('a shot into the corner stays in the corner');
{
// Corners are a chain of short board segments; the seams between them are
// where a fast small body escapes if anything is going to.
for (const angle of [0.6, 1.1, -0.7, 2.4]) {
const physics = createPhysicsWorld();
const puck = createPuck(physics, { position: { x: 0, y: 0.02, z: 0 } });
puck.setVelocity(Math.sin(angle) * 48, 0, Math.cos(angle) * 48);
for (let n = 0; n < 5 / DT; n++) physics.step(DT);
const p = puck.position();
ok(insideRink(p.x, p.z, 0), `a shot at ${angle.toFixed(1)} rad stayed inside`);
puck.destroy();
physics.destroy();
}
}
section('a dumped puck slides a long way but does stop');
{
const physics = createPhysicsWorld();
const puck = createPuck(physics, { position: { x: -RINK.halfX * 0.9, y: 0.02, z: 0 } });
puck.setVelocity(14, 0, 0);
let travelled = 0;
const start = puck.position().x;
for (let n = 0; n < 6 / DT; n++) physics.step(DT);
travelled = Math.abs(puck.position().x - start);
ok(travelled > 15, `it carries down the ice (${travelled.toFixed(1)}m in 6s)`);
ok(puck.speed() < 14, `and does lose speed (${puck.speed().toFixed(1)} m/s left)`);
puck.destroy();
physics.destroy();
}
section('a skater picks up a loose puck');
{
const { physics, match } = arena(1);
clearIce(match, [0]);
match.states[0].x = 0;
match.states[0].z = 0;
match.states[0].yaw = Math.PI / 2;
match.skaters[0].proxy.teleport(0, 0);
// Drop the puck right where skater 0's blade is.
match.puck.place(0.8, 0.02, 0.2);
for (let n = 0; n < 1 / DT; n++) match.update(DT);
ok(match.possession.carrier === 0, `skater 0 picked it up (carrier=${match.possession.carrier})`);
physics.destroy();
}
section('a carried puck stays with the skater');
{
const { physics, match } = arena(1);
clearIce(match, [0]);
match.states[0].x = -20;
match.states[0].z = 0;
match.states[0].yaw = Math.PI / 2;
match.skaters[0].proxy.teleport(-20, 0);
match.puck.place(-20 + 0.8, 0.02, 0);
match.setControl(0, { x: 1, y: 0, sprint: true, brake: false, cameraYaw: 0 });
for (let n = 0; n < 0.5 / DT; n++) match.update(DT);
ok(match.possession.carrier === 0, 'possession established');
let maxGap = 0;
for (let n = 0; n < 2.5 / DT; n++) {
match.update(DT);
if (match.possession.carrier !== 0) break;
const p = match.puck.position();
maxGap = Math.max(maxGap, Math.hypot(p.x - match.states[0].x, p.z - match.states[0].z));
}
ok(match.possession.carrier === 0, 'and survived a full-speed rush');
ok(maxGap < CARRY.breakRadius + 1, `the puck stayed with the stick (worst ${maxGap.toFixed(2)}m)`);
ok(match.states[0].x > -12, `while actually covering ground (x=${match.states[0].x.toFixed(1)})`);
physics.destroy();
}
section('the dial does what it says at both ends');
{
function carryWander(magnetism) {
const { physics, match } = arena(1);
clearIce(match, [0]);
match.states[0].x = -20;
match.states[0].z = 0;
match.states[0].yaw = Math.PI / 2;
match.skaters[0].proxy.teleport(-20, 0);
match.puck.place(-20 + 0.8, 0.02, 0);
match.possession.tuning.magnetism = magnetism;
match.setControl(0, { x: 1, y: 0, sprint: true, brake: false, cameraYaw: 0 });
for (let n = 0; n < 0.5 / DT; n++) match.update(DT);
const had = match.possession.carrier === 0;
let worst = 0;
let held = 0;
for (let n = 0; n < 2 / DT; n++) {
match.update(DT);
if (match.possession.carrier === 0) {
held++;
const p = match.puck.position();
const c = match.possession.carryPoint(new THREE.Vector3());
if (c) worst = Math.max(worst, p.distanceTo(c));
}
}
physics.destroy();
return { had, worst, held: held / (2 / DT) };
}
const glued = carryWander(1);
const loose = carryWander(0.15);
ok(glued.had && loose.had, 'both settings pick the puck up');
ok(
glued.worst < loose.worst,
`high magnetism keeps the puck tighter to the blade (${glued.worst.toFixed(3)}m vs ${loose.worst.toFixed(3)}m)`,
);
ok(glued.held > 0.9, `and holds possession through the rush (${(glued.held * 100).toFixed(0)}% of frames)`);
}
section('shooting sends the puck away and gives up possession');
{
const { physics, match } = arena(1);
clearIce(match, [0]);
match.states[0].x = -10;
match.states[0].z = 0;
match.states[0].yaw = Math.PI / 2;
match.skaters[0].proxy.teleport(-10, 0);
match.puck.place(-10 + 0.8, 0.02, 0);
for (let n = 0; n < 0.5 / DT; n++) match.update(DT);
ok(match.possession.carrier === 0, 'carrying first');
const fired = match.possession.shoot(1, Math.PI / 2);
ok(fired, 'the shot fired');
ok(match.possession.carrier === null, 'and possession was given up');
ok(match.puck.speed() > 25, `the puck is moving like a shot (${match.puck.speed().toFixed(1)} m/s)`);
// And it must not be instantly re-captured by the shooter.
for (let n = 0; n < 0.1 / DT; n++) match.update(DT);
ok(match.possession.carrier !== 0, 'the shooter cannot immediately vacuum it back up');
physics.destroy();
}
section('a harder shot travels faster than a soft one');
{
function fire(power) {
const { physics, match } = arena(1);
clearIce(match, [0]);
match.states[0].x = -10;
match.states[0].z = 0;
match.states[0].yaw = Math.PI / 2;
match.skaters[0].proxy.teleport(-10, 0);
match.puck.place(-10 + 0.8, 0.02, 0);
for (let n = 0; n < 0.5 / DT; n++) match.update(DT);
match.possession.shoot(power, Math.PI / 2);
const speed = match.puck.speed();
physics.destroy();
return speed;
}
const soft = fire(0.2);
const hard = fire(1);
ok(hard > soft * 2, `full power is far harder than a soft one (${hard.toFixed(1)} vs ${soft.toFixed(1)} m/s)`);
ok(hard < PUCK.maxSpeed, 'and stays under the ceiling');
}
section('a knockdown loses the puck');
{
const { physics, match } = arena(1);
clearIce(match, [0]);
match.states[0].x = 0;
match.states[0].z = 0;
match.skaters[0].proxy.teleport(0, 0);
match.puck.place(0.8, 0.02, 0.2);
for (let n = 0; n < 1 / DT; n++) match.update(DT);
ok(match.possession.carrier === 0, 'carrying first');
match.skaters[0].goDown(null);
match.update(DT);
ok(match.possession.carrier === null, 'going down gives the puck up');
physics.destroy();
}
section('the Skill Stick moves the puck around the carrier');
{
const { physics, match } = arena(1);
clearIce(match, [0]);
match.states[0].x = 0;
match.states[0].z = 0;
match.states[0].yaw = Math.PI / 2;
match.skaters[0].proxy.teleport(0, 0);
match.puck.place(0.8, 0.02, 0);
const control = { x: 0, y: 0, sprint: false, brake: false, cameraYaw: 0, skill: { x: 0, y: 0 }, pressed: {} };
match.setControl(0, control);
for (let n = 0; n < 1 / DT; n++) match.update(DT);
ok(match.possession.carrier === 0, 'carrying first');
control.skill.x = 1;
for (let n = 0; n < 0.6 / DT; n++) match.update(DT);
const right = match.puck.position().clone();
control.skill.x = -1;
for (let n = 0; n < 0.6 / DT; n++) match.update(DT);
const left = match.puck.position().clone();
// Skater faces +X (yaw = π/2). Mesh-right is local X (handR side), which
// is world +Z at that yaw — not the sim's up×forward "right", which is
// mirrored from the skeleton. Stick-right must follow the mesh.
ok(
right.z > left.z + 0.25,
`stick-right moves the puck to the skater's right (${right.z.toFixed(2)} vs ${left.z.toFixed(2)})`,
);
physics.destroy();
}
section('a 3-on-3 with a puck stays sane and produces contact');
{
// This is the payoff: give six skaters one thing to want and they converge,
// which is what finally exercises the hit system under normal play instead
// of only in staged collisions.
const { physics, match } = arena(3);
let hits = 0;
const seen = new Set();
for (let n = 0; n < 60 / DT; n++) {
match.update(DT);
for (const h of match.recentHits) {
const id = `${h.at}|${h.attacker}|${h.victim}`;
if (!seen.has(id)) {
seen.add(id);
hits++;
}
}
}
for (let i = 0; i < match.states.length; i++) {
const s = match.states[i];
ok(Number.isFinite(s.x) && Number.isFinite(s.z), `skater ${i} finite after 60s`);
ok(insideRink(s.x, s.z, 0.3), `skater ${i} still on the ice`);
}
const p = match.puck.position();
ok(Number.isFinite(p.x) && Number.isFinite(p.z), 'the puck is finite');
ok(insideRink(p.x, p.z, 0), `the puck is still on the ice (${p.x.toFixed(1)}, ${p.z.toFixed(1)})`);
ok(hits > 0, `chasing a puck produced contact without staging it (${hits} hits in 60s)`);
physics.destroy();
}
done('puck');