feat: add production campaigns, gameplay labs, and review documentation

This commit is contained in:
cabinw
2026-09-13 12:27:10 +08:00
parent d1dffa9338
commit 4644e975b0
214 changed files with 98994 additions and 134 deletions
+115 -44
View File
@@ -1,3 +1,4 @@
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';
@@ -6,7 +7,9 @@ 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 { selectedCampaign, readCampaignProgress, writeCampaignProgress } from './campaign-progress.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';
@@ -16,22 +19,29 @@ const canvas=$('board'),ctx=canvas.getContext('2d'),painter=new CanvasPainter(ct
let boardViewport;
let progressStorage;try{progressStorage=window.localStorage;}catch{}
let saveUnavailable=!progressStorage;
let campaign=selectedCampaign(progressStorage),LEVELS=campaign.levels;
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=saved.current;
let game=new Game(LEVELS[current]),mode='play',trial=false,draft=null,editHistory=[],tool='tile',direction=1,color=0,selected=null,path=null,hint=null,animation=null,rotationAnimation=null,shake=null,toastTimer,view,width=0,height=0,frame=0;
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:themeWithColors(requestedTheme||themeForScore(score),Number(new URLSearchParams(location.search).get('colors'))||7);
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.style.setProperty('--color-background',theme.background);
document.body.style.setProperty('--scene-ink',theme.ink);
$('theme-caption').textContent=activeTheme?`${requestedTheme?'配色预览':theme.label} · ${theme.name}`:'自由配色 · 12 色棋子库';
}
let hitSoundTimer;
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;
@@ -39,17 +49,28 @@ const metricLabels={bends:['平均弯道',22],longestRoute:['最多弯道',12],d
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);
completed=new Set(progress.completed);startLevel(progress.current);
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}`;
$('level-list').innerHTML=chapters.map((ch,c)=>`<div class="chapter-title">${String(c+1).padStart(2,'0')} / ${escapeHtml(ch)}</div><div class="level-grid">${LEVELS.slice(c*chapterSize,(c+1)*chapterSize).map((l,j)=>{const i=c*chapterSize+j;return `<button class="level-btn ${i===current?'current':''} ${completed.has(i)?'done':''}" data-level="${i}" aria-label="第 ${i+1} 关 ${escapeHtml(l.name)}${completed.has(i)?',已通关':''}" ${i===current?'aria-current="true"':''}>${String(i+1).padStart(2,'0')}</button>`;}).join('')}</div>`).join('');
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){
@@ -57,21 +78,29 @@ function rotationHint(direction){
return exits?`免费${turn}转 60°,${exits} 枚棋子的出口就打开了`:`先免费${turn}转 60°,再继续观察路线`;
}
function renderStats(){
const editing=mode==='edit',level=editing?draft:game.initialLevel,a=analyze(level);
applyTheme(a.score);
$('level-name').textContent=level.name;$('level-eyebrow').textContent=editing?'YOUR CREATION':trial?'PLAYTEST':`LEVEL ${String(current+1).padStart(2,'0')}`;
$('breadcrumb').textContent=editing?'关卡工坊 / 自由创作':trial?'关卡工坊 / 试玩中':`第 ${Math.floor(current/chapterSize)+1} 章 / ${level.chapterName||chapters[Math.floor(current/chapterSize)]}`;
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 ↗';
$('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||!!animation||!!rotationAnimation;
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=!!animation||!!rotationAnimation||game.won||game.lost||!canRotate(game.level)||game.level.rotationEnabled===false;
$('hint-button').disabled=!!animation||!!rotationAnimation||game.won;
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&&!animation&&!rotationAnimation&&!game.won&&game.moves>0&&!available(game.level,game.tiles).length){
const solution=solveRotations({...game.level,tiles:game.tiles,moveLimit:game.moves});
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;
@@ -86,24 +115,35 @@ 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};
if(animation){const progress=Math.min(1,(now-animation.start)/animation.duration);state.animation={route:animation.route,progress};if(progress>=1){animation=null;renderStats();}}
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(!animation&&!rotationAnimation){
if(!rotationAnimation&&mode==='play'&&!$('info-dialog').open){
const next=taps.take(game.tiles);
if(next&&mode==='play'&&!$('info-dialog').open){playTile(next);return;}
if(next){playTile(next);return;}
finish();
}
if(animation||rotationAnimation||shake){cancelAnimationFrame(frame);frame=requestAnimationFrame(draw);}
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(preserveTaps=false){if(!preserveTaps)taps.clear();clearTimeout(hitSoundTimer);if($('result').open)$('result').close();clearTimeout(toastTimer);$('board-toast').classList.remove('visible');$('board-toast').textContent='';animation=null;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();current=i;trial=false;mode='play';game=new Game(LEVELS[i]);helpUsed=false;resetEffects();applyMode();persist();renderMap();renderStats();resize();showLesson();}
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(game.level);draft.id='custom';draft.name='我的第一条轨迹';editHistory=[];}
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)))));}
@@ -113,8 +153,15 @@ function openResult(){ $('result').hidden=false;$('result').showModal();playSoun
$('result').addEventListener('cancel',e=>e.preventDefault());
function finish(){
if($('result').open)return;
if(mode==='edit'||animation||rotationAnimation)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;
@@ -123,25 +170,26 @@ function finish(){
}
}
function restart(){game=new Game(game.initialLevel);helpUsed=false;resetEffects();renderStats();draw();finish();showLesson();}
function undo(){if(animation||rotationAnimation)return;if(game.undo()){resetEffects();renderStats();draw();toast('已回到上一次操作前,可以继续观察');}}
function undo(){if(animations.length||rotationAnimation)return;if(game.undo()){resetEffects();renderStats();draw();toast('已回到上一次操作前,可以继续观察');}}
function playTile(tile){
if(animation||rotationAnimation){
if(taps.add(tile.id,animation?.route.tile.id)){draw();}
return;
}
resetEffects(true);const before=new Set(available(game.level,game.tiles).map(t=>t.id)),route=game.tap(tile.id);
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');animation={route,start:performance.now(),duration:reduced?100:exitTiming(route).duration};
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'){
taps.clear();path=null;
if(route.travelPoints){animation={route,start:performance.now(),duration:reduced?150:MOTION.collisionMs};hitSoundTimer=setTimeout(()=>playSound('hit'),(reduced?150:MOTION.collisionMs)*.42);}else shake={id:tile.id,start:performance.now()};
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(!animation)finish();
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);}}
@@ -161,7 +209,7 @@ function editCell(cell){
changed();
}
function rotateGame(direction) {
if(animation||rotationAnimation||mode==='edit')return;
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');
@@ -185,9 +233,9 @@ $('level-list').onclick=e=>{const b=e.target.closest('[data-level]');if(b)startL
$('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(animation||rotationAnimation)return;
if(animations.length||rotationAnimation)return;
resetEffects();helpUsed=true;
const solution=solveRotations({...game.level,tiles:game.tiles,moveLimit:game.moves});
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;
@@ -217,21 +265,44 @@ $('export-button').onclick=()=>{const errors=validateLevel(draft);if(errors.leng
$('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,a=analyze(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>`);}
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);
});
$('dialog-content').addEventListener('change',e=>{if(e.target.id==='campaign-version'){switchCampaign(e.target.value);showMapDialog();$('campaign-version').focus();}});
for(const b of document.querySelectorAll('.help-trigger'))b.onclick=showHelp;
function showMapDialog(){
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">${campaign.description}<span class="campaign-progress-note"><strong>${campaign.id} · 已完成 ${completed.size} / ${LEVELS.length} 关</strong> · ${chapters.length} 章</span></p><div class="level-dialog-grid">${LEVELS.map((l,i)=>`<button class="level-btn ${i===current?'current':''}" data-pick="${i}" aria-label="${campaign.id} 第 ${i+1} 关 ${escapeHtml(l.name)},${escapeHtml(l.chapterName||chapters[Math.floor(i/chapterSize)])}${completed.has(i)?',已完成':''}" ${i===current?'aria-current="true"':''}>${completed.has(i)?icon('star'):''}${String(i+1).padStart(2,'0')}<small>${analyze(l).label}</small></button>`).join('')}</div><p>${saveUnavailable?'浏览器未允许保存:各版本进度仅在本页临时保留,刷新后会丢失。':'各版本分别保存通关进度与上次选关。'}切换版本会重新开始该版本上次选择的关卡。</p>`);
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>`;
}
$('map-button').onclick=showMapDialog;
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();