animations

This commit is contained in:
ryanfitzpatrickio
2026-08-03 19:20:00 -05:00
parent fb1ebeed05
commit afd764e5e7
22 changed files with 2049 additions and 212 deletions
+56 -4
View File
@@ -228,7 +228,8 @@ section('triggers are analog, not boolean');
h.press(PAD.LT, 1);
s = h.input.read(1 / 60);
ok(s.brake, 'the left trigger stops');
ok(s.backskate, 'the left trigger is backskating (EA LT)');
ok(!s.brake, 'and is not a hockey stop');
ok(s.protect > 0.9, `and reports analog (${s.protect.toFixed(2)})`);
h.restore();
}
@@ -260,6 +261,29 @@ section('buttons report as actions, and only on the edge');
h.restore();
}
section('RB holds stick-spread and blocks the Skill Stick wind-up');
{
const h = harness();
const dt = 1 / 60;
h.input.read(dt);
h.press(PAD.RB);
let s = h.input.read(dt);
ok(s.spread, 'RB is stick-spread');
ok(s.held.deke, 'and is held on the deke binding');
// Skill Stick wound back while spreading must not charge a shot.
h.pad.axes = [0, 0, 0, 1];
for (let i = 0; i < 20; i++) s = h.input.read(dt);
near(s.charge, 0, 1e-9, 'no wind-up charge while spreading');
ok(s.shot === null, 'and no shot fires from the skill stick');
h.release(PAD.RB);
s = h.input.read(dt);
ok(!s.spread, 'releasing RB ends the spread');
h.restore();
}
section('the Skill Stick fires a shot on pull-back-and-push');
{
const h = harness();
@@ -322,17 +346,42 @@ section('an abandoned wind-up is forgotten, not banked');
h.restore();
}
section('the shot carries aim from the stick');
section('the shot carries aim from how you moved during the wind-up');
{
const h = harness();
const dt = 1 / 60;
h.input.read(dt);
h.pad.axes = [0, 0, 0.8, 1]; // wound back, stick held to the right
// Wind up while holding left stick right — that is the aim sample.
// Right stick is pulled back (pad Y +) with no lateral skill aim.
h.pad.axes = [0.85, 0, 0, 1];
let s;
for (let i = 0; i < 20; i++) s = h.input.read(dt);
ok(s.charge > 0.2, 'winding up');
ok(s.aimHint > 0.4, `live aim hint follows the move stick (${s.aimHint.toFixed(2)})`);
// Release: still holding left-right, skill stick forward.
h.pad.axes = [0.85, 0, 0, -1];
s = h.input.read(dt);
ok(s.shot, 'the shot fired');
ok(s.shot.move.x > 0.5, `move sample is right (${s.shot.move.x.toFixed(2)})`);
ok(Math.abs(s.shot.skillAim) < 0.2, `skill lateral is not the aim (${s.shot.skillAim.toFixed(2)})`);
ok(s.shot.aim > 0.4, `combined aim hint is right (${s.shot.aim.toFixed(2)})`);
h.restore();
}
section('Skill Stick lateral is recorded as a fine-tune, not primary aim');
{
const h = harness();
const dt = 1 / 60;
h.input.read(dt);
// No move stick; Skill Stick wound back and held right.
h.pad.axes = [0, 0, 0.8, 1];
for (let i = 0; i < 20; i++) h.input.read(dt);
h.pad.axes = [0, 0, 0.8, -1];
const s = h.input.read(dt);
ok(s.shot, 'the shot fired');
ok(s.shot.aim > 0.5, `and remembers it was aimed right (${s.shot.aim.toFixed(2)})`);
ok(s.shot.skillAim > 0.5, `skillAim remembers the right push (${s.shot.skillAim.toFixed(2)})`);
near(s.shot.move.x, 0, 0.15, 'move sample stayed near centre');
h.restore();
}
@@ -340,10 +389,13 @@ section('the shoot button works for anyone who never learns the Skill Stick');
{
const h = harness();
h.input.read(1 / 60);
// Aim with left stick, fire with X.
h.pad.axes = [-0.9, 0, 0, 0];
h.press(PAD.X);
const s = h.input.read(1 / 60);
ok(s.shot, 'X shoots');
ok(s.shot.power > 0 && s.shot.power <= 1, `at a sensible power (${s.shot.power.toFixed(2)})`);
ok(s.shot.move.x < -0.5, `and samples the move stick for aim (${s.shot.move.x.toFixed(2)})`);
h.restore();
}
+52
View File
@@ -118,6 +118,58 @@ section('a dumped puck slides a long way but does stop');
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);
+133
View File
@@ -0,0 +1,133 @@
import { goalLineX } from '../shared/net.js';
import { resolveShotAim, SHOT_AIM } from '../src/game/shotAim.js';
import { done, near, ok, section } from './harness.mjs';
/** Shortest-arc wrap to (−π, π]. */
function wrap(a) {
let x = a;
while (x > Math.PI) x -= Math.PI * 2;
while (x <= -Math.PI) x += Math.PI * 2;
return x;
}
/**
* EA-style shot aim: movement stick + wind-up skating places the puck;
* Skill Stick is a light fine-tune.
*/
section('a neutral shot goes where the body points');
{
const r = resolveShotAim({
x: 0, z: 0, yaw: Math.PI / 2, vx: 0, vz: 0,
moveX: 0, moveY: 0, skillAim: 0,
});
near(r.aimYaw, Math.PI / 2, 1e-9, 'facing +X → aim +X');
near(r.placement, 0, 1e-6, 'no body lean');
ok(r.targetZ === null, 'no net target without a move stick');
}
section('skate velocity yaws the shot off pure facing');
{
// Facing +X, but skating hard toward +Z (body left when facing +X:
// forward = (1,0), left = (0? wait: right = (cos, -sin) of π/2 = (0,-1) = Z;
// so +Z is body left).
const r = resolveShotAim({
x: 0, z: 0, yaw: Math.PI / 2, vx: 0, vz: 5,
moveX: 0, moveY: 0, skillAim: 0,
});
// Velocity heading is 0; aim should pull off π/2 toward 0.
ok(r.aimYaw < Math.PI / 2 - 0.05, `velocity pulls aim (${r.aimYaw.toFixed(3)})`);
ok(r.aimYaw > 0, 'but not all the way to pure velocity');
}
section('move stick opens net corners when facing the goal');
{
// Attacker on the slot, facing the +X net. Camera behind them looking +X:
// look dir = (+1, 0) = (sin θ, cos θ) ⇒ θ = −π/2.
const cameraYaw = -Math.PI / 2;
const right = resolveShotAim({
x: 15, z: 0, yaw: Math.PI / 2, vx: 2, vz: 0,
moveX: 1, moveY: 0.15, cameraYaw, skillAim: 0,
goalX: goalLineX(1),
});
const left = resolveShotAim({
x: 15, z: 0, yaw: Math.PI / 2, vx: 2, vz: 0,
moveX: -1, moveY: 0.15, cameraYaw, skillAim: 0,
goalX: goalLineX(1),
});
const middle = resolveShotAim({
x: 15, z: 0, yaw: Math.PI / 2, vx: 2, vz: 0,
moveX: 0, moveY: 1, cameraYaw, skillAim: 0,
goalX: goalLineX(1),
});
ok(right.targetZ != null, 'right stick engages net aim');
ok(left.targetZ != null, 'left stick engages net aim');
ok(
right.targetZ * left.targetZ < 0,
`opposite sticks open opposite posts (${right.targetZ?.toFixed(2)} vs ${left.targetZ?.toFixed(2)})`,
);
ok(Math.abs(right.targetZ) > 0.4, `opens a real corner (${right.targetZ?.toFixed(2)} m)`);
if (middle.targetZ != null) {
ok(
Math.abs(middle.targetZ) < Math.abs(right.targetZ),
'forward stick is closer to the middle than a full cut',
);
}
ok(Math.abs(wrap(right.aimYaw - left.aimYaw)) > 0.08, 'left and right aim yaws diverge');
}
section('Skill Stick is only a light fine-tune');
{
const base = resolveShotAim({
x: 12, z: 0, yaw: Math.PI / 2, vx: 0, vz: 0,
moveX: 0, moveY: 0, skillAim: 0,
});
const tuned = resolveShotAim({
x: 12, z: 0, yaw: Math.PI / 2, vx: 0, vz: 0,
moveX: 0, moveY: 0, skillAim: 1,
});
const delta = Math.abs(wrap(tuned.aimYaw - base.aimYaw));
near(delta, SHOT_AIM.SKILL_RAD, 1e-9, `full skillAim is ${SHOT_AIM.SKILL_RAD} rad`);
ok(delta < 0.25, 'far smaller than the old 0.6 rad primary aim');
}
section('far from the net, stick does not magnetize to the mouth');
{
// Centre ice, range past the net-aim window — still free to dump wide.
const r = resolveShotAim({
x: 0, z: 0, yaw: Math.PI / 2, vx: 0, vz: 0,
moveX: 1, moveY: 0, cameraYaw: -Math.PI / 2, skillAim: 0,
goalX: goalLineX(1),
});
ok(r.targetZ === null, 'outside shooting range: no net corner lock');
}
section('placement sign matches Skill Stick right for the animator');
{
// Skill Stick +X only → placement must be positive (poseWindup / poseShot).
const r = resolveShotAim({
x: 0, z: 0, yaw: 0, vx: 0, vz: 0,
moveX: 0, moveY: 0, skillAim: 1,
});
ok(r.placement > 0.1, `skill right → positive placement (${r.placement.toFixed(3)})`);
near(r.aimYaw, SHOT_AIM.SKILL_RAD, 1e-9, 'and yaw adds by the fine-tune amount');
}
section('move stick right of body yields positive placement');
{
// Facing +Z (yaw 0). Body right is +X. Camera yaw 0: screen right is +X world.
// Stick right → world +X → aim turns right of facing → positive placement.
const r = resolveShotAim({
x: 0, z: 0, yaw: 0, vx: 0, vz: 0,
moveX: 1, moveY: 0, cameraYaw: 0, skillAim: 0,
// No net: pure world stick pull (out of range / not forced)
goalX: goalLineX(1),
});
// At centre ice net aim is off; stick right still pulls aimYaw toward +X (π/2).
ok(r.aimYaw > 0.1, `stick right yaws positive (${r.aimYaw.toFixed(3)})`);
ok(r.placement > 0.1, `and placement is positive for the pose (${r.placement.toFixed(3)})`);
}
done('shotAim');
+107
View File
@@ -183,6 +183,113 @@ section('turning is harder at speed than at rest');
);
}
section('LT does not spin the body around');
{
// Facing +X, stick pulled toward X under LT — should retreat heels-first
// without yaw flipping to face the stick.
const s = createSkaterState(0, { x: 0, z: 0, yaw: Math.PI / 2 });
const startYaw = s.yaw;
run(s, 2.5, (st) => {
st.ix = -1;
st.iz = 0;
st.backskate = true;
});
const yawDrift = Math.abs(wrap(s.yaw - startYaw));
ok(yawDrift < 0.45, `facing holds under LT (yaw drift ${yawDrift.toFixed(2)} rad)`);
ok(s.bladeSpeed < -1.2, `stick back drives reverse (${s.bladeSpeed.toFixed(2)} m/s)`);
ok(s.x < -1.5, `and the body retreats (x ${s.x.toFixed(2)})`);
}
section('LT with stick forward is a crawl, not a rush');
{
const free = createSkaterState(0, START);
run(free, 5, forward);
const contain = createSkaterState(1, START);
run(contain, 5, (st) => {
forward(st);
st.backskate = true;
});
ok(
speedOf(free) > speedOf(contain) + 1.2,
`contain is much slower than free skate (${speedOf(contain).toFixed(2)} vs ${speedOf(free).toFixed(2)})`,
);
ok(speedOf(contain) <= SKATE.containForwardSpeed + 0.25, 'under the forward-contain ceiling');
ok(contain.bladeSpeed > 0, 'still facing the stick way, not flipped reverse');
}
section('LT reverse and lateral accelerate quickly for gap control');
{
// Reverse from a standstill under LT should get moving fast.
const back = createSkaterState(0, { x: 0, z: 0, yaw: Math.PI / 2 });
run(back, 0.45, (st) => {
st.ix = -1;
st.iz = 0;
st.backskate = true;
});
ok(back.bladeSpeed < -2.5, `quick reverse accel (${back.bladeSpeed.toFixed(2)} m/s in 0.45s)`);
ok(Math.abs(back.x) > 0.6, 'and covers ground retreating');
// Lateral shuffle under LT.
const side = createSkaterState(1, { x: 0, z: 0, yaw: Math.PI / 2 });
run(side, 0.45, (st) => {
st.ix = 0;
st.iz = 1; // body right when facing +X
st.backskate = true;
});
ok(Math.abs(side.z) > 0.7, `quick lateral under LT (z ${side.z.toFixed(2)})`);
ok(Math.abs(side.x) < Math.abs(side.z), 'mostly sideways relative to facing (+X)');
// Flip reverse → forward under LT (offense ↔ defense switch).
const flip = createSkaterState(2, { x: 0, z: 0, yaw: Math.PI / 2 });
run(flip, 0.8, (st) => {
st.ix = -1;
st.iz = 0;
st.backskate = true;
});
const rev = flip.bladeSpeed;
ok(rev < -2, 'was reverse');
run(flip, 0.35, (st) => {
st.ix = 1;
st.iz = 0;
st.backskate = true;
});
ok(flip.bladeSpeed > rev + 2.5, `snaps out of reverse toward forward (${flip.bladeSpeed.toFixed(2)} from ${rev.toFixed(2)})`);
}
section('LT stick neutral bleeds a rush toward neutral');
{
const s = createSkaterState(0, START);
run(s, 5, forward);
const entry = speedOf(s);
ok(entry > 3.5, 'at cruise');
// Hold LT, stick centred — sit in the gap instead of flying.
run(s, 1.2, (st) => {
st.ix = 0;
st.iz = 0;
st.backskate = true;
});
ok(speedOf(s) < entry * 0.55, `bled speed under LT (${speedOf(s).toFixed(2)} of ${entry.toFixed(2)})`);
ok(speedOf(s) > 0.15, 'but not a hard snowplow to zero');
const yawStill = Math.abs(wrap(s.yaw - Math.PI / 2));
ok(yawStill < 0.3, 'and did not spin to face the other way');
}
section('applyIntent accepts backskate');
{
const s = createSkaterState(0, START);
applyIntent(s, { ix: 0, iz: 1, backskate: 1 });
ok(s.backskate === true, 'backskate flag is boolean true');
applyIntent(s, { ix: 0, iz: 1, backskate: 0 });
ok(s.backskate === false, 'and clears');
}
function wrap(a) {
let x = a;
while (x > Math.PI) x -= Math.PI * 2;
while (x <= -Math.PI) x += Math.PI * 2;
return x;
}
section('nothing leaves the rink');
{
// Point skaters at the boards from centre ice and hold it for ten seconds.