feat: expand campaign and refine puzzle visuals and controls
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
import { hexagon, random, randomTracks, rotateBoard, trace, available, analyze, key, opposite, Game } from './core.js';
|
||||
|
||||
export const CAMPAIGN_PLAN = {
|
||||
id:'observe-v3', chapterSize:6,
|
||||
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.
|
||||
export function generateFlowLevel(seed,{radius=3,target=18,bendRate=.12,maxTurns=4,startRotation=0,allFree=false,clearBlockers=false}={}) {
|
||||
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;
|
||||
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;
|
||||
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,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;
|
||||
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++;
|
||||
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});
|
||||
}
|
||||
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};
|
||||
}
|
||||
Reference in New Issue
Block a user