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=`
${game.level.tiles.length} 枚棋子全部离场 · 转动 ${game.rotations} 次
${game.mistakes===0?'零失误,漂亮的路线。':`还有 ${game.moves} 步余量,做得不错。`}${helpUsed?'
在提示的陪伴下完成':''}
${a.score}/100 · ${a.label},六项结构分相加后取整。
${bars(a)}已计入沿途碰撞;最优解需要 ${a.rotations} 次旋转、${a.minimumMoves??'—'} 步。当前初始布局有 ${a.free} 枚棋子可以安全离场。
评分是结构估计,未经真人通关率校准。不可解与搜索未确定会单独标识,不作为高难度关卡。
`);} $('score-help').onclick=showScore;$('difficulty-button').onclick=showScore; -function showHelp(){showDialog(``+'① 点击箭头
棋子沿轨道出发,遇到弯道自动转向。
② 转动底盘
按左右按钮,或沿棋盘边缘拖动,每次旋转 60°。上层棋子位置和箭头不动,底下的轨道转到新位置,路线随之改变。
③ 小心碰撞
会检查棋子整段运动轨迹,包括弯道擦碰。棋子是有宽度的,不能穿过旁边的棋子;碰撞会让棋子退回原位。
④ 全部离场
点击棋子花 1 步,碰撞也扣步;左右旋转免费。动画期间可连续点击不同棋子,按顺序执行,碰撞后清空等待队列。撤销可以恢复棋盘、方向与步数。没有倒计时。
卡住了?提示既能找到安全棋子,也会告诉你什么时候该转盘。四种颜色只负责好看,不改变规则。
');} +function showHelp(){showDialog(``+'① 点击箭头
棋子沿轨道出发,遇到弯道自动转向。
② 转动底盘
按左右按钮,或沿棋盘边缘拖动,每次旋转 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(`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), []); +});