From 4ea5949cfb2ad6f733dcb1023df9deafb49aa316 Mon Sep 17 00:00:00 2001 From: cabinw Date: Wed, 9 Sep 2026 12:37:18 +0800 Subject: [PATCH] feat: add v2 queued taps and free board rotation --- README.md | 13 ++++++++++++- docs/level-design.md | 8 ++++---- package.json | 2 +- scripts/package-release.mjs | 21 +++++++++++++++++++++ src/app.js | 27 +++++++++++++++++---------- src/core.js | 4 ++-- src/tap-queue.js | 7 +++++++ tests/core.test.js | 10 +++++----- tests/tap-queue.test.js | 22 ++++++++++++++++++++++ 9 files changed, 91 insertions(+), 23 deletions(-) create mode 100644 scripts/package-release.mjs create mode 100644 src/tap-queue.js create mode 100644 tests/tap-queue.test.js diff --git a/README.md b/README.md index 5008457..c1a1328 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,13 @@ 项目使用原生 JavaScript ES modules 和 Canvas,无第三方运行依赖,无在线字体或 CDN。包含 24 个主线关卡、关卡工坊、求解器、确定性关卡生成器,以及 Cocos Creator 适配示例。 +## 版本 + +- [v1](https://zeroplus.fun/games/hextrails/v1.html):首次发布版本,对应 `v1` 分支。 +- [v2](https://zeroplus.fun/games/hextrails/v2.html):对应 `v2` 分支,支持动画期间连续点击不同棋子,按顺序执行;碰撞清空等待队列,重复点击不重复排队。左右旋转免费。 + +两个版本保留同一套 24 关顺序。v2 难度按免费旋转重新计算,因此分数不保证逐关递增。 + ## 快速开始 需要 Node.js 20 或更高版本,以及 Git;无需执行 `npm install`。 @@ -25,7 +32,7 @@ npm run dev - 点击棋子,从箭头方向进入轨道;弯道自动转向,交叉处不换轨。 - 点击左右旋转按钮,或沿棋盘边缘拖动,将底盘旋转 60°。棋子的位置与箭头方向保持不动,底板格子与轨道配对一起旋转。 - 棋子不能穿过其他棋子。碰撞检测覆盖整段运动路径,包括弯道与邻格擦碰;碰撞动画展示接触后退回原位。 -- 点击棋子或旋转底盘各消耗 1 步,碰撞也扣步。撤销恢复棋盘、棋子、朝向和步数;没有倒计时。 +- 点击棋子消耗 1 步,碰撞也扣步;左右旋转不消耗步数。撤销恢复棋盘、棋子、朝向和步数;没有倒计时。 - 提示会高亮可安全离场的棋子,或建议旋转方向。棋子配色不影响规则。 主线关卡均经过求解验证,且需要旋转才能完成。所有关卡可自由选择,通关进度保存在当前浏览器。音效使用 Web Audio 合成,首次交互后启用,可在设置中关闭并保存偏好。 @@ -94,3 +101,7 @@ Cocos 示例复用规则、碰撞、渲染命令和图集;HTML 控件、文件 `cocos/` 是适配示例,不是完整 Creator 工程;原生场景及 Android/iOS 构建尚未完成验收。`docs/ui-layout.json` 是旧版布局快照,不应直接作为当前场景模板。 更多背景见 [视频与规则分析](docs/video-analysis.md)。 + +## 版本发布 + +构建后运行 `node scripts/package-release.mjs v2 <唯一资源版本号> <临时输出目录>`,生成 `v2.html` 和独立资源目录。将两者部署到服务器网站根目录的 `games/hextrails/`;入口需使用游戏专用的样式 CSP 和禁缓存策略。先上传资源,最后原子替换 HTML,保留旧资源供回滚。v1 分支固定在首次上线的 `9f8990b`,旧地址 `/games/hextrails.html` 跳转到 v1。v2 使用独立的浏览器进度存储。 diff --git a/docs/level-design.md b/docs/level-design.md index 127b26e..0ceb31c 100644 --- a/docs/level-design.md +++ b/docs/level-design.md @@ -33,7 +33,7 @@ 顺时针一步将所有底板格子变为 `(q,r) → (-r,q+r)`,同时将轨道端口和对应出口都加 1(模 6)。上层棋子的 `(q,r,dir)` 均不改变。六次旋转恢复原状。 -每次旋转扣 1 步。撤销保存并恢复底板、棋子、步数、朝向和旋转计数。重开读取原始关卡,不能从已旋转的中间状态重开。 +v2 旋转不扣步,点击棋子(含碰撞)扣 1 步。撤销保存并恢复底板、棋子、步数、朝向和旋转计数。重开读取原始关卡,不能从已旋转的中间状态重开。 ## 连续碰撞 @@ -43,19 +43,19 @@ 例如:移动棋子在 `(1,1)` 向上走,另一棋子在 `(0,0)`。路线经过的格子不含 `(0,0)`,但经过相邻两格之间时,移动棋子的侧面可能擦到它,因此仍被阻挡。这比仅检查路线格子占用更严格。 -判定发生在移动前,动画只展示结果:成功沿线离场;失败走到接触位置再退回。不允许多个棋子同时运动,避免时序歧义。生成器、求解器、提示与工坊都调用同一个 `trace()`。 +判定发生在移动前,动画只展示结果:成功沿线离场;失败走到接触位置再退回。动画期间允许连续点击不同棋子进入队列,执行前按最新棋盘重新判断路线;碰撞会清空等待队列。棋子仍依次运动,避免时序歧义。生成器、求解器、提示与工坊都调用同一个 `trace()`。 ## 旋转求解与生成 移走可行棋子只会减少障碍,不改变轨道,因此在任意朝向下先移除所有安全棋子不会损害后续可解性。求解器对“朝向+剩余棋子集合”做 BFS,每次转盘代价为 1,到达一个朝向后先消除所有可离场棋子。 -最短步数 = 初始棋子数 + 最少旋转数。BFS 返回混合 `tap` / `rotate` 动作序列。最大扩展状态数为 20,000;若触及上限,则返回 `exhausted` 表示尚未确定,不能宣称无解。 +v2 最短扣费步数 = 初始棋子数;最少操作次数 = 初始棋子数 + 最少旋转数。BFS 返回混合 `tap` / `rotate` 动作序列。最大扩展状态数为 20,000;若触及上限,则返回 `exhausted` 表示尚未确定,不能宣称无解。 关卡生成先在碰撞规则下逆向放入可离场棋子,再旋转初始底盘,筛选必须旋转才能完成的布局,最后重新求解验证。主线按评分非递减排列,共 24 关,必要旋转 1–3 次。 ## 数字化难度(0–100) -设 N=棋子数、T=忽略占用的平均转弯数、W=解答中的消除层数、F=初始无碰撞可离场数量、R=最少旋转数、K=N+R=最短步数、M=上限: +设 N=棋子数、T=忽略占用的平均转弯数、W=解答中的消除层数、F=初始无碰撞可离场数量、R=最少旋转数、K=N=最短扣费步数、M=上限: ```text 规模 = min(1, N/37) × 15 diff --git a/package.json b/package.json index acd9283..71c4476 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hex-arrow-trails", - "version": "1.0.0", + "version": "2.0.0", "private": true, "type": "module", "scripts": { diff --git a/scripts/package-release.mjs b/scripts/package-release.mjs new file mode 100644 index 0000000..c945125 --- /dev/null +++ b/scripts/package-release.mjs @@ -0,0 +1,21 @@ +import { readFile, writeFile, cp, mkdir } from 'node:fs/promises'; +import { resolve, join } from 'node:path'; + +// Run after npm run build. Use a new asset ID for every deployment to avoid +// mixing cached JavaScript from different versions on the static server. +const [version, assetId, destination] = process.argv.slice(2); +if (!/^v\d+$/.test(version || '') || !/^[a-zA-Z0-9-]+$/.test(assetId || '') || !destination) { + throw new Error('Usage: node scripts/package-release.mjs v2 '); +} +const output = resolve(destination); +const prefix = `/games/hextrails/${assetId}/`; +await mkdir(output, { recursive: true }); +await cp('dist', join(output, assetId), { recursive: true, errorOnExist: true, force: false }); +const html = (await readFile('dist/index.html', 'utf8')).replace(/(href|src)="(?!#|https?:|\/)([^"]+)"/g, + (_, attr, path) => `${attr}="${prefix}${path}"`); +await writeFile(join(output, `${version}.html`), html); +for (const name of ['src/sprites.js', 'src/ui-skin.js']) { + const path = join(output, assetId, name); + await writeFile(path, (await readFile(path, 'utf8')).replaceAll("'assets/", `'${prefix}assets/`)); +} +console.log(`Release package: ${output}/${version}.html`); diff --git a/src/app.js b/src/app.js index ccec5d7..bc74fbe 100644 --- a/src/app.js +++ b/src/app.js @@ -1,3 +1,5 @@ +import { TapQueue } from './tap-queue.js'; +const taps=new TapQueue(); import { unlockAudio, playSound, toggleSound, soundEnabled } from './audio.js'; import { icon, installUI } from './ui.js'; import { Game, analyze, available, clone, generateLevel, hexagon, key, random, randomTracks, rotateTracks, rotateBoard, canRotate, solveRotations, trace, validateLevel } from './core.js'; @@ -7,7 +9,7 @@ import { TILE_ATLAS } from './sprites.js'; import { CanvasPainter, COLORS, drawScene } from './renderer.js'; const $=id=>document.getElementById(id); const canvas=$('board'),ctx=canvas.getContext('2d'),painter=new CanvasPainter(ctx); -const STORE='hex-trails-v2'; +const STORE='hex-trails-release-v2'; let saved={};try{saved=JSON.parse(localStorage.getItem(STORE)||'{}')||{};}catch{} let completed=new Set(Array.isArray(saved.completed)?saved.completed.filter(i=>Number.isInteger(i)&&i>=0&&i<24):[]); let current=Number.isInteger(saved.current)&&saved.current>=0&&saved.current<24?saved.current:0; @@ -55,14 +57,19 @@ function draw(now=performance.now()){ if(!view)return; ctx.clearRect(0,0,width,height); const state={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();finish();}} - 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();finish();}} + 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;}} drawScene(painter,mode==='edit'?draft:game.level,mode==='edit'?draft.tiles:game.tiles,view,state); + if(!animation&&!rotationAnimation){ + const next=mode==='play'&&!game.won&&!game.lost&&!$('info-dialog').open?taps.take(game.tiles):null; + if(next){playTile(next);return;} + finish(); + } if(animation||rotationAnimation||shake){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(){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();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(){ @@ -88,10 +95,10 @@ 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 playTile(tile){ - if(animation||rotationAnimation)return; - resetEffects();const route=game.tap(tile.id); + 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)};} - else if(route.status==='blocked'||route.status==='loop'){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(); } const matchings=[]; @@ -124,7 +131,7 @@ let pointerStart=null; canvas.addEventListener('pointerdown',e=>{if(!view)return;const r=canvas.getBoundingClientRect();pointerStart={x:e.clientX-r.left,y:e.clientY-r.top};canvas.setPointerCapture(e.pointerId);}); canvas.addEventListener('pointercancel',()=>{pointerStart=null;}); canvas.addEventListener('pointerup',e=>{ - if(animation||rotationAnimation||!view){pointerStart=null;return;} + if(!view){pointerStart=null;return;} const rect=canvas.getBoundingClientRect(),x=e.clientX-rect.left,y=e.clientY-rect.top; if(pointerStart&&Math.hypot(x-pointerStart.x,y-pointerStart.y)>14){ const start=pointerStart;pointerStart=null; @@ -174,12 +181,12 @@ function loadDraft(data){const errors=validateLevel(data);if(errors.length)throw $('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){$('dialog-content').innerHTML=html;$('info-dialog').showModal();} +function showDialog(html){taps.clear();$('dialog-content').innerHTML=html;$('info-dialog').showModal();} $('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 a=analyze(mode==='edit'?draft:game.initialLevel);showDialog(`
LEVEL CHALLENGE

这关有多难?

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

${bars(a)}

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

  • 棋子规模 15 分;轨道转弯 20 分。
  • 解锁层次 20 分;初始碰撞比例 15 分。
  • 必要旋转 20 分:最少旋转数 ÷ 4,封顶。
  • 步数压力 10 分:按包含旋转的最短解计算余量。

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

`);} $('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/core.js b/src/core.js index fda0a68..8e9b0c1 100644 --- a/src/core.js +++ b/src/core.js @@ -161,7 +161,7 @@ export function solveRotations(level, maxStates = 20000) { if (seen.has(signature)) continue; seen.add(signature); visited++; if (!node.remaining.length && level.tiles.length) { - const minimumMoves = level.tiles.length + node.rotations; + const minimumMoves = level.tiles.length; return { solvable: minimumMoves <= level.moveLimit, reachable: true, minimumMoves, rotations: node.rotations, actions: node.actions, order: node.actions.filter(a => a.type === 'tap').map(a => a.id), waves: node.waves, stuck: [], states: visited, exhausted: false }; } if (!rotating) continue; @@ -238,7 +238,7 @@ export class Game { this.snapshot(); this.level = rotateBoard(this.level, direction); this.orientation = (this.orientation + direction + 6) % 6; - this.rotations++; this.moves--; + this.rotations++; return { status: 'rotated', direction, orientation: this.orientation }; } tap(id) { diff --git a/src/tap-queue.js b/src/tap-queue.js new file mode 100644 index 0000000..d8b75ca --- /dev/null +++ b/src/tap-queue.js @@ -0,0 +1,7 @@ +// Store identities, not coordinates: re-check each move against the latest board. +export class TapQueue { + constructor(){this.ids=[];} + add(id,activeId){if(id===activeId||this.ids.includes(id))return false;this.ids.push(id);return true;} + take(tiles){while(this.ids.length){const id=this.ids.shift(),tile=tiles.find(t=>t.id===id);if(tile)return tile;}return null;} + clear(){this.ids.length=0;} +} diff --git a/tests/core.test.js b/tests/core.test.js index 4406c1d..eb51cfe 100644 --- a/tests/core.test.js +++ b/tests/core.test.js @@ -28,9 +28,8 @@ test('mutual blocking reaches a fixed point; move shortage is separate',()=>{ const easy=level([{q:0,r:0},{q:0,r:-1}],[tile('a',0,0),tile('b',0,-1)],1); assert.equal(solve(easy).stuck.length,0);assert.equal(solve(easy).solvable,false); }); -test('all 24 campaign levels validate, solve and increase in score',()=>{ - let previous=-1; - for(const l of LEVELS){assert.deepEqual(validateLevel(l),[]);const a=analyze(l);assert.ok(a.solvable,l.name);assert.ok(a.rotations>=1);assert.equal(solve(l).solvable,false);assert.ok(a.score>=previous);previous=a.score;const g=new Game(l);for(const action of a.actions){if(action.type==='rotate')assert.equal(g.rotate(action.direction).status,'rotated');else assert.equal(g.tap(action.id).status,'exit');}assert.ok(g.won);assert.ok(g.moves>=0);} +test('all 24 campaign levels retain their order, validate and solve with free rotations',()=>{ + for(const l of LEVELS){assert.deepEqual(validateLevel(l),[]);const a=analyze(l);assert.ok(a.solvable,l.name);assert.ok(a.rotations>=1);assert.equal(solve(l).solvable,false);assert.ok(a.score>=0&&a.score<=100);const g=new Game(l);for(const action of a.actions){if(action.type==='rotate')assert.equal(g.rotate(action.direction).status,'rotated');else assert.equal(g.tap(action.id).status,'exit');}assert.ok(g.won);assert.ok(g.moves>=0);} }); test('100 generated seeds are deterministic and solvable',()=>{ for(let seed=0;seed<100;seed++){const l=generateLevel(seed,seed%2?2:3,seed%2?15:32,.8);assert.deepEqual(validateLevel(l),[]);assert.ok(solve(l).solvable,`seed ${seed}`);assert.ok(l.tiles.length>0);} @@ -62,7 +61,7 @@ test('six board rotations restore tracks; tokens and arrows stay fixed',()=>{ let board=l;for(let i=0;i<6;i++)board=rotateBoard(board); assert.deepEqual(board,l); const g=new Game(l),original=clone(g.tiles),before=clone(g.level.cells); - assert.equal(g.rotate(1).status,'rotated');assert.deepEqual(g.tiles,original);assert.notDeepEqual(g.level.cells,before);assert.equal(g.orientation,1);assert.equal(g.moves,l.moveLimit-1); + assert.equal(g.rotate(1).status,'rotated');assert.deepEqual(g.tiles,original);assert.notDeepEqual(g.level.cells,before);assert.equal(g.orientation,1);assert.equal(g.moves,l.moveLimit); assert.ok(g.undo());assert.equal(g.orientation,0);assert.deepEqual(g.level.cells,before);assert.equal(g.moves,l.moveLimit); }); test('a stuck orientation can be rescued by rotating and must not immediately lose',()=>{ @@ -83,8 +82,9 @@ test('swept segment detection cannot tunnel through a collider between frames',( const hit=segmentHexHit({x:-10,y:0},{x:10,y:0},{x:0,y:0});assert.ok(hit>0&&hit<1); assert.equal(segmentHexHit({x:-10,y:3},{x:10,y:3},{x:0,y:0}),null); }); -test('rotation step cost is included in the minimum move budget',()=>{ +test('free rotations are excluded from the minimum move budget',()=>{ const l=clone(LEVELS[0]),a=solveRotations(l);assert.ok(a.rotations>0); + assert.equal(a.minimumMoves,l.tiles.length);l.moveLimit=l.tiles.length;assert.ok(solveRotations(l).solvable);const g=new Game(l);for(const action of a.actions){if(action.type==='rotate'){const before=g.moves;g.rotate(action.direction);assert.equal(g.moves,before);}else g.tap(action.id);}assert.ok(g.won);assert.equal(g.moves,0); l.moveLimit=a.minimumMoves-1;const tooShort=solveRotations(l);assert.equal(tooShort.reachable,true);assert.equal(tooShort.solvable,false); }); test('irregular boards retain classic play and do not rotate tiles off the base',()=>{ diff --git a/tests/tap-queue.test.js b/tests/tap-queue.test.js new file mode 100644 index 0000000..fa7fd8c --- /dev/null +++ b/tests/tap-queue.test.js @@ -0,0 +1,22 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { TapQueue } from '../src/tap-queue.js'; +import { Game, available } from '../src/core.js'; +import { LEVELS } from '../src/levels.js'; +test('rapid taps retain order, ignore duplicate/active pieces, and discard stale IDs',()=>{ + const queue=new TapQueue(); + assert.equal(queue.add('a','a'),false); + queue.add('b');queue.add('gone');queue.add('c');assert.equal(queue.add('b'),false); + const tiles=[{id:'c'},{id:'b'}]; + assert.equal(queue.take(tiles).id,'b');assert.equal(queue.take(tiles).id,'c');assert.equal(queue.take(tiles),null); + queue.add('b');queue.clear();assert.equal(queue.take(tiles),null); +}); +test('buffered removals are evaluated in order against the updated game, with undo intact',()=>{ + const game=new Game(LEVELS[0]),queue=new TapQueue(); + const free=available(game.level,game.tiles).slice(0,3);assert.ok(free.length>=2); + for(const tile of free)queue.add(tile.id); + const start=game.moves; + while(queue.ids.length){const tile=queue.take(game.tiles);assert.equal(game.tap(tile.id).status,'exit');} + assert.equal(game.moves,start-free.length); + assert.ok(game.undo());assert.equal(game.moves,start-free.length+1); +});