import { artDirection, directionTheme } from './art-directions.js'; import { attachBoardViewport } from './board-viewport.js'; import { TapQueue } from './tap-queue.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, center } from './geometry.js'; import { TILE_ATLAS } from './sprites.js'; import { CanvasPainter, COLORS, drawScene, exitDuration } 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,baseView=null; 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,intro=null,celebrate=null,celebrated=false,lastMoves=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){ // Journey designs (signpost, macaron) spread their five worlds evenly over the campaign, the last one at night. const chapterTheme=requestedDesign.journeyWorlds?requestedDesign.themes[Math.min(requestedDesign.themes.length-1,Math.floor(current/Math.max(1,LEVELS.length)*requestedDesign.themes.length))]:null; activeTheme=mode==='edit'||trial?null:directionTheme(requestedDesign,(requestedTheme||chapterTheme||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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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]])=>`
${label}${a.parts[k].toFixed(0)}/${max}
`).join('');} function renderMap(){ $('map-button').ariaLabel=`选择关卡,当前 ${campaign.id}`; const page=campaignPage(campaign,Math.floor(current/chapterSize)); $('level-list').innerHTML=`
${String(page.chapter+1).padStart(2,'0')} / ${escapeHtml(page.title)}
${page.levels.map(({level:l,index:i})=>``).join('')}
`; $('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,mode==='edit'?0:requestedDesign.layout?.pad||0);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=`
${icon('star')}${icon('star')}${icon('star')}
入门 ${teaching.step} / ${campaign.tutorials.length}

${escapeHtml(teaching.title)}

${escapeHtml(teaching.completion)}

`; openResult();$('next-level').onclick=()=>last?startLevel(0):startTutorial(tutorialIndex+1);$('replay-level').onclick=restart;return; } if(!trial){completed.add(current);persist();renderMap();} $('result').innerHTML=`
${icon('star')}${icon('star')}${icon('star')}
A LITTLE MOMENT OF JOY

${trial?'这条轨迹,通了!':'每一条路,都被你解开了!'}

${game.level.tiles.length} 枚棋子全部找到出口
一条接一条,你清空了整片棋盘。${helpUsed?'
在提示的陪伴下完成':''}

`; openResult();$('next-level').onclick=()=>trial?enterEditor():startLevel((current+1)%LEVELS.length);$('replay-level').onclick=restart; }else if(game.lost){ $('result').innerHTML=`
${icon('track')}

${game.moves<=0?'这份关卡的离场额度不足':'轨迹暂时打了个结'}

${game.moves<=0?'仍有棋子等待离场,可以返回工坊增加额度。旋转和试探都没有扣除额度。':'剩余棋子的路线被阻挡,或轨道形成了闭环。'}

`;openResult();$('recover').onclick=()=>trial?enterEditor():game.history.length?undo():restart();$('retry').onclick=restart; } } function restart(){game=new Game(game.initialLevel);helpUsed=false;resetEffects();beginIntro();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:exitDuration(route,activeTheme?.artStyle)}); 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;ip!==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)=>it!==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)=>it.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)=>``).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)=>``).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(`
IMPORT CHECK

这份关卡需要调整

${escapeHtml(error.message)}

当前编辑内容已保留。

`);}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.clientXr.right||e.clientYr.bottom)e.target.close();}}; function showScore(){const level=mode==='edit'?draft:game.initialLevel,production=mode==='play'&&!trial?productionPresentation(level):null; if(activeTutorial()){showDialog(`
入门序章

${escapeHtml(level.tutorial.title)}

${escapeHtml(level.lesson)}

让所有棋子沿轨道离场,就完成这一课。没有倒计时,点错了也可以继续尝试。

`);return;} if(production){showDialog(`
OBSERVE & ENJOY

${escapeHtml(production.experience)}

${escapeHtml(production.label)} · 结构预估

${escapeHtml(production.intent)}

${level.tiles.length} 枚棋子${production.reward?' · '+escapeHtml(production.reward):''}。路线数据来自完整清盘回放,实际游玩路线会随操作选择变化。

难度依据放行关系与换路要求估计,尚待真人试玩校准。长路和转弯描述观看内容,不直接增加难度。旋转与试探免费,没有倒计时。

${campaign.id==='v0.5'?`

查看这关的制作记录

`:''}`);return;} const a=mode==='edit'?analyze(level):cachedAnalysis(level);showDialog(`
OBSERVE & ENJOY

观察难度与游玩节奏

${a.score}/100 · ${a.label},反映读懂路线和解开依赖所需的观察。棋盘大小、棋子总数和旋转额度都不加难度分。

${bars(a)}

初始有 ${a.free} 枚棋子可以直接离场。工作量为 ${level.tiles.length} 次成功离场,加上最少 ${a.rotations} 次免费旋转;共 ${a.minimumActions??'—'} 次操作,参考时长约 ${a.workload.estimatedSeconds} 秒。大棋盘可以简单而充实,小棋盘也可以更需要观察。

  • 平均弯道 22 分:平均转弯数 ÷ 4;最多弯道 12 分:单条路线最多转弯数 ÷ 8。
  • 解锁依赖 18 分:解锁波次按棋子数量归一化。
  • 初始阻挡比例 28 分;仅由侧面擦碰形成的阻挡比例 12 分。
  • 必要旋转 8 分:最少旋转数 ÷ 3。各项分别封顶。

分数和时长都是设计估计,尚未经真人试玩校准。参考时长不是倒计时。${a.exhausted?'搜索尚未确定这份关卡能否完成。':!a.reachable?'这份关卡存在无法解开的路线,需要调整。':!a.solvable?'这份关卡的成功离场额度不足,需要在工坊增加。':'所有路线已验证可解。'}

`);} $('score-help').onclick=showScore;$('difficulty-button').onclick=showScore; function showHelp(){showDialog(``+'
HOW TO PLAY

观察、转一转,路就通了

① 观察箭头与路线
点击棋子,它会沿轨道出发,遇到弯道自动转向。找到出口,就能离场。

② 自由转动底盘
按左右按钮,或沿棋盘边缘拖动,每次旋转 60°。左右旋转都免费,次数不限。上层棋子位置和箭头不动,底下的轨道转到新位置。

③ 试一试,看清阻挡
棋子有宽度,遇到另一枚棋子或擦到它的侧面,会退回原位。试探不扣额度;可以移开挡路的棋子,或转动底盘观察另一条路。

④ 解开整片棋盘
让所有棋子找到出口即可完成,没有倒计时,也不评价尝试次数。撤销可以完整回到上一次操作前。自定义关卡的额度只计成功离场,每枚 1 额度。

提示能找到安全棋子,也会指出下一次旋转方向。每关使用主题色库中的少量棋子颜色,颜色不改变规则。

');} $('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 `
入门序章
${campaign.tutorials.map((level,i)=>``).join('')}
`; } function showMapDialog(){ const page=campaignPage(campaign,mapChapter);mapChapter=page.chapter; showDialog(`
CHOOSE YOUR NEXT TRAIL

挑一关,慢慢解开

${escapeHtml(campaign.description)}${campaign.id} · 已完成 ${completed.size} / ${LEVELS.length} 关 · ${chapters.length} 章

${tutorialChoices()}
${page.chapter+1} / ${page.count}
${page.levels.map(({level:l,index:i})=>``).join('')}

${saveUnavailable?'浏览器未允许保存:各版本进度仅在本页临时保留,刷新后会丢失。':'各版本分别保存通关进度与上次选关。'}切换版本会重新开始该版本上次选择的关卡。

${campaign.id==='v0.5'?`

浏览千关制作记录

`:''}`); } $('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); beginIntro();applyMode();renderMap();renderStats();resize(); installUI(); if(new URLSearchParams(location.search).get('editor')==='1')enterEditor();else showLesson();