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
+47 -25
View File
@@ -3,11 +3,15 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { Game, analyze, available, clone, generateLevel, hexagon, rotateTracks, rotateBoard, solveRotations, canRotate, solve, trace, validateLevel } from '../src/core.js';
import { pathPoints, segmentHexHit } from '../src/path-math.js';
import { LEVELS } from '../src/levels.js';
import { center, hitCell, layout, pointAlong, routePoints } from '../src/geometry.js';
const straight=[3,4,5,0,1,2];
const level=(cells,tiles,moveLimit=10)=>({version:1,name:'test',cells:cells.map(c=>({tracks:[...straight],...c})),tiles,moveLimit});
const tile=(id,q,r,dir=0)=>({id,q,r,dir,color:0});
const blockedFixture=()=>level(hexagon(2),[tile('moving',1,1,0),tile('beside',0,0,0)]);
const rotatingLoopFixture=()=>{
const bends=[{q:0,r:0,tracks:[3,2,1,0,5,4]},{q:1,r:-1,tracks:[1,0,5,4,3,2]},{q:1,r:0,tracks:[5,2,1,4,3,0]}];
return level(hexagon(1).map(c=>bends.find(b=>b.q===c.q&&b.r===c.r)??c),[tile('a',0,0,1)],1);
};
test('bends follow the paired exit, never a crossing branch',()=>{
const l=level([{q:0,r:0},{q:0,r:-1,tracks:[2,3,0,1,5,4]},{q:1,r:-2}],[tile('a',0,0)]);
const path=trace(l,l.tiles[0]);assert.equal(path.status,'exit');assert.equal(path.steps[1].enter,3);assert.equal(path.steps[1].exit,1);assert.deepEqual(path.steps[2],{q:1,r:-2,enter:4,exit:1});
@@ -15,7 +19,7 @@ test('bends follow the paired exit, never a crossing branch',()=>{
test('occupied cell blocks even when its own arrow points elsewhere',()=>{
const l=level([{q:0,r:0},{q:0,r:-1}],[tile('a',0,0),tile('b',0,-1,1)]);
assert.equal(trace(l,l.tiles[0]).blocker,'b');assert.deepEqual(available(l).map(t=>t.id),['b']);
const g=new Game(l);const original=clone(g.tiles);assert.equal(g.tap('a').status,'blocked');assert.equal(g.moves,9);assert.deepEqual(g.tiles,original);assert.equal(g.mistakes,1);assert.ok(g.undo());assert.equal(g.moves,10);assert.equal(g.mistakes,0);
const g=new Game(l);const original=clone(g.tiles);assert.equal(g.tap('a').status,'blocked');assert.equal(g.moves,10);assert.deepEqual(g.tiles,original);assert.equal(g.mistakes,1);assert.ok(g.undo());assert.equal(g.moves,10);assert.equal(g.mistakes,0);
assert.equal(g.tap('b').status,'exit');assert.equal(g.tap('a').status,'exit');assert.ok(g.won);
});
test('closed loop returns to the starting occupied tile and is unsolvable',()=>{
@@ -28,17 +32,20 @@ test('mutual blocking reaches a fixed point; move shortage is separate',()=>{
const easy=level([{q:0,r:0},{q:0,r:-1}],[tile('a',0,0),tile('b',0,-1)],1);
assert.equal(solve(easy).stuck.length,0);assert.equal(solve(easy).solvable,false);
});
test('all 24 campaign levels validate, solve and increase in score',()=>{
let previous=-1;
for(const l of LEVELS){assert.deepEqual(validateLevel(l),[]);const a=analyze(l);assert.ok(a.solvable,l.name);assert.ok(a.rotations>=1);assert.equal(solve(l).solvable,false);assert.ok(a.score>=previous);previous=a.score;const g=new Game(l);for(const action of a.actions){if(action.type==='rotate')assert.equal(g.rotate(action.direction).status,'rotated');else assert.equal(g.tap(action.id).status,'exit');}assert.ok(g.won);assert.ok(g.moves>=0);}
test('a solver plan remains executable with exactly one move per successful removal',()=>{
const l=rotatingLoopFixture(),a=analyze(l),g=new Game(l);
assert.equal(a.solvable,true);assert.equal(a.rotations,1);assert.equal(a.minimumMoves,1);assert.equal(a.minimumActions,2);
for(const action of a.actions){if(action.type==='rotate')assert.equal(g.rotate(action.direction).status,'rotated');else assert.equal(g.tap(action.id).status,'exit');}
assert.ok(g.won);assert.equal(g.moves,0);
});
test('100 generated seeds are deterministic and solvable',()=>{
for(let seed=0;seed<100;seed++){const l=generateLevel(seed,seed%2?2:3,seed%2?15:32,.8);assert.deepEqual(validateLevel(l),[]);assert.ok(solve(l).solvable,`seed ${seed}`);assert.ok(l.tiles.length>0);}
assert.deepEqual(generateLevel(1234),generateLevel(1234));
});
test('imports reject duplicate positions, invalid matching, bad IDs and directions',()=>{
const good=clone(LEVELS[0]);assert.deepEqual(validateLevel(good),[]);
for(const mutate of [l=>l.cells.push(l.cells[0]),l=>l.cells[0].tracks=[1,2,3,4,5,0],l=>l.tiles.push({...l.tiles[0],id:'other'}),l=>l.tiles[0].dir=6,l=>l.tiles[0].color=4,l=>l.moveLimit=0,l=>l.version=2,l=>l.tiles[0].q=99,l=>l.tiles[0].id=null]){const l=clone(good);mutate(l);assert.ok(validateLevel(l).length);}
const good=rotatingLoopFixture();assert.deepEqual(validateLevel(good),[]);
for(const mutate of [l=>l.cells.push(l.cells[0]),l=>l.cells[0].tracks=[1,2,3,4,5,0],l=>l.tiles.push({...l.tiles[0],id:'other'}),l=>l.tiles[0].dir=6,l=>l.tiles[0].color=12,l=>l.moveLimit=0,l=>l.version=2,l=>l.tiles[0].q=99,l=>l.tiles[0].id=null]){const l=clone(good);mutate(l);assert.ok(validateLevel(l).length);}
for(let color=0;color<12;color++){const l=clone(good);l.tiles[0].color=color;assert.deepEqual(validateLevel(l),[]);}
assert.ok(validateLevel(null).length);assert.ok(validateLevel({version:1,cells:[null],tiles:[null]}).length);
});
test('empty boards are not publishable wins and report a finite score',()=>{
@@ -47,26 +54,35 @@ test('empty boards are not publishable wins and report a finite score',()=>{
test('six rotations preserve track connectivity',()=>{let tracks=[1,0,4,5,2,3];for(let n=0;n<6;n++)tracks=rotateTracks(tracks);assert.deepEqual(tracks,[1,0,4,5,2,3]);});
test('geometry hit testing selects cell centers and rejects outside points',()=>{
const cells=hexagon(3),v=layout(cells,390,390);for(const c of cells){const p=center(c,v);assert.equal(hitCell(cells,v,p.x,p.y),c);}assert.equal(hitCell(cells,v,-100,-100),undefined);
const l=LEVELS[0],t=available(l)[0],route=routePoints(trace(l,t),layout(l.cells,390,390),true);for(const p of [0,.5,1]){const point=pointAlong(route,p);assert.ok(Number.isFinite(point.x)&&Number.isFinite(point.angle));}
const l=blockedFixture(),t=available(l)[0],route=routePoints(trace(l,t),layout(l.cells,390,390),true);for(const p of [0,.5,1]){const point=pointAlong(route,p);assert.ok(Number.isFinite(point.x)&&Number.isFinite(point.angle));}
});
test('undo also restores a completed board; finished games ignore taps',()=>{
const g=new Game(level([{q:0,r:0}],[tile('a',0,0)],1));g.tap('a');assert.ok(g.won);assert.equal(g.tap('a').status,'finished');g.undo();assert.equal(g.won,false);assert.equal(g.moves,1);
});
test('difficulty stays within 0–100 even with insufficient moves',()=>{
const l=clone(LEVELS[23]);l.moveLimit=1;const a=analyze(l);assert.equal(a.solvable,false);assert.ok(a.score<=100);assert.ok(a.parts.precision<=10);
test('difficulty ignores spare moves while custom removal shortages still fail',()=>{
const l=blockedFixture(),before=analyze(l);l.moveLimit=1;const a=analyze(l);
assert.equal(a.solvable,false);assert.equal(a.reachable,true);assert.equal(a.score,before.score);assert.deepEqual(a.parts,before.parts);assert.ok(a.score>=0&&a.score<=100);
assert.equal(a.sweptOnlyBlockers,1);assert.equal(a.parts.sweptBlockers,6);
});
test('board area and piece count add workload without adding intrinsic difficulty',()=>{
const small=analyze(level(hexagon(1),[tile('a',0,0)])),large=analyze(level(hexagon(3),[tile('a',0,0)]));
assert.equal(small.score,0);assert.equal(large.score,small.score);assert.ok(large.workload.routeCells>small.workload.routeCells);
const outward=[tile('a',0,-1,0),tile('b',1,-1,1),tile('c',1,0,2),tile('d',0,1,3),tile('e',-1,1,4),tile('f',-1,0,5)];
const many=analyze(level(hexagon(1),outward));
assert.equal(many.score,small.score);assert.equal(many.freeRatio,1);assert.equal(many.workload.minimumActions,6);assert.ok(many.workload.estimatedSeconds>small.workload.estimatedSeconds);
});
test('six board rotations restore tracks; tokens and arrows stay fixed',()=>{
const l=clone(LEVELS[0]);assert.ok(canRotate(l));
const l=rotatingLoopFixture();assert.ok(canRotate(l));
assert.deepEqual(rotateBoard(l,6),l);
let board=l;for(let i=0;i<6;i++)board=rotateBoard(board);
assert.deepEqual(board,l);
const g=new Game(l),original=clone(g.tiles),before=clone(g.level.cells);
assert.equal(g.rotate(1).status,'rotated');assert.deepEqual(g.tiles,original);assert.notDeepEqual(g.level.cells,before);assert.equal(g.orientation,1);assert.equal(g.moves,l.moveLimit-1);
assert.ok(g.undo());assert.equal(g.orientation,0);assert.deepEqual(g.level.cells,before);assert.equal(g.moves,l.moveLimit);
assert.equal(g.rotate(1).status,'rotated');assert.deepEqual(g.tiles,original);assert.notDeepEqual(g.level.cells,before);assert.equal(g.orientation,1);assert.equal(g.moves,l.moveLimit);
assert.ok(g.undo());assert.equal(g.orientation,0);assert.equal(g.rotations,0);assert.deepEqual(g.level.cells,before);assert.equal(g.moves,l.moveLimit);
});
test('a stuck orientation can be rescued by rotating and must not immediately lose',()=>{
const l=LEVELS[0],g=new Game(l);
const l=rotatingLoopFixture(),g=new Game(l);
while(available(g.level,g.tiles).length)g.tap(available(g.level,g.tiles)[0].id);
assert.ok(g.tiles.length);assert.equal(g.lost,false);
const result=solveRotations({...g.level,tiles:g.tiles,moveLimit:g.moves});assert.ok(result.solvable);assert.equal(result.actions[0].type,'rotate');
@@ -77,15 +93,18 @@ test('swept collision blocks a neighboring piece even when the center-line visit
const centerLine=trace(l,l.tiles[0],l.tiles,true);assert.equal(centerLine.status,'exit');assert.deepEqual(centerLine.blockers,[]);
const route=trace(l,l.tiles[0]);assert.equal(route.status,'blocked');assert.equal(route.blocker,'beside');assert.ok(route.collision);assert.ok(route.travelPoints.length<pathPoints(centerLine).length);
const clear=trace(l,l.tiles[0],[l.tiles[0]]);assert.equal(clear.status,'exit');
const g=new Game(l),before=clone(g.tiles);g.tap('moving');assert.deepEqual(g.tiles,before);assert.equal(g.moves,l.moveLimit-1);
const g=new Game(l),before=clone(g.tiles);g.tap('moving');assert.deepEqual(g.tiles,before);assert.equal(g.moves,l.moveLimit);
});
test('swept segment detection cannot tunnel through a collider between frames',()=>{
const hit=segmentHexHit({x:-10,y:0},{x:10,y:0},{x:0,y:0});assert.ok(hit>0&&hit<1);
assert.equal(segmentHexHit({x:-10,y:3},{x:10,y:3},{x:0,y:0}),null);
});
test('rotation step cost is included in the minimum move budget',()=>{
const l=clone(LEVELS[0]),a=solveRotations(l);assert.ok(a.rotations>0);
l.moveLimit=a.minimumMoves-1;const tooShort=solveRotations(l);assert.equal(tooShort.reachable,true);assert.equal(tooShort.solvable,false);
test('unlimited clockwise and counterclockwise observation preserves the removal budget',()=>{
const l=rotatingLoopFixture(),g=new Game(l);
for(let i=0;i<120;i++)assert.equal(g.rotate(i%2?1:-1).status,'rotated');
assert.equal(g.moves,1);assert.equal(g.rotations,120);assert.equal(g.orientation,0);assert.equal(g.lost,false);
assert.equal(g.rotate(0).status,'invalid-direction');assert.equal(g.history.length,120);
assert.ok(g.undo());assert.equal(g.moves,1);assert.equal(g.rotations,119);assert.equal(g.orientation,5);
});
test('irregular boards retain classic play and do not rotate tiles off the base',()=>{
const l=level([{q:0,r:0},{q:0,r:-1}],[tile('a',0,0)]);const g=new Game(l);assert.equal(canRotate(l),false);assert.equal(g.rotate().status,'rotation-disabled');assert.equal(g.moves,l.moveLimit);
@@ -96,11 +115,14 @@ test('exit animation cannot pass through another piece beyond an irregular board
const route=trace(l,l.tiles[0]);assert.equal(route.status,'blocked');assert.equal(route.blocker,'b');assert.ok(route.collision);
});
test('level 1 last piece reports a loop and wins after one clockwise turn',()=>{
const g=new Game(LEVELS[0]);while(available(g.level,g.tiles).length)g.tap(available(g.level,g.tiles)[0].id);
assert.equal(g.tiles.length,1);const last=g.tiles[0].id;
assert.equal(trace(g.level,g.tiles[0],g.tiles).status,'loop');
assert.equal(g.rotate(1).status,'rotated');assert.equal(g.tap(last).status,'exit');assert.ok(g.won);
test('loop probes are free and undo restores probes, rotations, and successful removals',()=>{
const g=new Game(rotatingLoopFixture()),initial=clone(g.level);
for(let i=0;i<4;i++)assert.equal(g.tap('a').status,'loop');
assert.equal(g.moves,1);assert.equal(g.mistakes,4);assert.equal(g.lost,false);
assert.ok(g.undo());assert.equal(g.mistakes,3);assert.equal(g.moves,1);
assert.equal(g.rotate(1).status,'rotated');assert.equal(g.tap('a').status,'exit');assert.ok(g.won);assert.equal(g.moves,0);
assert.ok(g.undo());assert.equal(g.won,false);assert.equal(g.moves,1);assert.equal(g.orientation,1);assert.equal(g.rotations,1);assert.equal(g.mistakes,3);
assert.ok(g.undo());assert.equal(g.orientation,0);assert.equal(g.rotations,0);assert.deepEqual(g.level,initial);assert.equal(g.mistakes,3);
});
test('moving tile does not collide with its vacated starting cell',()=>{
const l=level([{q:0,r:0,tracks:[3,4,5,0,1,2]},{q:1,r:-1,tracks:[1,0,5,4,3,2]},{q:1,r:0,tracks:[5,2,1,4,3,0]}],[tile('a',0,0,1)]);
@@ -112,12 +134,12 @@ test('collision animation pauses at contact, recoils, and returns without moving
assert.equal(collisionPhase(0).distance,0);assert.equal(collisionPhase(.5).distance,1);assert.equal(collisionPhase(.6).distance,1);assert.equal(collisionPhase(1).distance,0);
assert.ok(collisionPhase(.5).impact>0);assert.equal(collisionPhase(.5,true).kick,0);
const p=contactPath([{x:-5,y:0},{x:0,y:0}],{x:0,y:0},1);assert.equal(p.at(-1).x,-2);
const g=new Game(LEVELS[0]),before=clone(g.tiles),blocked=g.tiles.find(t=>trace(g.level,t,g.tiles).status==='blocked');
const g=new Game(blockedFixture()),before=clone(g.tiles),blocked=g.tiles.find(t=>trace(g.level,t,g.tiles).status==='blocked');
assert.ok(blocked);g.tap(blocked.id);assert.deepEqual(g.tiles,before);
});
test('rendered collision moves the blocker only during impact and returns both sprites',async()=>{
const {drawScene}=await import('../src/renderer.js');const l=LEVELS[0],g=new Game(l),t=g.tiles.find(t=>trace(l,t).status==='blocked'),route=g.tap(t.id),v=layout(l.cells,390,390);
const {drawScene}=await import('../src/renderer.js');const l=blockedFixture(),g=new Game(l),t=g.tiles.find(t=>trace(l,t).status==='blocked'),route=g.tap(t.id),v=layout(l.cells,390,390);
const capture=progress=>{const sprites=[];const p={sprite:(index,x,y,r)=>{sprites.push({index,x,y,r});return true;},polygon(){},line(){},circle(){}};drawScene(p,l,g.tiles,v,{animation:{route,progress}});return sprites;};
const a=capture(0),impact=capture(.48),end=capture(1);
assert.deepEqual(end,a);assert.ok(impact.some((s,i)=>Math.abs(s.x-a[i].x)+Math.abs(s.y-a[i].y)>.1));