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:
@@ -0,0 +1,80 @@
|
||||
// Read-only campaign audit: pin the original engine so later rule/scoring edits
|
||||
// cannot silently change our account of the first 24 levels.
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdtemp, readFile, writeFile, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
const root=fileURLToPath(new URL('../',import.meta.url));
|
||||
const commit='ef2724bb2e6582c92ac26b3a45fcc18c406d6c21';
|
||||
const hash=data=>createHash('sha256').update(data).digest('hex');
|
||||
const scratch=await mkdtemp(join(tmpdir(),'hex-v01-design-'));
|
||||
try {
|
||||
const engineSources=[];
|
||||
await writeFile(join(scratch,'package.json'),'{"type":"module"}\n');
|
||||
for(const path of ['src/core.js','src/path-math.js']){
|
||||
const bytes=execFileSync('git',['show',`${commit}:${path}`],{cwd:root});
|
||||
await writeFile(join(scratch,path.split('/').at(-1)),bytes);
|
||||
engineSources.push({path,sha256:hash(bytes)});
|
||||
}
|
||||
const {analyze,trace,Game,available,opposite,validateLevel}=await import(pathToFileURL(join(scratch,'core.js')));
|
||||
const bendCount=route=>route.steps.filter(s=>s.enter!==null&&opposite(s.enter)!==s.exit).length;
|
||||
const auditLevel=(level,index)=>{
|
||||
const errors=validateLevel(level);if(errors.length)throw new Error(`${level.name}: ${errors.join('; ')}`);
|
||||
const a=analyze(level),game=new Game({...level,moveLimit:999});
|
||||
if(!a.reachable||a.exhausted)throw new Error(`Unconfirmed layout: ${level.name}`);
|
||||
const opening=level.tiles.map(t=>{
|
||||
const route=trace(level,t),solo=trace(level,t,level.tiles,true);
|
||||
const blocker=level.tiles.find(b=>b.id===route.blocker);
|
||||
const sweptOnly=route.status==='blocked'&&blocker&&!solo.steps.some(s=>s.q===blocker.q&&s.r===blocker.r);
|
||||
return {id:t.id,q:t.q,r:t.r,dir:t.dir,status:route.status,blocker:route.blocker,independentLoop:solo.status==='loop',sweptOnly:!!sweptOnly};
|
||||
});
|
||||
const removals=[],rotations=[];
|
||||
for(const action of a.actions){
|
||||
if(action.type==='rotate'){
|
||||
const before=available(game.level,game.tiles).map(t=>t.id);
|
||||
if(game.rotate(action.direction).status!=='rotated')throw new Error('Failed rotation replay');
|
||||
rotations.push({direction:action.direction,remaining:game.tiles.map(t=>t.id),before,after:available(game.level,game.tiles).map(t=>t.id)});
|
||||
}else{
|
||||
const route=game.tap(action.id);if(route.status!=='exit')throw new Error(`Failed removal: ${level.name}`);
|
||||
removals.push({id:action.id,orientation:game.orientation,bends:bendCount(route),cells:route.steps.length});
|
||||
}
|
||||
}
|
||||
if(!game.won)throw new Error('Incomplete replay');
|
||||
return {
|
||||
level:index+1,id:level.id,name:level.name,cells:level.cells.length,tiles:level.tiles.length,
|
||||
occupancy:level.tiles.length/level.cells.length,originalScore:a.score,originalMoveLimit:level.moveLimit,
|
||||
originalMinimumMoves:a.minimumMoves,originalBudgetSolvable:a.solvable,minimumRotations:a.rotations,
|
||||
free:a.free,blocked:opening.filter(p=>p.status==='blocked').length,initialIndependentLoops:opening.filter(p=>p.independentLoop).length,
|
||||
initialSweptOnlyBlockers:opening.filter(p=>p.sweptOnly).length,meanIndependentTurns:a.meanTurns,
|
||||
removalWaves:a.depth,opening,actions:a.actions,waves:a.waves,rotations,removals,
|
||||
curvedRemovals:removals.filter(p=>p.bends>0).length,totalSuccessfulBends:removals.reduce((s,p)=>s+p.bends,0),
|
||||
};
|
||||
};
|
||||
const sources=[['v0.1','levels/versions/v0.1.json'],['v0.2','levels/versions/v0.2.json'],['v0.3','levels/campaign.json']];
|
||||
const editions=[];
|
||||
for(const [version,path] of sources){
|
||||
const bytes=await readFile(join(root,path)),levels=JSON.parse(bytes),rows=levels.map(auditLevel);
|
||||
if(version==='v0.1'){
|
||||
const historical=execFileSync('git',['show',`${commit}:levels/campaign.json`],{cwd:root});
|
||||
if(!bytes.equals(historical)||rows.some(r=>!r.originalBudgetSolvable))throw new Error('Original layout or historical budget verification failed');
|
||||
}
|
||||
const sum=field=>rows.reduce((s,r)=>s+r[field],0),bounds=field=>[Math.min(...rows.map(r=>r[field])),Math.max(...rows.map(r=>r[field]))];
|
||||
editions.push({version,path,sha256:hash(bytes),count:rows.length,
|
||||
summary:{cells:[...new Set(rows.map(r=>r.cells))].sort((a,b)=>a-b),tiles:bounds('tiles'),totalTiles:sum('tiles'),
|
||||
requiresRotation:rows.filter(r=>r.minimumRotations>0).length,rotationRange:bounds('minimumRotations'),rotationCounts:Object.fromEntries([0,1,2,3].map(n=>[n,rows.filter(r=>r.minimumRotations===n).length])),
|
||||
initialFree:sum('free'),initialFreeRatio:sum('free')/sum('tiles'),zeroFreeLevels:rows.filter(r=>!r.free).map(r=>r.level),
|
||||
initialIndependentLoops:sum('initialIndependentLoops'),initialSweptOnlyBlockers:sum('initialSweptOnlyBlockers'),
|
||||
removalWaves:bounds('removalWaves'),curvedRemovals:sum('curvedRemovals'),curvedRatio:sum('curvedRemovals')/sum('tiles'),
|
||||
totalSuccessfulBends:sum('totalSuccessfulBends'),originalScores:version==='v0.1'?bounds('originalScore'):undefined,
|
||||
originalScoresNondecreasing:version==='v0.1'?rows.every((r,i)=>!i||r.originalScore>=rows[i-1].originalScore):undefined},
|
||||
levels:version==='v0.1'?rows:undefined});
|
||||
}
|
||||
const report={basis:{commit,engineSources,
|
||||
note:'All geometry, opening states and reference solutions use the pinned original engine. All reference replays use budget 999 to compare route structures; v0.1 additionally verifies its original move budget. Original scores and move budgets are interpreted only for v0.1; reference route usage is not all possible player orders. No player telemetry.'},editions};
|
||||
await writeFile(join(root,'docs/v0.1-design-evidence.json'),JSON.stringify(report,null,2)+'\n');
|
||||
for(const edition of editions)console.log(edition.version,JSON.stringify(edition.summary));
|
||||
console.log('Original 24:',editions[0].levels.map(r=>`${r.level} ${r.name}: ${r.cells}/${r.tiles}, free ${r.free}, turns ${r.minimumRotations}, waves ${r.removalWaves}, score ${r.originalScore}`).join('\n'));
|
||||
} finally { await rm(scratch,{recursive:true,force:true}); }
|
||||
@@ -0,0 +1,15 @@
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
const root=new URL('../',import.meta.url);
|
||||
const manifest=JSON.parse(await readFile(new URL('levels/versions/provenance.json',root),'utf8'));
|
||||
const archives={};
|
||||
for(const source of manifest.versions) {
|
||||
const bytes=await readFile(new URL(source.archivePath,root));
|
||||
if(createHash('sha256').update(bytes).digest('hex')!==source.sha256)throw new Error(`Historical archive changed: ${source.version}`);
|
||||
const levels=JSON.parse(bytes.toString('utf8'));
|
||||
if(levels.length!==source.levelCount)throw new Error(`Historical level count changed: ${source.version}`);
|
||||
archives[source.version]=levels;
|
||||
}
|
||||
await writeFile(new URL('src/levels-archive.js',root),'// Generated by npm run levels:versions from byte-preserved historical JSON.\nexport const ARCHIVED_LEVELS = '+JSON.stringify(archives)+';\n');
|
||||
console.log('Historical versions synchronized: '+Object.entries(archives).map(([id,levels])=>`${id} (${levels.length} levels)`).join(', '));
|
||||
@@ -1,52 +0,0 @@
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { resolve, extname, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
const root = fileURLToPath(new URL('../labs/hex-video/', import.meta.url));
|
||||
const markdown = readFileSync(resolve(root, 'designv2.md'), 'utf8');
|
||||
const samples = JSON.parse(readFileSync(resolve(root, 'reference/color-study/samples.json'), 'utf8'));
|
||||
const esc = value => String(value).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
const mime = {'.png':'image/png','.svg':'image/svg+xml','.json':'application/json','.py':'text/plain','.md':'text/plain'};
|
||||
const assets = new Map();
|
||||
function asset(path) {
|
||||
const absolute = resolve(root, path);
|
||||
if (!assets.has(absolute)) assets.set(absolute, `data:${mime[extname(path)] || 'application/octet-stream'};base64,${readFileSync(absolute).toString('base64')}`);
|
||||
return assets.get(absolute);
|
||||
}
|
||||
function inline(text) {
|
||||
const held = [];
|
||||
const hold = html => `\u0000${held.push(html)-1}\u0000`;
|
||||
text = text.replace(/`([^`]+)`/g, (_, value) => hold(`<code>${esc(value)}</code>`));
|
||||
text = text.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_, alt, path) => hold(`<img class="palette" alt="${esc(alt)}" src="${asset(path)}">`));
|
||||
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, path) => {
|
||||
if (/^https?:\/\//.test(path)) return hold(`<a href="${esc(path)}" target="_blank" rel="noopener noreferrer">${esc(label)}</a>`);
|
||||
const image = /\.(png|svg|jpg)$/.test(path);
|
||||
return hold(`<a href="${asset(path)}" ${image ? 'data-image="true"' : `download="${esc(path.split('/').at(-1))}"`}>${esc(label)}</a>`);
|
||||
});
|
||||
return esc(text).replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>').replace(/\u0000(\d+)\u0000/g, (_, n) => held[n]);
|
||||
}
|
||||
const lines = markdown.split('\n'), rendered = [], toc = [];
|
||||
for (let i=0; i<lines.length;) {
|
||||
const line=lines[i];
|
||||
if (!line.trim()) { i++; continue; }
|
||||
const h=line.match(/^(#{1,6})\s+(.+)$/);
|
||||
if(h){const level=h[1].length,id=`section-${i}`;rendered.push(`<h${level} id="${id}">${inline(h[2])}</h${level}>`);if(level===2)toc.push(`<a href="#${id}">${esc(h[2])}</a>`);i++;continue;}
|
||||
if(line.startsWith('|')){const rows=[];while(i<lines.length&&lines[i].startsWith('|'))rows.push(lines[i++].split('|').slice(1,-1).map(s=>s.trim()));const header=rows.shift();if(rows[0]?.every(s=>/^:?-+:?$/.test(s)))rows.shift();rendered.push(`<div class="table-wrap"><table><thead><tr>${header.map(s=>`<th>${inline(s)}</th>`).join('')}</tr></thead><tbody>${rows.map(row=>`<tr>${row.map(s=>`<td>${inline(s)}</td>`).join('')}</tr>`).join('')}</tbody></table></div>`);continue;}
|
||||
if(/^(- |\d+\. )/.test(line)){const ordered=/^\d/.test(line),items=[];while(i<lines.length&&(ordered?/^\d+\. /:/^- /).test(lines[i]))items.push(lines[i++].replace(/^(- |\d+\. )/,''));const tag=ordered?'ol':'ul';rendered.push(`<${tag}>${items.map(s=>`<li>${inline(s)}</li>`).join('')}</${tag}>`);continue;}
|
||||
const p=[line];i++;while(i<lines.length&&lines[i].trim()&&!/^(#|\||- |\d+\. )/.test(lines[i]))p.push(lines[i++]);rendered.push(`<p>${inline(p.join(' '))}</p>`);
|
||||
}
|
||||
const swatches=samples.samples.map(s=>`<button class="swatch" data-color="${s.hex}" data-search="${s.role} ${s.hex}" aria-label="复制 ${s.role} ${s.hex}"><span class="chip" style="background:${s.hex}"></span><b>${s.role}</b><code>${s.hex}</code><small>${s.second}s · ROI ${s.roi.join(', ')}</small></button>`).join('');
|
||||
const seconds=[0,14,42,55,85,110,114,130,170,187];
|
||||
const gallery=seconds.map(t=>`<button class="frame" data-frame="${t}"><img loading="lazy" src="${asset(`reference/color-study/${String(t).padStart(3,'0')}.png`)}" alt="视频参考帧 ${Math.floor(t/60)}:${String(t%60).padStart(2,'0')}"><span>${Math.floor(t/60)}:${String(t%60).padStart(2,'0')}</span></button>`).join('');
|
||||
const contrast=samples.samples.filter(s=>/^tile\./.test(s.role)&&!s.role.includes('Side')&&s.role!=='tile.arrow').map(s=>`<tr><td>${s.role}</td><td><span class="arrow" style="background:${s.hex};color:white">↑</span>${s.whiteContrast}:1 ${s.whiteContrast<3?'· 需适配':''}</td><td><span class="arrow" style="background:${s.hex};color:#273B50">↑</span>${s.darkContrast}:1</td></tr>`).join('');
|
||||
const html=`<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>HEX TRAILS · Design v2 视频用色手册</title><style>
|
||||
:root{font-family:Inter,"PingFang SC","Microsoft YaHei",sans-serif;color:#273b50;background:#f4f6f9;line-height:1.8}*{box-sizing:border-box}body{margin:0}a{color:#1763ac;text-underline-offset:3px}button,input{font:inherit}button{cursor:pointer}button:focus-visible,input:focus-visible,a:focus-visible{outline:3px solid #007ac5;outline-offset:3px}[hidden]{display:none!important}header{padding:48px max(24px,calc((100vw - 1240px)/2));background:linear-gradient(120deg,#0c61e8,#158fcc);color:white}header p{opacity:.9;margin:8px 0}header h1{font-size:34px;line-height:1.4;margin:12px 0}header a{color:white}.eyebrow{font-size:12px;letter-spacing:3px}main{display:grid;grid-template-columns:210px minmax(0,1fr);gap:40px;max-width:1290px;margin:36px auto;padding:0 24px}aside{position:sticky;top:24px;align-self:start}aside a{display:block;font-size:13px;text-decoration:none;padding:5px 8px;border-radius:6px}aside a:hover{background:#e3edf8}article{min-width:0}section,.spec{background:white;border:1px solid #e0e7ee;border-radius:18px;padding:28px;margin-bottom:28px}h2{font-size:24px;margin-top:8px}h3{font-size:19px;margin-top:32px}h4{font-size:16px}h1,h2,h3,h4{scroll-margin-top:24px}p,li{font-size:14px}code{font-family:ui-monospace,monospace;font-size:.9em;overflow-wrap:anywhere;background:#eff3f7;border-radius:4px;padding:2px 4px}.tools{display:flex;gap:12px;flex-wrap:wrap;align-items:center}.tools a,.tools button{border:1px solid #b9cce0;border-radius:8px;padding:7px 14px;text-decoration:none;background:white;color:#285578;font-size:13px}input{padding:10px 12px;width:100%;border:1px solid #bccbd8;border-radius:9px;margin-bottom:20px}.swatches{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px}.swatch{border:1px solid #dce3eb;border-radius:10px;padding:0 0 10px;overflow:hidden;text-align:left;background:white;color:#273b50;min-width:0}.chip{display:block;height:76px;border-bottom:1px solid #0001}.swatch b,.swatch code,.swatch small{display:block;margin:6px 10px 0;overflow-wrap:anywhere}.swatch b{font-size:11px}.swatch small{font-size:10px;color:#5e7182}.gallery{display:grid;grid-template-columns:repeat(5,1fr);gap:12px}.frame{padding:0;border:1px solid #d9e2ed;border-radius:9px;overflow:hidden;background:#eef3f8;color:#334e64}.frame img{width:100%;display:block}.frame span{font-size:12px}.table-wrap{overflow:auto;margin:18px 0}table{border-collapse:collapse;width:100%;font-size:13px}td,th{padding:12px;text-align:left;border-bottom:1px solid #dfe6ee;vertical-align:top;min-width:90px}th{background:#edf3f8;color:#314d67}td code{white-space:normal}.arrow{display:inline-grid;place-items:center;width:44px;height:44px;clip-path:polygon(25% 0,75% 0,100% 50%,75% 100%,25% 100%,0 50%);font-size:27px;font-weight:bold;margin-right:12px;vertical-align:middle}.palette{width:100%;height:auto}.note{color:#617489;font-size:12px}.spec>h1{font-size:28px}.spec>h2{border-top:1px solid #dfe6ee;padding-top:28px;margin-top:40px}dialog{border:0;border-radius:14px;padding:14px;max-width:95vw;max-height:95vh;background:#f6f8fb}dialog::backdrop{background:#111c2ae0}dialog img{display:block;max-width:85vw;max-height:80vh;object-fit:contain;margin:auto}dialog button{display:block;margin:10px auto 0;border:0;border-radius:6px;background:#214f79;color:white;padding:6px 20px}#toast{position:fixed;bottom:22px;left:50%;transform:translateX(-50%);background:#233f58;color:white;border-radius:9px;padding:10px 18px;z-index:9;font-size:13px}footer{text-align:center;padding:20px;color:#627487;font-size:12px}@media(max-width:900px){main{grid-template-columns:1fr}aside{position:static;display:flex;flex-wrap:wrap;gap:5px}.swatches{grid-template-columns:repeat(3,minmax(0,1fr))}.gallery{grid-template-columns:repeat(5,1fr)}}@media(max-width:520px){header{padding:30px 20px}header h1{font-size:26px}main{padding:0 12px;gap:16px}section,.spec{padding:18px}.swatches{grid-template-columns:repeat(2,minmax(0,1fr))}.gallery{grid-template-columns:repeat(3,1fr)}}@media print{aside,.tools,input,#toast,dialog{display:none!important}main{display:block;padding:0;margin:0}header{background:white;color:#273b50;padding:12px}section,.spec{border:0;padding:0}table{font-size:10px}tr{break-inside:avoid}*{print-color-adjust:exact}}
|
||||
</style></head><body><header><div class="eyebrow">HEX TRAILS / VIDEO COLOR STUDY</div><h1>Design v2 · 视频用色手册</h1><p>七个棋子色族 · 三套场景节奏 · 单片薄倒角适配</p><p>10 张证据帧 / 36 个区域采样 / 完整规范 · 独立离线 HTML</p><div class="tools"><a download="designv2.md" href="${asset('designv2.md')}">下载 Markdown</a><a download="samples.json" href="${asset('reference/color-study/samples.json')}">下载采样 JSON</a><button id="print">打印 / 保存 PDF</button></div></header><main><aside><a href="#colors">采样色板</a><a href="#frames">视频证据帧</a><a href="#contrast">箭头对比</a>${toc.join('')}</aside><article><section id="colors"><h2>采样色板</h2><p class="note">点击复制色值。HEX 是解码帧区域中位数,不是原游戏材质常量;本页未改动 Lab 配色。</p><label for="search">搜索角色或 HEX</label><input id="search" type="search" placeholder="例如 tile、map、#4787ED"><div class="swatches">${swatches}</div><p id="empty" hidden>没有匹配的颜色。</p></section><section id="frames"><h2>视频证据帧</h2><p class="note">点击查看原尺寸参考图。图片已内嵌,断网也可查看。</p><div class="gallery">${gallery}</div></section><section id="contrast"><h2>白箭头与深色箭头</h2><p class="note">代表底色 + 不透明前景色的计算值。3:1 是项目必要图形的诊断目标,不代表整页已合规。</p><div class="table-wrap"><table><thead><tr><th>色族</th><th>白色 #FFFFFF</th><th>深色 #273B50</th></tr></thead><tbody>${contrast}</tbody></table></div></section><div class="spec">${rendered.join('\n')}</div></article></main><footer>由 designv2.md 与 samples.json 同步生成 · npm run design:v2</footer><dialog id="viewer" aria-label="视频原尺寸参考帧"><img id="viewer-image" alt="视频参考"><button id="close">关闭</button></dialog><div id="toast" role="status" hidden></div><script>
|
||||
const toast=document.getElementById('toast');let timer;
|
||||
function notify(message){toast.textContent=message;toast.hidden=false;clearTimeout(timer);timer=setTimeout(()=>toast.hidden=true,2300);}
|
||||
document.querySelectorAll('[data-color]').forEach(button=>button.onclick=async()=>{const color=button.dataset.color;try{await navigator.clipboard.writeText(color);notify('已复制 '+color);}catch{notify('请手动复制 '+color);const range=document.createRange();range.selectNodeContents(button.querySelector('code'));const selection=getSelection();selection.removeAllRanges();selection.addRange(range);}});
|
||||
document.getElementById('search').oninput=e=>{const query=e.target.value.trim().toLowerCase();let count=0;document.querySelectorAll('[data-search]').forEach(el=>{el.hidden=!el.dataset.search.toLowerCase().includes(query);if(!el.hidden)count++;});document.getElementById('empty').hidden=count>0;};
|
||||
const viewer=document.getElementById('viewer'),picture=document.getElementById('viewer-image');function openImage(src,alt){picture.src=src;picture.alt=alt;viewer.showModal();}
|
||||
document.querySelectorAll('[data-frame]').forEach(b=>b.onclick=()=>openImage(b.querySelector('img').src,b.querySelector('img').alt));document.querySelectorAll('[data-image]').forEach(a=>a.onclick=e=>{e.preventDefault();openImage(a.href,a.textContent);});document.getElementById('close').onclick=()=>viewer.close();viewer.onclick=e=>{if(e.target===viewer)viewer.close();};document.getElementById('print').onclick=()=>print();
|
||||
</script></body></html>`;
|
||||
writeFileSync(resolve(root,'designv2.html'),html);
|
||||
console.log(`designv2.html: ${samples.samples.length} colors, ${seconds.length} frames, ${toc.length} chapters, standalone/offline, ${Math.round(Buffer.byteLength(html)/1024)} KiB.`);
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { cp, mkdir, rm } from 'node:fs/promises';
|
||||
const out=new URL('../dist/',import.meta.url);
|
||||
await rm(out,{recursive:true,force:true});await mkdir(out,{recursive:true});
|
||||
for(const name of ['index.html','design-showcase.html','style.css','labs','src','levels','assets','design','Design.md','Design.html'])await cp(new URL('../'+name,import.meta.url),new URL(name,out),{recursive:true});
|
||||
for(const name of ['index.html','design-showcase.html','style.css','src','levels','assets','design','Design.md','Design.html'])await cp(new URL('../'+name,import.meta.url),new URL(name,out),{recursive:true});
|
||||
console.log('Static build ready in dist/');
|
||||
|
||||
+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