Pieces still read as slow. Cruising speed goes from 120ms per unit to 85: the median departure across the shipped fifty drops 970ms → 763ms and a level's whole animation budget 15.6s → 12.7s, about 18% off. The longest departures do not move — the cap holds them at 3.46s — so if those still drag, the dial is the cap, which was settled separately and is left alone. The gates that guarantee a level opens with something worth watching are stated in milliseconds, and they were picked while a piece crossed a unit in 120ms. Held fixed while the speed changes, they stop meaning what they meant: this change alone made 20 of the 50 shipped levels "fail" an opening gate every one of them had passed, purely from the unscaled number. Scaled to the dial, none fail — the set is unchanged, and the alarm was an artifact of the constant. So the cruise threshold and the opening floor now derive from the speed, naming the speed they were calibrated at. exitTiming takes msPerUnit the way it already took knee and maxMs, and the comparison lab gets a speed slider, reads the levels actually shipping rather than the prototype sweep they were selected from, and totals every route in the campaign instead of the four on screen — those four skew long, where the cap flattens any speed change to 6%. The test that pins constant cruising speed now reads the dial from the token instead of hard-coding 120, so tuning motion no longer looks like a broken guarantee. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
101 lines
6.8 KiB
JavaScript
101 lines
6.8 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { MOTION, PIGMENTS, LEVEL_THEMES, contrast, themeForScore } from '../src/design-tokens.js';
|
|
import { Game, clone, validateLevel, available } from '../src/core.js';
|
|
import { LEVELS } from '../src/levels.js';
|
|
import { drawExit, drawScene, CanvasPainter, exitGeometry } from '../src/renderer.js';
|
|
import { layout } from '../src/geometry.js';
|
|
test('new pigments import without changing route solutions; out-of-range values are rejected',()=>{
|
|
for(let color=0;color<12;color++){const level=clone(LEVELS[0]);level.tiles[0].color=color;assert.deepEqual(validateLevel(level),[]);assert.deepEqual(available(level).map(t=>t.id),available(LEVELS[0]).map(t=>t.id));}
|
|
for(const color of [-1,12,1.5]){const level=clone(LEVELS[0]);level.tiles[0].color=color;assert.ok(validateLevel(level).length);}
|
|
});
|
|
test('scene text and all directional arrows remain readable across curated themes',()=>{
|
|
for(const p of PIGMENTS)assert.ok(contrast(p.arrow,p.color)>=3,p.name);
|
|
for(const t of LEVEL_THEMES){assert.ok(contrast(t.ink,t.background)>=4.5,t.name);assert.ok(t.palette.length>=3&&t.palette.length<=4);for(const i of t.palette)assert.ok(PIGMENTS[i]);}
|
|
assert.deepEqual([29,30,49,50,69,70].map(n=>themeForScore(n).id),['lagoon','apricot','apricot','lavender','lavender','rose']);
|
|
});
|
|
test('theme assignments are stable after removal and undo; all new colors fall back from the four-frame atlas',()=>{
|
|
const game=new Game(LEVELS[0]),view=layout(game.level.cells,390,390),theme=LEVEL_THEMES[1];
|
|
const capture=()=>{const sprites=[];drawScene({polygon(){},line(){},circle(){},sprite(i,x,y){sprites.push([i,x,y]);return true;}},game.level,game.tiles,view,{theme});return sprites;};
|
|
const before=capture();game.tap(available(game.level,game.tiles)[0].id);const after=capture();for(const entry of after)assert.ok(before.some(b=>JSON.stringify(b)===JSON.stringify(entry)));game.undo();assert.deepEqual(capture(),before);
|
|
const painter=new CanvasPainter({});painter.atlas={complete:true,naturalWidth:1293};for(let i=4;i<12;i++)assert.equal(painter.sprite(i,0,0,10),false);
|
|
});
|
|
test('exit fragments stay near the actual board port, finish cleanly, and respect reduced motion',()=>{
|
|
const game=new Game(LEVELS[0]),route=game.tap(available(game.level,game.tiles)[0].id),view=layout(game.level.cells,390,390);
|
|
const shapes=[],p={polygon(pts,c){shapes.push({pts,c});},line(){},circle(){}};
|
|
const edge=exitGeometry(route,view).edge;
|
|
drawExit(p,route,view,.95,{...route.tile,color:10});assert.equal(shapes.length,6);
|
|
for(const s of shapes){assert.equal(s.c,PIGMENTS[10].color);for(const point of s.pts)assert.ok(Math.hypot(point.x-edge.x,point.y-edge.y)<view.size);}
|
|
shapes.length=0;drawExit(p,route,view,1,route.tile);assert.equal(shapes.length,0);
|
|
drawExit(p,route,view,.95,route.tile,true);assert.equal(shapes.length,0);
|
|
});
|
|
|
|
test('edge exits are brisk and longer routes preserve a bounded readable travel phase',async()=>{
|
|
const {exitTiming}=await import('../src/exit-animation.js');
|
|
const edge={status:'exit',steps:[{q:0,r:0,enter:null,exit:0}],end:{q:0,r:-1}};
|
|
const short=exitTiming(edge);
|
|
const long=exitTiming({...edge,steps:[edge.steps[0],{q:0,r:-1,enter:3,exit:0},{q:0,r:-2,enter:3,exit:0}]});
|
|
assert.ok(short.duration>260&&short.duration<400);
|
|
assert.ok(long.duration>short.duration&&long.duration<1200);
|
|
for(const timing of [short,long])assert.ok(Math.abs(timing.duration*(1-timing.travelFraction)-180)<.001);
|
|
});
|
|
|
|
|
|
test('motion ramps gently at both ends, never reverses or overshoots',async()=>{
|
|
const {smoothMotion}=await import('../src/exit-animation.js');
|
|
const positions=Array.from({length:101},(_,i)=>smoothMotion(i/100));
|
|
const steps=positions.slice(1).map((x,i)=>x-positions[i]);
|
|
assert.equal(positions[0],0);assert.equal(positions.at(-1),1);
|
|
assert.ok(steps.every(d=>d>=0));
|
|
assert.ok(steps[0]<steps[49]/10&&steps.at(-1)<steps[49]/10);
|
|
assert.equal(smoothMotion(-1),0);assert.equal(smoothMotion(2),1);
|
|
});
|
|
|
|
|
|
test('routes below the knee cruise at one speed; longer ones accelerate instead of dragging',async()=>{
|
|
const {exitTiming,travelProgress}=await import('../src/exit-animation.js');
|
|
// Below the knee the old guarantee still holds exactly: same cruising speed
|
|
// for every piece, no cap. The speed itself is a tuning dial, so it is read
|
|
// from the token rather than written in — pinning the number here would make
|
|
// every speed change look like a broken guarantee.
|
|
const perUnit=MOTION.exitMsPerUnit;
|
|
assert.ok(20*perUnit<MOTION.exitMaxTravelMs,'距离 20 以内必须还在拐点以下,否则下面的匀速断言就测不到匀速段');
|
|
for(const distance of [1,5,15]){
|
|
const timing=exitTiming({travelPoints:[{x:0,y:0},{x:distance,y:0}],steps:[]});
|
|
assert.equal(timing.travelMs,distance*perUnit+Math.min(MOTION.exitRampMs,distance*perUnit/3));
|
|
const t=timing.travelMs*.5,dt=1;
|
|
const speed=(travelProgress((t+dt)/timing.travelMs,timing.travelMs)-travelProgress(t/timing.travelMs,timing.travelMs))*distance;
|
|
assert.ok(Math.abs(speed-1/perUnit)<1e-9);
|
|
assert.equal(travelProgress(0,timing.travelMs),0);assert.equal(travelProgress(1,timing.travelMs),1);
|
|
assert.ok(Math.abs(timing.duration-timing.travelMs-MOTION.exitEffectMs)<1e-9);
|
|
assert.equal(timing.capped,false);
|
|
}
|
|
// Past the knee the piece speeds up so the journey stays watchable. Uncapped
|
|
// linear timing put a single departure at seventeen seconds.
|
|
const long=exitTiming({travelPoints:[{x:0,y:0},{x:150,y:0}],steps:[]});
|
|
assert.equal(long.capped,true);
|
|
assert.ok(long.duration<4200,`一条超长路线不该超过 4.2s,实际 ${long.duration}`);
|
|
assert.ok(long.duration>exitTiming({travelPoints:[{x:0,y:0},{x:20,y:0}],steps:[]}).duration,
|
|
'再长的路线仍然要比中等路线久,长度感不能丢');
|
|
// Monotone: a longer route never plays shorter than a shorter one.
|
|
let prev=0;
|
|
for(const d of [1,5,15,25,40,80,150,400]){
|
|
const ms=exitTiming({travelPoints:[{x:0,y:0},{x:d,y:0}],steps:[]}).duration;
|
|
assert.ok(ms>=prev,`距离 ${d} 的时长 ${ms} 不该短于更短的路线 ${prev}`);
|
|
prev=ms;
|
|
}
|
|
});
|
|
|
|
test('showcase boards open with a long curved route and can be cleared completely',async()=>{
|
|
const {createStudyLevel}=await import('../src/study-levels.js');
|
|
const {available,trace,Game,validateLevel}=await import('../src/core.js');
|
|
for(const [radius,minLength] of [[3,5],[5,8],[6,11]]){
|
|
const level=createStudyLevel(radius),game=new Game(level);
|
|
assert.deepEqual(validateLevel(level),[]);
|
|
const routes=available(level,level.tiles).map(t=>trace(level,t,level.tiles));
|
|
assert.ok(routes.some(r=>r.steps.length>=minLength&&r.steps.some(s=>s.enter!==null&&(s.enter+3)%6!==s.exit)));
|
|
while(game.tiles.length){const tile=available(game.level,game.tiles)[0];assert.ok(tile);game.tap(tile.id);}
|
|
assert.ok(game.won);
|
|
}
|
|
});
|