Files

154 lines
12 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(()=>{});