feat: version campaigns and document original puzzle design

Restore v0.1 and v0.2 archives with separate progress, publish the curved v0.3 campaign, and document the original 24-level design with reproducible evidence. Refine board presentation and movement, and keep video lab tooling within its own directory.
This commit is contained in:
cabinw
2026-09-09 13:17:30 +08:00
parent 70b021d4cc
commit e92cccba82
52 changed files with 76449 additions and 15076 deletions
+11 -6
View File
@@ -1,7 +1,7 @@
import { hexagon, random, randomTracks, rotateBoard, trace, available, analyze, key, opposite, Game } from './core.js';
export const CAMPAIGN_PLAN = {
id:'observe-v3', chapterSize:6,
id:'observe-v3', revision:2, chapterSize:6, minimumCurvedRatio:.65,
chapters:['看见出口','转出新路','连成节奏','轻巧解结','展开天地','从容变换','长路常新','自在回响'],
};
const STRAIGHT=[3,4,5,0,1,2];
@@ -26,7 +26,9 @@ export function generateLaneLevel(seed,{radius=3,target=18,axis=seed%3}={}) {
// Independent board size, route vocabulary and piece count. Reverse insertion
// makes the unrotated board solvable; a selected starting rotation may add a
// small routing decision. No route is allowed to hide a solitary-piece loop.
export function generateFlowLevel(seed,{radius=3,target=18,bendRate=.12,maxTurns=4,startRotation=0,allFree=false,clearBlockers=false}={}) {
// min/maxBaseTurns constrain the construction orientation, before startRotation.
// Published route usage is measured again in the final reference solution.
export function generateFlowLevel(seed,{radius=3,target=18,bendRate=.12,maxTurns=4,startRotation=0,allFree=false,clearBlockers=false,minBaseTurns=0,maxBaseTurns=maxTurns,curvePreference=0}={}) {
const rng=random(seed);
let level={version:1,id:`flow-${seed}`,name:'流动的轨迹',seed,rotationEnabled:true,cells:hexagon(radius),tiles:[],moveLimit:target};
level.cells.forEach(c=>{c.tracks=rng()<bendRate?randomTracks(rng):[...STRAIGHT];});
@@ -35,6 +37,7 @@ export function generateFlowLevel(seed,{radius=3,target=18,bendRate=.12,maxTurns
const t={id:'candidate',q:cell.q,r:cell.r,dir,color:0};
const routes=boards.map(b=>trace(b,t,[t],true));
if(routes.some(r=>r.status!=='exit'||turnCount(r)>maxTurns))continue;
if(turnCount(routes[0])<minBaseTurns||turnCount(routes[0])>maxBaseTurns)continue;
candidates.push({t,steps:routes[0].steps.length,turns:turnCount(routes[0])});
}
for(let n=0;n<target;n++){
@@ -50,7 +53,7 @@ export function generateFlowLevel(seed,{radius=3,target=18,bendRate=.12,maxTurns
})))continue;
// Avoid filling the board with one-step edge exits; give readable routes
// a small preference, while random tie-breaking varies spatial patterns.
const weight=allFree?rng()*2-c.steps*2:rng()*3+Math.min(c.steps,5)*.35-c.turns*.25;
const weight=(allFree?rng()*2-c.steps*2:rng()*3+Math.min(c.steps,5)*.35-c.turns*.25)+Math.min(c.turns,2)*curvePreference;
choices.push({tile,weight});
}
if(!choices.length)break;
@@ -68,26 +71,28 @@ export function inspectFlowLevel(level) {
const a=analyze(level),boards=Array.from({length:6},(_,i)=>rotateBoard(level,i));
let independentLoops=0,maxSoloTurns=0;
for(const board of boards)for(const tile of level.tiles){const r=trace(board,tile,[tile],true);if(r.status!=='exit')independentLoops++;maxSoloTurns=Math.max(maxSoloTurns,turnCount(r));}
const game=new Game(level);let maxDryRotations=0,dry=0,maxUnlockDistance=0,hiddenBlockerStates=0,blockedStates=0,lowChoiceStates=0,states=0;
const game=new Game(level);let maxDryRotations=0,dry=0,maxUnlockDistance=0,hiddenBlockerStates=0,blockedStates=0,lowChoiceStates=0,noCurvedChoiceStates=0,states=0;
const samples=[];
function inspectState(){
if(!game.tiles.length)return;
const routes=game.tiles.map(t=>trace(game.level,t,game.tiles));
const free=routes.filter(r=>r.status==='exit').length;
const freeCurved=routes.filter(r=>r.status==='exit'&&turnCount(r)>0).length;
for(let i=0;i<routes.length;i++)if(routes[i].status==='blocked'){
blockedStates++;const base=trace(game.level,game.tiles[i],game.tiles,true);
if(!base.blockers.includes(routes[i].blocker))hiddenBlockerStates++;
}
if(game.tiles.length>=6&&free<2)lowChoiceStates++;
if(game.tiles.length>=4&&free>0&&!freeCurved)noCurvedChoiceStates++;
let distance=0;
if(!free){distance=3;for(let turns=1;turns<=3;turns++)if(available(rotateBoard(game.level,turns),game.tiles).length||available(rotateBoard(game.level,-turns),game.tiles).length){distance=turns;break;}}
maxUnlockDistance=Math.max(maxUnlockDistance,distance);states++;
samples.push({remaining:game.tiles.length,free,rotationDistance:distance});
samples.push({remaining:game.tiles.length,free,freeCurved,rotationDistance:distance});
}
for(const action of a.actions){
inspectState();
if(action.type==='rotate'){dry++;maxDryRotations=Math.max(maxDryRotations,dry);game.rotate(action.direction);}
else{dry=0;game.tap(action.id);}
}
return {analysis:a,independentLoops,maxSoloTurns,maxDryRotations,maxUnlockDistance,hiddenBlockerStates,blockedStates,lowChoiceStates,states,samples,won:game.won};
return {analysis:a,independentLoops,maxSoloTurns,maxDryRotations,maxUnlockDistance,hiddenBlockerStates,blockedStates,lowChoiceStates,noCurvedChoiceStates,states,samples,won:game.won};
}