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])=>`