feat: expand campaign and refine puzzle visuals and controls

This commit is contained in:
cabinw
2026-09-09 11:02:45 +08:00
parent 9f8990b579
commit 70b021d4cc
66 changed files with 41108 additions and 10553 deletions
+66
View File
@@ -0,0 +1,66 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { 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.equal(short.duration,320);
assert.ok(long.duration>short.duration&&long.duration<=980);
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('long routes regain original pacing while edge exits and the finish stay brisk',async()=>{
const {exitTiming}=await import('../src/exit-animation.js');
const route=n=>({status:'exit',steps:Array.from({length:n},(_,i)=>({q:0,r:-i,enter:i?3:null,exit:0}))});
assert.equal(exitTiming(route(1)).duration,320);
for(const [cells,expected] of [[5,975],[10,1400],[17,1700]]){
const timing=exitTiming(route(cells));
assert.equal(timing.duration,expected);
assert.ok(Math.abs(timing.duration*(1-timing.travelFraction)-180)<.001);
}
const durations=Array.from({length:17},(_,i)=>exitTiming(route(i+1)).duration);
assert.ok(durations.every((t,i)=>i===0||t>=durations[i-1]));
});