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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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)=>``).join(''); const kinds=new Map(PRODUCTION_LEVELS.map(level=>[level.experience.kind,level.beat])); $('kind').innerHTML+=[...kinds].map(([id,name])=>``).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])=>``).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 ``;}).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])=>`
${escape(label)}${escape(value)}
`).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)=>`
  • ${action.type==='rotate'?`底盘${action.direction===1?'右':'左'}转 60°`:`放出棋子 ${escape(action.id)}`}
  • `).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{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(()=>{});