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:
+30
-35
@@ -1,5 +1,5 @@
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { generateFlowLevel, generateLaneLevel, inspectFlowLevel, CAMPAIGN_PLAN } from '../src/campaign-generator.js';
|
||||
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=['进入','换路','展开','小挑战','释放','长局回报'];
|
||||
@@ -12,8 +12,8 @@ const intents=[
|
||||
'用较大的棋盘和更多清晰路线收束;增加完成感与体验长度。',
|
||||
];
|
||||
const names=[
|
||||
['一眼看见出口','转一下,就通了','顺着线出发','解开小结','一片畅通','轻松清空大盘'],
|
||||
['熟悉的方向','换条短路','接连找到出口','小小转机','一路放行','铺开好心情'],
|
||||
['跟着弯道走','转一下,路会变','交叉处不换线','先解开这道弯','一弯接一弯','把弯路连起来'],
|
||||
['沿着轨道看出口','换条短路','接连找到出口','小小转机','一路放行','铺开好心情'],
|
||||
['找到节奏','转出一条新路','继续向外走','先解哪一枚','宽阔的回报','长路也轻巧'],
|
||||
['慢慢看,都来得及','只换一个方向','连成顺畅的路','轻巧绕一个弯','松一口气','从容的大舞台'],
|
||||
['又是新的出口','看见新的可能','顺路解开','留意这个转角','满盘好走','长长的成就感'],
|
||||
@@ -21,45 +21,38 @@ const names=[
|
||||
['从容观察','换个角度看','一枚接着一枚','给路线松个结','轻轻一扫','大盘小心思'],
|
||||
['自在出发','最后一个新转角','熟悉的长路','小结轻解','收获一片畅通','自在回响'],
|
||||
];
|
||||
// Selected deterministic candidates preserve pacing; never difficulty-sort.
|
||||
const turnSeeds=[324,541,45,2034,4684,7089,7104,7672];
|
||||
const challengeSeeds=[104,1161,3171,3422,3980,2908,1017,6269];
|
||||
const turnCandidate=seed=>generateFlowLevel(seed,{radius:2,target:4+seed%9,bendRate:.25+(seed%7)*.1,startRotation:1+seed%5,maxTurns:5});
|
||||
|
||||
function pickLanes(chapter,target,salt){
|
||||
const radius=target>20?4:3;
|
||||
for(let attempt=0;attempt<100;attempt++){
|
||||
const l=generateLaneLevel(42000+chapter*199+salt*13+attempt,{radius,target}),q=inspectFlowLevel(l);
|
||||
if(l.tiles.length===target&&q.analysis.score<=27&&q.analysis.free>=Math.min(8,Math.ceil(target*.3))&&q.hiddenBlockerStates===0)return l;
|
||||
}
|
||||
throw new Error(`No clear lane level for chapter ${chapter+1}, target ${target}`);
|
||||
}
|
||||
function pickFlow(chapter){
|
||||
const [seed,target,bendRate]=[[100777,14,.10],[107993,16,.14],[115912,16,.14],[123868,18,.18],[131787,18,.18],[140076,20,.10],[140010,20,.15],[150011,20,.18]][chapter];
|
||||
const l=generateFlowLevel(seed,{radius:4,target,bendRate,maxTurns:3,clearBlockers:true}),q=inspectFlowLevel(l),a=q.analysis;
|
||||
if(l.tiles.length!==target||!a.solvable||a.score>30||a.freeRatio<.3||a.meanTurns<=0||a.meanTurns>1||q.hiddenBlockerStates||q.lowChoiceStates)throw new Error(`Flowing level no longer meets its pacing gate: chapter ${chapter+1}`);
|
||||
return l;
|
||||
}
|
||||
// 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=[
|
||||
chapter===0?generateFlowLevel(3,{radius:3,target:12,bendRate:0,allFree:true}):pickLanes(chapter,12+chapter*2,0),
|
||||
turnCandidate(turnSeeds[chapter]),
|
||||
pickFlow(chapter),
|
||||
turnCandidate(challengeSeeds[chapter]),
|
||||
pickLanes(chapter,[16,18,22,24,26,28,28,30][chapter],4),
|
||||
pickLanes(chapter,[18,20,24,26,28,30,32,33][chapter],5),
|
||||
];
|
||||
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,estimatedSeconds:a.workload.estimatedSeconds,estimateKind:'structural-heuristic-not-playtest',maxProgressGapRotations:q.maxDryRotations};
|
||||
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],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,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,actions:a.actions,openingAndSolutionStates:q.samples});
|
||||
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}`);
|
||||
@@ -67,7 +60,9 @@ for(let chapter=0;chapter<CAMPAIGN_PLAN.chapters.length;chapter++){
|
||||
}
|
||||
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');
|
||||
await writeFile(new URL('../levels/campaign-analysis.json',import.meta.url),JSON.stringify({campaign:CAMPAIGN_PLAN,estimateNote:'Time values are structural heuristics, not measured player completion times.',levels:report},null,2)+'\n');
|
||||
const md='# 主线关卡验收与节奏表\n\n自动生成:`npm run levels`。共 48 关,8 章,每章顺序为进入、换路、展开、小挑战、释放、长局回报。认知难度与棋盘规模分别记录。\n\n全部关卡:可解且回放通关;每枚棋子在全部六朝向独立路线无闭环;参考解答无路线格外擦碰阻挡;相邻消除之间最多连续旋转一次。参考解答检查不等于穷尽玩家的任意旋转顺序,实际阻挡对象会在游戏中标记。\n\n**预计时长只用于内部比较,没有真人测试数据。** 公式为 3 × 棋子数 + 0.75 × 初始独立路线总转弯 + 4 × 最少旋转。所有时间均为估计秒数,不能当作通关时限。\n\n| 关 | 名称 | 节奏 | 格数 | 棋子 | 认知难度 | 开局可解 | 必要旋转 | 预计秒数 |\n|---|---|---|---:|---:|---:|---:|---:|---:|\n'+report.map(r=>`| ${r.level} | ${r.name} | ${r.beat} | ${r.cells} | ${r.tiles} | ${r.difficulty} | ${r.free} | ${r.necessaryRotations} | ${r.estimatedSeconds} |`).join('\n')+'\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.');
|
||||
|
||||
Reference in New Issue
Block a user