377 lines
14 KiB
JavaScript
377 lines
14 KiB
JavaScript
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('proximity grab rewards lining up a moving puck');
|
||
{
|
||
const {
|
||
interceptQuality, speedGrabFactor, crowdGrabFactor, CARRY: C,
|
||
} = await import('../src/game/possession.js');
|
||
const s = { x: 0, z: 0, yaw: Math.PI / 2, vx: 4, vz: 0 };
|
||
// Puck ahead on the line (within grab bubble), coming toward the skater.
|
||
const onLine = interceptQuality(
|
||
s,
|
||
{ x: 1.5, y: 0.02, z: 0 },
|
||
{ x: -12, y: 0, z: 0 },
|
||
C.grabRadius,
|
||
);
|
||
// Same distance, puck flying wide past the skater.
|
||
const wide = interceptQuality(
|
||
s,
|
||
{ x: 1.5, y: 0.02, z: 1.4 },
|
||
{ x: -12, y: 0, z: 0 },
|
||
C.grabRadius,
|
||
);
|
||
ok(onLine > 0.35, `lined-up hard puck is grabable (${onLine.toFixed(2)})`);
|
||
ok(onLine > wide + 0.08, `and beats a wide miss (${onLine.toFixed(2)} vs ${wide.toFixed(2)})`);
|
||
|
||
// Still puck at the feet — easy.
|
||
const soft = interceptQuality(
|
||
{ x: 0, z: 0, yaw: 0, vx: 0, vz: 0 },
|
||
{ x: 0.4, y: 0.02, z: 0.3 },
|
||
{ x: 0, y: 0, z: 0 },
|
||
C.grabRadius,
|
||
);
|
||
ok(soft > 0.4, `soft puck nearby is easy (${soft.toFixed(2)})`);
|
||
}
|
||
|
||
section('fast pucks and crowds lower proximity grab odds');
|
||
{
|
||
const { speedGrabFactor, crowdGrabFactor, CARRY: C } = await import('../src/game/possession.js');
|
||
const still = speedGrabFactor(0, C.grabSpeedHalf);
|
||
const pass = speedGrabFactor(12, C.grabSpeedHalf);
|
||
const slap = speedGrabFactor(35, C.grabSpeedHalf);
|
||
ok(still > 0.9, `still puck is free to grab (${still.toFixed(2)})`);
|
||
ok(pass < still * 0.55, `a firm pass is harder (${pass.toFixed(2)} vs ${still.toFixed(2)})`);
|
||
ok(slap < pass, `a rocket is harder still (${slap.toFixed(2)})`);
|
||
ok(slap < 0.2, `slapshot proximity is weak (${slap.toFixed(2)})`);
|
||
|
||
near(crowdGrabFactor(1, C.grabCrowdK), 1, 1e-9, 'one body: full claim');
|
||
ok(crowdGrabFactor(2, C.grabCrowdK) < 0.75, `two bodies cut odds (${crowdGrabFactor(2, C.grabCrowdK).toFixed(2)})`);
|
||
ok(
|
||
crowdGrabFactor(4, C.grabCrowdK) < crowdGrabFactor(2, C.grabCrowdK),
|
||
'more bodies cut further',
|
||
);
|
||
}
|
||
|
||
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');
|