`;
$('progress-label').textContent=`${completed.size} / ${LEVELS.length}`;$('campaign-progress').style.width=`${completed.size/LEVELS.length*100}%`;
}
function rotationHint(direction){
const turn=direction===1?'右':'左',exits=available(rotateBoard(game.level,direction),game.tiles).length;
return exits?`免费${turn}转 60°,${exits} 枚棋子的出口就打开了`:`先免费${turn}转 60°,再继续观察路线`;
}
function renderStats(){
document.body.dataset.campaign=campaign.id;
const teaching=activeTutorial();
canvas.setAttribute('aria-label',teaching&&teaching.step<3?'教学轨道棋盘,点击棋子沿弯路离场':'六角轨道棋盘,点击棋子沿轨道离场,拖动边缘旋转底盘');
const editing=mode==='edit',level=editing?draft:game.initialLevel,production=!editing&&!trial?productionPresentation(level):null;
const a=production?null:editing?analyze(level):cachedAnalysis(level);
applyTheme(production?production.themeScore:a.score);
$('level-name').textContent=production?.displayName||level.name;$('level-eyebrow').textContent=editing?'YOUR CREATION':trial?'PLAYTEST':teaching?`入门 ${teaching.step} / ${campaign.tutorials.length}`:`LEVEL ${String(current+1).padStart(2,'0')}`;
$('breadcrumb').textContent=editing?'关卡工坊 / 自由创作':trial?'关卡工坊 / 试玩中':teaching?`入门序章 / ${teaching.title}`:`第 ${Math.floor(current/chapterSize)+1} 章 / ${level.chapterName||chapters[Math.floor(current/chapterSize)]}`;
$('moves-title').textContent=editing?'离场额度':trial?'可用额度':(requestedDesign.hud?.moves||'待解棋子');$('remaining-title').textContent=editing?'已放置':'待离场';
// Stacked HUD (macaron): a small label over a big number, the reference game's 关卡 / 分数 columns.
if(requestedDesign.hud&&!editing&&!trial){const h=requestedDesign.hud;$('level-eyebrow').innerHTML=teaching?`${h.tutorial}${teaching.step}/${campaign.tutorials.length}`:`${h.level}${current+1}`;}
const shownMoves=editing?level.moveLimit:trial?game.moves:game.tiles.length;$('moves').textContent=shownMoves;
// Macaron: the counter pill pops when a token leaves (the reference game's score-pill bump).
if(requestedDesign.motion?.pop&&lastMoves!==null&&shownMoves / ${editing?level.cells.length:level.tiles.length}`;
$('mini-caption').innerHTML=editing?'MAKE YOUR OWN WAY ↗':'FOLLOW THE FLOW ↗';
if(production){
$('difficulty-label').textContent=`${production.label} · 预估`;$('score-value').textContent=`D${production.tier}`;$('score-name').textContent=production.label;$('score-bars').innerHTML='';$('score-summary').textContent=`${production.estimate} · ${level.tiles.length} 枚棋子${production.reward?' · '+production.reward:''}`;
}else{
$('difficulty-label').textContent=`${a.label} · ${a.score}`;$('score-value').textContent=a.score;$('score-name').textContent=a.label;$('score-bars').innerHTML=bars(a);$('score-summary').textContent=`观察难度 ${a.score}/100 · ${level.tiles.length} 枚棋子 · 约 ${a.workload.estimatedSeconds} 秒体验(估算,无计时限制)`;
}
$('undo-button').disabled=game.history.length===0||animations.length>0||!!rotationAnimation;
$('orientation-title').textContent=editing?'最少旋转':'底盘朝向';$('orientation').innerHTML=editing?`${a.rotations}次`:`${game.orientation*60}°`;
for(const id of ['rotate-left','rotate-right'])$(id).disabled=animations.length>0||!!rotationAnimation||game.won||game.lost||!canRotate(game.level)||game.level.rotationEnabled===false;
$('hint-button').disabled=animations.length>0||!!rotationAnimation||game.won;
$('instruction').innerHTML=`${editing?({tile:'点击空格放置棋子;点击已有棋子更新方向和配色',rotate:'点击棋子,将箭头顺时针旋转 60°',track:'点击格子,循环切换 15 种轨道连接', 'rotate-track':'点击底板,整体旋转轨道 60°',erase:'点击棋子,将它从关卡中移除',inspect:'点击棋子,查看完整移动路线'}[tool]):(game.won?'所有棋子都找到出口了!':game.lost?'撤销操作,或返回工坊调整关卡':available(game.level,game.tiles).length?(!trial&&level.lesson?escapeHtml(level.lesson):'点击箭头离场 · 左右旋转免费,慢慢观察'):'暂时没有出口 · 可以继续免费旋转,观察路线')}`;
if(!editing&&!animations.length&&!rotationAnimation&&!game.won&&game.moves>0&&!available(game.level,game.tiles).length){
const solution=stateSolution();
const action=solution.actions[0];
if(solution.solvable&&action?.type==='rotate'){
const right=action.direction===1;
$(right?'rotate-right':'rotate-left').classList.add('hinted');
$('instruction').textContent=rotationHint(action.direction);
}else if(solution.reachable){$('instruction').textContent='离场额度少于剩余棋子 · 返回工坊增加额度';}
}
if(editing){$('editor-valid').textContent=level.tiles.length===0?'○ 等待放置棋子':a.exhausted?'… 尚未确定可解性':a.solvable?'✓ 已验证可解':'! 关卡暂不可解';$('editor-valid').classList.toggle('error',!a.solvable&&!a.exhausted&&level.tiles.length>0);$('editor-score').innerHTML=`${a.score}/100`;$('editor-analysis-text').textContent=`观察难度 ${a.score}/100 · ${a.free} 枚可直接离场 · 最少 ${a.rotations} 次旋转。工作量 ${a.minimumActions??'—'} 次操作,参考 ${a.workload.estimatedSeconds} 秒(估算)。仅成功离场使用 1 额度,旋转与试探免费${a.exhausted?' · 搜索达到上限,尚未确定':!a.reachable?' · 存在无法避开的碰撞或闭环':!a.solvable?' · 离场额度不足':''}。`;$('editor-bars').innerHTML=bars(a);$('trial-button').disabled=!level.tiles.length;$('editor-undo').disabled=!editHistory.length;}
}
function resize(){const rect=canvas.getBoundingClientRect();width=rect.width;height=rect.height;const dpr=Math.min(devicePixelRatio||1,3);canvas.width=Math.round(width*dpr);canvas.height=Math.round(height*dpr);ctx.setTransform(dpr,0,0,dpr,0,0);const base=layout((mode==='edit'?draft:game.level).cells,width,height);baseView=base;view=boardViewport?boardViewport.camera.fit(base,width,height):base;boardViewport?.update();placeRotation();draw();}
// Designs with layout.rotation 'under-board' (signpost, macaron) hang the rotate controls under the board's lower slanted
// edges: from each edge midpoint drop a vertical to the tool discs' top edge and centre the button on that line's midpoint.
// layout.rim is the design's own margin around the cells. Other designs keep their layout row.
function placeRotation(){
const card=document.querySelector?.('.game-card');if(!card)return;
if(requestedDesign.layout?.rotation!=='under-board'||mode==='edit'||!baseView){for(const k of ['--rot-x','--rot-x2','--rot-y'])card.style.removeProperty(k);return;}
const s=baseView.size,cs=game.level.cells.map(c=>center(c,baseView)),rim=Math.max(1.5,s*(requestedDesign.layout.rim??.06));
const oL=Math.min(...cs.map(c=>c.x))-s-rim,oR=Math.max(...cs.map(c=>c.x))+s+rim,oT=Math.min(...cs.map(c=>c.y))-s*Math.sqrt(3)/2-rim,oB=Math.max(...cs.map(c=>c.y))+s*Math.sqrt(3)/2+rim;
const cr=canvas.getBoundingClientRect(),kr=card.getBoundingClientRect(),tool=card.querySelector('.board-tools .item-face')?.getBoundingClientRect();
const midY=cr.top-kr.top+oT+(oB-oT)*.875,toolTop=tool?tool.top-kr.top:kr.height-100;
card.style.setProperty('--rot-x',`${(cr.left-kr.left+oL+(oR-oL)*.25).toFixed(1)}px`);card.style.setProperty('--rot-x2',`${(cr.left-kr.left+oL+(oR-oL)*.75).toFixed(1)}px`);card.style.setProperty('--rot-y',`${((midY+toolTop)/2).toFixed(1)}px`);
}
function draw(now=performance.now()){
if(!view)return;
ctx.clearRect(0,0,width,height);
const state={selected:mode==='edit'?selected:null,path,hint,reduced,theme:activeTheme,queued:taps.ids};
const activeCount=animations.length;
state.animations=animations.map(a=>{
const progress=Math.min(1,(now-a.start)/a.duration);
if(a.route.status==='blocked'&&!a.sounded&&progress>=.42){a.sounded=true;playSound('hit');}
return {route:a.route,progress};
});
animations=animations.filter(a=>now-a.start=1){rotationAnimation=null;renderStats();}}
if(shake){const t=(now-shake.start)/550;if(t>=1)shake=null;else{state.shake=shake.id;state.shakeTime=t;}}
if(intro){const t=Math.min(1,(now-intro.start)/intro.duration);state.intro=t;if(t>=1)intro=null;}
if(celebrate){const t=Math.min(1,(now-celebrate.start)/celebrate.duration);state.celebrate=t;if(t>=1)celebrate=null;}
drawScene(painter,mode==='edit'?draft:game.level,mode==='edit'?draft.tiles:game.tiles,view,state);
if(!rotationAnimation&&mode==='play'&&!$('info-dialog').open){
const next=taps.take(game.tiles);
if(next){playTile(next);return;}
finish();
}
if(animations.length||rotationAnimation||shake||intro||celebrate){cancelAnimationFrame(frame);frame=requestAnimationFrame(draw);}
}
function toast(message,duration=3200){$('board-toast').textContent=message;$('board-toast').classList.add('visible');clearTimeout(toastTimer);toastTimer=setTimeout(()=>$('board-toast').classList.remove('visible'),duration);}
function showLesson(){if(mode==='play'&&!trial&&game.initialLevel.lesson)toast(game.initialLevel.lesson,6500);}
// Macaron plays a board entrance on every fresh board; other designs have motion.intro unset.
function beginIntro(){lastMoves=null;intro=requestedDesign.motion?.intro&&!reduced?{start:performance.now(),duration:requestedDesign.motion.intro}:null;}
function resetEffects(){taps.clear();intro=null;celebrate=null;celebrated=false;if($('result').open)$('result').close();clearTimeout(toastTimer);$('board-toast').classList.remove('visible');$('board-toast').textContent='';animations=[];rotationAnimation=null;for(const id of ['rotate-left','rotate-right'])$(id).classList.remove('hinted');shake=null;path=null;hint=null;cancelAnimationFrame(frame);$('result').hidden=true;}
function startLevel(i){if(!Number.isInteger(i)||i<0||i>=LEVELS.length)return;boardViewport?.camera.reset();tutorialIndex=null;current=i;mapChapter=Math.floor(i/chapterSize);trial=false;mode='play';game=new Game(LEVELS[i]);helpUsed=false;resetEffects();beginIntro();applyMode();persist();renderMap();renderStats();resize();showLesson();}
function startTutorial(i){
if(!Number.isInteger(i)||!campaign.tutorials?.[i])return;
boardViewport?.camera.reset();tutorialIndex=i;tutorialProgress={...tutorialProgress,current:i};trial=false;mode='play';game=new Game(campaign.tutorials[i]);helpUsed=false;resetEffects();beginIntro();applyMode();persistTutorial();renderMap();renderStats();resize();showLesson();
}
function applyMode(){const edit=mode==='edit';document.body.classList.toggle('editing',edit);$('play-tab').classList.toggle('active',!edit);$('edit-tab').classList.toggle('active',edit);$('play-inspector').hidden=edit;$('editor-panel').hidden=!edit;$('game-tools').hidden=edit;$('editor-board-tools').hidden=!edit;$('return-editor').hidden=!trial;$('map-button').hidden=edit;$('rotation-controls').hidden=edit;}
function enterEditor(){
if(!draft){draft=clone(activeTutorial()?LEVELS[current]:game.level);draft.id='custom';draft.name='我的第一条轨迹';editHistory=[];}
mode='edit';trial=false;resetEffects();applyMode();syncEditor();renderStats();resize();
}
function syncEditor(){$('editor-name').value=draft.name;$('editor-moves').value=draft.moveLimit;$('editor-radius').value=String(Math.max(...draft.cells.map(c=>Math.max(Math.abs(c.q),Math.abs(c.r),Math.abs(c.q+c.r)))));}
function checkpoint(){editHistory.push(clone(draft));if(editHistory.length>100)editHistory.shift();}
function changed(){resetEffects();renderStats();resize();}
function openResult(){ $('result').hidden=false;$('result').showModal();playSound(game.won?'win':'lose');}
$('result').addEventListener('cancel',e=>e.preventDefault());
function finish(){
if($('result').open)return;
if(mode==='edit'||animations.length||rotationAnimation||celebrate||$('info-dialog').open)return;
// Macaron celebrates on the board first (motion.celebrate), then opens the result once the burst has settled.
if(game.won&&requestedDesign.motion?.celebrate&&!celebrated&&!reduced){celebrated=true;celebrate={start:performance.now(),duration:requestedDesign.motion.celebrate};draw();return;}
if(game.won){
const teaching=activeTutorial();
if(teaching){
tutorialProgress={current:Math.min(tutorialIndex+1,campaign.tutorials.length-1),completed:[...new Set([...tutorialProgress.completed,tutorialIndex])]};persistTutorial();
const last=tutorialIndex===campaign.tutorials.length-1;
$('result').innerHTML=`