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.
99 lines
5.9 KiB
JavaScript
99 lines
5.9 KiB
JavaScript
import { hexagon, random, randomTracks, rotateBoard, trace, available, analyze, key, opposite, Game } from './core.js';
|
|
|
|
export const CAMPAIGN_PLAN = {
|
|
id:'observe-v3', revision:2, chapterSize:6, minimumCurvedRatio:.65,
|
|
chapters:['看见出口','转出新路','连成节奏','轻巧解结','展开天地','从容变换','长路常新','自在回响'],
|
|
};
|
|
const STRAIGHT=[3,4,5,0,1,2];
|
|
const turnCount=route=>route.steps.filter(s=>s.enter!==null&&opposite(s.enter)!==s.exit).length;
|
|
|
|
// Spacious parallel lanes create long, readable release levels. Alternate
|
|
// columns leave enough physical clearance to avoid off-route grazing blockers;
|
|
// each lane splits outwards, so several independent choices stay available.
|
|
export function generateLaneLevel(seed,{radius=3,target=18,axis=seed%3}={}) {
|
|
const rng=random(seed),cells=hexagon(radius);
|
|
const sets=[0,1].map(parity=>cells.filter(c=>Math.abs(c.q)%2===parity));
|
|
let pool=sets[seed%2];if(pool.length<target)pool=sets[1-seed%2];
|
|
const selected=pool.map(c=>({c,w:rng()})).sort((a,b)=>a.w-b.w).slice(0,target).map(({c})=>c);
|
|
const tiles=selected.map((c,i)=>{
|
|
let q=c.q,r=c.r,dir=c.r+c.q/2<0?0:3;
|
|
for(let n=0;n<axis;n++){[q,r]=[-r||0,q+r||0];dir=(dir+1)%6;}
|
|
return{id:`t${i}`,q,r,dir,color:i%4};
|
|
});
|
|
return{version:1,id:`lanes-${seed}`,name:'从容的长路',seed,rotationEnabled:true,cells,tiles,moveLimit:tiles.length};
|
|
}
|
|
|
|
// 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.
|
|
// 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];});
|
|
const boards=Array.from({length:6},(_,i)=>rotateBoard(level,i)),candidates=[];
|
|
for(const cell of level.cells)for(let dir=0;dir<6;dir++){
|
|
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++){
|
|
const occupied=new Set(level.tiles.map(t=>key(t.q,t.r))),choices=[];
|
|
for(const c of candidates){
|
|
if(occupied.has(key(c.t.q,c.t.r)))continue;
|
|
const tile={...c.t,id:`t${n}`,color:n%4},tiles=[...level.tiles,tile];
|
|
if(trace(level,tile,tiles).status!=='exit')continue;
|
|
if(allFree&&tiles.some(t=>trace(level,t,tiles).status!=='exit'))continue;
|
|
if(clearBlockers&&level.tiles.some(other=>[tile,other].some((t,i)=>{
|
|
const r=trace(level,t,[tile,other]);
|
|
return r.status==='blocked'&&!trace(level,t,[tile,other],true).blockers.includes((i?tile:other).id);
|
|
})))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)+Math.min(c.turns,2)*curvePreference;
|
|
choices.push({tile,weight});
|
|
}
|
|
if(!choices.length)break;
|
|
choices.sort((a,b)=>b.weight-a.weight);
|
|
level.tiles.push(choices[0].tile);
|
|
}
|
|
level=rotateBoard(level,startRotation);
|
|
level.moveLimit=level.tiles.length;
|
|
return level;
|
|
}
|
|
|
|
// These are structural checks, not playtest claims. Observe every solver state
|
|
// in all six orientations to find long no-progress stretches before shipping.
|
|
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,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,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,noCurvedChoiceStates,states,samples,won:game.won};
|
|
}
|