|
|
@@ -1,3 +1,5 @@
|
|
|
|
|
|
|
|
import { ExitAnimations } from './exit-animations.js';
|
|
|
|
|
|
|
|
const exits=new ExitAnimations();
|
|
|
|
import { TapQueue } from './tap-queue.js';
|
|
|
|
import { TapQueue } from './tap-queue.js';
|
|
|
|
const taps=new TapQueue();
|
|
|
|
const taps=new TapQueue();
|
|
|
|
import { unlockAudio, playSound, toggleSound, soundEnabled } from './audio.js';
|
|
|
|
import { unlockAudio, playSound, toggleSound, soundEnabled } from './audio.js';
|
|
|
@@ -36,10 +38,10 @@ function renderStats(){
|
|
|
|
$('moves').textContent=editing?level.moveLimit:game.moves;$('remaining').innerHTML=`${editing?level.tiles.length:game.tiles.length}<small> / ${editing?level.cells.length:level.tiles.length}</small>`;
|
|
|
|
$('moves').textContent=editing?level.moveLimit:game.moves;$('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 ↗';
|
|
|
|
$('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=`${level.tiles.length} 枚棋子 · ${a.depth} 层解锁 · 平均 ${a.meanTurns.toFixed(1)} 次转弯`;
|
|
|
|
$('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=`${level.tiles.length} 枚棋子 · ${a.depth} 层解锁 · 平均 ${a.meanTurns.toFixed(1)} 次转弯`;
|
|
|
|
$('undo-button').disabled=game.history.length===0||!!animation||!!rotationAnimation;
|
|
|
|
$('undo-button').disabled=game.history.length===0||!!animation||!!rotationAnimation||exits.active;
|
|
|
|
$('orientation-title').textContent=editing?'最少旋转':'底盘朝向';$('orientation').innerHTML=editing?`${a.rotations}<small>次</small>`:`${game.orientation*60}<small>°</small>`;
|
|
|
|
$('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.moves<=0||!canRotate(game.level);
|
|
|
|
for(const id of ['rotate-left','rotate-right'])$(id).disabled=!!animation||!!rotationAnimation||exits.active||game.won||game.moves<=0||!canRotate(game.level);
|
|
|
|
$('hint-button').disabled=!!animation||!!rotationAnimation||game.won;
|
|
|
|
$('hint-button').disabled=!!animation||!!rotationAnimation||exits.active||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?'点击箭头离场 · 拖动边缘或按按钮转动底盘':'暂时过不去?转一下底盘,换条路线!')}`;
|
|
|
|
$('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?'点击箭头离场 · 拖动边缘或按按钮转动底盘':'暂时过不去?转一下底盘,换条路线!')}`;
|
|
|
|
if(!editing&&!animation&&!rotationAnimation&&!game.won&&game.moves>0&&!available(game.level,game.tiles).length){
|
|
|
|
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});
|
|
|
|
const solution=solveRotations({...game.level,tiles:game.tiles,moveLimit:game.moves});
|
|
|
@@ -56,7 +58,9 @@ function resize(){const rect=canvas.getBoundingClientRect();width=rect.width;hei
|
|
|
|
function draw(now=performance.now()){
|
|
|
|
function draw(now=performance.now()){
|
|
|
|
if(!view)return;
|
|
|
|
if(!view)return;
|
|
|
|
ctx.clearRect(0,0,width,height);
|
|
|
|
ctx.clearRect(0,0,width,height);
|
|
|
|
const state={selected:mode==='edit'?selected:null,path,hint,reduced};
|
|
|
|
const wasExiting=exits.active,exitFrames=exits.frames(now);
|
|
|
|
|
|
|
|
if(wasExiting&&!exits.active)renderStats();
|
|
|
|
|
|
|
|
const state={exits:exitFrames,selected:mode==='edit'?selected:null,path,hint,reduced};
|
|
|
|
if(animation){const progress=Math.min(1,(now-animation.start)/animation.duration);state.animation={route:animation.route,progress};if(progress>=1){animation=null;renderStats();}}
|
|
|
|
if(animation){const progress=Math.min(1,(now-animation.start)/animation.duration);state.animation={route:animation.route,progress};if(progress>=1){animation=null;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(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;}}
|
|
|
|
if(shake){const t=(now-shake.start)/550;if(t>=1)shake=null;else{state.shake=shake.id;state.shakeTime=t;}}
|
|
|
@@ -66,10 +70,10 @@ function draw(now=performance.now()){
|
|
|
|
if(next){playTile(next);return;}
|
|
|
|
if(next){playTile(next);return;}
|
|
|
|
finish();
|
|
|
|
finish();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if(animation||rotationAnimation||shake){cancelAnimationFrame(frame);frame=requestAnimationFrame(draw);}
|
|
|
|
if(animation||rotationAnimation||shake||exits.active){cancelAnimationFrame(frame);frame=requestAnimationFrame(draw);}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
function toast(message){$('board-toast').textContent=message;$('board-toast').classList.add('visible');clearTimeout(toastTimer);toastTimer=setTimeout(()=>$('board-toast').classList.remove('visible'),3200);}
|
|
|
|
function toast(message){$('board-toast').textContent=message;$('board-toast').classList.add('visible');clearTimeout(toastTimer);toastTimer=setTimeout(()=>$('board-toast').classList.remove('visible'),3200);}
|
|
|
|
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 resetEffects(preserveTaps=false){if(!preserveTaps){taps.clear();exits.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){current=i;trial=false;mode='play';game=new Game(LEVELS[i]);helpUsed=false;resetEffects();applyMode();persist();renderMap();renderStats();resize();}
|
|
|
|
function startLevel(i){current=i;trial=false;mode='play';game=new Game(LEVELS[i]);helpUsed=false;resetEffects();applyMode();persist();renderMap();renderStats();resize();}
|
|
|
|
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 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(){
|
|
|
|
function enterEditor(){
|
|
|
@@ -83,7 +87,7 @@ function openResult(){ $('result').hidden=false;$('result').showModal();playSoun
|
|
|
|
$('result').addEventListener('cancel',e=>e.preventDefault());
|
|
|
|
$('result').addEventListener('cancel',e=>e.preventDefault());
|
|
|
|
function finish(){
|
|
|
|
function finish(){
|
|
|
|
if($('result').open)return;
|
|
|
|
if($('result').open)return;
|
|
|
|
if(mode==='edit'||animation||rotationAnimation)return;
|
|
|
|
if(mode==='edit'||animation||rotationAnimation||exits.active)return;
|
|
|
|
if(game.won){
|
|
|
|
if(game.won){
|
|
|
|
if(!trial){completed.add(current);persist();renderMap();}
|
|
|
|
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} 枚棋子全部离场 · 转动 ${game.rotations} 次<br>${game.mistakes===0?'零失误,漂亮的路线。':`还有 ${game.moves} 步余量,做得不错。`}${helpUsed?'<br>在提示的陪伴下完成':''}</p><button class="primary" id="next-level">${trial?'返回关卡工坊':current===23?'完成旅程 · 回到第一关':'下一段轨迹'} <svg><use href="#i-arrow"/></svg></button><button class="text-button" id="replay-level">再走一遍</button>`;
|
|
|
|
$('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} 枚棋子全部离场 · 转动 ${game.rotations} 次<br>${game.mistakes===0?'零失误,漂亮的路线。':`还有 ${game.moves} 步余量,做得不错。`}${helpUsed?'<br>在提示的陪伴下完成':''}</p><button class="primary" id="next-level">${trial?'返回关卡工坊':current===23?'完成旅程 · 回到第一关':'下一段轨迹'} <svg><use href="#i-arrow"/></svg></button><button class="text-button" id="replay-level">再走一遍</button>`;
|
|
|
@@ -93,11 +97,11 @@ function finish(){
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
function restart(){game=new Game(game.initialLevel);helpUsed=false;resetEffects();renderStats();draw();finish();}
|
|
|
|
function restart(){game=new Game(game.initialLevel);helpUsed=false;resetEffects();renderStats();draw();finish();}
|
|
|
|
function undo(){if(animation||rotationAnimation)return;if(game.undo()){resetEffects();renderStats();draw();toast('已撤销一步,步数已恢复');}}
|
|
|
|
function undo(){if(animation||rotationAnimation||exits.active)return;if(game.undo()){resetEffects();renderStats();draw();toast('已撤销一步,步数已恢复');}}
|
|
|
|
function playTile(tile){
|
|
|
|
function playTile(tile){
|
|
|
|
if(animation||rotationAnimation){taps.add(tile.id,animation?.route.tile.id);return;}
|
|
|
|
if(animation||rotationAnimation){taps.add(tile.id,animation?.route.tile.id);return;}
|
|
|
|
resetEffects(true);const route=game.tap(tile.id);
|
|
|
|
resetEffects(true);const route=game.tap(tile.id);
|
|
|
|
if(route.status==='exit'){playSound('exit');animation={route,start:performance.now(),duration:reduced?100:Math.min(1700,550+route.steps.length*85)};}
|
|
|
|
if(route.status==='exit'){playSound('exit');exits.add(route,performance.now(),reduced?100:Math.min(1700,550+route.steps.length*85));}
|
|
|
|
else if(route.status==='blocked'||route.status==='loop'){taps.clear();path=route;if(route.travelPoints){animation={route,start:performance.now(),duration:reduced?500:1250};hitSoundTimer=setTimeout(()=>playSound('hit'),(reduced?500:1250)*.42);}else shake={id:tile.id,start:performance.now()};toast(route.status==='loop'?'轨道形成闭环,试着旋转底盘':route.collision?'被这枚棋子挡住了 · 先移走它,或转动底盘 · −1 步':'这条路被挡住了,换个方向试试 · −1 步');}
|
|
|
|
else if(route.status==='blocked'||route.status==='loop'){taps.clear();path=route;if(route.travelPoints){animation={route,start:performance.now(),duration:reduced?500:1250};hitSoundTimer=setTimeout(()=>playSound('hit'),(reduced?500:1250)*.42);}else shake={id:tile.id,start:performance.now()};toast(route.status==='loop'?'轨道形成闭环,试着旋转底盘':route.collision?'被这枚棋子挡住了 · 先移走它,或转动底盘 · −1 步':'这条路被挡住了,换个方向试试 · −1 步');}
|
|
|
|
renderStats();draw();if(!animation)finish();
|
|
|
|
renderStats();draw();if(!animation)finish();
|
|
|
|
}
|
|
|
|
}
|
|
|
@@ -119,7 +123,7 @@ function editCell(cell){
|
|
|
|
changed();
|
|
|
|
changed();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
function rotateGame(direction) {
|
|
|
|
function rotateGame(direction) {
|
|
|
|
if(animation||rotationAnimation||mode==='edit')return;
|
|
|
|
if(animation||rotationAnimation||exits.active||mode==='edit')return;
|
|
|
|
resetEffects();const result=game.rotate(direction);
|
|
|
|
resetEffects();const result=game.rotate(direction);
|
|
|
|
if(result.status!=='rotated'){toast(result.status==='rotation-disabled'?'这个底板不支持整盘旋转':'当前无法旋转');return;}
|
|
|
|
if(result.status!=='rotated'){toast(result.status==='rotation-disabled'?'这个底板不支持整盘旋转':'当前无法旋转');return;}
|
|
|
|
playSound('rotate');
|
|
|
|
playSound('rotate');
|
|
|
@@ -186,7 +190,7 @@ $('info-dialog').querySelector('.dialog-close').onclick=()=>$('info-dialog').clo
|
|
|
|
$('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();}};
|
|
|
|
$('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 a=analyze(mode==='edit'?draft:game.initialLevel);showDialog(`<div class="eyebrow">LEVEL CHALLENGE</div><h2>这关有多难?</h2><p><b>${a.score}/100 · ${a.label}</b>,六项结构分相加后取整。</p>${bars(a)}<p>已计入沿途碰撞;最优解需要 ${a.rotations} 次旋转、${a.minimumMoves??'—'} 步。当前初始布局有 ${a.free} 枚棋子可以安全离场。</p><ul><li>棋子规模 15 分;轨道转弯 20 分。</li><li>解锁层次 20 分;初始碰撞比例 15 分。</li><li>必要旋转 20 分:最少旋转数 ÷ 4,封顶。</li><li>步数压力 10 分:按包含旋转的最短解计算余量。</li></ul><p class="dialog-note">评分是结构估计,未经真人通关率校准。不可解与搜索未确定会单独标识,不作为高难度关卡。</p>`);}
|
|
|
|
function showScore(){const a=analyze(mode==='edit'?draft:game.initialLevel);showDialog(`<div class="eyebrow">LEVEL CHALLENGE</div><h2>这关有多难?</h2><p><b>${a.score}/100 · ${a.label}</b>,六项结构分相加后取整。</p>${bars(a)}<p>已计入沿途碰撞;最优解需要 ${a.rotations} 次旋转、${a.minimumMoves??'—'} 步。当前初始布局有 ${a.free} 枚棋子可以安全离场。</p><ul><li>棋子规模 15 分;轨道转弯 20 分。</li><li>解锁层次 20 分;初始碰撞比例 15 分。</li><li>必要旋转 20 分:最少旋转数 ÷ 4,封顶。</li><li>步数压力 10 分:按包含旋转的最短解计算余量。</li></ul><p class="dialog-note">评分是结构估计,未经真人通关率校准。不可解与搜索未确定会单独标识,不作为高难度关卡。</p>`);}
|
|
|
|
$('score-help').onclick=showScore;$('difficulty-button').onclick=showScore;
|
|
|
|
$('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>');}
|
|
|
|
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()?'开':'关'}`;}});
|
|
|
|
$('dialog-content').addEventListener('click',e=>{if(e.target.closest('#sound-toggle')){toggleSound();$('sound-toggle').textContent=`音效:${soundEnabled()?'开':'关'}`;}});
|
|
|
|
for(const b of document.querySelectorAll('.help-trigger'))b.onclick=showHelp;
|
|
|
|
for(const b of document.querySelectorAll('.help-trigger'))b.onclick=showHelp;
|
|
|
|
$('map-button').onclick=()=>{showDialog(`<div class="eyebrow">CHOOSE YOUR NEXT TRAIL</div><h2>挑一关,转起来</h2><div class="level-dialog-grid">${LEVELS.map((l,i)=>`<button class="level-btn ${i===current?'current':''}" data-pick="${i}">${completed.has(i)?icon('star'):''}${String(i+1).padStart(2,'0')}<small>${analyze(l).score} / 100</small></button>`).join('')}</div><p>24 个旋转谜题 · 每关都已验证可以避开碰撞。</p>`);$('dialog-content').onclick=e=>{const b=e.target.closest('[data-pick]');if(b){$('info-dialog').close();startLevel(Number(b.dataset.pick));}};};
|
|
|
|
$('map-button').onclick=()=>{showDialog(`<div class="eyebrow">CHOOSE YOUR NEXT TRAIL</div><h2>挑一关,转起来</h2><div class="level-dialog-grid">${LEVELS.map((l,i)=>`<button class="level-btn ${i===current?'current':''}" data-pick="${i}">${completed.has(i)?icon('star'):''}${String(i+1).padStart(2,'0')}<small>${analyze(l).score} / 100</small></button>`).join('')}</div><p>24 个旋转谜题 · 每关都已验证可以避开碰撞。</p>`);$('dialog-content').onclick=e=>{const b=e.target.closest('[data-pick]');if(b){$('info-dialog').close();startLevel(Number(b.dataset.pick));}};};
|
|
|
|