2 Commits
v1 .. v2
12 changed files with 151 additions and 32 deletions
+12 -1
View File
@@ -4,6 +4,13 @@
项目使用原生 JavaScript ES modules 和 Canvas,无第三方运行依赖,无在线字体或 CDN。包含 24 个主线关卡、关卡工坊、求解器、确定性关卡生成器,以及 Cocos Creator 适配示例。 项目使用原生 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`。 需要 Node.js 20 或更高版本,以及 Git;无需执行 `npm install`。
@@ -25,7 +32,7 @@ npm run dev
- 点击棋子,从箭头方向进入轨道;弯道自动转向,交叉处不换轨。 - 点击棋子,从箭头方向进入轨道;弯道自动转向,交叉处不换轨。
- 点击左右旋转按钮,或沿棋盘边缘拖动,将底盘旋转 60°。棋子的位置与箭头方向保持不动,底板格子与轨道配对一起旋转。 - 点击左右旋转按钮,或沿棋盘边缘拖动,将底盘旋转 60°。棋子的位置与箭头方向保持不动,底板格子与轨道配对一起旋转。
- 棋子不能穿过其他棋子。碰撞检测覆盖整段运动路径,包括弯道与邻格擦碰;碰撞动画展示接触后退回原位。 - 棋子不能穿过其他棋子。碰撞检测覆盖整段运动路径,包括弯道与邻格擦碰;碰撞动画展示接触后退回原位。
- 点击棋子或旋转底盘各消耗 1 步,碰撞也扣步。撤销恢复棋盘、棋子、朝向和步数;没有倒计时。 - 点击棋子消耗 1 步,碰撞也扣步;左右旋转不消耗步数。撤销恢复棋盘、棋子、朝向和步数;没有倒计时。
- 提示会高亮可安全离场的棋子,或建议旋转方向。棋子配色不影响规则。 - 提示会高亮可安全离场的棋子,或建议旋转方向。棋子配色不影响规则。
主线关卡均经过求解验证,且需要旋转才能完成。所有关卡可自由选择,通关进度保存在当前浏览器。音效使用 Web Audio 合成,首次交互后启用,可在设置中关闭并保存偏好。 主线关卡均经过求解验证,且需要旋转才能完成。所有关卡可自由选择,通关进度保存在当前浏览器。音效使用 Web Audio 合成,首次交互后启用,可在设置中关闭并保存偏好。
@@ -94,3 +101,7 @@ Cocos 示例复用规则、碰撞、渲染命令和图集;HTML 控件、文件
`cocos/` 是适配示例,不是完整 Creator 工程;原生场景及 Android/iOS 构建尚未完成验收。`docs/ui-layout.json` 是旧版布局快照,不应直接作为当前场景模板。 `cocos/` 是适配示例,不是完整 Creator 工程;原生场景及 Android/iOS 构建尚未完成验收。`docs/ui-layout.json` 是旧版布局快照,不应直接作为当前场景模板。
更多背景见 [视频与规则分析](docs/video-analysis.md)。 更多背景见 [视频与规则分析](docs/video-analysis.md)。
## 版本发布
构建后运行 `node scripts/package-release.mjs v2 <唯一资源版本号> <临时输出目录>`,生成 `v2.html` 和独立资源目录。将两者部署到服务器网站根目录的 `games/hextrails/`;入口需使用游戏专用的样式 CSP 和禁缓存策略。先上传资源,最后原子替换 HTML,保留旧资源供回滚。v1 分支固定在首次上线的 `9f8990b`,旧地址 `/games/hextrails.html` 跳转到 v1。v2 使用独立的浏览器进度存储。
+4 -4
View File
@@ -33,7 +33,7 @@
顺时针一步将所有底板格子变为 `(q,r) → (-r,q+r)`,同时将轨道端口和对应出口都加 1(模 6)。上层棋子的 `(q,r,dir)` 均不改变。六次旋转恢复原状。 顺时针一步将所有底板格子变为 `(q,r) → (-r,q+r)`,同时将轨道端口和对应出口都加 1(模 6)。上层棋子的 `(q,r,dir)` 均不改变。六次旋转恢复原状。
每次旋转扣 1 步。撤销保存并恢复底板、棋子、步数、朝向和旋转计数。重开读取原始关卡,不能从已旋转的中间状态重开。 v2 旋转不扣步,点击棋子(含碰撞)扣 1 步。撤销保存并恢复底板、棋子、步数、朝向和旋转计数。重开读取原始关卡,不能从已旋转的中间状态重开。
## 连续碰撞 ## 连续碰撞
@@ -43,19 +43,19 @@
例如:移动棋子在 `(1,1)` 向上走,另一棋子在 `(0,0)`。路线经过的格子不含 `(0,0)`,但经过相邻两格之间时,移动棋子的侧面可能擦到它,因此仍被阻挡。这比仅检查路线格子占用更严格。 例如:移动棋子在 `(1,1)` 向上走,另一棋子在 `(0,0)`。路线经过的格子不含 `(0,0)`,但经过相邻两格之间时,移动棋子的侧面可能擦到它,因此仍被阻挡。这比仅检查路线格子占用更严格。
判定发生在移动前,动画只展示结果:成功沿线离场;失败走到接触位置再退回。不允许多个棋子同时运动,避免时序歧义。生成器、求解器、提示与工坊都调用同一个 `trace()`。 判定发生在移动前,动画只展示结果:成功沿线离场;失败走到接触位置再退回。成功点击立即更新逻辑棋盘,各棋子的离场动画独立并行播放;后续点击按最新棋盘判断,离场中的棋子不再占用原格。碰撞回退期间暂存后续点击。转盘和撤销等待全部动画结束。生成器、求解器、提示与工坊都调用同一个 `trace()`。
## 旋转求解与生成 ## 旋转求解与生成
移走可行棋子只会减少障碍,不改变轨道,因此在任意朝向下先移除所有安全棋子不会损害后续可解性。求解器对“朝向+剩余棋子集合”做 BFS,每次转盘代价为 1,到达一个朝向后先消除所有可离场棋子。 移走可行棋子只会减少障碍,不改变轨道,因此在任意朝向下先移除所有安全棋子不会损害后续可解性。求解器对“朝向+剩余棋子集合”做 BFS,每次转盘代价为 1,到达一个朝向后先消除所有可离场棋子。
最短步数 = 初始棋子数 + 最少旋转数。BFS 返回混合 `tap` / `rotate` 动作序列。最大扩展状态数为 20,000;若触及上限,则返回 `exhausted` 表示尚未确定,不能宣称无解。 v2 最短扣费步数 = 初始棋子数;最少操作次数 = 初始棋子数 + 最少旋转数。BFS 返回混合 `tap` / `rotate` 动作序列。最大扩展状态数为 20,000;若触及上限,则返回 `exhausted` 表示尚未确定,不能宣称无解。
关卡生成先在碰撞规则下逆向放入可离场棋子,再旋转初始底盘,筛选必须旋转才能完成的布局,最后重新求解验证。主线按评分非递减排列,共 24 关,必要旋转 1–3 次。 关卡生成先在碰撞规则下逆向放入可离场棋子,再旋转初始底盘,筛选必须旋转才能完成的布局,最后重新求解验证。主线按评分非递减排列,共 24 关,必要旋转 1–3 次。
## 数字化难度(0–100) ## 数字化难度(0–100)
设 N=棋子数、T=忽略占用的平均转弯数、W=解答中的消除层数、F=初始无碰撞可离场数量、R=最少旋转数、K=N+R=最短步数、M=上限: 设 N=棋子数、T=忽略占用的平均转弯数、W=解答中的消除层数、F=初始无碰撞可离场数量、R=最少旋转数、K=N=最短扣费步数、M=上限:
```text ```text
规模 = min(1, N/37) × 15 规模 = min(1, N/37) × 15
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "hex-arrow-trails", "name": "hex-arrow-trails",
"version": "1.0.0", "version": "2.0.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+21
View File
@@ -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`);
+30 -19
View File
@@ -1,3 +1,7 @@
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'; import { unlockAudio, playSound, toggleSound, soundEnabled } from './audio.js';
import { icon, installUI } from './ui.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'; import { Game, analyze, available, clone, generateLevel, hexagon, key, random, randomTracks, rotateTracks, rotateBoard, canRotate, solveRotations, trace, validateLevel } from './core.js';
@@ -7,7 +11,7 @@ import { TILE_ATLAS } from './sprites.js';
import { CanvasPainter, COLORS, drawScene } from './renderer.js'; import { CanvasPainter, COLORS, drawScene } from './renderer.js';
const $=id=>document.getElementById(id); const $=id=>document.getElementById(id);
const canvas=$('board'),ctx=canvas.getContext('2d'),painter=new CanvasPainter(ctx); 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 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 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; let current=Number.isInteger(saved.current)&&saved.current>=0&&saved.current<24?saved.current:0;
@@ -34,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});
@@ -54,15 +58,22 @@ 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(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(wasExiting&&!exits.active)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();finish();}} 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;}} 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); drawScene(painter,mode==='edit'?draft:game.level,mode==='edit'?draft.tiles:game.tiles,view,state);
if(animation||rotationAnimation||shake){cancelAnimationFrame(frame);frame=requestAnimationFrame(draw);} 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||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(){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(){
@@ -76,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>`;
@@ -86,12 +97,12 @@ 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)return; if(animation||rotationAnimation){taps.add(tile.id,animation?.route.tile.id);return;}
resetEffects();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'){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();
} }
const matchings=[]; const matchings=[];
@@ -112,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');
@@ -124,7 +135,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('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('pointercancel',()=>{pointerStart=null;});
canvas.addEventListener('pointerup',e=>{ 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; 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){ if(pointerStart&&Math.hypot(x-pointerStart.x,y-pointerStart.y)>14){
const start=pointerStart;pointerStart=null; const start=pointerStart;pointerStart=null;
@@ -174,12 +185,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 关卡');}; $('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-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='';}; $('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').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();}}; $('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));}};};
+2 -2
View File
@@ -161,7 +161,7 @@ export function solveRotations(level, maxStates = 20000) {
if (seen.has(signature)) continue; if (seen.has(signature)) continue;
seen.add(signature); visited++; seen.add(signature); visited++;
if (!node.remaining.length && level.tiles.length) { 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 }; 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; if (!rotating) continue;
@@ -238,7 +238,7 @@ export class Game {
this.snapshot(); this.snapshot();
this.level = rotateBoard(this.level, direction); this.level = rotateBoard(this.level, direction);
this.orientation = (this.orientation + direction + 6) % 6; this.orientation = (this.orientation + direction + 6) % 6;
this.rotations++; this.moves--; this.rotations++;
return { status: 'rotated', direction, orientation: this.orientation }; return { status: 'rotated', direction, orientation: this.orientation };
} }
tap(id) { tap(id) {
+11
View File
@@ -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; }
}
+4
View File
@@ -77,6 +77,10 @@ export function drawScene(p,level,tiles,view,state={}) {
} }
drawTile(p,c.x+dx,c.y+dy,size,tile); 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) { if(state.animation) {
const {route,progress}=state.animation; const {route,progress}=state.animation;
const blocked=route.status!=='exit'; const blocked=route.status!=='exit';
+7
View File
@@ -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
View File
@@ -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); 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); assert.equal(solve(easy).stuck.length,0);assert.equal(solve(easy).solvable,false);
}); });
test('all 24 campaign levels validate, solve and increase in score',()=>{ test('all 24 campaign levels retain their order, validate and solve with free rotations',()=>{
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>=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);}
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('100 generated seeds are deterministic and solvable',()=>{ 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);} 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); let board=l;for(let i=0;i<6;i++)board=rotateBoard(board);
assert.deepEqual(board,l); assert.deepEqual(board,l);
const g=new Game(l),original=clone(g.tiles),before=clone(g.level.cells); 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); 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',()=>{ 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); 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); 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); 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); 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',()=>{ test('irregular boards retain classic play and do not rotate tiles off the base',()=>{
+32
View File
@@ -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), []);
});
+22
View File
@@ -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);
});