diff --git a/README.md b/README.md index c1a1328..cb87c38 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ ## 版本 - [v1](https://zeroplus.fun/games/hextrails/v1.html):首次发布版本,对应 `v1` 分支。 -- [v2](https://zeroplus.fun/games/hextrails/v2.html):对应 `v2` 分支,支持动画期间连续点击不同棋子,按顺序执行;碰撞清空等待队列,重复点击不重复排队。左右旋转免费。 +- [v2](https://zeroplus.fun/games/hextrails/v2.html):对应 `v2` 分支,支持连续点击不同棋子立即出发,多枚离场动画同时播放;每次点击按最新棋盘判定路线。离场中的棋子不可重复点击,全部动画结束后再显示通关结果。左右旋转免费。 两个版本保留同一套 24 关顺序。v2 难度按免费旋转重新计算,因此分数不保证逐关递增。 diff --git a/docs/level-design.md b/docs/level-design.md index 0ceb31c..206c30e 100644 --- a/docs/level-design.md +++ b/docs/level-design.md @@ -43,7 +43,7 @@ v2 旋转不扣步,点击棋子(含碰撞)扣 1 步。撤销保存并恢 例如:移动棋子在 `(1,1)` 向上走,另一棋子在 `(0,0)`。路线经过的格子不含 `(0,0)`,但经过相邻两格之间时,移动棋子的侧面可能擦到它,因此仍被阻挡。这比仅检查路线格子占用更严格。 -判定发生在移动前,动画只展示结果:成功沿线离场;失败走到接触位置再退回。动画期间允许连续点击不同棋子进入队列,执行前按最新棋盘重新判断路线;碰撞会清空等待队列。棋子仍依次运动,避免时序歧义。生成器、求解器、提示与工坊都调用同一个 `trace()`。 +判定发生在移动前,动画只展示结果:成功沿线离场;失败走到接触位置再退回。成功点击立即更新逻辑棋盘,各棋子的离场动画独立并行播放;后续点击按最新棋盘判断,离场中的棋子不再占用原格。碰撞回退期间暂存后续点击。转盘和撤销等待全部动画结束。生成器、求解器、提示与工坊都调用同一个 `trace()`。 ## 旋转求解与生成 diff --git a/src/app.js b/src/app.js index bc74fbe..1c6d63f 100644 --- a/src/app.js +++ b/src/app.js @@ -1,3 +1,5 @@ +import { ExitAnimations } from './exit-animations.js'; +const exits=new ExitAnimations(); import { TapQueue } from './tap-queue.js'; const taps=new TapQueue(); 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} / ${editing?level.cells.length:level.tiles.length}`; $('mini-caption').innerHTML=editing?'MAKE YOUR
OWN WAY ↗':'FOLLOW
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)} 次转弯`; - $('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}次`:`${game.orientation*60}°`; - for(const id of ['rotate-left','rotate-right'])$(id).disabled=!!animation||!!rotationAnimation||game.won||game.moves<=0||!canRotate(game.level); - $('hint-button').disabled=!!animation||!!rotationAnimation||game.won; + 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||exits.active||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?'点击箭头离场 · 拖动边缘或按按钮转动底盘':'暂时过不去?转一下底盘,换条路线!')}`; 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}); @@ -56,7 +58,9 @@ function resize(){const rect=canvas.getBoundingClientRect();width=rect.width;hei function draw(now=performance.now()){ if(!view)return; 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(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;}} @@ -66,10 +70,10 @@ function draw(now=performance.now()){ if(next){playTile(next);return;} 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 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 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(){ @@ -83,7 +87,7 @@ 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'||animation||rotationAnimation||exits.active)return; if(game.won){ 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} 枚棋子全部离场 · 转动 ${game.rotations} 次
${game.mistakes===0?'零失误,漂亮的路线。':`还有 ${game.moves} 步余量,做得不错。`}${helpUsed?'
在提示的陪伴下完成':''}

`; @@ -93,11 +97,11 @@ function 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){ if(animation||rotationAnimation){taps.add(tile.id,animation?.route.tile.id);return;} 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 步');} renderStats();draw();if(!animation)finish(); } @@ -119,7 +123,7 @@ function editCell(cell){ changed(); } function rotateGame(direction) { - if(animation||rotationAnimation||mode==='edit')return; + if(animation||rotationAnimation||exits.active||mode==='edit')return; resetEffects();const result=game.rotate(direction); if(result.status!=='rotated'){toast(result.status==='rotation-disabled'?'这个底板不支持整盘旋转':'当前无法旋转');return;} 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.clientXr.right||e.clientYr.bottom)e.target.close();}}; function showScore(){const a=analyze(mode==='edit'?draft:game.initialLevel);showDialog(`
LEVEL CHALLENGE

这关有多难?

${a.score}/100 · ${a.label},六项结构分相加后取整。

${bars(a)}

已计入沿途碰撞;最优解需要 ${a.rotations} 次旋转、${a.minimumMoves??'—'} 步。当前初始布局有 ${a.free} 枚棋子可以安全离场。

评分是结构估计,未经真人通关率校准。不可解与搜索未确定会单独标识,不作为高难度关卡。

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

转转六角,轻松过关!

① 点击箭头
棋子沿轨道出发,遇到弯道自动转向。

② 转动底盘
按左右按钮,或沿棋盘边缘拖动,每次旋转 60°。上层棋子位置和箭头不动,底下的轨道转到新位置,路线随之改变。

③ 小心碰撞
会检查棋子整段运动轨迹,包括弯道擦碰。棋子是有宽度的,不能穿过旁边的棋子;碰撞会让棋子退回原位。

④ 全部离场
点击棋子花 1 步,碰撞也扣步;左右旋转免费。动画期间可连续点击不同棋子,按顺序执行,碰撞后清空等待队列。撤销可以恢复棋盘、方向与步数。没有倒计时。

卡住了?提示既能找到安全棋子,也会告诉你什么时候该转盘。四种颜色只负责好看,不改变规则。

');} +function showHelp(){showDialog(``+'
HOW TO PLAY

转转六角,轻松过关!

① 点击箭头
棋子沿轨道出发,遇到弯道自动转向。

② 转动底盘
按左右按钮,或沿棋盘边缘拖动,每次旋转 60°。上层棋子位置和箭头不动,底下的轨道转到新位置,路线随之改变。

③ 小心碰撞
会检查棋子整段运动轨迹,包括弯道擦碰。棋子是有宽度的,不能穿过旁边的棋子;碰撞会让棋子退回原位。

④ 全部离场
点击棋子花 1 步,碰撞也扣步;左右旋转免费。连续点击不同棋子即可立即出发,多枚离场动画同时播放。碰撞回退期间的点击暂存,碰撞后重新判断路线。撤销可以恢复棋盘、方向与步数。没有倒计时。

卡住了?提示既能找到安全棋子,也会告诉你什么时候该转盘。四种颜色只负责好看,不改变规则。

');} $('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; $('map-button').onclick=()=>{showDialog(`
CHOOSE YOUR NEXT TRAIL

挑一关,转起来

${LEVELS.map((l,i)=>``).join('')}

24 个旋转谜题 · 每关都已验证可以避开碰撞。

`);$('dialog-content').onclick=e=>{const b=e.target.closest('[data-pick]');if(b){$('info-dialog').close();startLevel(Number(b.dataset.pick));}};}; diff --git a/src/exit-animations.js b/src/exit-animations.js new file mode 100644 index 0000000..668497d --- /dev/null +++ b/src/exit-animations.js @@ -0,0 +1,11 @@ +// Successful moves update the model immediately; each visual keeps its own clock. +export class ExitAnimations { + constructor() { this.items = []; } + get active() { return this.items.length > 0; } + add(route, start, duration) { this.items.push({ route, start, duration }); } + frames(now) { + this.items = this.items.filter(item => now < item.start + item.duration); + return this.items.map(item => ({ route: item.route, progress: Math.max(0, (now - item.start) / item.duration) })); + } + clear() { this.items.length = 0; } +} diff --git a/src/renderer.js b/src/renderer.js index 96a7df8..1bd17a2 100644 --- a/src/renderer.js +++ b/src/renderer.js @@ -77,6 +77,10 @@ export function drawScene(p,level,tiles,view,state={}) { } drawTile(p,c.x+dx,c.y+dy,size,tile); } + for(const {route,progress} of state.exits||[]) { + const loc=pointAlong(routePoints(route,view,true),progress); + drawTile(p,loc.x,loc.y,size,route.tile,loc.angle,progress>.9?Math.max(.2,(1-progress)/.1):1); + } if(state.animation) { const {route,progress}=state.animation; const blocked=route.status!=='exit'; diff --git a/tests/exit-animations.test.js b/tests/exit-animations.test.js new file mode 100644 index 0000000..8892f55 --- /dev/null +++ b/tests/exit-animations.test.js @@ -0,0 +1,32 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { ExitAnimations } from '../src/exit-animations.js'; +import { Game, available } from '../src/core.js'; +import { LEVELS } from '../src/levels.js'; +import { layout } from '../src/geometry.js'; +import { drawScene } from '../src/renderer.js'; + +test('rapid successful taps update immediately and render overlapping independent animations', () => { + const game = new Game(LEVELS[0]), exits = new ExitAnimations(); + const tiles = available(game.level, game.tiles).slice(0, 2); + assert.equal(tiles.length, 2); + const before = game.moves; + exits.add(game.tap(tiles[0].id), 0, 1000); + exits.add(game.tap(tiles[1].id), 100, 1000); + assert.equal(game.moves, before - 2); + assert.equal(game.tap(tiles[0].id).status, 'missing'); + const frames = exits.frames(200); + assert.deepEqual(frames.map(f => f.progress), [.2, .1]); + let sprites = 0; + const painter = { polygon(){}, line(){}, circle(){}, sprite(){sprites++;return true;} }; + drawScene(painter, game.level, game.tiles, layout(game.level.cells, 390, 390), {exits:frames}); + assert.equal(sprites, game.tiles.length + 2); + assert.equal(exits.frames(1000).length, 1); + assert.equal(exits.frames(1100).length, 0); + assert.equal(exits.active, false); + assert.ok(game.undo());assert.equal(game.moves, before - 1); +}); +test('restart clears in-flight visuals', () => { + const exits = new ExitAnimations();exits.add({}, 0, 1000);exits.clear(); + assert.deepEqual(exits.frames(100), []); +});