Files
HexTrails/scripts/generate-levels.mjs
T
cabinw e92cccba82 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.
2026-09-09 13:17:30 +08:00

69 lines
8.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { readFile, writeFile } from 'node:fs/promises';
import { generateFlowLevel, inspectFlowLevel, CAMPAIGN_PLAN } from '../src/campaign-generator.js';
import { validateLevel } from '../src/core.js';
const beats=['进入','换路','展开','小挑战','释放','长局回报'];
const intents=[
'开局就有多个可行出口,重新进入观察与消除节奏。',
'用一次有明确收益的免费旋转打开出口;每次只处理一个温和变化。',
'在熟悉规则中展开路线,提供多次连续成功。',
'少量棋子带来一次路线或先后顺序判断,形成组内小高点。',
'比上一关明显轻松,增加可并行处理的出口,释放思考压力。',
'用较大的棋盘和更多清晰路线收束;增加完成感与体验长度。',
];
const names=[
['跟着弯道走','转一下,路会变','交叉处不换线','先解开这道弯','一弯接一弯','把弯路连起来'],
['沿着轨道看出口','换条短路','接连找到出口','小小转机','一路放行','铺开好心情'],
['找到节奏','转出一条新路','继续向外走','先解哪一枚','宽阔的回报','长路也轻巧'],
['慢慢看,都来得及','只换一个方向','连成顺畅的路','轻巧绕一个弯','松一口气','从容的大舞台'],
['又是新的出口','看见新的可能','顺路解开','留意这个转角','满盘好走','长长的成就感'],
['熟练地出发','一点小变化','接住这个节奏','两条路的相遇','把出口打开','顺畅走到最后'],
['从容观察','换个角度看','一枚接着一枚','给路线松个结','轻轻一扫','大盘小心思'],
['自在出发','最后一个新转角','熟悉的长路','小结轻解','收获一片畅通','自在回响'],
];
// Explicit curated seeds keep all 48 route designs reviewable and reproducible.
const {seeds}=JSON.parse(await readFile(new URL('../levels/campaign-seeds.json',import.meta.url),'utf8'));
if(seeds.length!==48||new Set(seeds.map(s=>s.seed)).size!==48)throw new Error('Expected 48 distinct curated seeds');
const lessons=[
'箭头决定起步方向,棋子会沿弯轨自动转向',
'转动底盘会换路,棋子和箭头留在原位',
'交叉处不会换轨,沿同一条线找到出口',
'先看谁挡住了路线,再决定消除或免费转盘',
'跟过一两处弯道,接连解开棋子的出口',
'盘面变大,仍然顺着每条弯轨逐一解开',
'先看完整路线,再选择出口或免费转盘',
];
const levels=[],report=[];
for(let chapter=0;chapter<CAMPAIGN_PLAN.chapters.length;chapter++){
const block=seeds.slice(chapter*6,chapter*6+6).map(p=>{
const l=generateFlowLevel(p.seed,p.options);
if(l.tiles.length!==p.options.target)throw new Error(`Incomplete generated layout for level ${p.level}`);
return l;
});
for(let beat=0;beat<block.length;beat++){
const index=levels.length,level={...block[beat],id:`observe-v3-${String(index+1).padStart(2,'0')}`,name:names[chapter][beat],chapter:chapter+1,chapterName:CAMPAIGN_PLAN.chapters[chapter],beat:beats[beat],designIntent:intents[beat]};
if(lessons[index]){level.lesson=lessons[index];level.designIntent=lessons[index]+'。在明确反馈中学习路径规则,直线路径只作少量节奏调剂。';}
const q=inspectFlowLevel(level),a=q.analysis,errors=validateLevel(level);
if(errors.length||!a.solvable||a.exhausted||!q.won||q.independentLoops||q.hiddenBlockerStates||q.maxDryRotations>1||q.maxUnlockDistance>1)throw new Error(`Campaign gate failed: ${level.name} ${JSON.stringify({errors,q})}`);
if(a.free<2)throw new Error(`Not enough opening choices: ${level.name}`);
if(a.pathUsage.curvedRatio<CAMPAIGN_PLAN.minimumCurvedRatio||a.pathUsage.curvedRemovals<3)throw new Error(`Too many straight-only removals: ${level.name}`);
if(index===0&&(a.free!==level.tiles.length||a.rotations||a.pathUsage.singleBendRemovals!==level.tiles.length))throw new Error('First level must teach one visible bend per piece without forced order or rotation');
level.requiresRotation=a.rotations>0;
level.pacing={cognitiveDifficulty:a.score,removals:level.tiles.length,curvedRemovals:a.pathUsage.curvedRemovals,curvedRatio:a.pathUsage.curvedRatio,estimatedSeconds:a.workload.estimatedSeconds,estimateKind:'structural-heuristic-not-playtest',maxProgressGapRotations:q.maxDryRotations};
levels.push(level);
report.push({level:index+1,name:level.name,chapter:chapter+1,beat:beats[beat],lesson:level.lesson,cells:level.cells.length,tiles:level.tiles.length,difficulty:a.score,free:a.free,freeRatio:Number(a.freeRatio.toFixed(3)),meanTurns:Number(a.meanTurns.toFixed(3)),maxTurns:a.maxTurns,pathUsage:a.pathUsage,dependencyRatio:Number(a.dependencyRatio.toFixed(3)),necessaryRotations:a.rotations,estimatedSeconds:a.workload.estimatedSeconds,independentLoops:q.independentLoops,solutionHiddenBlockers:q.hiddenBlockerStates,maxProgressGapRotations:q.maxDryRotations,lowChoiceStates:q.lowChoiceStates,noCurvedChoiceStates:q.noCurvedChoiceStates,actions:a.actions,openingAndSolutionStates:q.samples});
}
const rows=report.slice(chapter*6,chapter*6+6);
if(rows[4].difficulty>rows[3].difficulty-12||rows[5].difficulty>rows[3].difficulty-10||rows[5].tiles<rows[3].tiles||rows[5].estimatedSeconds<=rows[3].estimatedSeconds)throw new Error(`Missing challenge/release rhythm in chapter ${chapter+1}`);
console.log(`${chapter+1}. ${CAMPAIGN_PLAN.chapters[chapter]}: difficulty ${rows.map(r=>r.difficulty).join(' → ')}; tiles ${rows.map(r=>r.tiles).join('/')}`);
}
await writeFile(new URL('../levels/campaign.json',import.meta.url),JSON.stringify(levels,null,2)+'\n');
await writeFile(new URL('../src/levels.js',import.meta.url),'// Generated by npm run levels. Free observation, spiral pacing; do not difficulty-sort.\nexport const CAMPAIGN = '+JSON.stringify(CAMPAIGN_PLAN)+';\nexport const LEVELS = '+JSON.stringify(levels)+';\n');
const pathSummary={removals:report.reduce((n,r)=>n+r.tiles,0),curvedRemovals:report.reduce((n,r)=>n+r.pathUsage.curvedRemovals,0),pureStraightLevels:report.filter(r=>!r.pathUsage.curvedRemovals).length,minimumCurvedRatio:Math.min(...report.map(r=>r.pathUsage.curvedRatio))};
pathSummary.curvedRatio=pathSummary.curvedRemovals/pathSummary.removals;
await writeFile(new URL('../levels/campaign-analysis.json',import.meta.url),JSON.stringify({campaign:CAMPAIGN_PLAN,pathSummary,estimateNote:'Time values are structural heuristics, not measured player completion times.',levels:report},null,2)+'\n');
const md='# 主线关卡验收与节奏表\n\n自动生成:`npm run levels`;固定布局参数见 `levels/campaign-seeds.json`。共 48 关,8 章,每章顺序为进入、换路、展开、小挑战、释放、长局回报。认知难度与棋盘规模分别记录。\n\n全部关卡:可解且回放通关;每枚棋子在全部六朝向独立路线无闭环;参考解答无路线格外擦碰阻挡;相邻消除之间最多连续旋转一次。参考解答检查不等于穷尽玩家的任意旋转顺序,实际阻挡对象会在游戏中标记。\n\n**每关至少 65% 的参考消除路径实际经过弯道,至少 3 枚弯路棋子;空格中没有走过的弯线不计入。** 全部参考解答合计 '+pathSummary.removals+' 次消除,其中 '+pathSummary.curvedRemovals+' 次经过弯道('+(pathSummary.curvedRatio*100).toFixed(1)+'%);纯直出关卡 '+pathSummary.pureStraightLevels+' 个。这里统计的是求解器的实际离场路径,不是任意玩家操作顺序。\n\n**预计时长只用于内部比较,没有真人测试数据。** 公式为 3 × 棋子数 + 0.75 × 初始独立路线总转弯 + 4 × 最少旋转。所有时间均为估计秒数,不能当作通关时限。\n\n| 关 | 名称 | 节奏 | 格数 | 棋子 | 弯路离场 | 弯路占比 | 认知难度 | 开局可解 | 必要旋转 | 预计秒数 |\n|---|---|---|---:|---:|---:|---:|---:|---:|---:|---:|\n'+report.map(r=>`| ${r.level} | ${r.name} | ${r.beat} | ${r.cells} | ${r.tiles} | ${r.pathUsage.curvedRemovals} | ${(r.pathUsage.curvedRatio*100).toFixed(1)}% | ${r.difficulty} | ${r.free} | ${r.necessaryRotations} | ${r.estimatedSeconds} |`).join('\n')+'\n\n## 前七关的路径教学\n\n'+report.slice(0,7).map(r=>`${r.level}. **${r.name}**:${r.lesson}。`).join('\n')+'\n';
await writeFile(new URL('../docs/campaign-review.md',import.meta.url),md);
console.log('48 levels pass all publishing gates. Campaign, shared module and pacing report synchronized.');