feat: add v2 queued taps and free board rotation
This commit is contained in:
@@ -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 使用独立的浏览器进度存储。
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hex-arrow-trails",
|
||||
"version": "1.0.0",
|
||||
"version": "2.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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 <unique-asset-id> <output-directory>');
|
||||
}
|
||||
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`);
|
||||
+17
-10
@@ -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(`<div class="eyebrow">IMPORT CHECK</div><h2>这份关卡需要调整</h2><p>${escapeHtml(error.message)}</p><p>当前编辑内容已保留。</p>`);}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.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>`);}
|
||||
$('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()?'开':'关'}`;}});
|
||||
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));}};};
|
||||
|
||||
+2
-2
@@ -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) {
|
||||
|
||||
@@ -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;}
|
||||
}
|
||||
+5
-5
@@ -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',()=>{
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user