Files
HexTrails/src/app.js
T
cabinw 10cf93a324 feat: add Paper Routes and package five design systems
Add the paper direction with thin tracks, outlined hexagons and line arrows, without exterior color markers or piece numbers. Add a unified catalog for five designs and their 25 themes, with direct review links.

Organize each design into an authored design.md, generated tokens.json and preview. Add Agent routing, shared integration guidance, a machine-readable manifest and an executable rendering example. Preserve legacy preview and document links.

Validation: 16 focused design, theme and package tests passed; npm run design and static build passed. Confirmed all eight authored documents survive generation unchanged. Browser checks covered mobile, tablet and desktop layouts, theme links and migrated previews. Native engine integration and visual verification remain pending.
2026-09-13 13:24:34 +08:00

314 lines
38 KiB
JavaScript
Raw 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 { artDirection, directionTheme } from './art-directions.js';
import { attachBoardViewport } from './board-viewport.js';
import { TapQueue } from './tap-queue.js';
import { exitTiming } from './exit-animation.js';
import { PIGMENTS, LEVEL_THEMES, themeForScore, MOTION, themeWithColors } from './design-tokens.js';
import { unlockAudio, playSound, toggleSound, soundEnabled } from './audio.js';
import { icon, installUI, decorateUI } from './ui.js';
import { Game, analyze, available, clone, generateLevel, hexagon, key, random, randomTracks, rotateTracks, rotateBoard, canRotate, solveRotations, trace, validateLevel } from './core.js';
import { CAMPAIGN_VERSIONS, campaignVersion } from './campaign-versions.js';
import { CAMPAIGN_SELECTION_KEY, selectedCampaign, readCampaignProgress, writeCampaignProgress } from './campaign-progress.js';
import { campaignLaunch, campaignPage, levelIndexFromInput, productionPresentation, createAnalysisCache } from './campaign-browser.js';
import { readTutorialProgress, writeTutorialProgress, tutorialLaunch } from './tutorial-progress.js';
import { layout, hitCell } from './geometry.js';
import { TILE_ATLAS } from './sprites.js';
import { CanvasPainter, COLORS, drawScene } from './renderer.js';
const taps=new TapQueue();
const $=id=>document.getElementById(id);
const canvas=$('board'),ctx=canvas.getContext('2d'),painter=new CanvasPainter(ctx);
let boardViewport;
let progressStorage;try{progressStorage=window.localStorage;}catch{}
let saveUnavailable=!progressStorage;
const launch=campaignLaunch(location.search,CAMPAIGN_VERSIONS,selectedCampaign(progressStorage));
let campaign=launch.campaign,LEVELS=campaign.levels;
const sessionProgress=new Map();
const saved=readCampaignProgress(progressStorage,campaign);
let completed=new Set(saved.completed),current=launch.levelIndex??saved.current;
const sessionTutorialProgress=new Map();
let tutorialProgress=readTutorialProgress(progressStorage,campaign.id,campaign.tutorials?.length||0);
let tutorialIndex=tutorialLaunch(location.search,campaign.tutorials?.length||0,tutorialProgress,saved);
const cachedAnalysis=createAnalysisCache(analyze);
let mapChapter=Math.floor(current/campaign.chapterSize),lastSolution=null;
let game=new Game(tutorialIndex===null?LEVELS[current]:campaign.tutorials[tutorialIndex]),mode='play',trial=false,draft=null,editHistory=[],tool='tile',direction=1,color=0,selected=null,path=null,hint=null,animations=[],rotationAnimation=null,shake=null,toastTimer,view,width=0,height=0,frame=0;
let helpUsed=false;
let activeTheme=null;
const requestedDesign=artDirection(new URLSearchParams(location.search).get('design'));
document.body.dataset.design=requestedDesign.id;
const requestedTheme=LEVEL_THEMES.find(t=>t.id===new URLSearchParams(location.search).get('theme'));
function applyTheme(score){
activeTheme=mode==='edit'||trial?null:directionTheme(requestedDesign,(requestedTheme||themeForScore(score)).id,Number(new URLSearchParams(location.search).get('colors'))||7);
const theme=activeTheme||LEVEL_THEMES[0];
document.body.dataset.theme=theme.id;
document.body.style.setProperty('--color-background',theme.background);
document.body.style.setProperty('--scene-ink',theme.ink);
$('theme-caption').textContent=activeTheme?`${requestedTheme?'配色预览':theme.label} · ${theme.name}`:'自由配色 · 12 色棋子库';
}
document.addEventListener('pointerdown',unlockAudio,{passive:true});
document.addEventListener('click',e=>{if(e.target.closest('button'))playSound('tap');});
const reduced=matchMedia('(prefers-reduced-motion: reduce)').matches;
const metricLabels={bends:['平均弯道',22],longestRoute:['最多弯道',12],dependencies:['解锁依赖',18],blocked:['初始阻挡',28],sweptBlockers:['侧面阻挡',12],rotation:['必要旋转',8]};
let chapters=campaign.chapters,chapterSize=campaign.chapterSize;
const escapeHtml=s=>String(s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
function persist(){const progress={current,completed:[...completed]};sessionProgress.set(campaign.id,progress);saveUnavailable=!writeCampaignProgress(progressStorage,campaign,progress);if(saveUnavailable)toast('浏览器未允许保存,进度仅保留在当前页面');}
function persistTutorial(){sessionTutorialProgress.set(campaign.id,tutorialProgress);if(!writeTutorialProgress(progressStorage,campaign.id,campaign.tutorials.length,tutorialProgress))saveUnavailable=true;}
function activeTutorial(){return tutorialIndex!==null&&mode==='play'&&!trial?game.initialLevel.tutorial:null;}
function switchCampaign(id){
const next=campaignVersion(id);if(next.id===campaign.id)return;
persist();
campaign=next;LEVELS=campaign.levels;chapters=campaign.chapters;chapterSize=campaign.chapterSize;
const progress=sessionProgress.get(campaign.id)||readCampaignProgress(progressStorage,campaign);
tutorialProgress=sessionTutorialProgress.get(campaign.id)||readTutorialProgress(progressStorage,campaign.id,campaign.tutorials?.length||0);
completed=new Set(progress.completed);current=progress.current;
try{progressStorage?.setItem(CAMPAIGN_SELECTION_KEY,campaign.id);}catch{saveUnavailable=true;}
const nextTutorial=tutorialLaunch('',campaign.tutorials?.length||0,tutorialProgress,progress);
if(nextTutorial===null)startLevel(current);else startTutorial(nextTutorial);
}
function stateSolution(){
if(!lastSolution||lastSolution.level!==game.level||lastSolution.tiles!==game.tiles||lastSolution.moves!==game.moves)lastSolution={level:game.level,tiles:game.tiles,moves:game.moves,value:solveRotations({...game.level,tiles:game.tiles,moveLimit:game.moves})};
return lastSolution.value;
}
function bars(a){return Object.entries(metricLabels).filter(([k])=>Number.isFinite(a.parts[k])).map(([k,[label,max]])=>`<div class="metric-row"><span>${label}</span><span class="metric-track"><i style="width:${a.parts[k]/max*100}%"></i></span><span>${a.parts[k].toFixed(0)}/${max}</span></div>`).join('');}
function renderMap(){
$('map-button').ariaLabel=`选择关卡,当前 ${campaign.id}`;
const page=campaignPage(campaign,Math.floor(current/chapterSize));
$('level-list').innerHTML=`<div class="chapter-title">${String(page.chapter+1).padStart(2,'0')} / ${escapeHtml(page.title)}</div><div class="level-grid">${page.levels.map(({level:l,index:i})=>`<button class="level-btn ${tutorialIndex===null&&i===current?'current':''} ${completed.has(i)?'done':''}" data-level="${i}" aria-label="第 ${i+1} 关 ${escapeHtml(productionPresentation(l)?.displayName||l.name)}${completed.has(i)?',已通关':''}" ${tutorialIndex===null&&i===current?'aria-current="true"':''}>${String(i+1).padStart(2,'0')}</button>`).join('')}</div>`;
$('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?'可用额度':'待解棋子';$('remaining-title').textContent=editing?'已放置':'待离场';
$('moves').textContent=editing?level.moveLimit:trial?game.moves:game.tiles.length;$('remaining').innerHTML=`${editing?level.tiles.length:game.tiles.length}<small> / ${editing?level.cells.length:level.tiles.length}</small>`;
$('mini-caption').innerHTML=editing?'MAKE YOUR<br>OWN WAY ↗':'FOLLOW<br>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}<small>次</small>`:`${game.orientation*60}<small>°</small>`;
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=`<span class="pulse-dot"></span>${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}<small>/100</small>`;$('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);view=boardViewport?boardViewport.camera.fit(base,width,height):base;boardViewport?.update();draw();}
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<a.duration);
if(animations.length!==activeCount)renderStats();
if(rotationAnimation){const t=Math.min(1,(now-rotationAnimation.start)/rotationAnimation.duration);state.boardAngle=-rotationAnimation.direction*Math.PI/3*(1-(1-Math.pow(1-t,3)));if(t>=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;}}
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){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);}
function resetEffects(){taps.clear();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();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();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||$('info-dialog').open)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=`<div class="result-emblem">${icon('star')}${icon('star')}${icon('star')}</div><div class="eyebrow">入门 ${teaching.step} / ${campaign.tutorials.length}</div><h3>${escapeHtml(teaching.title)}</h3><p>${escapeHtml(teaching.completion)}</p><button class="primary" id="next-level">${last?'开始第 1 关':'继续下一课'} <svg><use href="#i-arrow"/></svg></button><button class="text-button" id="replay-level">再试一次</button>`;
openResult();$('next-level').onclick=()=>last?startLevel(0):startTutorial(tutorialIndex+1);$('replay-level').onclick=restart;return;
}
if(!trial){completed.add(current);persist();renderMap();}
$('result').innerHTML=`<div class="result-emblem">${icon('star')}${icon('star')}${icon('star')}</div><div class="eyebrow">A LITTLE MOMENT OF JOY</div><h3>${trial?'这条轨迹,通了!':'每一条路,都被你解开了!'}</h3><p>${game.level.tiles.length} 枚棋子全部找到出口<br>一条接一条,你清空了整片棋盘。${helpUsed?'<br>在提示的陪伴下完成':''}</p><button class="primary" id="next-level">${trial?'返回关卡工坊':current===LEVELS.length-1?'完成旅程 · 回到第一关':'下一段轨迹'} <svg><use href="#i-arrow"/></svg></button><button class="text-button" id="replay-level">再走一遍</button>`;
openResult();$('next-level').onclick=()=>trial?enterEditor():startLevel((current+1)%LEVELS.length);$('replay-level').onclick=restart;
}else if(game.lost){
$('result').innerHTML=`<div class="result-emblem">${icon('track')}</div><h3>${game.moves<=0?'这份关卡的离场额度不足':'轨迹暂时打了个结'}</h3><p>${game.moves<=0?'仍有棋子等待离场,可以返回工坊增加额度。旋转和试探都没有扣除额度。':'剩余棋子的路线被阻挡,或轨道形成了闭环。'}</p><button class="primary" id="recover">${trial?'返回编辑关卡':game.history.length?'撤销上一步':'重新开始'}</button><button class="text-button" id="retry">重新开始</button>`;openResult();$('recover').onclick=()=>trial?enterEditor():game.history.length?undo():restart();$('retry').onclick=restart;
}
}
function restart(){game=new Game(game.initialLevel);helpUsed=false;resetEffects();renderStats();draw();finish();showLesson();}
function undo(){if(animations.length||rotationAnimation)return;if(game.undo()){resetEffects();renderStats();draw();toast('已回到上一次操作前,可以继续观察');}}
function playTile(tile){
if(rotationAnimation){if(taps.add(tile.id))draw();return;}
if(animations.some(a=>a.route.tile.id===tile.id)||!game.tiles.some(t=>t.id===tile.id))return;
clearTimeout(toastTimer);$('board-toast').classList.remove('visible');
path=null;hint=null;shake=null;
for(const id of ['rotate-left','rotate-right'])$(id).classList.remove('hinted');
const before=new Set(available(game.level,game.tiles).map(t=>t.id)),route=game.tap(tile.id);
if(route.status==='exit'){
playSound('exit');animations.push({route,start:performance.now(),duration:reduced?100:exitTiming(route).duration});
const opened=available(game.level,game.tiles).filter(t=>!before.has(t.id));
if(opened.length){hint=opened[0].id;toast(`移开这一枚,${opened.length} 枚棋子的路通了!`);}
}
else if(route.status==='blocked'||route.status==='loop'){
path=null;
if(route.travelPoints){animations.push({route,start:performance.now(),duration:reduced?150:MOTION.collisionMs});}else shake={id:tile.id,start:performance.now()};
const blocker=game.tiles.find(t=>t.id===route.blocker),blockerCanExit=blocker&&trace(game.level,blocker,game.tiles).status==='exit';
toast(route.status==='loop'?'这条路线形成闭环 · 试探免费,可以转动底盘换条路':blockerCanExit?'前方棋子挡住了路线 · 可以先移走它':'前方棋子也在等出口 · 试探免费,可以转盘换条路');
}
renderStats();draw();if(!animations.length)finish();
}
const matchings=[];
function enumerate(ports,map=[]){if(!ports.length){matchings.push([...map]);return;}const a=ports[0];for(let i=1;i<ports.length;i++){const b=ports[i],next=[...map];next[a]=b;next[b]=a;enumerate(ports.filter(p=>p!==a&&p!==b),next);}}
enumerate([0,1,2,3,4,5]);
function editCell(cell){
selected={q:cell.q,r:cell.r};const tile=draft.tiles.find(t=>t.q===cell.q&&t.r===cell.r);
$('selection-info').textContent=`坐标 (${cell.q}, ${cell.r}) · 连接 ${cell.tracks.map((v,i)=>i<v?`${i}↔${v}`:null).filter(Boolean).join(' / ')}`;
if(tool==='inspect'){path=tile?trace(draft,tile):null;hint=null;toast(tile?(path.status==='exit'?'路径畅通,可以离场':path.status==='loop'?'这条路线是闭环':'路线被另一枚棋子阻挡'):'请选择一枚棋子查看路线');draw();return;}
if((tool==='erase'||tool==='rotate')&&!tile){toast('这里还没有棋子');draw();return;}
checkpoint();
if(tool==='tile'){if(tile){tile.dir=direction;tile.color=color;}else draft.tiles.push({id:`custom-${Date.now()}-${draft.tiles.length}`,q:cell.q,r:cell.r,dir:direction,color});}
if(tool==='rotate')tile.dir=(tile.dir+1)%6;
if(tool==='erase')draft.tiles=draft.tiles.filter(t=>t!==tile);
if(tool==='track'){const index=matchings.findIndex(m=>m.every((p,i)=>p===cell.tracks[i]));cell.tracks=[...matchings[(index+1)%matchings.length]];}
if(tool==='rotate-track')cell.tracks=rotateTracks(cell.tracks);
$('selection-info').textContent=`坐标 (${cell.q}, ${cell.r}) · 连接 ${cell.tracks.map((v,i)=>i<v?`${i}↔${v}`:null).filter(Boolean).join(' / ')}`;
changed();
}
function rotateGame(direction) {
if(animations.length||rotationAnimation||mode==='edit')return;
resetEffects();const before=new Set(available(game.level,game.tiles).map(t=>t.id)),result=game.rotate(direction);
if(result.status!=='rotated'){toast(result.status==='rotation-disabled'?'这个底板不支持整盘旋转':'当前无法旋转');return;}
playSound('rotate');
const after=available(game.level,game.tiles),opened=after.filter(t=>!before.has(t.id));
if(opened.length){hint=opened[0].id;toast(`转一转,${opened.length} 枚棋子的出口打开了!`);}
else if(!after.length)toast('路线换好了 · 可以继续免费旋转,慢慢观察');
rotationAnimation={direction,start:performance.now(),duration:reduced?100:MOTION.rotationMs};
renderStats();draw();
}
$('rotate-left').onclick=()=>rotateGame(-1);$('rotate-right').onclick=()=>rotateGame(1);
boardViewport=attachBoardViewport(canvas,{changed:resize,tap:({x,y})=>{
if(!view)return;const cell=hitCell((mode==='edit'?draft:game.level).cells,view,x,y);
if(!cell)return;if(mode==='edit')editCell(cell);else{const tile=game.tiles.find(t=>t.q===cell.q&&t.r===cell.r);if(tile)playTile(tile);}
},drag:(start,end)=>{
if(mode==='edit'||!view||Math.hypot(start.x-view.ox,start.y-view.oy)<=width*.28)return;
let delta=Math.atan2(end.y-view.oy,end.x-view.ox)-Math.atan2(start.y-view.oy,start.x-view.ox);
if(delta>Math.PI)delta-=Math.PI*2;if(delta<-Math.PI)delta+=Math.PI*2;
if(Math.abs(delta)>.18)rotateGame(delta>0?1:-1);
}});
$('level-list').onclick=e=>{const b=e.target.closest('[data-level]');if(b)startLevel(Number(b.dataset.level));};
$('play-tab').onclick=()=>{if(mode==='edit'||trial)startLevel(current);};$('edit-tab').onclick=enterEditor;$('workshop-link').onclick=enterEditor;
$('undo-button').onclick=undo;$('reset-button').onclick=restart;
$('hint-button').onclick=()=>{
if(animations.length||rotationAnimation)return;
resetEffects();helpUsed=true;
const solution=stateSolution();
if(!solution.reachable){toast(solution.exhausted?'路线较复杂,暂未找到提示':'当前关卡无法避开所有碰撞,请撤销或重新编辑');return;}
if(!solution.solvable){toast(`还有 ${solution.minimumMoves} 枚棋子,需要在工坊增加离场额度`);return;}
const action=solution.actions[0];if(!action)return;
if(action.type==='rotate'){$(action.direction===1?'rotate-right':'rotate-left').classList.add('hinted');toast(rotationHint(action.direction));}
else{const tile=game.tiles.find(t=>t.id===action.id);hint=tile.id;path=trace(game.level,tile,game.tiles);toast('这枚棋子沿途不会碰撞,可以离场');}
draw();
};
$('tool-palette').onclick=e=>{const b=e.target.closest('[data-tool]');if(!b)return;tool=b.dataset.tool;document.querySelectorAll('[data-tool]').forEach(x=>x.classList.toggle('active',x===b));path=null;hint=null;renderStats();draw();};
$('directions').innerHTML=['↑','↗','↘','↓','↙','↖'].map((s,i)=>`<button data-direction="${i}" class="${i===direction?'active':''}" aria-label="方向 ${s}">${icon('arrow',`style="transform:rotate(${i*60}deg)"`)}</button>`).join('');
$('directions').onclick=e=>{const b=e.target.closest('[data-direction]');if(!b)return;direction=Number(b.dataset.direction);document.querySelectorAll('[data-direction]').forEach(x=>x.classList.toggle('active',x===b));};
$('swatches').innerHTML=COLORS.map((c,i)=>`<button style="background:${c}" data-color="${i}" aria-label="${PIGMENTS[i].name}" class="${i===color?'active':''}"></button>`).join('');
$('swatches').onclick=e=>{const b=e.target.closest('[data-color]');if(!b)return;color=Number(b.dataset.color);document.querySelectorAll('[data-color]').forEach(x=>x.classList.toggle('active',x===b));};
$('editor-name').onchange=()=>{checkpoint();draft.name=$('editor-name').value.trim()||'未命名轨迹';syncEditor();changed();};
$('editor-moves').onchange=()=>{checkpoint();draft.moveLimit=Math.max(1,Math.min(999,Math.round(Number($('editor-moves').value)||1)));syncEditor();changed();};
$('editor-radius').onchange=()=>{checkpoint();const cells=hexagon(Number($('editor-radius').value)),old=new Map(draft.cells.map(c=>[key(c.q,c.r),c]));draft.cells=cells.map(c=>old.get(key(c.q,c.r))||c);const keys=new Set(cells.map(c=>key(c.q,c.r)));draft.tiles=draft.tiles.filter(t=>keys.has(key(t.q,t.r)));selected=null;changed();};
function rotateDraft(direction){if(!canRotate(draft)){toast('不规则底板不能整体旋转');return;}checkpoint();draft=rotateBoard(draft,direction);changed();}
$('editor-turn-left').onclick=()=>rotateDraft(-1);$('editor-turn-right').onclick=()=>rotateDraft(1);
$('editor-undo').onclick=()=>{if(!editHistory.length)return;draft=editHistory.pop();selected=null;syncEditor();changed();};
$('generate-button').onclick=()=>{checkpoint();const radius=Number($('editor-radius').value);draft=rotateBoard(generateLevel(Math.floor(Math.random()*1e8),radius,radius===2?13:27,.7),1);draft.moveLimit=draft.tiles.length+12;draft.name='偶遇的轨迹';draft.id='custom';selected=null;syncEditor();changed();toast('已生成,并通过旋转与碰撞规则验证');};
$('blank-button').onclick=()=>{checkpoint();const radius=Number($('editor-radius').value);draft={version:1,id:'custom',name:'一条新的轨迹',cells:hexagon(radius),tiles:[],moveLimit:30};selected=null;syncEditor();changed();};
$('trial-button').onclick=()=>{const errors=validateLevel(draft);if(errors.length){toast(errors[0]);return;}mode='play';trial=true;game=new Game(draft);helpUsed=false;resetEffects();applyMode();renderStats();resize();finish();window.scrollTo({top:0,behavior:reduced?'instant':'smooth'});};
$('return-editor').onclick=enterEditor;
$('save-button').onclick=()=>{try{localStorage.setItem('hex-trails-draft',JSON.stringify(draft));toast('关卡已保存到此浏览器');}catch{toast('本机存储不可用,请导出 JSON 保存');}};
$('load-button').onclick=()=>{try{const data=localStorage.getItem('hex-trails-draft');if(!data){toast('还没有本机存档');return;}loadDraft(JSON.parse(data));toast('已读取本机关卡');}catch(e){toast(`读取失败:${e.message}`);}};
function loadDraft(data){const errors=validateLevel(data);if(errors.length)throw new Error(errors.join(';'));checkpoint();draft=clone(data);selected=null;syncEditor();changed();}
$('export-button').onclick=()=>{const errors=validateLevel(draft);if(errors.length){toast(errors[0]);return;}const blob=new Blob([JSON.stringify(draft,null,2)],{type:'application/json'}),url=URL.createObjectURL(blob),a=document.createElement('a');a.href=url;a.download='hex-trails-level.json';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);toast('已导出通用 JSON 关卡');};
$('import-button').onclick=()=>$('import-file').click();
$('import-file').onchange=async e=>{const file=e.target.files[0];if(!file)return;try{if(file.size>200000)throw new Error('文件不能超过 200 KB');loadDraft(JSON.parse(await file.text()));toast('导入成功,已重新计算可解性与难度');}catch(error){showDialog(`<div class="eyebrow">IMPORT CHECK</div><h2>这份关卡需要调整</h2><p>${escapeHtml(error.message)}</p><p>当前编辑内容已保留。</p>`);}e.target.value='';};
function showDialog(html){taps.clear();$('dialog-content').innerHTML=html;decorateUI();if(!$('info-dialog').open)$('info-dialog').showModal();}
$('info-dialog').addEventListener('close',()=>draw());
$('info-dialog').querySelector('.dialog-close').onclick=()=>$('info-dialog').close();
$('info-dialog').onclick=e=>{if(e.target===$('info-dialog')){const r=e.target.getBoundingClientRect();if(e.clientX<r.left||e.clientX>r.right||e.clientY<r.top||e.clientY>r.bottom)e.target.close();}};
function showScore(){const level=mode==='edit'?draft:game.initialLevel,production=mode==='play'&&!trial?productionPresentation(level):null;
if(activeTutorial()){showDialog(`<div class="eyebrow">入门序章</div><h2>${escapeHtml(level.tutorial.title)}</h2><p>${escapeHtml(level.lesson)}</p><p>让所有棋子沿轨道离场,就完成这一课。没有倒计时,点错了也可以继续尝试。</p>`);return;}
if(production){showDialog(`<div class="eyebrow">OBSERVE & ENJOY</div><h2>${escapeHtml(production.experience)}</h2><p><b>${escapeHtml(production.label)} · 结构预估</b></p><p>${escapeHtml(production.intent)}</p><p>${level.tiles.length} 枚棋子${production.reward?' · '+escapeHtml(production.reward):''}。路线数据来自完整清盘回放,实际游玩路线会随操作选择变化。</p><p class="dialog-note">难度依据放行关系与换路要求估计,尚待真人试玩校准。长路和转弯描述观看内容,不直接增加难度。旋转与试探免费,没有倒计时。</p><p><a href="labs/campaign-review/index.html?level=${current+1}">查看这关的制作记录</a></p>`);return;}
const a=mode==='edit'?analyze(level):cachedAnalysis(level);showDialog(`<div class="eyebrow">OBSERVE & ENJOY</div><h2>观察难度与游玩节奏</h2><p><b>${a.score}/100 · ${a.label}</b>,反映读懂路线和解开依赖所需的观察。棋盘大小、棋子总数和旋转额度都不加难度分。</p>${bars(a)}<p>初始有 ${a.free} 枚棋子可以直接离场。工作量为 ${level.tiles.length} 次成功离场,加上最少 ${a.rotations} 次免费旋转;共 ${a.minimumActions??'—'} 次操作,参考时长约 ${a.workload.estimatedSeconds} 秒。大棋盘可以简单而充实,小棋盘也可以更需要观察。</p><ul><li>平均弯道 22 分:平均转弯数 ÷ 4;最多弯道 12 分:单条路线最多转弯数 ÷ 8。</li><li>解锁依赖 18 分:解锁波次按棋子数量归一化。</li><li>初始阻挡比例 28 分;仅由侧面擦碰形成的阻挡比例 12 分。</li><li>必要旋转 8 分:最少旋转数 ÷ 3。各项分别封顶。</li></ul><p class="dialog-note">分数和时长都是设计估计,尚未经真人试玩校准。参考时长不是倒计时。${a.exhausted?'搜索尚未确定这份关卡能否完成。':!a.reachable?'这份关卡存在无法解开的路线,需要调整。':!a.solvable?'这份关卡的成功离场额度不足,需要在工坊增加。':'所有路线已验证可解。'}</p>`);}
$('score-help').onclick=showScore;$('difficulty-button').onclick=showScore;
function showHelp(){showDialog(`<button id="sound-toggle" type="button">音效:${soundEnabled()?'开':'关'}</button>`+'<div class="eyebrow">HOW TO PLAY</div><h2>观察、转一转,路就通了</h2><p><b>① 观察箭头与路线</b><br>点击棋子,它会沿轨道出发,遇到弯道自动转向。找到出口,就能离场。</p><p><b>② 自由转动底盘</b><br>按左右按钮,或沿棋盘边缘拖动,每次旋转 60°。左右旋转都免费,次数不限。上层棋子位置和箭头不动,底下的轨道转到新位置。</p><p><b>③ 试一试,看清阻挡</b><br>棋子有宽度,遇到另一枚棋子或擦到它的侧面,会退回原位。试探不扣额度;可以移开挡路的棋子,或转动底盘观察另一条路。</p><p><b>④ 解开整片棋盘</b><br>让所有棋子找到出口即可完成,没有倒计时,也不评价尝试次数。撤销可以完整回到上一次操作前。自定义关卡的额度只计成功离场,每枚 1 额度。</p><p class="dialog-note">提示能找到安全棋子,也会指出下一次旋转方向。每关使用主题色库中的少量棋子颜色,颜色不改变规则。</p>');}
$('dialog-content').addEventListener('click',e=>{
if(e.target.closest('#sound-toggle')){toggleSound();$('sound-toggle').textContent=`音效:${soundEnabled()?'开':'关'}`;}
const pick=e.target.closest('[data-pick]');if(pick){$('info-dialog').close();startLevel(Number(pick.dataset.pick));}
const teaching=e.target.closest('[data-tutorial-pick]');if(teaching){$('info-dialog').close();startTutorial(Number(teaching.dataset.tutorialPick));}
});
$('dialog-content').addEventListener('change',e=>{
if(e.target.id==='campaign-version'){switchCampaign(e.target.value);showMapDialog();$('campaign-version').focus();}
if(e.target.id==='campaign-chapter'){mapChapter=Number(e.target.value);showMapDialog();$('campaign-chapter').focus();}
});
$('dialog-content').addEventListener('click',e=>{
const page=e.target.closest('[data-chapter-step]');if(page){mapChapter+=Number(page.dataset.chapterStep);showMapDialog();}
if(e.target.closest('[data-current-chapter]')){mapChapter=Math.floor(current/chapterSize);showMapDialog();}
});
$('dialog-content').addEventListener('submit',e=>{
if(e.target.id!=='campaign-jump')return;e.preventDefault();
const index=levelIndexFromInput($('campaign-level-number').value,LEVELS.length);
if(index===null){$('campaign-jump-feedback').textContent=`请输入 1 至 ${LEVELS.length} 的关卡编号`;return;}
$('info-dialog').close();startLevel(index);
});
for(const b of document.querySelectorAll('.help-trigger'))b.onclick=showHelp;
function tutorialChoices(){
if(!campaign.tutorials?.length)return '';
return `<div class="chapter-title">入门序章</div><div class="level-dialog-grid">${campaign.tutorials.map((level,i)=>`<button type="button" class="level-btn ${tutorialIndex===i?'current':''} ${tutorialProgress.completed.includes(i)?'done':''}" data-tutorial-pick="${i}" aria-label="教学 ${i+1}:${escapeHtml(level.tutorial.title)}" ${tutorialIndex===i?'aria-current="true"':''}>${String(i+1).padStart(2,'0')}<small>${escapeHtml(level.tutorial.title)}</small></button>`).join('')}</div>`;
}
function showMapDialog(){
const page=campaignPage(campaign,mapChapter);mapChapter=page.chapter;
showDialog(`<div class="eyebrow">CHOOSE YOUR NEXT TRAIL</div><h2>挑一关,慢慢解开</h2><label class="field campaign-picker">关卡版本<select id="campaign-version" aria-describedby="campaign-description">${CAMPAIGN_VERSIONS.map(c=>`<option value="${c.id}" ${c.id===campaign.id?'selected':''}>${c.id} · ${c.name} · ${c.levels.length} 关</option>`).join('')}</select></label><p class="campaign-description" id="campaign-description">${escapeHtml(campaign.description)}<span class="campaign-progress-note"><strong>${campaign.id} · 已完成 ${completed.size} / ${LEVELS.length} 关</strong> · ${chapters.length} 章</span></p>${tutorialChoices()}<label class="field">选择章节<select id="campaign-chapter">${chapters.map((name,i)=>`<option value="${i}" ${i===page.chapter?'selected':''}>第 ${i+1} 章 · ${escapeHtml(name)} · ${i*chapterSize+1}–${Math.min((i+1)*chapterSize,LEVELS.length)} 关</option>`).join('')}</select></label><form id="campaign-jump" class="campaign-jump"><label class="field">直接前往<input id="campaign-level-number" type="number" inputmode="numeric" min="1" max="${LEVELS.length}" step="1" required placeholder="1–${LEVELS.length}" aria-describedby="campaign-jump-feedback"></label><button type="submit">前往</button><button type="button" data-current-chapter>当前章节</button><span id="campaign-jump-feedback" role="status"></span></form><div class="campaign-page-controls"><button data-chapter-step="-1" ${page.chapter===0?'disabled':''}>上一章</button><span>${page.chapter+1} / ${page.count}</span><button data-chapter-step="1" ${page.chapter===page.count-1?'disabled':''}>下一章</button></div><div class="level-dialog-grid">${page.levels.map(({level:l,index:i})=>`<button class="level-btn ${tutorialIndex===null&&i===current?'current':''} ${completed.has(i)?'done':''}" data-pick="${i}" aria-label="${campaign.id} 第 ${i+1} 关 ${escapeHtml(productionPresentation(l)?.displayName||l.name)},${escapeHtml(l.chapterName||page.title)}${completed.has(i)?',已完成':''}" ${tutorialIndex===null&&i===current?'aria-current="true"':''}>${completed.has(i)?icon('star'):''}${String(i+1).padStart(2,'0')}<small>${escapeHtml(productionPresentation(l)?.label||(l.experience?l.beat:cachedAnalysis(l).label))}</small></button>`).join('')}</div><p>${saveUnavailable?'浏览器未允许保存:各版本进度仅在本页临时保留,刷新后会丢失。':'各版本分别保存通关进度与上次选关。'}切换版本会重新开始该版本上次选择的关卡。</p>${campaign.id==='v0.5'?`<p><a href="labs/campaign-review/index.html?level=${current+1}">浏览千关制作记录</a></p>`:''}`);
}
$('map-button').onclick=()=>{mapChapter=Math.floor(current/chapterSize);showMapDialog();};
const atlas=new Image();atlas.onload=()=>{painter.atlas=atlas;draw();};atlas.onerror=()=>toast('棋子图片加载失败,已切换基础绘制');atlas.src=TILE_ATLAS;
new ResizeObserver(resize).observe(canvas);
applyMode();renderMap();renderStats();resize();
installUI();
if(new URLSearchParams(location.search).get('editor')==='1')enterEditor();else showLesson();