animations
This commit is contained in:
+56
-4
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user