feat: add production campaigns, gameplay labs, and review documentation

This commit is contained in:
cabinw
2026-09-13 12:27:10 +08:00
parent d1dffa9338
commit 4644e975b0
214 changed files with 98994 additions and 134 deletions
+153
View File
@@ -0,0 +1,153 @@
import { PRODUCTION_CAMPAIGN, PRODUCTION_LEVELS } from '../../src/levels-v05.js';
import { campaignPage, levelIndexFromInput, productionPresentation, featuredTrackEmphasis } from '../../src/campaign-browser.js';
import { Game } from '../../src/core.js';
import { layout } from '../../src/geometry.js';
import { CanvasPainter, drawScene } from '../../src/renderer.js';
import { exitTiming } from '../../src/exit-animation.js';
const $=id=>document.getElementById(id),escape=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const campaign={...PRODUCTION_CAMPAIGN,levels:PRODUCTION_LEVELS},records=new Map(),manualRecords=new Map();
let chapter=0,observer,detailIndex=0,replay,step=0,motion=null,playing=false,frame=0;
const board=$('review-board'),painter=new CanvasPainter(board.getContext('2d'));
const reduced=matchMedia('(prefers-reduced-motion: reduce)').matches;
const initial=levelIndexFromInput(new URLSearchParams(location.search).get('level')||'',PRODUCTION_LEVELS.length);
if(initial!==null)chapter=Math.floor(initial/campaign.chapterSize);
$('chapter').innerHTML=campaign.chapters.map((name,i)=>`<option value="${i}">第 ${i+1} 章 · ${escape(name)}</option>`).join('');
const kinds=new Map(PRODUCTION_LEVELS.map(level=>[level.experience.kind,level.beat]));
$('kind').innerHTML+=[...kinds].map(([id,name])=>`<option value="${escape(id)}">${escape(name)}</option>`).join('');
const tiers=new Map(PRODUCTION_LEVELS.map(level=>{const p=productionPresentation(level);return [p.tier,p.label];}));
$('difficulty').innerHTML+=[...tiers].sort((a,b)=>a[0]-b[0]).map(([tier,label])=>`<option value="${tier}">${escape(label)}</option>`).join('');
function thumbnail(canvas){
const level=PRODUCTION_LEVELS[Number(canvas.dataset.index)];
const width=300;canvas.width=width;canvas.height=width;
drawScene(new CanvasPainter(canvas.getContext('2d')),level,level.tiles,layout(level.cells,width,width),{reduced:true,trackEmphasis:featuredTrackEmphasis(level)});
}
// Export every level in a chapter, independently of the on-screen filters and
// thumbnail visibility. The same entry point is available to review automation.
export function createChapterContactSheet(chapterIndex, {allTracks=false}={}){
const page=campaignPage(campaign,chapterIndex),cols=5,padding=30,cell=280,gap=16,header=136,caption=75;
const sheet=document.createElement('canvas');
sheet.width=padding*2+cols*cell+(cols-1)*gap;
sheet.height=header+Math.ceil(page.levels.length/cols)*(cell+caption+gap)+padding;
const context=sheet.getContext('2d');context.fillStyle='#f3f2eb';context.fillRect(0,0,sheet.width,sheet.height);
context.fillStyle='#253d45';context.font='bold 33px system-ui, sans-serif';context.fillText(`千段轨迹 · 第 ${page.chapter+1} 章 · ${page.title}`,padding,53);
context.font='19px system-ui, sans-serif';context.fillStyle='#65776c';context.fillText(`v0.5 / ${page.start+1}–${page.end} 关 / 共 ${page.levels.length} 关 / 难度为结构预估 / ${allTracks?'显示全部轨道':'突出设计路线,保留次要轨道'}`,padding,91);
const fitText=(text,x,y,maxWidth,size)=>{
context.font=`${size}px system-ui, sans-serif`;
let value=String(text);while(value.length>1&&context.measureText(value).width>maxWidth)value=value.slice(0,-2)+'…';
context.fillText(value,x,y);
};
for(const [offset,{level,index}] of page.levels.entries()){
const x=padding+(offset%cols)*(cell+gap),y=header+Math.floor(offset/cols)*(cell+caption+gap),info=productionPresentation(level);
context.fillStyle='#dce3d8';context.fillRect(x,y,cell,cell);
context.save();context.translate(x,y);
drawScene(new CanvasPainter(context),level,level.tiles,layout(level.cells,cell,cell),{reduced:true,trackEmphasis:allTracks?null:featuredTrackEmphasis(level)});
context.restore();context.fillStyle='#253d45';fitText(`${String(index+1).padStart(3,'0')} ${info.displayName}`,x,y+cell+25,cell,18);
context.fillStyle='#667d6d';fitText(`${info.experience} · ${info.label} · ${level.tiles.length} 枚`,x,y+cell+48,cell,14);
fitText(info.reward,x,y+cell+68,cell,13);
}
return sheet;
}
function downloadBlob(blob,filename){
const url=URL.createObjectURL(blob),link=document.createElement('a');link.href=url;link.download=filename;link.click();setTimeout(()=>URL.revokeObjectURL(url),1000);
}
function render(){
observer?.disconnect();
const page=campaignPage(campaign,chapter);chapter=page.chapter;$('chapter').value=String(chapter);
const levels=page.levels.filter(({level})=>(!$('kind').value||level.experience.kind===$('kind').value)&&(!$('difficulty').value||String(level.production.difficulty.tier)===$('difficulty').value));
$('chapter-title').textContent=page.title;$('chapter-count').textContent=`CHAPTER ${String(chapter+1).padStart(2,'0')} / 20 · ${page.start+1}–${page.end} 关 · 显示 ${levels.length} 关`;
$('previous').disabled=chapter===0;$('next').disabled=chapter===page.count-1;$('empty').hidden=levels.length>0;
$('grid').innerHTML=levels.map(({level,index})=>{const info=productionPresentation(level);return `<button class="card" data-level="${index}" aria-label="查看第 ${index+1} 关 ${escape(info.displayName)} 的制作记录"><canvas data-index="${index}" width="300" height="300" aria-hidden="true"></canvas><div class="card-meta"><strong>${String(index+1).padStart(3,'0')}</strong><span class="badge" data-tier="${info.tier}">${escape(info.label)} · 预估</span></div><h3>${escape(info.displayName)}</h3><p>${escape(info.experience)} · ${level.tiles.length} 枚棋子</p><p>${escape(info.reward||info.intent)}</p></button>`;}).join('');
observer=new IntersectionObserver(entries=>{for(const entry of entries)if(entry.isIntersecting){thumbnail(entry.target);observer.unobserve(entry.target);}},{rootMargin:'300px'});
document.querySelectorAll('#grid canvas').forEach(canvas=>observer.observe(canvas));
}
function evidence(){
const level=PRODUCTION_LEVELS[detailIndex],record=records.get(level.id);
const automatic=record||{id:level.id,production:level.production,experience:level.experience,recordStatus:'完整验证记录尚未载入'};
$('detail-evidence').textContent=JSON.stringify({...automatic,sourceName:level.name,manualReview:manualRecords.get(level.id)||{visual:'尚未记录',browserPlay:'尚未记录'}},null,2);
}
function detailMetrics(){
const level=PRODUCTION_LEVELS[detailIndex],info=productionPresentation(level),reward=level.production.reward||{};
const values=[['难度 · 结构预估',info.label],['棋子',`${level.tiles.length} 枚`],['最长巡游',Number.isFinite(reward.longestSteps)?`${reward.longestSteps} 格`:'见回放记录'],['最多转弯',Number.isFinite(reward.longestBends)?`${reward.longestBends} 次`:'见回放记录']];
$('detail-metrics').innerHTML=values.map(([label,value])=>`<div><span>${escape(label)}</span><strong>${escape(value)}</strong></div>`).join('');
}
function openDetail(index){
detailIndex=index;const level=PRODUCTION_LEVELS[index],info=productionPresentation(level);
$('detail-eyebrow').textContent=`第 ${index+1} 关 · ${level.chapterName||campaign.chapters[Math.floor(index/campaign.chapterSize)]} · ${info.experience}`;
$('detail-title').textContent=info.displayName;$('detail-intent').textContent=info.intent;$('play-level').href=`../../index.html?campaign=v0.5&level=${index+1}`;
detailMetrics();evidence();resetReplay();if(!$('detail').open)$('detail').showModal();
}
function actions(){return PRODUCTION_LEVELS[detailIndex].experience.actions||[];}
function stop(){playing=false;motion=null;cancelAnimationFrame(frame);$('replay-play').textContent='播放全程';}
function resetReplay(){
stop();replay=new Game(PRODUCTION_LEVELS[detailIndex]);step=0;
$('replay-actions').innerHTML=actions().map((action,i)=>`<li data-action="${i}">${action.type==='rotate'?`底盘${action.direction===1?'右':'左'}转 60°`:`放出棋子 ${escape(action.id)}`}</li>`).join('');
$('replay-status').textContent=`已准备 ${actions().length} 步回放。点击下一步可以逐个查看放行关系。`;
updateReplayControls();drawReplay();
}
function updateReplayControls(){
$('replay-step').disabled=!!motion||step>=actions().length;
$('replay-play').disabled=step>=actions().length&&!playing;
$('replay-play').textContent=playing?'暂停回放':'播放全程';
document.querySelectorAll('#replay-actions li').forEach((el,i)=>el.classList.toggle('active',i===step-1));
}
function advance(){
if(motion||step>=actions().length)return;
const action=actions()[step],now=performance.now();
if(action.type==='rotate'){
const result=replay.rotate(action.direction);
if(result.status!=='rotated'){stop();$('replay-status').textContent=`第 ${step+1} 步无法旋转,请检查制作记录。`;return;}
motion={type:'rotate',direction:action.direction,start:now,duration:reduced?100:300};
$('replay-status').textContent=`第 ${step+1} 步:${action.direction===1?'右':'左'}转底盘,棋子与箭头保持原位。`;
}else{
const route=replay.tap(action.id);
if(route.status!=='exit'){stop();$('replay-status').textContent=`第 ${step+1} 步未能离场(${route.status}),请检查制作记录。`;return;}
motion={type:'tap',route,start:now,duration:reduced?200:exitTiming(route).duration};
$('replay-status').textContent=`第 ${step+1} 步:棋子 ${action.id} 沿 ${route.steps.length} 格轨道离场。`;
}
step++;updateReplayControls();drawReplay();
}
function drawReplay(now=performance.now()){
if(!replay)return;
painter.ctx.clearRect(0,0,board.width,board.height);
const level=PRODUCTION_LEVELS[detailIndex],state={reduced,trackEmphasis:$('all-tracks').checked?null:featuredTrackEmphasis(level,replay.orientation)};
if(motion){
const progress=Math.min(1,(now-motion.start)/motion.duration);
if(motion.type==='tap')state.animations=[{route:motion.route,progress}];
else state.boardAngle=-motion.direction*Math.PI/3*(1-Math.pow(progress, .5));
if(progress>=1)motion=null;
}
drawScene(painter,replay.level,replay.tiles,layout(replay.level.cells,board.width,board.height),state);
if(motion){frame=requestAnimationFrame(drawReplay);return;}
if(replay.won){playing=false;$('replay-status').textContent=`${step} 步回放完成,${level.tiles.length} 枚棋子全部离场。`;}
updateReplayControls();
if(playing&&step<actions().length)advance();
}
$('chapter').onchange=()=>{chapter=Number($('chapter').value);render();};
for(const id of ['kind','difficulty'])$(id).onchange=render;
$('previous').onclick=()=>{chapter--;render();};$('next').onclick=()=>{chapter++;render();};
$('export-sheet').onclick=()=>{
const number=chapter;$('export-sheet').disabled=true;
createChapterContactSheet(number).toBlob(blob=>{if(blob)downloadBlob(blob,`hex-trails-v0.5-chapter-${String(number+1).padStart(2,'0')}.png`);$('export-sheet').disabled=false;},'image/png');
};
$('grid').onclick=e=>{const card=e.target.closest('[data-level]');if(card)openDetail(Number(card.dataset.level));};
$('jump').onsubmit=e=>{e.preventDefault();const index=levelIndexFromInput($('level-number').value,PRODUCTION_LEVELS.length);if(index===null)return;chapter=Math.floor(index/campaign.chapterSize);$('kind').value='';$('difficulty').value='';render();openDetail(index);};
$('close-detail').onclick=()=>$('detail').close();$('detail').addEventListener('close',stop);
$('replay-reset').onclick=resetReplay;$('replay-step').onclick=advance;
$('replay-play').onclick=()=>{playing=!playing;updateReplayControls();if(playing&&!motion)advance();};
$('all-tracks').onchange=()=>{cancelAnimationFrame(frame);drawReplay();};
$('download-level').onclick=()=>{const level=PRODUCTION_LEVELS[detailIndex];downloadBlob(new Blob([JSON.stringify(level,null,2)],{type:'application/json'}),`${level.id}.json`);};
render();if(initial!==null)openDetail(initial);
fetch('../../levels/v0.5-analysis.json').then(response=>{if(!response.ok)throw new Error('验证记录暂不可用');return response.json();}).then(data=>{
for(const record of data.levels||[])records.set(record.id,record);
$('summary').textContent=`${PRODUCTION_LEVELS.length} 关 · ${campaign.chapters.length} 章 · 已载入 ${records.size} 份验证记录。每章最多显示 ${campaign.chapterSize} 关;点击缩略图查看制作意图与保存的清盘回放。`;
if($('detail').open)evidence();
}).catch(error=>{$('summary').textContent=`${PRODUCTION_LEVELS.length} 关已载入。${error.message},关卡内保存的回放仍可查看。`;});
fetch('../../levels/v0.5-reviews.json').then(response=>response.ok?response.json():null).then(data=>{
for(const record of data?.levels||[])manualRecords.set(record.id,record);
if($('detail').open)evidence();
}).catch(()=>{});
+17
View File
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="zh-CN">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>千段轨迹 · 关卡制作记录</title><link rel="stylesheet" href="style.css"></head>
<body>
<header class="page-header"><div><p class="eyebrow">HEX TRAILS / v0.5</p><h1>千段轨迹</h1><p>按章看路线,逐关回放,找到值得继续打磨的地方。</p></div><a class="button primary" href="../../index.html?campaign=v0.5">进入游戏 ↗</a></header>
<main>
<div id="summary" class="summary" role="status">正在读取制作记录…</div>
<section class="browser" aria-label="浏览关卡">
<div class="controls"><label>章节<select id="chapter"></select></label><label>体验<select id="kind"><option value="">全部体验</option></select></label><label>难度预估<select id="difficulty"><option value="">全部难度</option></select></label><form id="jump"><label>关卡编号<input id="level-number" inputmode="numeric" type="number" min="1" max="1000" placeholder="1–1000" required></label><button type="submit">前往</button></form></div>
<div class="chapter-heading"><div><p id="chapter-count" class="eyebrow"></p><h2 id="chapter-title"></h2></div><nav aria-label="章节翻页"><button id="export-sheet">导出本章总览 PNG</button><button id="previous">← 上一章</button><button id="next">下一章 →</button></nav></div>
<div id="grid" class="grid"></div><p id="empty" hidden>本章没有符合筛选条件的关卡。可以更换体验或难度。</p>
</section>
<p class="footnote">结构分档和自动回放提供制作证据;玩家是否觉得清楚、轻巧和新鲜,仍需连续试玩校准。<a href="../../levels/v0.5-analysis.json">查看完整回放记录 JSON</a></p>
</main>
<dialog id="detail" aria-labelledby="detail-title"><button id="close-detail" class="close" aria-label="关闭制作记录">×</button><div class="detail-heading"><p id="detail-eyebrow" class="eyebrow"></p><h2 id="detail-title"></h2><p id="detail-intent"></p></div><div class="detail-columns"><div><canvas id="review-board" width="650" height="650" aria-label="关卡回放棋盘"></canvas><div class="replay-controls"><button id="replay-reset">重置回放</button><button id="replay-step">下一步</button><button id="replay-play" class="primary">播放全程</button></div><p id="replay-status" role="status"></p><label class="track-toggle"><input id="all-tracks" type="checkbox"> 同等显示全部轨道</label></div><div><div id="detail-metrics" class="detail-metrics"></div><p class="hint">这是制作时保存的一条清盘路线。实际操作可以选择其他顺序。</p><ol id="replay-actions" class="replay-actions"></ol><div class="detail-links"><a id="play-level" class="button primary">亲自试玩 ↗</a><button id="download-level">下载本关 JSON</button></div><details><summary>查看本关验证记录</summary><pre id="detail-evidence"></pre></details></div></div></dialog>
<script type="module" src="app.js"></script>
</body></html>
File diff suppressed because one or more lines are too long