154 lines
12 KiB
JavaScript
154 lines
12 KiB
JavaScript
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)=>`<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(()=>{});
|