初始提交:三玩法 Web 原型与设计文档

- 玩法一 数字一笔:规则库、求解器、26 关、竖屏触摸 Web 原型、测试
- 玩法二 连连气球:连消畅玩版核心、Web 原型、Cocos 球槽组件与导出脚本
- 玩法三 六边一笔:规则库、求解器、10 关 + 转墙实验包、Web 原型、测试
- 产品文档、UI 设计规范、各玩法 GDD / HANDOFF
- 文档移除全部外部参考作引用;删除玩法二 v0 旧稿
This commit is contained in:
cabinw
2026-09-16 14:30:59 +08:00
commit 8026e4628d
169 changed files with 22162 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
node_modules/
web/dist/
+113
View File
@@ -0,0 +1,113 @@
# 玩法三 · 六边一笔
六边形上一笔画走遍;墙贴在边上,挡住那一条邻接;线索数字必须踩在对应步号。
本目录自包含:规格、关卡、规则代码、求解器、生成器和 Web 原型。不 import 玩法一的矩形模型,也不把墙做成玩法一的后期关。壳的约定见 [`docs/PRODUCT.md`](../../docs/PRODUCT.md)。
几何锁六边形,墙是边不是格。关卡、资源、文案全部原创,不使用第三方题库或素材。
## 状态
M1/M2 已达:竖屏触摸 Web 原型可玩,10 关全部经求解器断言「唯一解 + 墙参与唯一性 + 无冗余墙」,存档、提示、音效、振动齐全。另有 **转墙** 实验包 9 关(GDD 附录 A)。下一步 M4 迁 Cocos。
## 目录
| 路径 | 用途 |
|---|---|
| [`docs/GDD.md`](docs/GDD.md) | 本玩法规格(权威) |
| [`docs/HANDOFF.md`](docs/HANDOFF.md) | 交接文档:实现了什么、怎么实现、与 GDD 的偏差、待办与 Cocos 迁移顺序 |
| [`levels/pack-1-tutorial.json`](levels/pack-1-tutorial.json) | 第一包 10 关 |
| [`levels/pack-2-spinner.json`](levels/pack-2-spinner.json) | 第二包(实验):转墙 9 关 |
| [`src/`](src/) | **纯 TS 规则层,零 DOM / 零 Cocos 依赖**。Web 与 Cocos 共用 |
| [`web/`](web/) | 浏览器原型:DOM / Canvas / PointerEvent / localStorage 粘合层 |
| [`tools/`](tools/) | 求解器命令行:全关卡校验、关卡设计辅助、随机生成器 |
| [`tests/`](tests/) | 规则层单元测试(node:test) |
`src/` 分层:
```
types.ts 关卡 JSON 结构(轴坐标 + 墙边)
board.ts HexBoard:轴坐标六邻接、墙、转墙(按步号判定)、几何邻接 / 可走邻接、连通性
game.ts 对局状态机:Command → GameEvent(start/append/undo/release/resume/reset;含 wallHit)
input.ts 手势语义:按下/移动/松手 + “当前压在哪个格” → Command
solver.ts 枚举满足线索的哈密顿路径、唯一解、墙有效性、提示(下一步 / 退回分叉)
geom.ts 纯几何:圆角多边形、凸裁剪、斜纹带
layout.ts 六边形布局:轴坐标 ↔ 像素、命中测试(带死区)、共用边、桥、墙带
view.ts HexBoardView:格/桥/数字/墙/冲突/提示/过关动效,只调用 Painter
painter.ts 绘制原语接口(多边形、线、圆、居中文字)
theme.ts 配色
progress.ts 存档(KeyValueStore 抽象,桶 mode:03-hex-walls)
levels.ts 关卡包解析
```
## 运行
```bash
cd modes/03-hex-walls
npm install
npm run dev # 起本地服务(默认 8100)+ 监听改动,终端会打印手机可访问的局域网地址
npm run build # 打包到 web/dist/app.js,之后可直接双击 web/index.html
npm run check # 类型检查(含 src/ 无 DOM 校验)+ 单元测试 + 全关卡校验
```
`?dev=1` 解锁全部关卡并暴露 `window.__hexWalls` 调试句柄(自动化手势测试用)。
关卡工具:
```bash
npm run validate # 每关:连通、恰好 1 解、第 3 关起去墙后不唯一、无冗余墙、转墙不可换静态墙
npm run validate -- 1-7 # 只看某关,并打印解
npm run design -- 1-7 --minimize # 当前线索里哪些可删
npm run design -- 1-7 --walls # 哪些墙可删
npm run design -- 1-7 --suggest 3 --path a,b,c,... # 给作者解找最少线索
npm run generate -- --shape hex2 --holes 2 --walls 3 --clues 3 --seed 7 --count 5 # 出候选盘
npm run generate -- --shape rect4x2 --walls 0 --spinners 1 --clues 2 --seed 212 # 带转墙的候选
```
## 第一包 · 10 关
每关只加一个旋钮(GDD §7)。「去墙解」= 拿掉全部墙后满足线索的解数,> 1 才说明墙在干活。
| 关 | N | 教什么 | 墙 | 线索 | 去墙解 |
|---|---|---|---|---|---|
| 1-1 | 3 | 滑动,邻格自动出步号 | 0 | 1 | – |
| 1-2 | 5 | 必须走完所有格 | 0 | 1, 5 | – |
| 1-3 | 4 | 第一堵墙:看起来相邻,滑不过去 | 1 | 1, 4 | 2 |
| 1-4 | 7 | 墙贴在 1 旁边,第一步只剩一个方向 | 1 | 1, 7 | 2 |
| 1-5 | 8 | 中段线索:3 在 1 旁边却要绕一步 | 1 | 1, 3, 8 | 2 |
| 1-6 | 11 | 空洞 + 两堵墙 | 2 | 1, 9 | 7 |
| 1-7 | 12 | 先想再画:旁边那格是陷阱 | 2 | 1, 9 | 8 |
| 1-8 | 12 | 综合:3 墙 3 线索 | 3 | 1, 6, 12 | 13 |
| 1-9 | 15 | 走廊:墙把一侧切成单向通道 | 3 | 1, 11, 15 | 16 |
| 1-10 | 19 | 小考:满花盘 4 墙 | 4 | 1, 10, 14 | 26 |
## 第二包 · 转墙(实验)
绕顶点转的墙,每走一格顺时针转 120°,轴心那端有圆点。规则与铁律见 GDD 附录 A。
| 关 | N | 教什么 | 静态墙 | 转墙 | 线索 | 去墙解 |
|---|---|---|---|---|---|---|
| 2-1 | 7 | 起手后墙正好挡住直觉的第一步,绕回来时它已转开 | 0 | 1 | 1, 7 | 2 |
| 2-2 | 8 | 用线索 4 算拍子:绕两格到 4,再往左时墙刚好开 | 0 | 1 | 1, 4 | 6 |
| 2-3 | 12 | 静态墙 + 转墙综合 | 1 | 1 | 1, 12 | 9 |
| 2-4 | 7 | 两个转墙,轴心相邻 | 0 | 2 | 1, 2 | 8 |
| 2-5 | 10 | 三角盘,两个转墙分居两端 | 0 | 2 | 1, 6 | 8 |
| 2-6 | 12 | 三个转墙围着中心格 | 0 | 3 | 1, 11 | 20 |
| 2-7 | 15 | 三个转墙挤在右侧 + 一堵静态墙 | 1 | 3 | 1, 7 | 49 |
| 2-8 | 17 | 三个转墙分布三处 | 0 | 3 | 1, 10 | 52 |
| 2-9 | 19 | 满花盘,四个转墙 | 0 | 4 | 1, 8, 17 | 41 |
## 交互(已实现)
- 有线索 `1` 只能从它起手,点别处 `1` 抖一下。
- 按住滑到相邻、无墙、未走格 → 追加(弹一下、桥长出来、步号淡入、音高随步号上升)。
- 滑向有墙的邻格 → 墙闪白 + 红描边 + 轻推、短振动、闷响;不追加,指针逻辑上仍在末端。撞转墙同样反馈。
- 每追加 / 撤销一格,转墙做 120° 旋转动画。
- 滑回上一格 → 撤销。不相邻 / 已走且非上一格 → 忽略。
- 松手保留;从末端继续;点前一格撤销;点末端旁的空格直接追加。
- 线索踩错 → 红底 + 角标写应有步号,不中断手势。走完且全对 → 提亮、扫光、自动下一关。
- 提示:起点 / 下一步 / 退回分叉(压暗要退的段)/ 起手错(重开按钮脉冲)。
## 移植到 Cocos Creator
见 [`docs/HANDOFF.md`](docs/HANDOFF.md) §4.2 D。要点:`src/` 整目录拷进 `assets/modes/03-hex-walls/scripts/` 不改一行;写 `GraphicsPainter`(`Graphics.moveTo/lineTo/close/fill/stroke` + `Label` 池);触摸事件转节点坐标后喂 `hitTest` → `PointerInput`;`sys.localStorage` 实现 `KeyValueStore`。
+386
View File
@@ -0,0 +1,386 @@
# 玩法三 · 六边一笔
本文只描述**玩法三**。玩法一、玩法二有自己的目录和 GDD,见仓库 [`docs/PRODUCT.md`](../../../docs/PRODUCT.md)。
在六边形拼板上画一条哈密顿路径。格子之间的边上可以有墙,墙挡住那一条邻接。预先写好的数字,必须刚好在那一步走到。
本玩法是**独立玩法**:六边形 + 边墙,不用矩形大格。墙的价值是把“第一步往哪走”收成推理——看起来相邻的格子,有墙就走不过去。关卡、资源、文案全部原创。
---
## 1. 一句话
在六边形盘上走遍每一格;黄黑条纹的墙贴在两条格子的共用边上,那条路走不通;线索数字必须踩在对应步号上。
## 2. 产品定位
| 项 | 决定 |
|---|---|
| 类型 | 单机、离线、无体力、无计时的逻辑 puzzle |
| 平台 | 手机竖屏。本玩法 V1 先在 `modes/03-hex-walls/web/` 可玩 |
| 单局 | 20–120 秒想,5–12 秒画完 |
| 会话 | 通勤 3 关,睡前 10 关 |
| 对象 | 已经能接受一笔画的人;墙是给“矩形走腻了”的第二层 |
| 付费 | 竖直切面免费无广告 |
和玩法一同属“顺序一笔画”,但几何不同:
| | 玩法一 | 玩法三 |
|---|---|---|
| 格子 | 矩形,可 2×2 只计一步 | 正六边形,一格一步 |
| 邻接 | 四向共边 | 六向共边 |
| 障碍 | 空洞 | 空洞 + **边墙** |
| 代码 | `modes/01-number-stroke` | `modes/03-hex-walls`,禁止互相 import |
不要把墙补进玩法一的后期关。墙是本玩法的主旋钮。
## 3. 核心循环
1. 看盘:线索在哪,墙封了哪几条边,哪一格看起来近其实过不去。
2. 推:从线索往外挤,尤其是被墙封死的第一步。
3. 画:滑到相邻且无墙的未走格,自动填步号。
4. 错了沿原路退,或重开。
5. 全填满且线索全部对上 → 过关。
没有分数。爽感来自“墙把表面的近路掐掉,只剩一条”。
---
## 4. 规则(权威)
### 4.1 盘面
- 格子是点顶(pointy-top)正六边形,用轴坐标 `(q, r)`。
- 每个格是图上一个节点,路径里计 1 步。没有大格。
- 单位格要么属于恰好一个六边形,要么是空洞。
- 盘面可不规则。
轴坐标邻接(未考虑墙):
```
(q+1, r), (q-1, r), (q, r+1), (q, r-1), (q+1, r-1), (q-1, r+1)
```
### 4.2 墙
墙是**无向边**,贴在两个相邻格的共用边上,画成黄黑斜纹警示条。
- 有墙的两格视为**不相邻**。其它邻接不受影响。
- 墙不占格,不能走在墙上。
- 同一对格最多一堵墙。
- 墙必须画在真实共用边上。不能“对角墙”。
这是本玩法相对玩法一多出来的唯一新原语。
### 4.3 路径
目标:在「去墙之后的格图」上找一条哈密顿路径。
- 每个格恰好一次。
- 只能走到相邻、无墙、尚未走过的格。
- 第 k 个格显示数字 k(从 1 起)。
- 不能分叉、不能跳、不能穿墙。
### 4.4 线索格
部分格预先写了正整数 `c`,带描边。
- 走到该格时步号必须等于 `c`。
- 可以没有 `1`(起点未知)。教学前 8 关必须有 `1`。
- 线索互不相同,`1 ≤ c ≤ N`。
- 可以没有 `N`。
### 4.5 对错
允许暂时画错:
- 踩到线索但步号 ≠ `c`:该格冲突态(红底),显示当前步号。
- 冲突不打断手势。
- **过关**:所有格走过,且所有线索步号正确。
- 无失败弹窗。穿墙或对角滑:忽略。
### 4.6 内容铁律
发布前求解器必须断言:
1. 去墙后的图连通。
2. 恰好 1 条哈密顿路径满足全部线索。
3. 线索尽量少。教学关可以多给。
若去掉全部墙后解变得不唯一,说明墙在干活,这关才算合格的“墙关”。第 3 关起,每关至少 1 堵墙,且去掉墙会破坏唯一性。
---
## 5. 交互
单指。和玩法一同一套手感,只是命中的是六边形、且要检查墙。
| 手势 | 行为 |
|---|---|
| 点合法起点 | 该格成为第 1 步 |
| 滑向相邻、无墙、未走格 | 追加 |
| 滑向有墙的邻居 | 忽略(可在墙上闪一下) |
| 滑回上一格 | 撤销 |
| 滑到不相邻 / 已走且非上一格 | 忽略 |
| 松手 | 保留路径,可从末端继续 |
合法起点:有线索 `1` 则只能从它起手;否则任意格。
按钮:重开、提示、菜单(本玩法选关 / 回壳)。顶栏:`{pack}-{index}`、剩余格、菜单。
输入状态机与玩法一相同:Empty → Drawing → Idle → Cleared。实现时不要 import 玩法一,允许各写一份。
---
## 6. 反馈与手感
| 事件 | 反馈 |
|---|---|
| 追加一步 | 六边形 80ms 弹,共用边拉一条短线 |
| 撞墙 | 墙闪白 100ms,短振动,不追加 |
| 线索步号正确 | 描边闪白 |
| 冲突 | 红底 |
| 过关 | 路径色提高,短线顺次扫过 |
未走:中灰。已走:蓝紫。墙:黄黑斜纹,对比足够,不靠颜色当唯一信息(色弱可把墙改成粗黑 + 缺口)。背景白。
---
## 7. 关卡语言
一次只加一个旋钮。
| 旋钮 | 作用 | 何时引入 |
|---|---|---|
| 格数 N | 6–8 教学,9–14 常规,15–20 后期 | 全程 |
| 墙的条数 | 1 堵教规则,2–4 堵做推理 | 第 3 关 |
| 墙贴在线索旁 | 第一步往往只剩一个方向 | 第 4 关 |
| 空洞 | 和墙叠加,近路更假 | 第 6 关 |
| 中段线索 | 不只靠起点 | 第 5 关 |
| 无线索 1 | 起点未知 | 第 13 关之后 |
不做:矩形大格、斜穿、传送、限时。大格是玩法一的旋钮,不要混进来。
### 7.1 教学 8 关(必须手做)
1-1 到 1-4 的坐标在 `levels/pack-1-tutorial.json`。
| 关 | 教什么 |
|---|---|
| 1-1 | 六边形、滑动、自动出数字。无墙。3 格 |
| 1-2 | 必须走完。5 格,标 1 和 5 |
| 1-3 | 第一堵墙:看起来相邻,滑不过去,必须绕 |
| 1-4 | 墙贴在 1 旁边,第一步不是“随便挑邻居” |
| 1-5 | 中段线索 |
| 1-6 | 两堵墙 + 一个空洞 |
| 1-7 | 先想再画,错了要退 |
| 1-8 | 综合:约 12 格、3 堵墙、3 个线索 |
1-3 是关键:过不了“墙是边不是格”,后面全废。
### 7.2 结构
一包 8 关。V1:教学 8 + 3 包 × 8 = **32 关**。线性解锁。
### 7.3 规则样例
规则样例直接用 `levels/pack-1-tutorial.json`:1-3 演示“墙是边不是格”(两格共边却被墙封住,看起来相邻,滑不过去,必须绕),1-4 演示“墙贴在线索旁,第一步只剩一个方向”。全部关卡由求解器断言唯一解且墙参与唯一性(§4.6)。
---
## 8. 生成器与求解器
教学关手做。第 9 关起可生成,人要过一眼。
求解器:
- 输入:六边形列表 + 墙边列表 + 线索。
- 邻接 = 轴坐标六邻接减去墙。
- 回溯哈密顿路径,线索处剪枝。
- `N ≤ 20`。发布条件 `paths.length === 1`。
- 额外检查:删掉所有墙之后 `paths.length !== 1`(墙必须改变解集合)。
生成:
1. 铺一块连通的六边形聚落,可挖洞。
2. 随机一条哈密顿路径当作者解。
3. 在**不是作者解所用的边**上插 1~3 堵墙。
4. 加 2~3 个线索直到唯一。
5. 跑“去墙不唯一”检查。失败则换墙。
关卡文件:
```json
{
"id": "1-3",
"pack": 1,
"index": 3,
"orientation": "pointy",
"cells": [
{ "id": "a", "q": 0, "r": 0, "clue": 1 },
{ "id": "b", "q": 1, "r": 0 },
{ "id": "c", "q": 0, "r": 1 }
],
"walls": [
["a", "b"]
]
}
```
`walls` 是格 id 对。求解器不写进 JSON。
---
## 9. 界面与流程
```
(壳:选玩法)→ 本玩法选关 → 对局 → 过关 → 下一关
↘ 菜单:关卡列表 / 回壳
```
对局层:顶栏关号与剩余格,中间六边形盘(按短边缩放),底栏重开 / 提示。过关短动画后自动下一关。
1-1 一行:“从一个数字滑到旁边的格子”。1-3 一行:“条纹是墙,绕开它”。之后无文案。
---
## 10. 提示、存档
提示:
- 无路径:指出合法起点。
- 路径是唯一解前缀:指出下一步。
- 已偏离:高亮分叉点,不继续给错下一步。
- 竖直切面无限次。
存档键前缀 `mode:03-hex-walls`。`clearedId[]`、`currentId`。不存半途路径。
---
## 11. 设计边界
V1 只做主规则(§4)。明确不做:
- 矩形大格(玩法一的旋钮)
- 矩形 + 墙、斜穿、传送、限时
- 计分
几何锁定为六边形,墙是边不是格。关卡、资源、文案全部原创,不使用第三方题库或素材。
---
## 12. 技术
全部落在 `modes/03-hex-walls/`。
禁止:
- import 玩法一的 Cell / 四向邻接 / 矩形碰撞
- 把墙做成“第三种格子”
| 项 | 决定 |
|---|---|
| 竖直切面 | `web/` 单页 |
| 坐标 | 轴坐标 pointy-top |
| 求解器 | `tools/solver.ts`,只服务本玩法 |
| 关卡 | `levels/*.json` |
模块:`model`(Hex、Wall、Path)、`rules`、`input`、`solver`、`view`。
---
## 13. 竖直切面
1. 1-1、1-2 无墙可过。
2. 1-3 直滑进墙无效,绕开后能过。
3. 1-4 有线索 1 时点别的格起手无效;墙让第一步只剩一个方向。
4. 冲突、撤销、重开与玩法一同样稳。
5. 求解器对 VS 关断言唯一解,且去墙后不唯一(1-3 起)。
6. 提示在偏离时拉回分叉。
---
## 14. 里程碑
| 阶段 | 交付 |
|---|---|
| M0 | 本 GDD 锁定 |
| M1 | 规则 + 求解器 + 1-1~1-4 可玩 |
| M2 | 1-1~1-8 + 存档 |
| M3 | 再 24 关 + 选关 |
| M4 | 迁 Cocos 本玩法场景 |
---
## 15. 关键决定
1. **独立玩法,不是玩法一的 DLC。** 六边形模型和边墙都住在本目录。
2. **墙是边,不是格。** 穿墙 = 那条邻接不存在。
3. **解唯一,且墙必须参与唯一性。**
4. **允许画错,随时撤回。**
5. **无大格。** 大格留给玩法一。
6. **先 Web,再 Cocos。**
---
## 16. 验收
- 没玩过的人在 1-3 结束前理解墙。
- 1-4 开始出现停顿,不是闭眼滑。
- 任意关关闭提示仍可人解。
- 撞墙 10 次不误走、不穿模。
---
## 17. 已关闭的问题
- 要不要合并进玩法一?不要。
- 要不要矩形 + 墙?不要。本玩法锁六边。
- 要不要计分?不要。
## 18. 留给实现的缺口
- 对外名称是否就用「六边一笔 / Hex Stroke」。
- 墙的最终画法(斜纹宽度、是否要缺口给色弱)。
- M3 关是手做还是生成后人工改,看 M2。
---
## 19. 附录 A · 转墙(实验机制,2026-09-15 加入)
### A.1 设计铁律:手指就是笔尖
路径的末端永远在指尖下面;手指走过哪些格,路径就是哪些格。任何让末端离开指尖的机制(滑行、传送、跳格、自动延伸)一律不做。新机制只能改三样东西:哪一步合法、数字怎么数、过关条件是什么。
### A.2 规则
- 六边形网格上每个顶点交汇三条边,互成 120°。转墙固定在一个顶点上,任意时刻占其中一条边,画法与静态墙相同,只在轴心那一端加一个圆点。
- 每走一格(含起手那一格),所有转墙同拍顺时针转 120°。已走 t 格时,墙在第 `(phase + t) mod 3` 条边上。
- 判定能否从末端走到邻格,用的是**出发前**的位置;走完再转。撤销一步则倒转回去。状态完全由步号推出,不需要额外存档。
- 转到盘外或空洞的边上时,墙暂时不挡任何东西。
- 转墙不得与静态墙重叠;周围至少要有两条盘上的边。
### A.3 玩家体验
玩家不仅要估计路线,还要估计到达某条边时墙在哪。线索数字给出步号,步号 mod 3 就是墙的位置,所以线索和转墙天然互相配合:为了让某一步“刚好开着”,路线要多绕或少绕一格。
### A.4 内容铁律(在 §4.6 之上追加)
每个转墙必须同时满足:
1. 去掉它,解不再唯一。
2. 把它换成钉在任意一条候选边上的静态墙,解都不再唯一(保证“会转”参与推理,而不是随便一堵墙就能替代)。
同一顶点只能放一个转墙;两个转墙可以共用一条候选边。实验包里最多放到 4 个转墙(19 格),正式包按试玩结果定上限。
### A.5 关卡文件
```json
"spinners": [{ "cell": "c", "corner": 0, "phase": 1 }]
```
`corner` 是该格第几个角(从 30° 起顺时针 0..5),`phase` 是空盘时墙在第几条候选边。候选边顺时针编号:0 = 背离该格的那条边,1 = 该格与顺时针下一邻格,2 = 该格与上一邻格。
### A.6 实验包
`levels/pack-2-spinner.json`,9 关:2-1 教规则(起手后墙正好挡住直觉的第一步),2-2 用中段线索算拍子,2-3 静态墙 + 转墙综合;2-4 起转墙数逐步加到 4,格数 7→19,让“满盘都在动”的感觉逐关加强。
+247
View File
@@ -0,0 +1,247 @@
# 玩法三 · 六边一笔 — 交接文档
> 面向接手的人。读完本文 + 跑一遍 `npm run check`,应该能不问人就继续往下做。
> 规格权威是 [`GDD.md`](GDD.md),目录/运行方式速查在 [`../README.md`](../README.md),本文讲“做了什么、怎么做的、为什么、还差什么”。
>
> 交接日期:2026-09-15。状态:M1/M2 达成(Web 原型可玩、10 关唯一解且墙参与推理、存档/提示/音效/振动齐全),另有转墙实验包 9 关(§1.4),未进 Cocos。
---
## 0. 一分钟上手
```bash
cd modes/03-hex-walls
npm install # 若 esbuild 的 postinstall 被 npm 拦住:npm approve-scripts esbuild && npm rebuild esbuild
npm run dev # 起服务(默认 8100,--port N 或 PORT=N 改)+ 监听改动
npm run check # 类型检查(含 src/ 无 DOM 校验)+ 30 个单元测试 + 10 关校验
```
浏览器打开 `http://localhost:8100/?dev=1`:`?dev=1` 解锁全部关卡并暴露 `window.__hexWalls`(`debugStart(id)` / `debugCellCenter(id)` / `debugState()`)。
---
## 1. 实现了什么
### 1.1 交付清单(对照 GDD)
| GDD 条目 | 状态 | 备注 |
|---|---|---|
| §4.1 点顶六边形、轴坐标、六邻接 | 完成 | `src/board.ts`,`HEX_DIRS` |
| §4.2 墙 = 无向边、去掉那条邻接、必须在真实共用边上、同一对格最多一堵 | 完成 | `HexBoard` 构造时校验;`adj`(可走)与 `geoAdj`(几何)分开 |
| §4.3–4.5 路径、线索、冲突态、过关 | 完成 | `src/game.ts`,有单测 |
| §4.6 内容铁律:连通、恰好 1 解、第 3 关起墙参与唯一性 | 完成 | `npm run validate` + `tests/solver.test.ts` 双重断言;额外断言无冗余墙 |
| §5 手势(含滑向有墙邻居 → 忽略并反馈) | 完成 | `src/input.ts`;`Game` 发 `wallHit` 事件 |
| §6 反馈:追加弹、共用边拉桥、撞墙闪白 + 振动、线索闪白、冲突红、过关提亮扫光 | 完成 | `src/view.ts`;音效 `web/audio.ts` |
| §7.1 教学关 | 完成 10 关(用户要求 10 关) | 见 §1.3 |
| §8 求解器(回溯 + 连通/线索距离剪枝,N ≤ 20) | 完成 | 19 格 1.5ms |
| §8 生成器五步 | 完成 | `tools/generate.ts`;出候选,人过一眼再进包 |
| §9 界面流程 | 完成 | 选关 → 对局 → 过关自动下一关 → 最后一关回选关 |
| §10 提示三态 + 起手错;存档 `mode:03-hex-walls` | 完成 | 存档带 `v: 1` |
| §12 模块 model/rules/input/solver/view,纯 TS | 完成 | `tsconfig.src.json` 用 `lib: ["ES2020"]` 强制 `src/` 不碰 DOM |
| §13 竖直切面 6 条 | 全部在浏览器触摸仿真下验证过 | 见 §2.7 |
### 1.2 关卡:10 关
坐标 `(q, r)` 点顶六边形,格 id 按阅读顺序编号(先 r 再 q)。下面每关左边是盘面(`[n]` 线索),右边是唯一解步号,`墙:` 列出被封的格对。全部由 `npm run validate` 断言。
**1-1** 滑动(字幕:从 1 滑到旁边的格子)
```
[1] b c 1 2 3
```
**1-2** 必须走完(字幕:走完每一格,数字要对上)
```
[1] 1
b 2
c d [5] 3 4 5
```
**1-3** 第一堵墙。a、b 共边但被封,必须经 c 绕到 b(字幕:条纹是墙,绕开它)。去墙后 2 解。
```
[1] b 1 3
c [4] 2 4 墙: a|b
```
**1-4** 墙贴在 1 旁边(字幕:先看墙和数字,再决定第一步)。1 在左下 f,f|g 被封;中心 d=7 只能最后进,所以第一步只能往上 c,绕外圈一周落中心。
```
a b 3 4
c [7] e 2 7 5
[1] g 1 6 墙: f|g
```
**1-5** 中段线索。3 就在 1 右边却不能直接过去,必须先下到 e 再折回 b;墙 b|c 堵住“3 之后直走”,逼出之字形。3 个线索全部必要。
```
[1] [3] c [8] 1 3 5 8
e f g h 2 4 6 7 墙: b|c
```
**1-6** 空洞 + 两堵墙。1 下方是洞只能往右;顶行到 c 被 b|e 逼下去,e、f、回 d、g,右侧下到 k,底行从右往左收到 9。去墙后 7 解。
```
[1] b c d 1 2 3 6
e f g 4 5 7
h i [9] k 11 10 9 8 墙: e|j b|e
```
**1-7** 先想再画。1 在底行 k,旁边 h 被 h|i、h|j 围住,直觉先进 h 会把 j 孤立;正解先右绕整圈,e=9 → h → g → j。无线索路径 320 条。
```
a b c 7 6 5
d [9] f 8 9 4
g h i 11 10 3
j [1] l 12 1 2 墙: h|i h|j
```
**1-8** 综合。i=1 起手、a=12 收尾、l=6 决定先走底行;e 被 b|e、e|f 夹住只剩上下两个口。去墙后 13 解。
```
[12] b c d 12 11 9 8
e f g h 2 10 5 7
[1] j k [6] 1 3 4 6 墙: f|k b|e e|f
```
**1-9** 走廊。1 在 g 五向可起手;a=15 定终点、l=11 定绕向;c|g、i|n、e|i 把右侧切成单向通道。去墙后 16 解。
```
[15] b c d e 15 14 3 4 5
f [1] h i j 13 1 2 7 6
k [11] m n o 12 11 10 9 8 墙: c|g i|n e|i
```
**1-10** 小考。19 格满花盘。i=1 在内圈左,k=14 / p=10 在右侧;先上绕到顶行、顺右侧下、穿底回内圈、左侧收尾到 h。去墙后 26 解。
```
a b c 4 5 6
d e f g 3 2 7 8
h [1] j [14] l 19 1 15 14 9
m n o [10] 18 16 13 10
q r s 17 12 11 墙: f|k d|i k|l b|f
```
难度旋钮引入顺序:格数 → 墙(1-3) → 墙贴起点(1-4) → 中段线索(1-5) → 空洞(1-6) → 陷阱格(1-7) → 3 墙 3 线索(1-8) → 走廊(1-9) → 满花盘(1-10)。GDD 的“无线索 1”按 §7 留到第 13 关之后,本包全部有 `1`。
关卡 JSON 里 `teach` 是设计备注(含剧透)**永远不要渲染**;只有 `caption` 显示在顶部。
### 1.4 转墙实验包(`levels/pack-2-spinner.json`)
规则见 GDD 附录 A。实现要点:
- `SpinnerDef {cell, corner, phase}` → `HexBoard.spinners[]`,构造时把顶点周围三条边算成 `edges[3]`(不在盘上的为 null)。角 k 夹在屏幕方向 60k 与 60(k+1) 之间,对应 `HEX_DIRS[(6-k)%6]` 与 `HEX_DIRS[(5-k)%6]`。
- 状态不存:`spinnerPos(sp, t) = (phase + t) mod 3`,t = 已走格数。`HexBoard.canStep(a, b, t)` / `adjAt(a, t)` 在静态邻接之上再过滤。`Game.canAppend` 传 `path.length`,求解器 DFS 传当前 step。撤销 / 重开天然一致。
- BFS 连通剪枝仍用静态邻接(转墙只挡一条边,这是有效的松弛)。
- `wallHit` 事件多了 `spinner` 字段;视图给转墙做 120° 指数逼近的旋转动画(`SPIN_SPEED 16`,约 120ms),轴心画深色圆 + 黄点。
- 校验:`spinnerReport()` 断言每个转墙「去掉后不唯一」且「钉成任意一条静态墙后不唯一」;`generate --spinners n` 只在作者解每一步都不被挡的顶点上放。
- 9 关:2-1~2-3 各一个转墙(教规则 / 算拍子 / 配静态墙),2-4~2-9 逐步加到 2、2、3、3、3、4 个转墙,格数 7→19。全部触摸仿真滑通。同一顶点不允许放两个转墙(构造时按轴心坐标去重);两个转墙共用一条候选边是允许的。
- Web 选关页已支持多包(拼成一条线性序列,包间线性解锁,分组标题)。
### 1.3 视觉
浅色单主题(`src/theme.ts`):未走 `#E4E6EE`、已走靛蓝 `#4F5BD5`(过关 `#6672F0`)、线索描边近黑、冲突红 `#E5484D`、墙黑底 `#1F1F27` + 黄斜纹 `#FFC826`、提示橙 `#FF7A1A`(刻意避开墙黄)。桌面浏览器 ≥ 520px 宽时显示手机框。选关页瓦片用 CSS `clip-path` 做六边形。
---
## 2. 如何实现
### 2.1 技术选型与理由
目标是“迁 Cocos Creator 3.x 不重写”。与玩法一保持同一套决策,但代码各写一份(隔离规则):
| 决策 | 理由 |
|---|---|
| `src/` 纯 TS,零 DOM / 零 Cocos | Cocos 脚本就是 TS;`tsconfig.src.json`(`lib: ["ES2020"]`, `types: []`)编译一遍,谁往 `src/` 写 `document` 就报错 |
| import 无后缀 | Cocos 不允许 `import './foo.ts'` |
| 渲染走 `Painter` 五原语:`fillPolygon / strokePolygon / line / fillCircle / text` | **所有多边形(含圆角、斜纹裁剪)在 `src/geom.ts` 用纯数学算好**,实现方只需 moveTo/lineTo/close。Cocos `Graphics` 没有 `arcTo`,所以不把圆角交给画笔 |
| 输入层只认“指针压在哪个格” | `hitTest` 做像素 → 轴坐标(立方取整)+ 六边形死区;Cocos 只换坐标来源 |
| 存档走 `KeyValueStore` | Web `localStorage`,Cocos `sys.localStorage`,测试 `MemoryStore` |
| esbuild 单文件 IIFE | `file://` 可开;关卡 JSON 打进 bundle |
### 2.2 六边形几何要点(`src/layout.ts`)
- 点顶六边形,`size` = 外接圆半径。中心 `x = √3·size·(q + r/2)`,`y = 1.5·size·r`。
- `fitBoard`:取所有格外接矩形,按短边缩放到容器,`maxSize` 上限(`web/main.ts`:N ≤ 6 用 52,否则 60)。
- 缝 `gap = 0.12·size`;绘制半径 `drawRadius = size − gap/√3`(内切圆各缩 gap/2)。
- `hitTest`:`pixelToAxial` 立方取整得到候选格,再用点顶六边形内部判定 `|dx| ≤ R√3/2 且 |dx| + √3|dy| ≤ √3R`(R = size·(1−0.14))做死区。
- `sharedEdge(a,b)`:边中点 = 两中心中点,法向 n = a→b 单位向量,边向 u ⊥ n,边长 = size。不用查顶点表。
- 桥 `bridgePolygon`:沿边居中、宽 0.5 边长、跨 `gap + 2px` 的矩形。墙 `wallBand`:长 0.92 size、厚 0.3 size 的带;底用圆头 `line`,斜纹用 `stripesInBand`(45° 平行四边形 Sutherland–Hodgman 裁到带内)。
### 2.3 数据流
```
PointerEvent (web/main.ts) → 容器像素坐标;pointermove 按 size·0.4 插值采样
▼ layout.hitTest → cellIndex | -1
▼ PointerInput.down/move/up → Command
▼ Game.apply → GameEvent[] step / unstep / reset / rejectStart / wallHit / deadEnd / cleared
▼ handleEvents: view.onEvent(动效)+ sfx/vibrate + HUD + 过关计时跳关
每帧 rAF:view.update(dt);isAnimating 时 clearRect + view.draw(painter)
```
`Game.append` 自己区分三种情况:可走 → `step`;几何相邻但有墙且未走 → `wallHit{from,to,wall}`;其它 → 无事件。`PointerInput.move` 因此不需要知道墙。`hoverCell` 去抖保证停在墙外只报一次。
### 2.4 求解器与工具
- `solve()`:回溯 + BFS 连通剪枝 + 线索距离剪枝 + Warnsdorff 排序;`random` 选项给生成器随机化候选顺序。
- `wallsMatter(board)`:`solve(board.withoutWalls(), {limit: 2}).length !== 1`。`redundantWalls(board)`:逐堵删掉仍唯一的墙。
- `tools/validate.ts` 断言:连通、恰好 1 解、第 3 关起 ≥ 1 墙且去墙不唯一;打印无线索路径数、去墙解数、可删墙。
- `tools/design.ts`:`--minimize`(线索可删性)、`--walls`、`--suggest k --path`。
- `tools/generate.ts`:GDD §8.2 五步。`--shape hex1|hex2|tri4|tri5|para3x4|rect4x3|rect5x3...` `--holes` `--walls` `--clues` `--seed` `--count`;只输出“唯一 + 去墙不唯一 + 无冗余墙”的候选。本包 1-4~1-10 都是先生成再人工挑选、改线索、重编 id。
### 2.5 测试
30 个 node:test 用例:`board`(六邻接、墙、连通、非法盘)、`game`(起手、撞墙、冲突、过关锁定、流转)、`input`(滑动/撤销/撞墙一次/点着走/非法起手/过关失效)、`solver`(10 关唯一 + 墙参与 + 无冗余 + 前 8 关有 1、提示四态、随机模式)、`layout`(适配、像素往返、死区、共用边、几何工具)、`progress`。
浏览器触摸仿真(ego-browser / 任意 CDP,390×844)已跑通:10 关沿唯一解滑通、`debugState().status === 'cleared'`、存档内容正确;1-3 直滑进墙路径不变;走错 → 冲突红底角标 → 提示回退高亮并压暗。脚本片段与玩法一 HANDOFF §2.7 相同,只是句柄换成 `window.__hexWalls`,多一个 `debugStart(id)`。建议落成 `tests/e2e/`。
---
## 3. 与 GDD 的偏差
| 项 | GDD | 实际 | 原因 |
|---|---|---|---|
| 包大小 | 教学 8 + 3 包 × 8 | 第一包 10 关 | 用户要求 10 关;1-9/1-10 相当于 GDD 的 2 包前两关 |
| 原有 `levels/pack-1-tutorial.json` 的 1-4 | 线索 1,3,5 + 墙 a\|b | 重做为 7 格花形 | 原盘去墙后仍唯一,不满足 §4.6“墙必须参与唯一性” |
| 字幕 | 只 1-1、1-3 | 1-1~1-4 | 帮第一次玩的人 |
| 冗余墙 | 未要求 | 额外断言每堵墙都必要 | 让“墙就是推理”成立 |
| 撞墙反馈 | 墙闪白 100ms | 闪白 + 红描边 + 沿法向轻推 + 闷响 + 振动 | 只闪白在浅色底上不够醒目 |
| 菜单 / 音效振动开关 | 壳 | 左上返回 + 选关页 chip | 壳不存在 |
| 提示色 | 未定 | 橙 | 避免与墙黄混淆 |
| `hintPath` 烘焙 | 可选 | 运行时 `uniqueSolution()` | 19 格 1.5ms |
GDD §18 留白的“对外名称”暂用「六边一笔」;墙画法定为黑底黄斜纹圆头带,色弱下靠黑黄明度差 + 形状(条纹带)可辨,未做“缺口”变体。
---
## 4. 待办
### 4.1 已知不足
1. 没有真机、真人数据:死区 14%、插值 0.4 size、墙厚 0.3 size、过关 1.06s 跳关,都要真机摸过再定。
2. 只支持一个包;M3 的多包选关和包间解锁未做(`Progress.isUnlocked` 已能按 id 数组判断)。
3. 生成器的“漂亮度”过滤没有(重复折返、线索挤在一起),靠人挑。
4. 无线索 `1` 的关一个都没有(按 GDD 留到第 13 关后);`hint` 的 `restart` 态界面上未真正触发过。
5. 高对比模式、多语言、存档迁移(已有 `v: 1`)未做。
6. 19 格盘在 390 宽屏上受宽度限制,size ≈ 42px;更大的盘(hex3)需要考虑横向滚动或缩小 gap。
### 4.2 接下来
**A. 收尾 M2**:真机跑一遍;找 3~5 人试玩看 1-3 是否理解“墙是边”、1-7 是否出现停顿、2-1 是否看懂“墙会转”;把触摸脚本落成 Playwright。转墙若验证通过,按 GDD 附录 A 出一整包 8~10 关(可用生成器 `--spinners`),并考虑“转到盘外暂时消失”的边界顶点关。
**B. M3 内容**:用 `npm run generate` 出 2~3 包,每包 8~10 关,人工挑 + `design.ts` 调线索;选关页改包列表。
**C. 壳接入**:把 `web/main.ts` 的 `App` 改造成 `PuzzleMode { id: '03-hex-walls', mount, unmount }`,存档/音效/振动从 `ShellContext` 读。
**D. M4 迁 Cocos Creator 3.x**
1. `assets/modes/03-hex-walls/scripts/` ← 拷 `src/` 全部文件,不改一行。
2. 关卡 JSON 放 `resources/levels/`,`resources.load(..., JsonAsset)` → `parsePack/loadPack`。
3. 写 `GraphicsPainter implements Painter`:`fillPolygon/strokePolygon` = `moveTo` 第一点、`lineTo` 其余、`close`、`fill/stroke`;`line` = `moveTo/lineTo/stroke`(`lineCap = ROUND`);`fillCircle` = `circle + fill`;`text` = `Label` 对象池(每帧先全部隐藏再按需取用,`setPosition` 到中心)。Cocos y 轴向上:在 Painter 内 `y' = H − y`。
4. `BoardController` 组件:`onLoad` 建 `Game / HexBoardView / PointerInput`;`update(dt)` 调 `view.update(dt)`,`isAnimating` 时 `graphics.clear()` + `view.draw(painter)`;`Node.on(TOUCH_START/MOVE/END/CANCEL)` → `UITransform.convertToNodeSpaceAR` → 翻 y → `hitTest` → `PointerInput`。TOUCH_MOVE 里照搬 `web/main.ts` 的插值采样。
5. `Progress(new CocosStore())`,`CocosStore` 包 `sys.localStorage`。
6. HUD、选关(六边形瓦片用 Sprite 或 Graphics)、字幕、toast 用 UI 节点复刻 `index.html`;音效换 `AudioSource` 五段短音(step/wall/conflict/cleared/reject)。
7. 用 §2.5 的路径数据在真机上把 10 关滑一遍。
8. 不搬:`web/`、`tools/`、`tests/`(测试继续在 Node 跑,只依赖 `src/`)。
---
## 附:常用命令
```bash
npm run dev # 起服务
npm run check # 全部检查
npm run validate -- 1-10 # 看某关盘面/解/去墙解数
npm run design -- 1-10 --minimize --walls
npm run generate -- --shape hex2 --holes 2 --walls 4 --clues 3 --seed 82 --count 3
```
@@ -0,0 +1,204 @@
{
"mode": "03-hex-walls",
"pack": 1,
"title": "教学",
"orientation": "pointy",
"notes": "轴坐标 (q, r),点顶六边形。walls 是格 id 对,必须是真实共用边。格 id 按阅读顺序(先 r 再 q)编号。teach 是设计备注不显示,caption 显示在对局顶部。",
"levels": [
{
"id": "1-1",
"teach": "六边形滑动,邻格自动出步号。无墙。",
"caption": "从 1 滑到旁边的格子",
"cells": [
{ "id": "a", "q": 0, "r": 0, "clue": 1 },
{ "id": "b", "q": 1, "r": 0 },
{ "id": "c", "q": 2, "r": 0 }
],
"walls": []
},
{
"id": "1-2",
"teach": "必须走完所有格,数字要对上。",
"caption": "走完每一格,数字要对上",
"cells": [
{ "id": "a", "q": 0, "r": 0, "clue": 1 },
{ "id": "b", "q": 0, "r": 1 },
{ "id": "c", "q": 0, "r": 2 },
{ "id": "d", "q": 1, "r": 2 },
{ "id": "e", "q": 2, "r": 2, "clue": 5 }
],
"walls": []
},
{
"id": "1-3",
"teach": "第一堵墙。a 和 b 看起来相邻,滑不过去,必须经 c 绕到 b。去墙后有两条解。",
"caption": "条纹是墙,绕开它",
"cells": [
{ "id": "a", "q": 0, "r": 0, "clue": 1 },
{ "id": "b", "q": 1, "r": 0 },
{ "id": "c", "q": 0, "r": 1 },
{ "id": "d", "q": 1, "r": 1, "clue": 4 }
],
"walls": [
["a", "b"]
]
},
{
"id": "1-4",
"teach": "花形 7 格。1 在左下 f,墙 f|g 封住它右边那格;中心 d 是 7 只能最后进,所以第一步只能往上走 c。绕外圈一周最后落中心。去墙后 2 条解。",
"caption": "先看墙和数字,再决定第一步",
"cells": [
{ "id": "a", "q": 0, "r": -1 },
{ "id": "b", "q": 1, "r": -1 },
{ "id": "c", "q": -1, "r": 0 },
{ "id": "d", "q": 0, "r": 0, "clue": 7 },
{ "id": "e", "q": 1, "r": 0 },
{ "id": "f", "q": -1, "r": 1, "clue": 1 },
{ "id": "g", "q": 0, "r": 1 }
],
"walls": [
["f", "g"]
]
},
{
"id": "1-5",
"teach": "中段线索。3 就在 1 右边,却不能直接过去(那样 b=2):必须先下到 e 再折回 b。墙 b|c 堵住“3 之后直走”,逼出 a,e,b,f,c,g,h,d 的之字形。3 个线索全部必要。",
"cells": [
{ "id": "a", "q": 0, "r": 0, "clue": 1 },
{ "id": "b", "q": 1, "r": 0, "clue": 3 },
{ "id": "c", "q": 2, "r": 0 },
{ "id": "d", "q": 3, "r": 0, "clue": 8 },
{ "id": "e", "q": 0, "r": 1 },
{ "id": "f", "q": 1, "r": 1 },
{ "id": "g", "q": 2, "r": 1 },
{ "id": "h", "q": 3, "r": 1 }
],
"walls": [
["b", "c"]
]
},
{
"id": "1-6",
"teach": "空洞 + 两堵墙。左上 1 的下方是洞,只能往右;顶行走到 c 后被墙 b|e 迫使从 c 下到 e,再 f、回 d、g,右侧下到 k,底行从右往左收,9 在 j。去墙后 7 条解。",
"cells": [
{ "id": "a", "q": 0, "r": 0, "clue": 1 },
{ "id": "b", "q": 1, "r": 0 },
{ "id": "c", "q": 2, "r": 0 },
{ "id": "d", "q": 3, "r": 0 },
{ "id": "e", "q": 1, "r": 1 },
{ "id": "f", "q": 2, "r": 1 },
{ "id": "g", "q": 3, "r": 1 },
{ "id": "h", "q": -1, "r": 2 },
{ "id": "i", "q": 0, "r": 2 },
{ "id": "j", "q": 1, "r": 2, "clue": 9 },
{ "id": "k", "q": 2, "r": 2 }
],
"walls": [
["e", "j"],
["b", "e"]
]
},
{
"id": "1-7",
"teach": "先想再画。1 在底行中间 k,旁边的 h 被两堵墙围住(h|i、h|j),直觉先进 h 会把 j 孤立;正解是先往右 l 绕整圈,最后 e=9 → h → g → j。无线索路径 320 条。",
"cells": [
{ "id": "a", "q": 0, "r": 0 },
{ "id": "b", "q": 1, "r": 0 },
{ "id": "c", "q": 2, "r": 0 },
{ "id": "d", "q": 0, "r": 1 },
{ "id": "e", "q": 1, "r": 1, "clue": 9 },
{ "id": "f", "q": 2, "r": 1 },
{ "id": "g", "q": 0, "r": 2 },
{ "id": "h", "q": 1, "r": 2 },
{ "id": "i", "q": 2, "r": 2 },
{ "id": "j", "q": 0, "r": 3 },
{ "id": "k", "q": 1, "r": 3, "clue": 1 },
{ "id": "l", "q": 2, "r": 3 }
],
"walls": [
["h", "i"],
["h", "j"]
]
},
{
"id": "1-8",
"teach": "综合:12 格、3 堵墙、3 个线索。左下 i=1 起手,左上 a=12 收尾;右下 l=6 决定先走底行。e 被 b|e、e|f 夹住只剩上下两个口。去墙后 13 条解。",
"cells": [
{ "id": "a", "q": 0, "r": 0, "clue": 12 },
{ "id": "b", "q": 1, "r": 0 },
{ "id": "c", "q": 2, "r": 0 },
{ "id": "d", "q": 3, "r": 0 },
{ "id": "e", "q": 0, "r": 1 },
{ "id": "f", "q": 1, "r": 1 },
{ "id": "g", "q": 2, "r": 1 },
{ "id": "h", "q": 3, "r": 1 },
{ "id": "i", "q": -1, "r": 2, "clue": 1 },
{ "id": "j", "q": 0, "r": 2 },
{ "id": "k", "q": 1, "r": 2 },
{ "id": "l", "q": 2, "r": 2, "clue": 6 }
],
"walls": [
["f", "k"],
["b", "e"],
["e", "f"]
]
},
{
"id": "1-9",
"teach": "15 格。1 在 g,五个方向都能起手;a=15 说明终点在左上,l=11 在底行决定绕行方向。c|g、i|n、e|i 三堵墙把右侧切成只能单向通过的走廊。去墙后 16 条解。",
"cells": [
{ "id": "a", "q": 0, "r": 0, "clue": 15 },
{ "id": "b", "q": 1, "r": 0 },
{ "id": "c", "q": 2, "r": 0 },
{ "id": "d", "q": 3, "r": 0 },
{ "id": "e", "q": 4, "r": 0 },
{ "id": "f", "q": 0, "r": 1 },
{ "id": "g", "q": 1, "r": 1, "clue": 1 },
{ "id": "h", "q": 2, "r": 1 },
{ "id": "i", "q": 3, "r": 1 },
{ "id": "j", "q": 4, "r": 1 },
{ "id": "k", "q": -1, "r": 2 },
{ "id": "l", "q": 0, "r": 2, "clue": 11 },
{ "id": "m", "q": 1, "r": 2 },
{ "id": "n", "q": 2, "r": 2 },
{ "id": "o", "q": 3, "r": 2 }
],
"walls": [
["c", "g"],
["i", "n"],
["e", "i"]
]
},
{
"id": "1-10",
"teach": "小考:19 格满花盘、4 堵墙、3 个线索。i=1 在内圈左侧,k=14 与 p=10 在右侧内外圈。先上绕到顶行,顺右侧下来,穿过底部回到内圈,最后从左侧收尾到 h。去墙后 26 条解。",
"cells": [
{ "id": "a", "q": 0, "r": -2 },
{ "id": "b", "q": 1, "r": -2 },
{ "id": "c", "q": 2, "r": -2 },
{ "id": "d", "q": -1, "r": -1 },
{ "id": "e", "q": 0, "r": -1 },
{ "id": "f", "q": 1, "r": -1 },
{ "id": "g", "q": 2, "r": -1 },
{ "id": "h", "q": -2, "r": 0 },
{ "id": "i", "q": -1, "r": 0, "clue": 1 },
{ "id": "j", "q": 0, "r": 0 },
{ "id": "k", "q": 1, "r": 0, "clue": 14 },
{ "id": "l", "q": 2, "r": 0 },
{ "id": "m", "q": -2, "r": 1 },
{ "id": "n", "q": -1, "r": 1 },
{ "id": "o", "q": 0, "r": 1 },
{ "id": "p", "q": 1, "r": 1, "clue": 10 },
{ "id": "q", "q": -2, "r": 2 },
{ "id": "r", "q": -1, "r": 2 },
{ "id": "s", "q": 0, "r": 2 }
],
"walls": [
["f", "k"],
["d", "i"],
["k", "l"],
["b", "f"]
]
}
]
}
@@ -0,0 +1,222 @@
{
"mode": "03-hex-walls",
"pack": 2,
"title": "转墙",
"orientation": "pointy",
"notes": "实验包。spinners = 绕顶点转的墙:{cell, corner, phase},顶点是 cell 的第 corner 个角(30° 起顺时针 0..5),三条候选边顺时针编号,已走 t 格时墙在第 (phase+t) mod 3 条边。格 id 按阅读顺序编号。",
"levels": [
{
"id": "2-1",
"teach": "教转墙。1 在中心,轴心在左下三格交汇的顶点。起手后墙正好挡住 1 的左邻,直觉往左会撞墙;绕右侧走一圈回来时墙已经转开。去墙后 2 解。",
"caption": "这堵墙会转:每走一格转一下",
"cells": [
{ "id": "a", "q": 0, "r": -1 },
{ "id": "b", "q": 1, "r": -1, "clue": 7 },
{ "id": "c", "q": -1, "r": 0 },
{ "id": "d", "q": 0, "r": 0, "clue": 1 },
{ "id": "e", "q": 1, "r": 0 },
{ "id": "f", "q": -1, "r": 1 },
{ "id": "g", "q": 0, "r": 1 }
],
"walls": [],
"spinners": [
{ "cell": "c", "corner": 0, "phase": 1 }
]
},
{
"id": "2-2",
"teach": "算拍子。4 在 1 的正上方,直接上去是 2,绕一格是 3,只有绕两格(g,h,d,c)才是 4;到了 4 再往左时墙刚好转开。去墙后 6 解。",
"cells": [
{ "id": "a", "q": 0, "r": 0 },
{ "id": "b", "q": 1, "r": 0 },
{ "id": "c", "q": 2, "r": 0, "clue": 4 },
{ "id": "d", "q": 3, "r": 0 },
{ "id": "e", "q": 0, "r": 1 },
{ "id": "f", "q": 1, "r": 1 },
{ "id": "g", "q": 2, "r": 1, "clue": 1 },
{ "id": "h", "q": 3, "r": 1 }
],
"walls": [],
"spinners": [
{ "cell": "b", "corner": 0, "phase": 2 }
]
},
{
"id": "2-3",
"teach": "静态墙 + 转墙综合。1 在右下角,12 就在旁边,说明要绕整盘一圈最后从上方进 k。左下角三格 e/i/j 的转墙决定绕到左侧时能不能顺利下去,拍子不对就得走错。去墙后 9 解。",
"cells": [
{ "id": "a", "q": 0, "r": 0 },
{ "id": "b", "q": 1, "r": 0 },
{ "id": "c", "q": 2, "r": 0 },
{ "id": "d", "q": 3, "r": 0 },
{ "id": "e", "q": 0, "r": 1 },
{ "id": "f", "q": 1, "r": 1 },
{ "id": "g", "q": 2, "r": 1 },
{ "id": "h", "q": 3, "r": 1 },
{ "id": "i", "q": -1, "r": 2 },
{ "id": "j", "q": 0, "r": 2 },
{ "id": "k", "q": 1, "r": 2, "clue": 12 },
{ "id": "l", "q": 2, "r": 2, "clue": 1 }
],
"walls": [
["d", "h"]
],
"spinners": [
{ "cell": "j", "corner": 3, "phase": 0 }
]
},
{
"id": "2-4",
"teach": "两个转墙,轴心相邻。1、2 并排在顶行,2 之后要下到中心再折向左;两堵墙一前一后决定下去和折回的拍子。去墙后 8 解。",
"cells": [
{ "id": "a", "q": 0, "r": -1 },
{ "id": "b", "q": 1, "r": -1 },
{ "id": "c", "q": -1, "r": 0 },
{ "id": "d", "q": 0, "r": 0 },
{ "id": "e", "q": 1, "r": 0 },
{ "id": "f", "q": -1, "r": 1, "clue": 2 },
{ "id": "g", "q": 0, "r": 1, "clue": 1 }
],
"walls": [],
"spinners": [
{ "cell": "d", "corner": 5, "phase": 0 },
{ "cell": "a", "corner": 0, "phase": 1 }
]
},
{
"id": "2-5",
"teach": "三角盘 10 格,两个转墙一个在顶行右侧、一个在底尖。1 在第二行右端,6 在第二行左端:先绕下面的小三角(f,i,j,h)再到 6,最后走顶行。去墙后 8 解。",
"cells": [
{ "id": "a", "q": 0, "r": 0 },
{ "id": "b", "q": 1, "r": 0 },
{ "id": "c", "q": 2, "r": 0 },
{ "id": "d", "q": 3, "r": 0 },
{ "id": "e", "q": 0, "r": 1, "clue": 6 },
{ "id": "f", "q": 1, "r": 1 },
{ "id": "g", "q": 2, "r": 1, "clue": 1 },
{ "id": "h", "q": 0, "r": 2 },
{ "id": "i", "q": 1, "r": 2 },
{ "id": "j", "q": 0, "r": 3 }
],
"walls": [],
"spinners": [
{ "cell": "c", "corner": 2, "phase": 1 },
{ "cell": "h", "corner": 0, "phase": 1 }
]
},
{
"id": "2-6",
"teach": "三个转墙围着中心 g:g 的三条边轮流被挡,什么时候进、什么时候出都要算。去墙后 20 解。",
"cells": [
{ "id": "a", "q": 0, "r": 0 },
{ "id": "b", "q": 1, "r": 0 },
{ "id": "c", "q": 2, "r": 0 },
{ "id": "d", "q": 3, "r": 0 },
{ "id": "e", "q": 0, "r": 1, "clue": 11 },
{ "id": "f", "q": 1, "r": 1 },
{ "id": "g", "q": 2, "r": 1 },
{ "id": "h", "q": 3, "r": 1 },
{ "id": "i", "q": -1, "r": 2, "clue": 1 },
{ "id": "j", "q": 0, "r": 2 },
{ "id": "k", "q": 1, "r": 2 },
{ "id": "l", "q": 2, "r": 2 }
],
"walls": [],
"spinners": [
{ "cell": "c", "corner": 0, "phase": 1 },
{ "cell": "k", "corner": 4, "phase": 2 },
{ "cell": "g", "corner": 3, "phase": 0 }
]
},
{
"id": "2-7",
"teach": "15 格,一堵静态墙加三个转墙挤在右侧。左半盘先走完,进右侧时三堵墙的拍子要同时对上。去墙后 49 解。",
"cells": [
{ "id": "a", "q": 0, "r": 0 },
{ "id": "b", "q": 1, "r": 0, "clue": 1 },
{ "id": "c", "q": 2, "r": 0 },
{ "id": "d", "q": 3, "r": 0 },
{ "id": "e", "q": 4, "r": 0 },
{ "id": "f", "q": 0, "r": 1 },
{ "id": "g", "q": 1, "r": 1, "clue": 7 },
{ "id": "h", "q": 2, "r": 1 },
{ "id": "i", "q": 3, "r": 1 },
{ "id": "j", "q": 4, "r": 1 },
{ "id": "k", "q": -1, "r": 2 },
{ "id": "l", "q": 0, "r": 2 },
{ "id": "m", "q": 1, "r": 2 },
{ "id": "n", "q": 2, "r": 2 },
{ "id": "o", "q": 3, "r": 2 }
],
"walls": [
["d", "e"]
],
"spinners": [
{ "cell": "h", "corner": 0, "phase": 1 },
{ "cell": "i", "corner": 0, "phase": 2 },
{ "cell": "n", "corner": 5, "phase": 2 }
]
},
{
"id": "2-8",
"teach": "17 格,三个转墙分布三处。1 在右侧内圈,先上顶行绕左半盘,最后从底部回右侧。去墙解 52。",
"cells": [
{ "id": "a", "q": 0, "r": -2 },
{ "id": "b", "q": 1, "r": -2 },
{ "id": "c", "q": 2, "r": -2 },
{ "id": "d", "q": -1, "r": -1 },
{ "id": "e", "q": 0, "r": -1 },
{ "id": "f", "q": 1, "r": -1 },
{ "id": "g", "q": -2, "r": 0 },
{ "id": "h", "q": -1, "r": 0, "clue": 1 },
{ "id": "i", "q": 0, "r": 0 },
{ "id": "j", "q": 1, "r": 0 },
{ "id": "k", "q": 2, "r": 0, "clue": 10 },
{ "id": "l", "q": -2, "r": 1 },
{ "id": "m", "q": -1, "r": 1 },
{ "id": "n", "q": 0, "r": 1 },
{ "id": "o", "q": 1, "r": 1 },
{ "id": "p", "q": -1, "r": 2 },
{ "id": "q", "q": 0, "r": 2 }
],
"walls": [],
"spinners": [
{ "cell": "h", "corner": 3, "phase": 0 },
{ "cell": "m", "corner": 0, "phase": 2 },
{ "cell": "k", "corner": 2, "phase": 2 }
]
},
{
"id": "2-9",
"teach": "19 格满花盘,四个转墙。1 在内圈下方,8 在内圈上方,17 在最左;两次穿过内圈都要踩准拍子。去墙后 41 解。",
"cells": [
{ "id": "a", "q": 0, "r": -2 },
{ "id": "b", "q": 1, "r": -2 },
{ "id": "c", "q": 2, "r": -2 },
{ "id": "d", "q": -1, "r": -1 },
{ "id": "e", "q": 0, "r": -1 },
{ "id": "f", "q": 1, "r": -1, "clue": 8 },
{ "id": "g", "q": 2, "r": -1 },
{ "id": "h", "q": -2, "r": 0, "clue": 17 },
{ "id": "i", "q": -1, "r": 0 },
{ "id": "j", "q": 0, "r": 0 },
{ "id": "k", "q": 1, "r": 0 },
{ "id": "l", "q": 2, "r": 0 },
{ "id": "m", "q": -2, "r": 1 },
{ "id": "n", "q": -1, "r": 1, "clue": 1 },
{ "id": "o", "q": 0, "r": 1 },
{ "id": "p", "q": 1, "r": 1 },
{ "id": "q", "q": -2, "r": 2 },
{ "id": "r", "q": -1, "r": 2 },
{ "id": "s", "q": 0, "r": 2 }
],
"walls": [],
"spinners": [
{ "cell": "n", "corner": 4, "phase": 1 },
{ "cell": "o", "corner": 4, "phase": 2 },
{ "cell": "r", "corner": 3, "phase": 0 },
{ "cell": "f", "corner": 0, "phase": 1 }
]
}
]
}
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "hex-walls",
"private": true,
"version": "0.1.0",
"description": "玩法三 · 六边一笔 — 竖屏触摸 Web 原型(纯 TS 规则层,可迁 Cocos Creator)",
"type": "module",
"scripts": {
"dev": "node scripts/build.mjs --serve",
"build": "node scripts/build.mjs",
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.src.json --noEmit",
"test": "node --import tsx --test tests/*.test.ts",
"validate": "node --import tsx tools/validate.ts",
"design": "node --import tsx tools/design.ts",
"generate": "node --import tsx tools/generate.ts",
"check": "npm run typecheck && npm run test && npm run validate"
},
"devDependencies": {
"@types/node": "^22.7.0",
"esbuild": "^0.24.0",
"tsx": "^4.19.0",
"typescript": "^5.6.0"
},
"allowScripts": {
"esbuild@0.24.2": true,
"esbuild@0.28.2": true
}
}
+42
View File
@@ -0,0 +1,42 @@
// 打包 web/main.ts → web/dist/app.js(IIFE,可 file:// 直接打开)。
// --serve 时起本地服务并监听改动,手机同一 Wi-Fi 下访问启动时打印的地址。
// 端口:--port 4000 或 PORT=4000,不给就从 8100 起自动找空闲端口。
import * as esbuild from 'esbuild';
import { networkInterfaces } from 'node:os';
const argv = process.argv.slice(2);
const serve = argv.includes('--serve');
const portArg = argv[argv.indexOf('--port') + 1];
const port = argv.includes('--port') ? Number(portArg) : process.env.PORT ? Number(process.env.PORT) : 8100;
const ctx = await esbuild.context({
entryPoints: ['web/main.ts'],
bundle: true,
format: 'iife',
target: ['es2020'],
outfile: 'web/dist/app.js',
sourcemap: serve ? 'inline' : false,
minify: !serve,
logLevel: 'info',
loader: { '.json': 'json' },
});
if (!serve) {
await ctx.rebuild();
await ctx.dispose();
} else {
await ctx.watch();
const served = await ctx.serve({ servedir: 'web', host: '0.0.0.0', port });
let lan = [];
try {
lan = Object.values(networkInterfaces())
.flat()
.filter((i) => i && i.family === 'IPv4' && !i.internal)
.map((i) => i.address);
} catch {
// 受限环境拿不到网卡列表,只打印 localhost
}
console.log(`\n 本机: http://localhost:${served.port}/`);
for (const ip of lan) console.log(` 手机: http://${ip}:${served.port}/`);
console.log(` (监听 ${served.host}:${served.port},改动自动重新打包,刷新页面即可)\n`);
}
+280
View File
@@ -0,0 +1,280 @@
// 盘面模型:点顶六边形、轴坐标、六邻接减去墙。规则来源 GDD §4.1–4.2。
import type { HexCellDef, LevelDef, SpinnerDef, WallDef } from './types';
/** 轴坐标六邻接方向,顺序:东、东北、西北、西、西南、东南 */
export const HEX_DIRS: ReadonlyArray<readonly [number, number]> = [
[1, 0],
[1, -1],
[0, -1],
[-1, 0],
[-1, 1],
[0, 1],
];
export interface HexCell {
/** 在 HexBoard.cells 中的下标,运行时统一用下标引用格 */
index: number;
id: string;
q: number;
r: number;
clue: number | null;
}
export interface Wall {
/** 在 HexBoard.walls 中的下标 */
index: number;
/** 两侧格下标,a < b */
a: number;
b: number;
}
/** 转墙(GDD 实验机制):绕一个顶点转,三条候选边顺时针编号,每走一格转到下一条 */
export interface Spinner {
index: number;
/** 轴心所在格与角号 */
cell: number;
corner: number;
phase: number;
/**
* 三条候选边(两侧格下标,a < b);边不在盘上(空洞 / 边界)为 null,墙转到那里时相当于消失。
* 顺序:0 = 背离 cell 的边,1 = cell 与顺时针下一邻格,2 = cell 与上一邻格。
*/
edges: ReadonlyArray<readonly [number, number] | null>;
}
/** 已走 t 格时转墙占哪条边 */
export function spinnerPos(sp: Spinner, t: number): number {
return (((sp.phase + t) % 3) + 3) % 3;
}
const coordKey = (q: number, r: number): string => `${q},${r}`;
const pairKey = (a: number, b: number): number => (a < b ? a * 4096 + b : b * 4096 + a);
export class HexBoard {
readonly cells: ReadonlyArray<HexCell>;
readonly n: number;
/** adj[i] = 与 i 相邻且无墙的格下标(路径用) */
readonly adj: ReadonlyArray<ReadonlyArray<number>>;
/** geoAdj[i] = 几何上共边的格下标(含被墙隔开的,画墙 / 撞墙判定用) */
readonly geoAdj: ReadonlyArray<ReadonlyArray<number>>;
readonly walls: ReadonlyArray<Wall>;
/** 线索步号 → 格下标 */
readonly clueCellOfStep: ReadonlyMap<number, number>;
/** 线索 1 所在格,没有则 -1 */
readonly startClueCell: number;
readonly spinners: ReadonlyArray<Spinner>;
private readonly coordMap: Map<string, number>;
private readonly wallByPair: Map<number, Wall>;
private constructor(cells: HexCell[], wallPairs: Array<[number, number]>, spinnerDefs: Array<{ cell: number; corner: number; phase: number }> = []) {
this.cells = cells;
this.n = cells.length;
this.coordMap = new Map();
for (const c of cells) this.coordMap.set(coordKey(c.q, c.r), c.index);
const geoAdj: number[][] = cells.map(() => []);
for (const c of cells) {
for (const [dq, dr] of HEX_DIRS) {
const j = this.coordMap.get(coordKey(c.q + dq, c.r + dr));
if (j !== undefined) geoAdj[c.index].push(j);
}
}
this.geoAdj = geoAdj;
const walls: Wall[] = [];
const wallByPair = new Map<number, Wall>();
for (const [x, y] of wallPairs) {
const a = Math.min(x, y);
const b = Math.max(x, y);
if (a === b) throw new Error(`墙两侧是同一格: ${cells[a].id}`);
if (!geoAdj[a].includes(b)) throw new Error(`墙 ${cells[a].id}-${cells[b].id} 不在共用边上`);
const k = pairKey(a, b);
if (wallByPair.has(k)) throw new Error(`墙重复: ${cells[a].id}-${cells[b].id}`);
const w: Wall = { index: walls.length, a, b };
walls.push(w);
wallByPair.set(k, w);
}
this.walls = walls;
this.wallByPair = wallByPair;
this.adj = geoAdj.map((list, i) => list.filter((j) => !wallByPair.has(pairKey(i, j))));
const spinners: Spinner[] = [];
const pivotKeys = new Set<string>();
for (const d of spinnerDefs) {
if (!Number.isInteger(d.corner) || d.corner < 0 || d.corner > 5) throw new Error(`转墙角号 ${d.corner} 必须在 0..5`);
const c = cells[d.cell];
// 角 k 夹在方向 k(角 30°+60k 之前)与方向 k+1 之间;HEX_DIRS 按 0°、-60°… 排列,屏幕顺时针方向 60k 对应 HEX_DIRS[(6 - k) % 6]
const dirA = HEX_DIRS[(6 - d.corner) % 6];
const dirB = HEX_DIRS[(5 - d.corner) % 6];
const n1 = this.coordMap.get(coordKey(c.q + dirA[0], c.r + dirA[1]));
const n2 = this.coordMap.get(coordKey(c.q + dirB[0], c.r + dirB[1]));
const pair = (x: number | undefined, y: number | undefined): readonly [number, number] | null =>
x === undefined || y === undefined ? null : x < y ? [x, y] : [y, x];
const edges = [pair(n1, n2), pair(c.index, n2), pair(c.index, n1)];
if (edges.filter(Boolean).length < 2) throw new Error(`转墙 ${c.id}/${d.corner} 周围可用的边少于 2 条`);
for (const e of edges) if (e && wallByPair.has(pairKey(e[0], e[1]))) throw new Error(`转墙 ${c.id}/${d.corner} 与静态墙重叠`);
// 同一个顶点只能有一个转墙(不同格的不同角可能是同一个顶点)
const ang = Math.PI / 6 + (d.corner * Math.PI) / 3;
const px = Math.sqrt(3) * (c.q + c.r / 2) + Math.cos(ang);
const py = 1.5 * c.r + Math.sin(ang);
const pk = `${Math.round(px * 1000)},${Math.round(py * 1000)}`;
if (pivotKeys.has(pk)) throw new Error(`转墙 ${c.id}/${d.corner} 与另一个转墙共用顶点`);
pivotKeys.add(pk);
spinners.push({ index: spinners.length, cell: c.index, corner: d.corner, phase: ((d.phase % 3) + 3) % 3, edges });
}
this.spinners = spinners;
const clueMap = new Map<number, number>();
for (const c of cells) {
if (c.clue !== null) {
if (clueMap.has(c.clue)) throw new Error(`线索重复: ${c.clue}`);
clueMap.set(c.clue, c.index);
}
}
this.clueCellOfStep = clueMap;
this.startClueCell = clueMap.get(1) ?? -1;
}
static fromLevel(level: LevelDef): HexBoard {
return HexBoard.fromCells(level.cells, level.walls ?? [], level.spinners ?? []);
}
static fromCells(defs: HexCellDef[], wallDefs: WallDef[] = [], spinnerDefs: SpinnerDef[] = []): HexBoard {
if (defs.length === 0) throw new Error('盘面为空');
const ids = new Map<string, number>();
const coords = new Set<string>();
const cells: HexCell[] = defs.map((d, index) => {
if (ids.has(d.id)) throw new Error(`格 id 重复: ${d.id}`);
ids.set(d.id, index);
if (!Number.isInteger(d.q) || !Number.isInteger(d.r)) throw new Error(`格 ${d.id} 坐标必须是整数`);
const ck = coordKey(d.q, d.r);
if (coords.has(ck)) throw new Error(`格 ${d.id} 与其它格坐标重叠 (${d.q},${d.r})`);
coords.add(ck);
const clue = d.clue ?? null;
if (clue !== null && (!Number.isInteger(clue) || clue < 1 || clue > defs.length)) {
throw new Error(`格 ${d.id} 线索 ${clue} 超出 1..${defs.length}`);
}
return { index, id: d.id, q: d.q, r: d.r, clue };
});
const wallPairs: Array<[number, number]> = wallDefs.map(([x, y]) => {
const a = ids.get(x);
const b = ids.get(y);
if (a === undefined || b === undefined) throw new Error(`墙引用了不存在的格: ${x}-${y}`);
return [a, b];
});
const spinners = spinnerDefs.map((d) => {
const cell = ids.get(d.cell);
if (cell === undefined) throw new Error(`转墙引用了不存在的格: ${d.cell}`);
return { cell, corner: d.corner, phase: d.phase ?? 0 };
});
return new HexBoard(cells, wallPairs, spinners);
}
/** 轴坐标 → 格下标;空洞返回 -1 */
cellAt(q: number, r: number): number {
return this.coordMap.get(coordKey(q, r)) ?? -1;
}
cellById(id: string): HexCell {
const c = this.cells.find((x) => x.id === id);
if (!c) throw new Error(`没有格 ${id}`);
return c;
}
/** 可走的邻接(共边且无墙) */
areAdjacent(a: number, b: number): boolean {
return this.adj[a].includes(b);
}
/** 几何共边(不管墙) */
isGeoNeighbor(a: number, b: number): boolean {
return this.geoAdj[a].includes(b);
}
wallBetween(a: number, b: number): Wall | null {
return this.wallByPair.get(pairKey(a, b)) ?? null;
}
/** 静态墙 */
isWalled(a: number, b: number): boolean {
return this.wallByPair.has(pairKey(a, b));
}
/** 已走 t 格时,哪个转墙正挡在 a-b 之间;没有返回 -1 */
spinnerBlocking(a: number, b: number, t: number): number {
const lo = Math.min(a, b);
const hi = Math.max(a, b);
for (const sp of this.spinners) {
const e = sp.edges[spinnerPos(sp, t)];
if (e && e[0] === lo && e[1] === hi) return sp.index;
}
return -1;
}
/** 已走 t 格时能否从 a 走到 b(共边、无静态墙、转墙没挡着) */
canStep(a: number, b: number, t: number): boolean {
return this.areAdjacent(a, b) && this.spinnerBlocking(a, b, t) === -1;
}
/** 已走 t 格时 a 的可走邻居 */
adjAt(a: number, t: number): number[] {
if (this.spinners.length === 0) return this.adj[a] as number[];
return this.adj[a].filter((b) => this.spinnerBlocking(a, b, t) === -1);
}
get hasSpinners(): boolean {
return this.spinners.length > 0;
}
private spinnerDefs(): Array<{ cell: number; corner: number; phase: number }> {
return this.spinners.map((s) => ({ cell: s.cell, corner: s.corner, phase: s.phase }));
}
/** 去掉全部墙(含转墙)的同一盘面(校验“墙是否参与唯一性”用) */
withoutWalls(): HexBoard {
return new HexBoard(this.cells.map((c) => ({ ...c })), []);
}
/** 去掉指定静态墙的盘面 */
withoutWall(wallIndex: number): HexBoard {
const pairs = this.walls.filter((w) => w.index !== wallIndex).map((w) => [w.a, w.b] as [number, number]);
return new HexBoard(this.cells.map((c) => ({ ...c })), pairs, this.spinnerDefs());
}
/** 去掉指定转墙的盘面 */
withoutSpinner(index: number): HexBoard {
const pairs = this.walls.map((w) => [w.a, w.b] as [number, number]);
return new HexBoard(this.cells.map((c) => ({ ...c })), pairs, this.spinnerDefs().filter((_, i) => i !== index));
}
/** 把指定转墙换成一堵钉在其第 pos 条边上的静态墙(校验“会转”是否参与推理)。该边不存在返回 null */
withSpinnerFrozen(index: number, pos: number): HexBoard | null {
const e = this.spinners[index].edges[pos];
if (!e) return null;
const pairs = this.walls.map((w) => [w.a, w.b] as [number, number]);
pairs.push([e[0], e[1]]);
return new HexBoard(this.cells.map((c) => ({ ...c })), pairs, this.spinnerDefs().filter((_, i) => i !== index));
}
/** 是否连通(去墙之后的图)。不连通的盘不可能有解 */
isConnected(): boolean {
const seen = new Uint8Array(this.n);
const stack = [0];
seen[0] = 1;
let count = 1;
while (stack.length) {
const i = stack.pop()!;
for (const j of this.adj[i]) {
if (!seen[j]) {
seen[j] = 1;
count++;
stack.push(j);
}
}
}
return count === this.n;
}
}
+192
View File
@@ -0,0 +1,192 @@
// 对局状态机:路径、冲突、撞墙、过关。规则来源 GDD §4.3–4.5、§5。
// 输入层把手势翻译成 Command,视图层消费 GameEvent;本文件不关心两者的实现。
import type { HexBoard } from './board';
export type Status = 'empty' | 'drawing' | 'idle' | 'cleared';
export type Command =
| { type: 'start'; cell: number }
| { type: 'append'; cell: number }
| { type: 'undo' }
| { type: 'release' }
| { type: 'resume' }
| { type: 'reset' };
export type GameEvent =
/** 追加一步 */
| { type: 'step'; cell: number; step: number; conflict: boolean; clueHit: boolean }
/** 撤销一步 */
| { type: 'unstep'; cell: number; step: number }
| { type: 'reset' }
/** 起手被拒:有线索 1 时只能从它起手 */
| { type: 'rejectStart'; cell: number; clueCell: number }
/** 想从末端滑进被墙隔开的邻格:不追加,只反馈。wall = 静态墙下标或 -1,spinner = 转墙下标或 -1 */
| { type: 'wallHit'; from: number; to: number; wall: number; spinner: number }
/** 无格可走,但还没过关(信息性,不是失败) */
| { type: 'deadEnd'; cell: number }
| { type: 'cleared' };
export class Game {
readonly board: HexBoard;
/** 已走格下标,按步序 */
readonly path: number[] = [];
status: Status = 'empty';
/** stepOfCell[i] = 第几步走到格 i,0 表示未走 */
private readonly stepOfCell: Int16Array;
constructor(board: HexBoard) {
this.board = board;
this.stepOfCell = new Int16Array(board.n);
}
get n(): number {
return this.board.n;
}
get last(): number {
return this.path.length ? this.path[this.path.length - 1] : -1;
}
get remaining(): number {
return this.board.n - this.path.length;
}
get isCleared(): boolean {
return this.status === 'cleared';
}
stepOf(cell: number): number {
return this.stepOfCell[cell];
}
isVisited(cell: number): boolean {
return this.stepOfCell[cell] !== 0;
}
/** 已走且步号与线索不符 */
isConflict(cell: number): boolean {
const clue = this.board.cells[cell].clue;
const step = this.stepOfCell[cell];
return clue !== null && step !== 0 && step !== clue;
}
hasConflict(): boolean {
for (const c of this.path) if (this.isConflict(c)) return true;
return false;
}
/** 合法起点:有线索 1 只能是它;否则任意格 */
isLegalStart(cell: number): boolean {
const s = this.board.startClueCell;
return s === -1 || s === cell;
}
/** 从当前末端能否追加到 cell(转墙按当前已走格数判定) */
canAppend(cell: number): boolean {
const last = this.last;
if (last === -1 || this.status === 'cleared') return false;
return !this.isVisited(cell) && this.board.canStep(last, cell, this.path.length);
}
/** 从当前末端滑向 cell 是否会撞墙(几何相邻、未走、但有静态墙或转墙)。返回 {wall, spinner},都为 -1 表示不撞 */
wallTo(cell: number): { wall: number; spinner: number } {
const none = { wall: -1, spinner: -1 };
const last = this.last;
if (last === -1 || this.status === 'cleared' || this.isVisited(cell)) return none;
if (!this.board.isGeoNeighbor(last, cell)) return none;
const w = this.board.wallBetween(last, cell);
if (w) return { wall: w.index, spinner: -1 };
const sp = this.board.spinnerBlocking(last, cell, this.path.length);
return sp >= 0 ? { wall: -1, spinner: sp } : none;
}
/** 当前末端所有可追加的格 */
legalMoves(): number[] {
const last = this.last;
if (last === -1) return [];
return this.board.adjAt(last, this.path.length).filter((c) => !this.isVisited(c));
}
isSolved(): boolean {
return this.path.length === this.board.n && !this.hasConflict();
}
apply(cmd: Command): GameEvent[] {
switch (cmd.type) {
case 'start':
return this.start(cmd.cell);
case 'append':
return this.append(cmd.cell);
case 'undo':
return this.undo();
case 'release':
if (this.status === 'drawing') this.status = 'idle';
return [];
case 'resume':
if (this.status === 'idle') this.status = 'drawing';
return [];
case 'reset':
return this.reset();
}
}
private start(cell: number): GameEvent[] {
if (this.status === 'cleared' || this.path.length !== 0) return [];
if (!this.isLegalStart(cell)) {
return [{ type: 'rejectStart', cell, clueCell: this.board.startClueCell }];
}
this.status = 'drawing';
return this.push(cell);
}
private append(cell: number): GameEvent[] {
if (!this.canAppend(cell)) {
const hit = this.wallTo(cell);
if (hit.wall >= 0 || hit.spinner >= 0) return [{ type: 'wallHit', from: this.last, to: cell, wall: hit.wall, spinner: hit.spinner }];
return [];
}
this.status = 'drawing';
return this.push(cell);
}
private push(cell: number): GameEvent[] {
this.path.push(cell);
const step = this.path.length;
this.stepOfCell[cell] = step;
const clue = this.board.cells[cell].clue;
const events: GameEvent[] = [
{
type: 'step',
cell,
step,
conflict: clue !== null && clue !== step,
clueHit: clue !== null && clue === step,
},
];
if (this.isSolved()) {
this.status = 'cleared';
events.push({ type: 'cleared' });
} else if (this.legalMoves().length === 0) {
events.push({ type: 'deadEnd', cell });
}
return events;
}
private undo(): GameEvent[] {
if (this.status === 'cleared' || this.path.length === 0) return [];
const cell = this.path.pop()!;
const step = this.stepOfCell[cell];
this.stepOfCell[cell] = 0;
if (this.path.length === 0) this.status = 'empty';
return [{ type: 'unstep', cell, step }];
}
private reset(): GameEvent[] {
if (this.path.length === 0 && this.status === 'empty') return [];
this.path.length = 0;
this.stepOfCell.fill(0);
this.status = 'empty';
return [{ type: 'reset' }];
}
}
+153
View File
@@ -0,0 +1,153 @@
// 纯几何工具:点、多边形圆角、凸多边形裁剪、局部坐标变换。
// 目的:让 Painter 只需要 moveTo/lineTo 级别的原语(Cocos Graphics 没有 arcTo)。
export interface Point {
x: number;
y: number;
}
/** 把多边形每个角换成 segments 段折线逼近的圆弧 */
export function roundPolygon(pts: Point[], radius: number, segments = 4): Point[] {
const n = pts.length;
if (n < 3 || radius <= 0) return pts;
const out: Point[] = [];
for (let i = 0; i < n; i++) {
const p0 = pts[(i + n - 1) % n];
const p1 = pts[i];
const p2 = pts[(i + 1) % n];
const v1 = norm(p0.x - p1.x, p0.y - p1.y);
const v2 = norm(p2.x - p1.x, p2.y - p1.y);
const len1 = Math.hypot(p0.x - p1.x, p0.y - p1.y);
const len2 = Math.hypot(p2.x - p1.x, p2.y - p1.y);
// 内角一半
const cosA = v1.x * v2.x + v1.y * v2.y;
const half = Math.acos(Math.max(-1, Math.min(1, cosA))) / 2;
const tan = Math.tan(half);
if (tan < 1e-6) {
out.push(p1);
continue;
}
// 切点离顶点的距离,不超过相邻边的一半
const cut = Math.min(radius / tan, len1 / 2, len2 / 2);
const r = cut * tan;
const a: Point = { x: p1.x + v1.x * cut, y: p1.y + v1.y * cut };
const b: Point = { x: p1.x + v2.x * cut, y: p1.y + v2.y * cut };
// 圆心在角平分线上
const bis = norm(v1.x + v2.x, v1.y + v2.y);
const dist = r / Math.sin(half);
const c: Point = { x: p1.x + bis.x * dist, y: p1.y + bis.y * dist };
const a0 = Math.atan2(a.y - c.y, a.x - c.x);
let a1 = Math.atan2(b.y - c.y, b.x - c.x);
// 走短弧
let d = a1 - a0;
while (d > Math.PI) d -= Math.PI * 2;
while (d < -Math.PI) d += Math.PI * 2;
a1 = a0 + d;
for (let s = 0; s <= segments; s++) {
const t = a0 + (d * s) / segments;
out.push({ x: c.x + Math.cos(t) * r, y: c.y + Math.sin(t) * r });
}
}
return out;
}
function norm(x: number, y: number): Point {
const l = Math.hypot(x, y) || 1;
return { x: x / l, y: y / l };
}
/** Sutherland–Hodgman:用凸多边形 clip(顶点顺时针或逆时针皆可)裁剪 subject */
export function clipPolygon(subject: Point[], clip: Point[]): Point[] {
let output = subject;
const m = clip.length;
// 判断 clip 的绕向,统一“内侧”的符号
let area = 0;
for (let i = 0; i < m; i++) {
const a = clip[i];
const b = clip[(i + 1) % m];
area += a.x * b.y - b.x * a.y;
}
const sign = area >= 0 ? 1 : -1;
for (let i = 0; i < m && output.length; i++) {
const a = clip[i];
const b = clip[(i + 1) % m];
const input = output;
output = [];
const inside = (p: Point): boolean => sign * ((b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x)) >= 0;
const intersect = (p: Point, q: Point): Point => {
const dx = q.x - p.x;
const dy = q.y - p.y;
const ex = b.x - a.x;
const ey = b.y - a.y;
const den = dx * ey - dy * ex;
if (Math.abs(den) < 1e-9) return q;
const t = ((a.x - p.x) * ey - (a.y - p.y) * ex) / den;
return { x: p.x + dx * t, y: p.y + dy * t };
};
for (let j = 0; j < input.length; j++) {
const cur = input[j];
const prev = input[(j + input.length - 1) % input.length];
const curIn = inside(cur);
const prevIn = inside(prev);
if (curIn) {
if (!prevIn) output.push(intersect(prev, cur));
output.push(cur);
} else if (prevIn) {
output.push(intersect(prev, cur));
}
}
}
return output;
}
/**
* 一条“带”:中心 c,沿 u 方向长 len,沿法向 n 宽 thick。
* 返回四个角(矩形)。u、n 应为单位向量且垂直。
*/
export function bandRect(c: Point, u: Point, n: Point, len: number, thick: number): Point[] {
const hl = len / 2;
const ht = thick / 2;
return [
{ x: c.x - u.x * hl - n.x * ht, y: c.y - u.y * hl - n.y * ht },
{ x: c.x + u.x * hl - n.x * ht, y: c.y + u.y * hl - n.y * ht },
{ x: c.x + u.x * hl + n.x * ht, y: c.y + u.y * hl + n.y * ht },
{ x: c.x - u.x * hl + n.x * ht, y: c.y - u.y * hl + n.y * ht },
];
}
/**
* 带内的斜纹:每条纹是与带长轴成 45° 的平行四边形,裁到带矩形内。
* period = 相邻两条纹起点间距,width = 纹宽。返回若干多边形。
*/
export function stripesInBand(
c: Point,
u: Point,
n: Point,
len: number,
thick: number,
period: number,
width: number,
phase = 0,
): Point[][] {
const rect = bandRect(c, u, n, len, thick);
const out: Point[][] = [];
const hl = len / 2;
const ht = thick / 2;
// 局部坐标 (s 沿 u, t 沿 n):纹带覆盖 s ∈ [k*period + t + phase, +width]
const toWorld = (s: number, t: number): Point => ({ x: c.x + u.x * s + n.x * t, y: c.y + u.y * s + n.y * t });
const start = -hl - thick - period;
for (let s0 = start + (phase % period); s0 < hl + thick; s0 += period) {
const poly = [toWorld(s0, -ht), toWorld(s0 + width, -ht), toWorld(s0 + width + thick, ht), toWorld(s0 + thick, ht)];
const clipped = clipPolygon(poly, rect);
if (clipped.length >= 3) out.push(clipped);
}
return out;
}
export function scalePolygon(pts: Point[], center: Point, s: number): Point[] {
return pts.map((p) => ({ x: center.x + (p.x - center.x) * s, y: center.y + (p.y - center.y) * s }));
}
export function translatePolygon(pts: Point[], dx: number, dy: number): Point[] {
return pts.map((p) => ({ x: p.x + dx, y: p.y + dy }));
}
+97
View File
@@ -0,0 +1,97 @@
// 手势 → 命令。GDD §5。
// 这里只认“指针现在压在哪个格上”(由 layout.hitTest 算出),不碰 DOM 事件;
// Web 用 PointerEvent 喂,Cocos 用 Node TOUCH_START/MOVE/END 喂,逻辑完全一致。
import type { Command, GameEvent } from './game';
import { Game } from './game';
export class PointerInput {
private readonly game: Game;
/** 手指是否在“画”的状态(压着且已接上路径) */
private drawing = false;
/** 指针上一次落在的格,避免同一格重复触发 */
private hoverCell = -1;
constructor(game: Game) {
this.game = game;
}
get isDrawing(): boolean {
return this.drawing;
}
/** 按下。cell < 0 表示按在空白处 */
down(cell: number): GameEvent[] {
this.drawing = false;
this.hoverCell = cell;
const g = this.game;
if (g.isCleared || cell < 0) return [];
if (g.path.length === 0) {
const ev = this.run({ type: 'start', cell });
this.drawing = g.status === 'drawing';
return ev;
}
const last = g.last;
if (cell === last) {
// 从末端继续画
this.drawing = true;
return this.run({ type: 'resume' });
}
if (g.path.length >= 2 && cell === g.path[g.path.length - 2]) {
// 点末端的前一格 = 撤销一步,然后手指停在新末端上继续
const ev = this.run({ type: 'undo' });
this.drawing = true;
this.run({ type: 'resume' });
return ev;
}
if (g.canAppend(cell)) {
// 直接点末端旁边的空格:追加(点着走)
const ev = this.run({ type: 'append', cell });
this.drawing = g.status === 'drawing';
return ev;
}
const hit = g.wallTo(cell);
if (hit.wall >= 0 || hit.spinner >= 0) {
// 点被墙隔开的邻格:只反馈撞墙
return this.run({ type: 'append', cell });
}
return [];
}
/** 移动到某格(或空白 -1)。只在 drawing 时生效 */
move(cell: number): GameEvent[] {
if (cell === this.hoverCell) return [];
this.hoverCell = cell;
if (!this.drawing || cell < 0) return [];
const g = this.game;
if (g.isCleared) {
this.drawing = false;
return [];
}
if (g.path.length >= 2 && cell === g.path[g.path.length - 2]) {
return this.run({ type: 'undo' });
}
// append 自己会区分“可走 / 撞墙 / 忽略”
const ev = this.run({ type: 'append', cell });
if (g.isCleared) this.drawing = false;
return ev;
}
/** 松手 */
up(): GameEvent[] {
this.hoverCell = -1;
if (!this.drawing) return [];
this.drawing = false;
return this.run({ type: 'release' });
}
/** 外部(按钮)触发重开时同步状态 */
cancel(): void {
this.drawing = false;
this.hoverCell = -1;
}
private run(cmd: Command): GameEvent[] {
return this.game.apply(cmd);
}
}
+194
View File
@@ -0,0 +1,194 @@
// 布局:点顶六边形铺进给定区域;格中心、六边形顶点、共用边、桥、墙带、命中测试。
// GDD §9「按短边缩放」。纯数学,Web 与 Cocos 共用(Cocos y 轴向上,Painter 内翻 y 即可)。
import type { HexBoard } from './board';
import { bandRect, roundPolygon, type Point } from './geom';
const SQRT3 = Math.sqrt(3);
export interface HexLayout {
/** 六边形外接圆半径(中心到顶点),也是相邻中心距的 1/√3 */
size: number;
/** 轴坐标 (0,0) 的中心在容器里的像素位置 */
originX: number;
originY: number;
/** 相邻格之间的缝 */
gap: number;
/** 绘制用六边形半径(已扣掉缝) */
drawRadius: number;
/** 圆角 */
corner: number;
/** 盘面总像素尺寸 */
width: number;
height: number;
}
export interface FitOptions {
/** size 上限,避免 3 格的教学关铺满全屏 */
maxSize?: number;
/** 容器四周留白 */
padding?: number;
/** 缝占 size 的比例 */
gapRatio?: number;
cornerRatio?: number;
}
/** 轴坐标 → 以 size=1 计的中心(未加 origin) */
export function axialToUnit(q: number, r: number): Point {
return { x: SQRT3 * (q + r / 2), y: 1.5 * r };
}
export function fitBoard(board: HexBoard, availW: number, availH: number, opts: FitOptions = {}): HexLayout {
const padding = opts.padding ?? 16;
const maxSize = opts.maxSize ?? 60;
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const c of board.cells) {
const p = axialToUnit(c.q, c.r);
minX = Math.min(minX, p.x - SQRT3 / 2);
maxX = Math.max(maxX, p.x + SQRT3 / 2);
minY = Math.min(minY, p.y - 1);
maxY = Math.max(maxY, p.y + 1);
}
const unitW = maxX - minX;
const unitH = maxY - minY;
const w = Math.max(0, availW - padding * 2);
const h = Math.max(0, availH - padding * 2);
const size = Math.max(6, Math.min(maxSize, w / unitW, h / unitH));
const width = unitW * size;
const height = unitH * size;
const gap = size * (opts.gapRatio ?? 0.12);
return {
size,
originX: (availW - width) / 2 - minX * size,
originY: (availH - height) / 2 - minY * size,
gap,
// 内切圆半径缩小 gap/2 → 外接圆半径缩小 gap/√3
drawRadius: size - gap / SQRT3,
corner: size * (opts.cornerRatio ?? 0.16),
width,
height,
};
}
export function cellCenter(board: HexBoard, cell: number, L: HexLayout): Point {
const c = board.cells[cell];
const p = axialToUnit(c.q, c.r);
return { x: L.originX + p.x * L.size, y: L.originY + p.y * L.size };
}
/** 点顶六边形顶点,从 30° 起逆时针(屏幕坐标 y 向下,视觉上是顺时针) */
export function hexCorners(center: Point, radius: number): Point[] {
const pts: Point[] = [];
for (let k = 0; k < 6; k++) {
const a = Math.PI / 6 + (k * Math.PI) / 3;
pts.push({ x: center.x + Math.cos(a) * radius, y: center.y + Math.sin(a) * radius });
}
return pts;
}
/** 绘制用六边形(扣缝、圆角),scale 用于动效缩放 */
export function cellPolygon(board: HexBoard, cell: number, L: HexLayout, scale = 1, shrink = 0): Point[] {
const c = cellCenter(board, cell, L);
const r = Math.max(1, (L.drawRadius - shrink) * scale);
return roundPolygon(hexCorners(c, r), L.corner * scale, 3);
}
export interface SharedEdge {
/** 边中点 */
mid: Point;
/** 沿边的单位向量 */
u: Point;
/** 从 a 指向 b 的单位向量(边的法向) */
n: Point;
/** 边长(= size) */
len: number;
}
/** 几何相邻两格的共用边。不相邻返回 null */
export function sharedEdge(board: HexBoard, a: number, b: number, L: HexLayout): SharedEdge | null {
if (!board.isGeoNeighbor(a, b)) return null;
const A = cellCenter(board, a, L);
const B = cellCenter(board, b, L);
const dx = B.x - A.x;
const dy = B.y - A.y;
const d = Math.hypot(dx, dy) || 1;
const n = { x: dx / d, y: dy / d };
return { mid: { x: (A.x + B.x) / 2, y: (A.y + B.y) / 2 }, u: { x: -n.y, y: n.x }, n, len: L.size };
}
/**
* 连接相邻两步的“桥”:横跨缝、沿共用边居中,宽为边长的一部分。
* 与两侧格同色时,视觉上就是一条连续的路径。
*/
export function bridgePolygon(board: HexBoard, a: number, b: number, L: HexLayout, widthRatio = 0.5, grow = 1): Point[] | null {
const e = sharedEdge(board, a, b, L);
if (!e) return null;
const span = (L.gap + 2) * grow;
return bandRect(e.mid, e.u, e.n, e.len * widthRatio * grow, span);
}
/** 墙带的几何:中心、方向、长、厚 */
export interface WallBand {
mid: Point;
u: Point;
n: Point;
len: number;
thick: number;
}
export function wallBand(board: HexBoard, wall: number, L: HexLayout): WallBand {
const w = board.walls[wall];
const e = sharedEdge(board, w.a, w.b, L)!;
return { mid: e.mid, u: e.u, n: e.n, len: L.size * 0.92, thick: Math.max(6, L.size * 0.3) };
}
/** 转墙轴心的像素位置:格 cell 的第 corner 个角 */
export function spinnerPivot(board: HexBoard, spinner: number, L: HexLayout): Point {
const sp = board.spinners[spinner];
const c = cellCenter(board, sp.cell, L);
const a = Math.PI / 6 + (sp.corner * Math.PI) / 3;
return { x: c.x + Math.cos(a) * L.size, y: c.y + Math.sin(a) * L.size };
}
/** 转墙第 pos 条边从轴心出发的方向角(弧度,屏幕坐标)。pos 0 = 背离格心,之后每条顺时针 +120° */
export function spinnerAngle(board: HexBoard, spinner: number, pos: number): number {
const sp = board.spinners[spinner];
return Math.PI / 6 + (sp.corner * Math.PI) / 3 + (pos * Math.PI * 2) / 3;
}
/** 像素 → 最近的轴坐标(立方取整) */
export function pixelToAxial(L: HexLayout, px: number, py: number): { q: number; r: number } {
const x = (px - L.originX) / L.size;
const y = (py - L.originY) / L.size;
const qf = (SQRT3 / 3) * x - y / 3;
const rf = (2 / 3) * y;
const sf = -qf - rf;
let q = Math.round(qf);
let r = Math.round(rf);
const s = Math.round(sf);
const dq = Math.abs(q - qf);
const dr = Math.abs(r - rf);
const ds = Math.abs(s - sf);
if (dq > dr && dq > ds) q = -r - s;
else if (dr > ds) r = -q - s;
return { q, r };
}
/**
* 像素点 → 格下标。inset 是每个格边缘的“死区”比例(相对 size),
* 手指扫过格与格的交界时不会误触;落在死区返回 -1(调用方保持上一格)。
*/
export function hitTest(board: HexBoard, L: HexLayout, px: number, py: number, insetRatio = 0.14): number {
const { q, r } = pixelToAxial(L, px, py);
const cell = board.cellAt(q, r);
if (cell < 0) return -1;
const c = cellCenter(board, cell, L);
const R = L.size * (1 - insetRatio);
const dx = Math.abs(px - c.x);
const dy = Math.abs(py - c.y);
// 点顶六边形内部:|dx| ≤ R√3/2 且 |dx| + √3|dy| ≤ √3 R
if (dx > (R * SQRT3) / 2 || dx + SQRT3 * dy > SQRT3 * R) return -1;
return cell;
}
+39
View File
@@ -0,0 +1,39 @@
// 关卡包读取与校验。运行时(Web / Cocos)和工具链共用同一份解析。
import { HexBoard } from './board';
import type { LevelDef, PackDef } from './types';
export interface LoadedLevel {
def: LevelDef;
board: HexBoard;
pack: number;
/** 包内序号,从 1 起 */
index: number;
}
export function parsePack(raw: unknown): PackDef {
if (!raw || typeof raw !== 'object') throw new Error('关卡包不是对象');
const p = raw as Partial<PackDef>;
if (typeof p.pack !== 'number' || !Array.isArray(p.levels)) throw new Error('关卡包缺少 pack / levels');
for (const lv of p.levels) {
if (typeof lv.id !== 'string' || !Array.isArray(lv.cells)) throw new Error(`关卡格式错误: ${JSON.stringify(lv).slice(0, 60)}`);
if (lv.walls !== undefined && !Array.isArray(lv.walls)) throw new Error(`关卡 ${lv.id} 的 walls 不是数组`);
}
return {
mode: String(p.mode ?? '03-hex-walls'),
pack: p.pack,
title: String(p.title ?? `包 ${p.pack}`),
orientation: 'pointy',
notes: p.notes,
levels: p.levels,
};
}
/** 把包展开成可玩关卡(构建 HexBoard 时会校验坐标 / 墙 / 线索) */
export function loadPack(pack: PackDef): LoadedLevel[] {
return pack.levels.map((def, i) => ({
def,
board: HexBoard.fromLevel(def),
pack: pack.pack,
index: i + 1,
}));
}
+14
View File
@@ -0,0 +1,14 @@
// 绘制接口:HexBoardView 只调用这几个原语,不知道底下是 Canvas 2D 还是 Cocos Graphics。
// 全部多边形已在几何层算好(含圆角折线),实现方只需 moveTo/lineTo/close + fill/stroke。
// Web 实现见 web/canvas-painter.ts;Cocos 用 Graphics(多边形/圆/线)+ Label(文字)。
import type { Point } from './geom';
export interface Painter {
fillPolygon(pts: Point[], color: string, alpha?: number): void;
strokePolygon(pts: Point[], color: string, lineWidth: number, alpha?: number): void;
/** 圆头线段 */
line(x1: number, y1: number, x2: number, y2: number, width: number, color: string, alpha?: number): void;
fillCircle(cx: number, cy: number, radius: number, color: string, alpha?: number): void;
/** 水平垂直居中的文字 */
text(s: string, cx: number, cy: number, size: number, color: string, bold?: boolean, alpha?: number): void;
}
+112
View File
@@ -0,0 +1,112 @@
// 存档:本地、按玩法分桶(GDD §10)。只存已通关 id 与当前关 id,不存半途路径。
// KeyValueStore 由宿主提供:Web 用 localStorage,Cocos 用 sys.localStorage。
export interface KeyValueStore {
get(key: string): string | null;
set(key: string, value: string): void;
remove(key: string): void;
}
export const STORAGE_KEY = 'mode:03-hex-walls';
interface Saved {
v: number;
cleared: string[];
currentId: string | null;
prefs?: Prefs;
}
export interface Prefs {
sound: boolean;
haptics: boolean;
}
export class Progress {
private readonly store: KeyValueStore;
private readonly key: string;
private cleared = new Set<string>();
currentId: string | null = null;
prefs: Prefs = { sound: true, haptics: true };
constructor(store: KeyValueStore, key: string = STORAGE_KEY) {
this.store = store;
this.key = key;
this.load();
}
isCleared(id: string): boolean {
return this.cleared.has(id);
}
clearedCount(): number {
return this.cleared.size;
}
markCleared(id: string): void {
this.cleared.add(id);
this.save();
}
setCurrent(id: string): void {
this.currentId = id;
this.save();
}
setPrefs(p: Partial<Prefs>): void {
this.prefs = { ...this.prefs, ...p };
this.save();
}
/** 线性解锁:第 i 关(0 起)解锁 ⇔ i === 0 或前一关已通 */
isUnlocked(ids: string[], i: number): boolean {
return i === 0 || this.cleared.has(ids[i - 1]);
}
/** 默认停在第一关未通的关;全通则最后一关 */
firstUncleared(ids: string[]): string {
for (const id of ids) if (!this.cleared.has(id)) return id;
return ids[ids.length - 1];
}
resetAll(): void {
this.cleared.clear();
this.currentId = null;
this.save();
}
private load(): void {
try {
const raw = this.store.get(this.key);
if (!raw) return;
const s = JSON.parse(raw) as Partial<Saved>;
if (Array.isArray(s.cleared)) this.cleared = new Set(s.cleared.filter((x) => typeof x === 'string'));
if (typeof s.currentId === 'string') this.currentId = s.currentId;
if (s.prefs) this.prefs = { ...this.prefs, ...s.prefs };
} catch {
// 存档损坏就当没有
}
}
private save(): void {
const s: Saved = { v: 1, cleared: [...this.cleared], currentId: this.currentId, prefs: this.prefs };
try {
this.store.set(this.key, JSON.stringify(s));
} catch {
// 隐私模式等写不进去,忽略
}
}
}
/** 纯内存实现:测试与无存储环境用 */
export class MemoryStore implements KeyValueStore {
private m = new Map<string, string>();
get(k: string): string | null {
return this.m.get(k) ?? null;
}
set(k: string, v: string): void {
this.m.set(k, v);
}
remove(k: string): void {
this.m.delete(k);
}
}
+213
View File
@@ -0,0 +1,213 @@
// 求解器:枚举满足全部线索的哈密顿路径;墙有效性检查;提示逻辑。GDD §8、§10。
// N ≤ 20,回溯 + 连通性 / 线索距离剪枝,毫秒级。
import type { HexBoard } from './board';
export interface SolveOptions {
/** 最多找几条就停(唯一性检查用 2) */
limit?: number;
/** 忽略线索,枚举全部哈密顿路径(设计工具用) */
ignoreClues?: boolean;
/** 只从这些格起手(默认按线索 1 / 任意) */
starts?: number[];
/** 候选顺序随机化(生成器用),传 0..1 的随机函数 */
random?: () => number;
}
export function solve(board: HexBoard, opts: SolveOptions = {}): number[][] {
const limit = opts.limit ?? Infinity;
const n = board.n;
const adj = board.adj;
const clueOf = new Int16Array(n).fill(0);
const cellOfStep = new Int16Array(n + 2).fill(-1);
if (!opts.ignoreClues) {
for (const c of board.cells) {
if (c.clue !== null) {
clueOf[c.index] = c.clue;
cellOfStep[c.clue] = c.index;
}
}
}
const futureClues: number[] = [];
for (let s = 1; s <= n; s++) if (cellOfStep[s] !== -1) futureClues.push(s);
const results: number[][] = [];
const visited = new Uint8Array(n);
const path: number[] = [];
const dist = new Int16Array(n);
const queue = new Int16Array(n);
const rnd = opts.random;
/** 从 cur 出发,只经未走格做 BFS。返回可达的未走格数;dist[] 填距离 */
function bfs(cur: number): number {
dist.fill(-1);
dist[cur] = 0;
let head = 0;
let tail = 0;
queue[tail++] = cur;
let reached = 0;
while (head < tail) {
const u = queue[head++];
for (const v of adj[u]) {
if (!visited[v] && dist[v] === -1) {
dist[v] = dist[u] + 1;
reached++;
queue[tail++] = v;
}
}
}
return reached;
}
function prune(cur: number, step: number): boolean {
const remaining = n - step;
if (remaining === 0) return false;
if (bfs(cur) < remaining) return true;
for (const s of futureClues) {
if (s <= step) continue;
const c = cellOfStep[s];
if (visited[c]) return true;
const d = dist[c];
if (d === -1 || d > s - step) return true;
}
return false;
}
function dfs(cur: number, step: number): boolean {
if (step === n) {
results.push(path.slice());
return results.length >= limit;
}
if (prune(cur, step)) return false;
const nextStep = step + 1;
const forced = cellOfStep[nextStep];
// 当前已走 step 格,转墙按 step 判定
if (forced !== -1) {
if (visited[forced] || !board.canStep(cur, forced, step)) return false;
return visit(forced, nextStep);
}
const cands = board.adjAt(cur, step).filter((v) => !visited[v] && clueOf[v] === 0);
if (cands.length > 1) {
if (rnd) {
for (let i = cands.length - 1; i > 0; i--) {
const j = Math.floor(rnd() * (i + 1));
[cands[i], cands[j]] = [cands[j], cands[i]];
}
} else {
// 出口少的先走(Warnsdorff),找第一条解更快
cands.sort((a, b) => freeDegree(a) - freeDegree(b));
}
}
for (const v of cands) {
if (visit(v, nextStep)) return true;
}
return false;
}
function freeDegree(v: number): number {
let d = 0;
for (const w of adj[v]) if (!visited[w]) d++;
return d;
}
function visit(v: number, step: number): boolean {
visited[v] = 1;
path.push(v);
const stop = dfs(v, step);
path.pop();
visited[v] = 0;
return stop;
}
let starts: number[];
if (opts.starts) starts = opts.starts.slice();
else if (cellOfStep[1] !== -1) starts = [cellOfStep[1]];
else starts = board.cells.map((c) => c.index);
if (rnd && starts.length > 1) {
for (let i = starts.length - 1; i > 0; i--) {
const j = Math.floor(rnd() * (i + 1));
[starts[i], starts[j]] = [starts[j], starts[i]];
}
}
for (const s of starts) {
if (clueOf[s] !== 0 && clueOf[s] !== 1) continue;
if (visit(s, 1)) break;
}
return results;
}
/** 唯一解;无解或多解返回 null */
export function uniqueSolution(board: HexBoard): number[] | null {
const paths = solve(board, { limit: 2 });
return paths.length === 1 ? paths[0] : null;
}
/**
* 墙是否参与唯一性(GDD §4.6):去掉全部墙(含转墙)后解不再唯一,才算合格的“墙关”。
* 没有墙的盘返回 false。
*/
export function wallsMatter(board: HexBoard): boolean {
if (board.walls.length === 0 && board.spinners.length === 0) return false;
return solve(board.withoutWalls(), { limit: 2 }).length !== 1;
}
export interface SpinnerReport {
index: number;
/** 去掉它仍唯一 → 冗余 */
removable: boolean;
/** 换成钉在第 pos 条边上的静态墙仍唯一 → “会转”没参与推理 */
freezable: number[];
}
/** 每个转墙:是否冗余、是否可用静态墙替代 */
export function spinnerReport(board: HexBoard): SpinnerReport[] {
return board.spinners.map((sp) => {
const removable = solve(board.withoutSpinner(sp.index), { limit: 2 }).length === 1;
const freezable: number[] = [];
for (let pos = 0; pos < 3; pos++) {
let b: HexBoard | null;
try {
b = board.withSpinnerFrozen(sp.index, pos);
} catch {
// 钉上去的静态墙与另一个转墙的候选边重叠:这种替代本身不合法,跳过
continue;
}
if (b && solve(b, { limit: 2 }).length === 1) freezable.push(pos);
}
return { index: sp.index, removable, freezable };
});
}
/** 每一堵墙单独去掉后是否仍唯一;返回“可删的墙”下标(设计工具用) */
export function redundantWalls(board: HexBoard): number[] {
const out: number[] = [];
for (const w of board.walls) {
if (solve(board.withoutWall(w.index), { limit: 2 }).length === 1) out.push(w.index);
}
return out;
}
export type Hint =
/** 还没起手:指出起点 */
| { kind: 'start'; cell: number }
/** 路径是解的前缀:下一步 */
| { kind: 'next'; cell: number }
/** 已偏离:退回到 cell(路径中最后一个仍在解上的格) */
| { kind: 'backtrack'; cell: number; undoCount: number }
/** 起手就错了(只在无线索 1 的盘出现):需要重开 */
| { kind: 'restart'; cell: number }
| { kind: 'none' };
export function hint(solution: number[], path: number[]): Hint {
if (path.length === 0) return { kind: 'start', cell: solution[0] };
let common = 0;
while (common < path.length && common < solution.length && path[common] === solution[common]) {
common++;
}
if (common === path.length) {
if (common === solution.length) return { kind: 'none' };
return { kind: 'next', cell: solution[common] };
}
if (common === 0) return { kind: 'restart', cell: solution[0] };
return { kind: 'backtrack', cell: path[common - 1], undoCount: path.length - common };
}
+28
View File
@@ -0,0 +1,28 @@
// 配色 V1(浅色)。GDD §6:未走中灰、已走蓝紫、墙黄黑斜纹、背景白。不要每关换主题色。
export const THEME = {
/** 页面背景 */
bg: '#FFFFFF',
/** 未走格 */
cell: '#E4E6EE',
/** 未走线索格的数字与描边(近黑) */
clueInk: '#20223A',
/** 已走主色(靛蓝,和黄色墙对比) */
visited: '#4F5BD5',
/** 过关后提亮 */
visitedBright: '#6672F0',
/** 已走格上的数字 */
visitedInk: '#FFFFFF',
/** 冲突 */
conflict: '#E5484D',
conflictInk: '#FFFFFF',
/** 墙:底色与斜纹 */
wallBase: '#1F1F27',
wallStripe: '#FFC826',
/** 提示高亮(琥珀偏橙,避免和墙黄混淆) */
hint: '#FF7A1A',
/** 线索踩对 / 撞墙时闪的颜色 */
flash: '#FFFFFF',
} as const;
export type Theme = typeof THEME;
+45
View File
@@ -0,0 +1,45 @@
// 关卡文件结构(levels/*.json),见 GDD §8。
// 本文件与整个 src/ 都不依赖 DOM / Cocos,可原样搬入 Cocos Creator。
export interface HexCellDef {
id: string;
/** 轴坐标,pointy-top */
q: number;
r: number;
/** 线索:路径走到本格时步号必须等于它 */
clue?: number;
}
/** 墙:两个相邻格 id。无向 */
export type WallDef = [string, string];
/**
* 转墙:绕某个顶点转动的墙。顶点用「格 id + 该格第几个角」标识(角从 30° 起顺时针 0..5,见 layout.hexCorners)。
* 顶点周围三条边按顺时针编号 0/1/2:0 = 背离该格的那条边(两个邻格之间),1 = 该格与顺时针下一个邻格的边,2 = 该格与上一个邻格的边。
* 已走 t 格时,墙在第 (phase + t) mod 3 条边上;每走一格顺时针转 120°。
*/
export interface SpinnerDef {
cell: string;
corner: number;
phase?: number;
}
export interface LevelDef {
id: string;
/** 教学备注(设计用,不显示) */
teach?: string;
/** 对局顶部一行文案(只有教学关有) */
caption?: string;
cells: HexCellDef[];
walls?: WallDef[];
spinners?: SpinnerDef[];
}
export interface PackDef {
mode: string;
pack: number;
title: string;
orientation?: 'pointy';
notes?: string;
levels: LevelDef[];
}
+381
View File
@@ -0,0 +1,381 @@
// 盘面视图:六边形、桥、数字、墙、冲突、提示、过关动画。GDD §6。
// 纯 TS:只依赖 Game 状态和 Painter 接口。宿主每帧调用 update(dt) + draw(painter)。
import type { Game, GameEvent } from './game';
import { bandRect, scalePolygon, stripesInBand, translatePolygon, type Point } from './geom';
import { spinnerPos } from './board';
import { bridgePolygon, cellCenter, cellPolygon, spinnerAngle, spinnerPivot, wallBand, type HexLayout } from './layout';
import type { Painter } from './painter';
import type { Hint } from './solver';
import { THEME } from './theme';
/** 单格动效状态 */
interface CellAnim {
scale: number;
scaleVel: number;
/** 描边闪白 0..1 */
flash: number;
/** 抖动剩余秒数 */
shake: number;
/** 步号淡入 0..1 */
numberAlpha: number;
/** 过关扫光 0..1 */
sweep: number;
}
interface WallAnim {
/** 撞墙闪白 0..1 */
flash: number;
/** 撞墙时沿法向的推挤(像素比例) */
push: number;
pushDir: 1 | -1;
}
interface SpinnerAnim {
/** 当前显示的方向角(未取模,便于连续转) */
shown: number;
target: number;
flash: number;
}
const SPIN_STEP = (Math.PI * 2) / 3;
const SPIN_SPEED = 16; // 指数逼近速率,约 120ms 转到位
const POP_IMPULSE = 2.6;
const SPRING_K = 260;
const SPRING_DAMP = 18;
const FLASH_DECAY = 3.2;
const WALL_FLASH_DECAY = 6;
const SHAKE_TIME = 0.28;
const NUMBER_FADE = 8;
const BRIDGE_GROW = 10;
const HINT_TIME = 2.4;
const CLEARED_SWEEP_TIME = 0.36;
export class HexBoardView {
readonly game: Game;
layout: HexLayout | null = null;
private readonly cells: CellAnim[];
private readonly walls: WallAnim[];
private readonly spinners: SpinnerAnim[];
/** bridges[i] = 第 i 步与第 i+1 步之间的桥的出现进度 0..1 */
private bridges: number[] = [];
private hintState: { hint: Hint; t: number } | null = null;
private cleared = false;
private clearedT = 0;
private animating = true;
constructor(game: Game) {
this.game = game;
this.cells = game.board.cells.map(() => ({ scale: 1, scaleVel: 0, flash: 0, shake: 0, numberAlpha: 0, sweep: 0 }));
this.walls = game.board.walls.map(() => ({ flash: 0, push: 0, pushDir: 1 }));
this.spinners = game.board.spinners.map((sp, i) => {
const a = spinnerAngle(game.board, i, spinnerPos(sp, 0));
return { shown: a, target: a, flash: 0 };
});
}
get isAnimating(): boolean {
return this.animating;
}
/** 过关动画时长(秒),宿主据此安排跳下一关 */
get clearedDuration(): number {
return CLEARED_SWEEP_TIME + 0.7;
}
invalidate(): void {
this.animating = true;
}
onEvent(e: GameEvent): void {
this.animating = true;
this.hintState = null;
switch (e.type) {
case 'step': {
for (const sp of this.spinners) sp.target += SPIN_STEP;
const a = this.cells[e.cell];
a.scaleVel = POP_IMPULSE;
a.numberAlpha = 0;
if (e.clueHit) a.flash = 1;
this.bridges.length = Math.max(0, e.step - 1);
if (e.step >= 2) this.bridges[e.step - 2] = 0;
break;
}
case 'unstep': {
for (const sp of this.spinners) sp.target -= SPIN_STEP;
const a = this.cells[e.cell];
a.numberAlpha = 0;
a.flash = 0;
a.scale = 1;
a.scaleVel = 0;
this.bridges.length = Math.max(0, e.step - 2);
break;
}
case 'reset':
for (const a of this.cells) {
a.flash = 0;
a.numberAlpha = 0;
a.scale = 1;
a.scaleVel = 0;
a.sweep = 0;
}
this.bridges.length = 0;
this.cleared = false;
this.clearedT = 0;
this.game.board.spinners.forEach((sp, i) => {
const a = spinnerAngle(this.game.board, i, spinnerPos(sp, 0));
this.spinners[i].shown = a;
this.spinners[i].target = a;
});
break;
case 'rejectStart':
if (e.clueCell >= 0) this.cells[e.clueCell].shake = SHAKE_TIME;
break;
case 'wallHit': {
if (e.wall >= 0) {
const w = this.walls[e.wall];
w.flash = 1;
w.push = 1;
// 从 from 推向 to:墙的 n 是 a→b,若 from 是 b 则反向
w.pushDir = this.game.board.walls[e.wall].a === e.from ? 1 : -1;
}
if (e.spinner >= 0) this.spinners[e.spinner].flash = 1;
break;
}
case 'deadEnd':
this.cells[e.cell].shake = SHAKE_TIME;
break;
case 'cleared':
this.cleared = true;
this.clearedT = 0;
break;
}
}
showHint(h: Hint): void {
this.hintState = h.kind === 'none' ? null : { hint: h, t: HINT_TIME };
this.animating = true;
}
update(dt: number): void {
if (dt <= 0) return;
dt = Math.min(dt, 0.05);
let busy = false;
for (const a of this.cells) {
if (a.scaleVel !== 0 || Math.abs(a.scale - 1) > 0.0005) {
const acc = -SPRING_K * (a.scale - 1) - SPRING_DAMP * a.scaleVel;
a.scaleVel += acc * dt;
a.scale += a.scaleVel * dt;
if (Math.abs(a.scale - 1) < 0.0005 && Math.abs(a.scaleVel) < 0.01) {
a.scale = 1;
a.scaleVel = 0;
} else busy = true;
}
if (a.flash > 0) {
a.flash = Math.max(0, a.flash - FLASH_DECAY * dt);
busy = true;
}
if (a.shake > 0) {
a.shake = Math.max(0, a.shake - dt);
busy = true;
}
if (a.numberAlpha < 1) {
a.numberAlpha = Math.min(1, a.numberAlpha + NUMBER_FADE * dt);
busy = true;
}
}
for (const w of this.walls) {
if (w.flash > 0) {
w.flash = Math.max(0, w.flash - WALL_FLASH_DECAY * dt);
busy = true;
}
if (w.push > 0) {
w.push = Math.max(0, w.push - 7 * dt);
busy = true;
}
}
for (const sp of this.spinners) {
const d = sp.target - sp.shown;
if (Math.abs(d) > 0.002) {
sp.shown += d * Math.min(1, SPIN_SPEED * dt);
busy = true;
} else sp.shown = sp.target;
if (sp.flash > 0) {
sp.flash = Math.max(0, sp.flash - WALL_FLASH_DECAY * dt);
busy = true;
}
}
for (let i = 0; i < this.bridges.length; i++) {
if (this.bridges[i] < 1) {
this.bridges[i] = Math.min(1, this.bridges[i] + BRIDGE_GROW * dt);
busy = true;
}
}
if (this.hintState) {
this.hintState.t -= dt;
if (this.hintState.t <= 0) this.hintState = null;
busy = true;
}
if (this.cleared) {
this.clearedT += dt;
const path = this.game.path;
for (let i = 0; i < path.length; i++) {
const start = (i / path.length) * CLEARED_SWEEP_TIME;
const local = (this.clearedT - start) / 0.28;
this.cells[path[i]].sweep = Math.max(0, Math.min(1, local));
}
if (this.clearedT < this.clearedDuration) busy = true;
}
this.animating = busy;
}
/** 某格当前绘制多边形(含抖动 / 缩放) */
private polyOf(cell: number, L: HexLayout, shrink = 0): Point[] {
const a = this.cells[cell];
let poly = cellPolygon(this.game.board, cell, L, a.scale, shrink);
if (a.shake > 0) {
const k = a.shake / SHAKE_TIME;
poly = translatePolygon(poly, Math.sin(k * Math.PI * 6) * L.size * 0.08 * k, 0);
}
return poly;
}
draw(p: Painter): void {
const L = this.layout;
if (!L) return;
const g = this.game;
const board = g.board;
const path = g.path;
// 1. 底板:所有格
for (const c of board.cells) {
const a = this.cells[c.index];
const poly = this.polyOf(c.index, L);
const visited = g.stepOf(c.index) !== 0;
const conflict = g.isConflict(c.index);
let fill: string = THEME.cell;
if (conflict) fill = THEME.conflict;
else if (visited) fill = this.cleared ? THEME.visitedBright : THEME.visited;
p.fillPolygon(poly, fill);
if (visited && a.sweep > 0 && a.sweep < 1) {
p.fillPolygon(poly, THEME.flash, 0.6 * Math.sin(a.sweep * Math.PI));
}
}
// 2. 桥:连接相邻两步(画在格上面,同色即融为一体)
for (let i = 0; i < path.length - 1; i++) {
const k = this.bridges[i] ?? 1;
if (k <= 0) continue;
const poly = bridgePolygon(board, path[i], path[i + 1], L, 0.5, k);
if (!poly) continue;
const anyConflict = g.isConflict(path[i]) || g.isConflict(path[i + 1]);
const color = anyConflict ? THEME.conflict : this.cleared ? THEME.visitedBright : THEME.visited;
p.fillPolygon(poly, color);
}
// 3. 线索描边、数字、冲突角标
const fontSize = L.size * 0.64;
for (const c of board.cells) {
const a = this.cells[c.index];
const center = this.centerOf(c.index, L);
const step = g.stepOf(c.index);
const visited = step !== 0;
const conflict = g.isConflict(c.index);
const isClue = c.clue !== null;
if (isClue) {
const lw = Math.max(2, L.size * 0.09);
const ring = this.polyOf(c.index, L, lw * 0.62);
const ringColor = visited ? (conflict ? THEME.conflictInk : THEME.visitedInk) : THEME.clueInk;
p.strokePolygon(ring, ringColor, lw, visited ? 0.85 : 1);
if (a.flash > 0) p.strokePolygon(ring, THEME.flash, lw * 1.5, a.flash);
}
if (visited) {
const ink = conflict ? THEME.conflictInk : THEME.visitedInk;
p.text(String(step), center.x, center.y, fontSize, ink, true, a.numberAlpha);
if (conflict) {
// 角标:应该是几。不只靠颜色表达冲突
const br = Math.max(7, L.size * 0.22);
const bx = center.x + L.size * 0.46;
const by = center.y - L.size * 0.5;
p.fillCircle(bx, by, br, THEME.clueInk);
p.text(String(c.clue), bx, by + br * 0.03, br * 1.25, THEME.conflictInk, true);
}
} else if (isClue) {
p.text(String(c.clue), center.x, center.y, fontSize, THEME.clueInk, true);
}
}
// 4. 墙:黑底圆头 + 黄斜纹,永远画在格与桥之上
for (const w of board.walls) {
const anim = this.walls[w.index];
const band = wallBand(board, w.index, L);
let mid = band.mid;
if (anim.push > 0) {
const k = Math.sin(anim.push * Math.PI) * L.size * 0.08 * anim.pushDir;
mid = { x: mid.x + band.n.x * k, y: mid.y + band.n.y * k };
}
const half = band.len / 2;
p.line(mid.x - band.u.x * half, mid.y - band.u.y * half, mid.x + band.u.x * half, mid.y + band.u.y * half, band.thick, THEME.wallBase);
const stripes = stripesInBand(mid, band.u, band.n, band.len, band.thick, band.thick * 1.05, band.thick * 0.5, 0);
for (const s of stripes) p.fillPolygon(s, THEME.wallStripe);
if (anim.flash > 0) {
const rect = bandRect(mid, band.u, band.n, band.len + band.thick, band.thick);
p.fillPolygon(rect, THEME.flash, anim.flash * 0.85);
p.strokePolygon(scalePolygon(rect, mid, 1.15), THEME.conflict, Math.max(2, L.size * 0.06), anim.flash * 0.9);
}
}
// 4b. 转墙:从轴心伸出的条纹带 + 轴心圆点;转动中用当前插值角
for (const sp of board.spinners) {
const anim = this.spinners[sp.index];
const P = spinnerPivot(board, sp.index, L);
const len = L.size * 0.95;
const thick = Math.max(6, L.size * 0.3);
const u = { x: Math.cos(anim.shown), y: Math.sin(anim.shown) };
const n = { x: -u.y, y: u.x };
const mid = { x: P.x + u.x * len * 0.5, y: P.y + u.y * len * 0.5 };
p.line(P.x, P.y, P.x + u.x * len, P.y + u.y * len, thick, THEME.wallBase);
const stripes = stripesInBand(mid, u, n, len, thick, thick * 1.05, thick * 0.5, 0);
for (const st of stripes) p.fillPolygon(st, THEME.wallStripe);
// 轴心:深色圆 + 亮点,标出“会转的那一端”
p.fillCircle(P.x, P.y, thick * 0.72, THEME.wallBase);
p.fillCircle(P.x, P.y, thick * 0.3, THEME.wallStripe);
if (anim.flash > 0) {
const rect = bandRect(mid, u, n, len + thick, thick);
p.fillPolygon(rect, THEME.flash, anim.flash * 0.85);
p.strokePolygon(scalePolygon(rect, mid, 1.15), THEME.conflict, Math.max(2, L.size * 0.06), anim.flash * 0.9);
}
}
// 5. 提示
if (this.hintState) {
const { hint, t } = this.hintState;
const pulse = 0.55 + 0.45 * Math.sin((HINT_TIME - t) * 7);
const alpha = Math.min(1, t / 0.4) * pulse;
const target = hint.kind === 'none' ? -1 : hint.cell;
if (target >= 0) {
const lw = Math.max(3, L.size * 0.1);
const outer = cellPolygon(board, target, L, 1, -lw * 0.9);
p.strokePolygon(outer, THEME.hint, lw, alpha);
}
if (hint.kind === 'backtrack') {
const from = path.indexOf(hint.cell) + 1;
for (let i = from; i < path.length; i++) {
p.fillPolygon(cellPolygon(board, path[i], L), THEME.bg, 0.5 * pulse);
}
}
}
}
private centerOf(cell: number, L: HexLayout): Point {
const c = cellCenter(this.game.board, cell, L);
const a = this.cells[cell];
if (a.shake > 0) {
const k = a.shake / SHAKE_TIME;
return { x: c.x + Math.sin(k * Math.PI * 6) * L.size * 0.08 * k, y: c.y };
}
return c;
}
}
+57
View File
@@ -0,0 +1,57 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { HexBoard } from '../src/board';
import type { HexCellDef } from '../src/types';
const c = (id: string, q: number, r: number, clue?: number): HexCellDef => ({ id, q, r, clue });
test('六邻接:中心格有 6 个邻居,角落只有共边的', () => {
const cells: HexCellDef[] = [c('o', 0, 0)];
const dirs: Array<[number, number]> = [[1, 0], [1, -1], [0, -1], [-1, 0], [-1, 1], [0, 1]];
dirs.forEach(([q, r], i) => cells.push(c(`n${i}`, q, r)));
const b = HexBoard.fromCells(cells);
assert.equal(b.adj[0].length, 6);
// 外圈每格:中心 + 两个相邻外圈格
for (let i = 1; i <= 6; i++) assert.equal(b.adj[i].length, 3);
// (1,1) 不是 (0,0) 的邻居
const b2 = HexBoard.fromCells([c('a', 0, 0), c('b', 1, 1)]);
assert.equal(b2.areAdjacent(0, 1), false);
assert.equal(b2.isConnected(), false);
});
test('墙去掉一条邻接,几何邻接不变', () => {
const b = HexBoard.fromCells([c('a', 0, 0), c('b', 1, 0), c('c', 0, 1)], [['a', 'b']]);
assert.equal(b.areAdjacent(0, 1), false);
assert.equal(b.isGeoNeighbor(0, 1), true);
assert.equal(b.isWalled(0, 1), true);
assert.equal(b.isWalled(1, 0), true);
assert.equal(b.areAdjacent(0, 2), true);
assert.equal(b.areAdjacent(1, 2), true);
assert.equal(b.wallBetween(1, 0)?.index, 0);
assert.equal(b.withoutWalls().areAdjacent(0, 1), true);
assert.equal(b.isConnected(), true);
});
test('墙把盘切开 → 不连通', () => {
const b = HexBoard.fromCells([c('a', 0, 0), c('b', 1, 0)], [['a', 'b']]);
assert.equal(b.isConnected(), false);
});
test('非法盘抛错:坐标重叠、id 重复、墙不在共边、墙重复、线索越界', () => {
assert.throws(() => HexBoard.fromCells([c('a', 0, 0), c('b', 0, 0)]), /重叠/);
assert.throws(() => HexBoard.fromCells([c('a', 0, 0), c('a', 1, 0)]), /重复/);
assert.throws(() => HexBoard.fromCells([c('a', 0, 0), c('b', 1, 1)], [['a', 'b']]), /共用边/);
assert.throws(() => HexBoard.fromCells([c('a', 0, 0), c('b', 1, 0)], [['a', 'b'], ['b', 'a']]), /重复/);
assert.throws(() => HexBoard.fromCells([c('a', 0, 0), c('b', 1, 0)], [['a', 'x']]), /不存在/);
assert.throws(() => HexBoard.fromCells([c('a', 0, 0, 3), c('b', 1, 0)]), /超出/);
assert.throws(() => HexBoard.fromCells([c('a', 0, 0, 1), c('b', 1, 0, 1)]), /线索重复/);
});
test('cellAt / cellById / 线索索引', () => {
const b = HexBoard.fromCells([c('a', -1, 2, 1), c('b', 0, 2, 2)]);
assert.equal(b.cellAt(-1, 2), 0);
assert.equal(b.cellAt(5, 5), -1);
assert.equal(b.cellById('b').index, 1);
assert.equal(b.startClueCell, 0);
assert.equal(b.clueCellOfStep.get(2), 1);
});
+114
View File
@@ -0,0 +1,114 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { HexBoard } from '../src/board';
import { Game } from '../src/game';
import type { HexCellDef } from '../src/types';
const c = (id: string, q: number, r: number, clue?: number): HexCellDef => ({ id, q, r, clue });
// 1-3:a(1) b / c d(4),墙 a|b
const level13 = () => HexBoard.fromCells([c('a', 0, 0, 1), c('b', 1, 0), c('c', 0, 1), c('d', 1, 1, 4)], [['a', 'b']]);
test('有线索 1 时只能从它起手', () => {
const g = new Game(level13());
assert.deepEqual(g.apply({ type: 'start', cell: 1 }), [{ type: 'rejectStart', cell: 1, clueCell: 0 }]);
assert.equal(g.status, 'empty');
assert.equal(g.apply({ type: 'start', cell: 0 })[0].type, 'step');
assert.equal(g.status, 'drawing');
});
test('无线索 1 时任意格可起手', () => {
const g = new Game(HexBoard.fromCells([c('a', 0, 0), c('b', 1, 0, 2)]));
assert.equal(g.apply({ type: 'start', cell: 0 })[0].type, 'step');
});
test('穿墙 → wallHit 事件,不追加', () => {
const b = level13();
const g = new Game(b);
g.apply({ type: 'start', cell: 0 });
const ev = g.apply({ type: 'append', cell: 1 });
assert.deepEqual(ev, [{ type: 'wallHit', from: 0, to: 1, wall: 0, spinner: -1 }]);
assert.equal(g.path.length, 1);
assert.deepEqual(g.wallTo(1), { wall: 0, spinner: -1 });
assert.equal(g.canAppend(1), false);
assert.equal(g.canAppend(2), true);
});
test('追加只能到相邻无墙未走格;冲突不打断;撤销恢复', () => {
const b = level13();
const g = new Game(b);
const id = (s: string) => b.cellById(s).index;
g.apply({ type: 'start', cell: id('a') });
assert.deepEqual(g.apply({ type: 'append', cell: id('d') }), []); // (1,1) 与 (0,0) 不相邻
g.apply({ type: 'append', cell: id('c') });
const ev = g.apply({ type: 'append', cell: id('d') }); // d=3,冲突
assert.equal(ev[0].type, 'step');
assert.equal((ev[0] as { conflict: boolean }).conflict, true);
assert.equal(g.isConflict(id('d')), true);
const last = g.apply({ type: 'append', cell: id('b') });
assert.equal(g.path.length, 4);
assert.equal(g.status, 'drawing', '填满但有冲突不算过关');
assert.ok(last.some((e) => e.type === 'deadEnd'));
g.apply({ type: 'undo' });
const un = g.apply({ type: 'undo' });
assert.deepEqual(un, [{ type: 'unstep', cell: id('d'), step: 3 }]);
assert.equal(g.isConflict(id('d')), false);
});
test('全走完且线索全对 → cleared,之后锁住', () => {
const b = level13();
const g = new Game(b);
const id = (s: string) => b.cellById(s).index;
g.apply({ type: 'start', cell: id('a') });
g.apply({ type: 'append', cell: id('c') });
g.apply({ type: 'append', cell: id('b') });
const ev = g.apply({ type: 'append', cell: id('d') });
assert.ok(ev.some((e) => e.type === 'cleared'));
assert.ok(ev.some((e) => e.type === 'step' && e.clueHit));
assert.equal(g.status, 'cleared');
assert.deepEqual(g.apply({ type: 'undo' }), []);
assert.deepEqual(g.apply({ type: 'append', cell: 0 }), []);
});
test('release / resume / reset 流转', () => {
const g = new Game(level13());
g.apply({ type: 'start', cell: 0 });
g.apply({ type: 'release' });
assert.equal(g.status, 'idle');
g.apply({ type: 'resume' });
assert.equal(g.status, 'drawing');
assert.deepEqual(g.apply({ type: 'reset' }), [{ type: 'reset' }]);
assert.equal(g.status, 'empty');
assert.deepEqual(g.apply({ type: 'reset' }), [], '空盘重开不产生事件');
g.apply({ type: 'start', cell: 0 });
g.apply({ type: 'undo' });
assert.equal(g.status, 'empty');
});
test('转墙:每走一格转 120°,按已走格数判定', () => {
// 花形 7 格,轴心在中心格 o 的第 4 个角(顶部),周围三格 o / nw / ne
const cells = [c('o', 0, 0, 1), c('e', 1, 0), c('ne', 1, -1), c('nw', 0, -1), c('w', -1, 0), c('sw', -1, 1), c('se', 0, 1)];
const b = HexBoard.fromCells(cells, [], [{ cell: 'o', corner: 4, phase: 0 }]);
const sp = b.spinners[0];
const id = (s: string) => b.cellById(s).index;
// 角 4 夹在方向 240°(nw) 与 300°(ne) 之间:边 0 = nw-ne,边 1 = o-ne,边 2 = o-nw
assert.deepEqual(sp.edges[0], [Math.min(id('nw'), id('ne')), Math.max(id('nw'), id('ne'))]);
assert.deepEqual(sp.edges[1], [id('o'), id('ne')]);
assert.deepEqual(sp.edges[2], [id('o'), id('nw')]);
// t=0 挡 nw-ne;t=1 挡 o-ne;t=2 挡 o-nw
assert.equal(b.spinnerBlocking(id('nw'), id('ne'), 0), 0);
assert.equal(b.spinnerBlocking(id('o'), id('ne'), 1), 0);
assert.equal(b.spinnerBlocking(id('o'), id('nw'), 2), 0);
assert.equal(b.spinnerBlocking(id('o'), id('ne'), 0), -1);
const g = new Game(b);
g.apply({ type: 'start', cell: id('o') }); // 已走 1 格 → 墙挡 o-ne
const hit = g.apply({ type: 'append', cell: id('ne') });
assert.deepEqual(hit, [{ type: 'wallHit', from: id('o'), to: id('ne'), wall: -1, spinner: 0 }]);
assert.equal(g.apply({ type: 'append', cell: id('nw') })[0].type, 'step'); // t=1 时 o-nw 开着
// 现在已走 2 格 → 墙挡 o-nw(已走,无所谓);nw-ne 开着
assert.equal(g.apply({ type: 'append', cell: id('ne') })[0].type, 'step');
g.apply({ type: 'undo' });
g.apply({ type: 'undo' });
assert.equal(g.path.length, 1);
assert.deepEqual(g.apply({ type: 'append', cell: id('ne') })[0].type, 'wallHit', '撤销后墙转回去');
});
+87
View File
@@ -0,0 +1,87 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { HexBoard } from '../src/board';
import { Game } from '../src/game';
import { PointerInput } from '../src/input';
import type { HexCellDef } from '../src/types';
const c = (id: string, q: number, r: number, clue?: number): HexCellDef => ({ id, q, r, clue });
const level13 = () => HexBoard.fromCells([c('a', 0, 0, 1), c('b', 1, 0), c('c', 0, 1), c('d', 1, 1, 4)], [['a', 'b']]);
function setup() {
const b = level13();
const g = new Game(b);
const input = new PointerInput(g);
const id = (s: string) => b.cellById(s).index;
return { b, g, input, id };
}
test('按下起点、滑到邻格追加、滑回撤销、松手保留', () => {
const { g, input, id } = setup();
assert.equal(input.down(id('a'))[0].type, 'step');
assert.ok(input.isDrawing);
assert.equal(input.move(id('c'))[0].type, 'step');
assert.deepEqual(input.move(id('c')), [], '同一格不重复触发');
assert.equal(input.move(id('a'))[0].type, 'unstep');
assert.equal(g.path.length, 1);
input.move(id('c'));
input.up();
assert.equal(g.status, 'idle');
assert.equal(g.path.length, 2);
});
test('滑向有墙的邻格 → wallHit 一次,路径不变', () => {
const { g, input, id } = setup();
input.down(id('a'));
const ev = input.move(id('b'));
assert.equal(ev.length, 1);
assert.equal(ev[0].type, 'wallHit');
assert.deepEqual(input.move(id('b')), [], '停在同一格不重复报');
assert.equal(g.path.length, 1);
// 指针逻辑上仍在末端:继续滑到 c 可追加
assert.equal(input.move(id('c'))[0].type, 'step');
});
test('点末端继续、点前一格撤销、点旁边空格追加、点墙外邻格报撞墙', () => {
const { g, input, id } = setup();
input.down(id('a'));
input.move(id('c'));
input.up();
assert.equal(input.down(id('c')).length, 0);
assert.ok(input.isDrawing);
input.up();
assert.equal(input.down(id('a'))[0].type, 'unstep');
assert.equal(g.path.length, 1);
input.up();
assert.equal(input.down(id('c'))[0].type, 'step', '点着走');
input.up();
// 路径 a,c;c 的邻居 b 无墙 → 追加;再从 b 去 a 已走 → 忽略
assert.equal(input.down(id('b'))[0].type, 'step');
input.up();
// 从 a 起手时点 b:撞墙反馈
input.cancel();
g.apply({ type: 'reset' });
input.down(id('a'));
input.up();
assert.equal(input.down(id('b'))[0].type, 'wallHit');
});
test('非法起手与空白按下', () => {
const { input, id } = setup();
assert.equal(input.down(id('b'))[0].type, 'rejectStart');
assert.ok(!input.isDrawing);
assert.deepEqual(input.down(-1), []);
assert.deepEqual(input.move(id('a')), [], '没在画,move 无效');
});
test('过关后手势失效', () => {
const { g, input, id } = setup();
input.down(id('a'));
input.move(id('c'));
input.move(id('b'));
const ev = input.move(id('d'));
assert.ok(ev.some((e) => e.type === 'cleared'));
assert.ok(!input.isDrawing);
assert.equal(g.isCleared, true);
assert.deepEqual(input.down(id('d')), []);
});
+79
View File
@@ -0,0 +1,79 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { HexBoard } from '../src/board';
import { clipPolygon, roundPolygon, stripesInBand } from '../src/geom';
import { bridgePolygon, cellCenter, fitBoard, hitTest, pixelToAxial, sharedEdge, wallBand } from '../src/layout';
import type { HexCellDef } from '../src/types';
const c = (id: string, q: number, r: number, clue?: number): HexCellDef => ({ id, q, r, clue });
const flower = () =>
HexBoard.fromCells([c('o', 0, 0), c('e', 1, 0), c('ne', 1, -1), c('nw', 0, -1), c('w', -1, 0), c('sw', -1, 1), c('se', 0, 1)], [['o', 'e']]);
test('fitBoard 按短边缩放并居中,不超过 maxSize', () => {
const b = flower();
const L = fitBoard(b, 400, 600, { padding: 10, maxSize: 60 });
assert.ok(L.size <= 60);
// 花形宽 = 3 × √3 × size,应贴合 380 宽或受 maxSize 限制
assert.ok(L.width <= 380 + 1e-6);
const o = cellCenter(b, 0, L);
assert.ok(Math.abs(o.x - 200) < 1e-6);
assert.ok(Math.abs(o.y - 300) < 1e-6);
const small = fitBoard(b, 100, 100, { padding: 0 });
assert.ok(small.width <= 100 + 1e-6 && small.height <= 100 + 1e-6);
});
test('像素 ↔ 轴坐标往返;命中中心;死区与空洞返回 -1', () => {
const b = flower();
const L = fitBoard(b, 400, 600, { padding: 10, maxSize: 60 });
for (const cell of b.cells) {
const p = cellCenter(b, cell.index, L);
assert.deepEqual(pixelToAxial(L, p.x, p.y), { q: cell.q, r: cell.r });
assert.equal(hitTest(b, L, p.x, p.y), cell.index);
}
const o = cellCenter(b, 0, L);
const e = cellCenter(b, 1, L);
// 两格中点在缝里 → 死区
assert.equal(hitTest(b, L, (o.x + e.x) / 2, (o.y + e.y) / 2), -1);
// 盘外
assert.equal(hitTest(b, L, 5, 5), -1);
// (2,0) 不存在
const far = { x: o.x + Math.sqrt(3) * L.size * 2, y: o.y };
assert.equal(hitTest(b, L, far.x, far.y), -1);
});
test('共用边、桥、墙带的几何', () => {
const b = flower();
const L = fitBoard(b, 400, 600, { padding: 10, maxSize: 60 });
const e = sharedEdge(b, 0, 1, L)!;
const o = cellCenter(b, 0, L);
const ea = cellCenter(b, 1, L);
assert.ok(Math.abs(e.mid.x - (o.x + ea.x) / 2) < 1e-6);
assert.ok(Math.abs(e.n.x - 1) < 1e-6, '东邻的法向是 +x');
assert.ok(Math.abs(e.u.y - 1) < 1e-6, '共用边竖直');
assert.equal(sharedEdge(b, 1, 3, L), null, 'ne 与 w 不相邻');
const br = bridgePolygon(b, 0, 1, L)!;
assert.equal(br.length, 4);
const wb = wallBand(b, 0, L);
assert.ok(wb.thick > 0 && wb.len > 0);
});
test('几何工具:圆角多边形顶点数、凸裁剪、斜纹都在带内', () => {
const sq = [
{ x: 0, y: 0 },
{ x: 10, y: 0 },
{ x: 10, y: 10 },
{ x: 0, y: 10 },
];
assert.equal(roundPolygon(sq, 2, 3).length, 16);
const tri = [
{ x: -5, y: 5 },
{ x: 15, y: 5 },
{ x: 5, y: 20 },
];
const clipped = clipPolygon(tri, sq);
assert.ok(clipped.length >= 3);
for (const p of clipped) assert.ok(p.x >= -1e-6 && p.x <= 10 + 1e-6 && p.y >= -1e-6 && p.y <= 10 + 1e-6);
const stripes = stripesInBand({ x: 0, y: 0 }, { x: 1, y: 0 }, { x: 0, y: 1 }, 40, 8, 8, 4);
assert.ok(stripes.length >= 4);
for (const s of stripes) for (const p of s) assert.ok(Math.abs(p.x) <= 20 + 1e-6 && Math.abs(p.y) <= 4 + 1e-6);
});
+41
View File
@@ -0,0 +1,41 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { MemoryStore, Progress, STORAGE_KEY } from '../src/progress';
const ids = ['1-1', '1-2', '1-3'];
test('线性解锁与继续', () => {
const p = new Progress(new MemoryStore());
assert.equal(p.isUnlocked(ids, 0), true);
assert.equal(p.isUnlocked(ids, 1), false);
p.markCleared('1-1');
assert.equal(p.isUnlocked(ids, 1), true);
assert.equal(p.firstUncleared(ids), '1-2');
p.markCleared('1-2');
p.markCleared('1-3');
assert.equal(p.firstUncleared(ids), '1-3');
});
test('存档桶是本玩法的,可恢复,含偏好', () => {
const store = new MemoryStore();
const p = new Progress(store);
p.markCleared('1-1');
p.setCurrent('1-2');
p.setPrefs({ sound: false });
assert.equal(STORAGE_KEY, 'mode:03-hex-walls');
assert.ok(store.get(STORAGE_KEY));
const p2 = new Progress(store);
assert.equal(p2.isCleared('1-1'), true);
assert.equal(p2.currentId, '1-2');
assert.equal(p2.prefs.sound, false);
assert.equal(p2.prefs.haptics, true);
p2.resetAll();
assert.equal(new Progress(store).clearedCount(), 0);
});
test('坏存档不炸', () => {
const store = new MemoryStore();
store.set(STORAGE_KEY, '{not json');
const p = new Progress(store);
assert.equal(p.clearedCount(), 0);
});
+97
View File
@@ -0,0 +1,97 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { test } from 'node:test';
import { HexBoard } from '../src/board';
import { loadPack, parsePack } from '../src/levels';
import { hint, redundantWalls, solve, spinnerReport, uniqueSolution, wallsMatter } from '../src/solver';
import type { HexCellDef } from '../src/types';
const c = (id: string, q: number, r: number, clue?: number): HexCellDef => ({ id, q, r, clue });
const pack = parsePack(JSON.parse(readFileSync(new URL('../levels/pack-1-tutorial.json', import.meta.url), 'utf8')));
const levels = loadPack(pack);
test('第一包恰好 10 关,每关连通且唯一解', () => {
assert.equal(levels.length, 10);
for (const lv of levels) assert.equal(lv.board.spinners.length, 0, '第一包不含转墙');
for (const lv of levels) {
assert.ok(lv.board.isConnected(), `${lv.def.id} 不连通`);
const paths = solve(lv.board, { limit: 2 });
assert.equal(paths.length, 1, `${lv.def.id} 解数 ${paths.length}`);
assert.equal(paths[0].length, lv.board.n);
// 解满足线索
paths[0].forEach((cell, i) => {
const clue = lv.board.cells[cell].clue;
if (clue !== null) assert.equal(clue, i + 1, `${lv.def.id} 线索不符`);
});
}
});
test('第 3 关起每关至少 1 堵墙,且墙参与唯一性、无冗余墙', () => {
for (const lv of levels) {
if (lv.index < 3) continue;
assert.ok(lv.board.walls.length >= 1, `${lv.def.id} 没有墙`);
assert.ok(wallsMatter(lv.board), `${lv.def.id} 去墙后仍唯一`);
assert.deepEqual(redundantWalls(lv.board), [], `${lv.def.id} 有冗余墙`);
}
});
test('前 8 关都有线索 1', () => {
for (const lv of levels) if (lv.index <= 8) assert.notEqual(lv.board.startClueCell, -1, lv.def.id);
});
test('墙改变解集合:同一盘有墙 1 解、无墙 2 解', () => {
const b = HexBoard.fromCells([c('a', 0, 0, 1), c('b', 1, 0), c('c', 0, 1), c('d', 1, 1, 4)], [['a', 'b']]);
assert.deepEqual(uniqueSolution(b), [0, 2, 1, 3]);
assert.equal(solve(b.withoutWalls()).length, 2);
});
test('无线索 1 时枚举全部起点;无解 / 多解返回 null', () => {
const b = HexBoard.fromCells([c('a', 0, 0), c('b', 1, 0), c('c', 2, 0)]);
assert.equal(solve(b).length, 2);
assert.equal(uniqueSolution(b), null);
const b2 = HexBoard.fromCells([c('a', 0, 0), c('b', 1, 0), c('c', 2, 0, 2)]);
assert.equal(solve(b2).length, 0);
});
test('随机模式也能找到解', () => {
const b = levels[9].board;
let seed = 1;
const rnd = () => (seed = (seed * 16807) % 2147483647) / 2147483647;
assert.equal(solve(b, { limit: 1, random: rnd }).length, 1);
});
test('提示四态', () => {
const sol = [0, 2, 1, 3];
assert.deepEqual(hint(sol, []), { kind: 'start', cell: 0 });
assert.deepEqual(hint(sol, [0, 2]), { kind: 'next', cell: 1 });
assert.deepEqual(hint(sol, [0, 1, 3]), { kind: 'backtrack', cell: 0, undoCount: 2 });
assert.deepEqual(hint(sol, [2, 0]), { kind: 'restart', cell: 0 });
assert.deepEqual(hint(sol, sol), { kind: 'none' });
});
const pack2 = parsePack(JSON.parse(readFileSync(new URL('../levels/pack-2-spinner.json', import.meta.url), 'utf8')));
const levels2 = loadPack(pack2);
test('第二包(转墙实验):每关唯一解,转墙不冗余且不能用静态墙替代', () => {
assert.ok(levels2.length >= 3);
for (const lv of levels2) {
assert.ok(lv.board.spinners.length >= 1, `${lv.def.id} 没有转墙`);
assert.equal(solve(lv.board, { limit: 2 }).length, 1, `${lv.def.id} 不唯一`);
assert.ok(wallsMatter(lv.board), `${lv.def.id} 去墙后仍唯一`);
for (const r of spinnerReport(lv.board)) {
assert.equal(r.removable, false, `${lv.def.id} 转墙 ${r.index} 冗余`);
assert.deepEqual(r.freezable, [], `${lv.def.id} 转墙 ${r.index} 可用静态墙替代`);
}
}
});
test('求解器按步号判定转墙:同一盘不同相位 2 解 / 1 解 / 0 解', () => {
// a(0,0)=1, b(1,0), d(1,-1);转墙轴在 b 的第 3 个角(左上),周围三格 a/b/d:边 0 = a|d,1 = b|d,2 = a|b
const mk = (phase: number) => HexBoard.fromCells([c('a', 0, 0, 1), c('b', 1, 0), c('d', 1, -1)], [], [{ cell: 'b', corner: 3, phase }]);
const b0 = mk(0);
assert.deepEqual(b0.spinners[0].edges, [[0, 2], [1, 2], [0, 1]]);
assert.equal(solve(b0).length, 2, '相位 0:a,b,d 与 a,d,b 都通');
assert.deepEqual(solve(mk(1)), [[0, 2, 1]], '相位 1:起手时 a|b 被挡,只能 a,d,b');
assert.equal(solve(mk(2)).length, 0, '相位 2:a→b 后 b|d 被挡,无解');
});
+53
View File
@@ -0,0 +1,53 @@
// 终端字符盘面:点顶六边形按行错位显示。每格 4 字符宽,相邻行错开 2 字符。
import type { HexBoard } from '../src/board';
export function renderAscii(board: HexBoard, path?: number[]): string {
const stepOf = new Map<number, number>();
if (path) path.forEach((c, i) => stepOf.set(c, i + 1));
let minR = Infinity;
let maxR = -Infinity;
let minX = Infinity;
for (const c of board.cells) {
minR = Math.min(minR, c.r);
maxR = Math.max(maxR, c.r);
minX = Math.min(minX, 2 * c.q + c.r);
}
const lines: string[] = [];
for (let r = minR; r <= maxR; r++) {
const row = board.cells.filter((c) => c.r === r).sort((a, b) => a.q - b.q);
let line = '';
for (const c of row) {
const col = (2 * c.q + c.r - minX) * 2;
while (line.length < col) line += ' ';
let tok: string;
if (path) {
const s = stepOf.get(c.index);
tok = s === undefined ? ' . ' : String(s).padStart(2) + ' ';
if (c.clue !== null && s !== undefined) tok = String(s).padStart(2) + (s === c.clue ? '*' : '!');
} else {
tok = c.clue !== null ? `[${c.clue}]`.padStart(3) : ` ${c.id.slice(0, 2).padEnd(2)}`;
}
line += tok.padEnd(4);
}
lines.push(line.replace(/\s+$/, ''));
}
if (board.walls.length) {
lines.push('墙: ' + board.walls.map((w) => `${board.cells[w.a].id}|${board.cells[w.b].id}`).join(' '));
}
if (board.spinners.length) {
lines.push(
'转墙: ' +
board.spinners
.map((sp) => {
const e = sp.edges.map((x) => (x ? `${board.cells[x[0]].id}|${board.cells[x[1]].id}` : '外')).join(' → ');
return `${board.cells[sp.cell].id}/${sp.corner} 相位${sp.phase} [${e}]`;
})
.join(' '),
);
}
return lines.join('\n');
}
export function pathToIds(board: HexBoard, path: number[]): string {
return path.map((i) => board.cells[i].id).join(',');
}
+113
View File
@@ -0,0 +1,113 @@
// 关卡设计辅助:
// npm run design -- 1-7 打印盘面、无线索路径数、当前解
// npm run design -- 1-7 --minimize 当前线索里哪些可删(删掉仍唯一)
// npm run design -- 1-7 --walls 哪些墙可删(删掉仍唯一)
// npm run design -- 1-7 --suggest 3 --path a,b,c,... 给作者解找最少线索(≤3 个)
// npm run design -- /path/draft.json --level 1-7 ... 也可以指定临时关卡文件
import { readdirSync, readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { HexBoard } from '../src/board';
import { loadPack, parsePack, type LoadedLevel } from '../src/levels';
import { redundantWalls, solve, spinnerReport, wallsMatter } from '../src/solver';
import type { LevelDef } from '../src/types';
import { pathToIds, renderAscii } from './ascii';
const here = dirname(fileURLToPath(import.meta.url));
const argv = process.argv.slice(2);
const flag = (name: string): string | undefined => {
const i = argv.indexOf(name);
return i >= 0 ? argv[i + 1] : undefined;
};
function findLevel(): LoadedLevel {
const target = argv[0];
if (!target) throw new Error('用法: npm run design -- <关卡id | 文件> [--level id] [--minimize|--walls|--suggest k --path ...]');
const files = target.endsWith('.json')
? [target]
: readdirSync(join(here, '..', 'levels'))
.filter((f) => f.endsWith('.json'))
.map((f) => join(here, '..', 'levels', f));
const wantId = target.endsWith('.json') ? flag('--level') : target;
for (const f of files) {
const pack = parsePack(JSON.parse(readFileSync(f, 'utf8')));
const levels = loadPack(pack);
const lv = wantId ? levels.find((l) => l.def.id === wantId) : levels[0];
if (lv) return lv;
}
throw new Error(`找不到关卡 ${wantId ?? target}`);
}
function withClues(def: LevelDef, clueOf: Map<string, number>): HexBoard {
return HexBoard.fromCells(
def.cells.map((c) => ({ id: c.id, q: c.q, r: c.r, clue: clueOf.get(c.id) })),
def.walls ?? [],
def.spinners ?? [],
);
}
const lv = findLevel();
const b = lv.board;
console.log(`\n${lv.def.id} N=${b.n} 墙=${b.walls.length}`);
console.log(renderAscii(b).replace(/^/gm, ' '));
const free = solve(b, { ignoreClues: true, limit: 2000 });
console.log(`无线索哈密顿路径: ${free.length >= 2000 ? '2000+' : free.length}`);
const cur = solve(b, { limit: 3 });
console.log(`当前线索下的解: ${cur.length}${cur.length === 1 ? '(唯一)' : ''}`);
for (const p of cur) console.log(' ' + pathToIds(b, p));
if (cur.length === 1) {
console.log(renderAscii(b, cur[0]).replace(/^/gm, ' '));
console.log(`去墙后解: ${solve(b.withoutWalls(), { limit: 100 }).length} 墙参与唯一性: ${wallsMatter(b) ? '是' : '否'}`);
for (const r of spinnerReport(b)) {
console.log(`转墙 ${r.index}: ${r.removable ? '冗余' : '必要'};${r.freezable.length ? `可换成静态墙(边 ${r.freezable.join('/')})` : '不能用静态墙替代'}`);
}
}
if (argv.includes('--minimize') && cur.length === 1) {
const clues = b.cells.filter((c) => c.clue !== null);
for (const c of clues) {
const m = new Map(clues.filter((x) => x !== c).map((x) => [x.id, x.clue!]));
const n = solve(withClues(lv.def, m), { limit: 2 }).length;
console.log(` 删掉 ${c.id}=${c.clue}: ${n === 1 ? '仍唯一(可删)' : n === 0 ? '无解' : '多解(必须留)'}`);
}
}
if (argv.includes('--walls') && cur.length === 1) {
const r = redundantWalls(b);
console.log(r.length ? `可删的墙: ${r.map((i) => `${b.cells[b.walls[i].a].id}|${b.cells[b.walls[i].b].id}`).join(', ')}` : '每堵墙都必要');
}
const suggestK = flag('--suggest');
if (suggestK) {
const pathIds = (flag('--path') ?? '').split(',').filter(Boolean);
const path = pathIds.length ? pathIds.map((id) => b.cellById(id).index) : cur[0];
if (!path || path.length !== b.n) throw new Error('--path 必须是覆盖全部格的作者解');
const k = Number(suggestK);
const stepOf = new Map<number, number>();
path.forEach((c, i) => stepOf.set(c, i + 1));
const cells = b.cells.map((c) => c.index);
const results: Array<{ ids: string[]; score: number }> = [];
const rec = (start: number, chosen: number[]): void => {
if (chosen.length > 0) {
const m = new Map(chosen.map((c) => [b.cells[c].id, stepOf.get(c)!]));
const n = solve(withClues(lv.def, m), { limit: 2 }).length;
if (n === 1) {
const steps = chosen.map((c) => stepOf.get(c)!).sort((x, y) => x - y);
// 偏好:线索少、少用端点、分布均匀
let score = chosen.length * 10;
for (const s of steps) if (s === 1 || s === b.n) score += 1;
for (let i = 1; i < steps.length; i++) score += Math.abs(steps[i] - steps[i - 1] - b.n / (steps.length + 1)) * 0.1;
results.push({ ids: chosen.map((c) => `${b.cells[c].id}=${stepOf.get(c)}`), score });
return;
}
}
if (chosen.length === k) return;
for (let i = start; i < cells.length; i++) rec(i + 1, [...chosen, cells[i]]);
};
rec(0, []);
results.sort((x, y) => x.score - y.score);
console.log(`\n作者解: ${pathToIds(b, path)}`);
console.log(`≤${k} 个线索可唯一的组合(前 12):`);
for (const r of results.slice(0, 12)) console.log(' ' + r.ids.join(' '));
if (!results.length) console.log(' 没有');
}
+189
View File
@@ -0,0 +1,189 @@
// 生成器(GDD §8.2):铺一块六边形聚落 → 随机作者解 → 在非解边上插墙 → 挑线索直到唯一 → 去墙不唯一检查。
// 只用来出候选,人过一眼再进包。
// npm run generate -- --shape hex2 --holes 2 --walls 3 --clues 3 --seed 7 --count 5
// npm run generate -- --from draft.json --level 1-9 --walls 2 --clues 2
// --spinners 1 额外放转墙(放在作者解不会被挡的顶点上;候选必须“去掉转墙不唯一、换静态墙也不唯一”)
// shape: hex1(7) hex2(19) tri4(10) tri5(15) para3x4(12) para4x4(16) rect4x3(12) rect5x3(15) rect4x4(16)
import { readFileSync } from 'node:fs';
import { HexBoard } from '../src/board';
import { loadPack, parsePack } from '../src/levels';
import { redundantWalls, solve, spinnerReport, wallsMatter } from '../src/solver';
import type { HexCellDef, SpinnerDef, WallDef } from '../src/types';
import { pathToIds, renderAscii } from './ascii';
const argv = process.argv.slice(2);
const flag = (name: string, def: string): string => {
const i = argv.indexOf(name);
return i >= 0 ? argv[i + 1] : def;
};
function mulberry32(seed: number): () => number {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function shape(name: string): Array<[number, number]> {
const out: Array<[number, number]> = [];
const m = /^(hex|tri|para|rect)(\d)(?:x(\d))?$/.exec(name);
if (!m) throw new Error(`未知形状 ${name}`);
const a = Number(m[2]);
const b = Number(m[3] ?? a);
if (m[1] === 'hex') {
for (let q = -a; q <= a; q++) for (let r = Math.max(-a, -q - a); r <= Math.min(a, -q + a); r++) out.push([q, r]);
} else if (m[1] === 'tri') {
for (let r = 0; r < a; r++) for (let q = 0; q < a - r; q++) out.push([q, r]);
} else if (m[1] === 'para') {
for (let r = 0; r < b; r++) for (let q = 0; q < a; q++) out.push([q, r]);
} else {
// 矩形(偶行错位):每行 a 个,共 b 行
for (let r = 0; r < b; r++) for (let x = 0; x < a; x++) out.push([x - Math.floor(r / 2), r]);
}
return out;
}
const rnd = mulberry32(Number(flag('--seed', String(Date.now() % 100000))));
const wantWalls = Number(flag('--walls', '2'));
const wantClues = Number(flag('--clues', '3'));
const holes = Number(flag('--holes', '0'));
const wantSpinners = Number(flag('--spinners', '0'));
const count = Number(flag('--count', '3'));
const maxTries = Number(flag('--tries', '400'));
const from = flag('--from', '');
let baseCells: HexCellDef[];
if (from) {
const pack = parsePack(JSON.parse(readFileSync(from, 'utf8')));
const lv = loadPack(pack).find((l) => l.def.id === flag('--level', pack.levels[0].id))!;
baseCells = lv.def.cells.map((c) => ({ id: c.id, q: c.q, r: c.r }));
} else {
const ids = 'abcdefghijklmnopqrstuvwxyz';
baseCells = shape(flag('--shape', 'hex1')).map(([q, r], i) => ({ id: ids[i] ?? `c${i}`, q, r }));
}
interface Candidate {
cells: HexCellDef[];
walls: WallDef[];
spinners: SpinnerDef[];
path: number[];
freePaths: number;
noWallPaths: number;
board: HexBoard;
}
const found: Candidate[] = [];
let tries = 0;
while (found.length < count && tries < maxTries) {
tries++;
// 1. 挖洞(保证连通)
let cells = baseCells.map((c) => ({ ...c }));
for (let h = 0; h < holes; h++) {
for (let attempt = 0; attempt < 20; attempt++) {
const i = Math.floor(rnd() * cells.length);
const trial = cells.filter((_, j) => j !== i);
if (HexBoard.fromCells(trial).isConnected()) {
cells = trial;
break;
}
}
}
const plain = HexBoard.fromCells(cells);
// 2. 随机作者解
const p = solve(plain, { ignoreClues: true, limit: 1, random: rnd });
if (!p.length) continue;
const path = p[0];
const onPath = new Set<string>();
for (let i = 0; i + 1 < path.length; i++) onPath.add(`${Math.min(path[i], path[i + 1])}-${Math.max(path[i], path[i + 1])}`);
// 3. 在非解边上插墙
const edges: Array<[number, number]> = [];
for (let i = 0; i < plain.n; i++) for (const j of plain.geoAdj[i]) if (i < j && !onPath.has(`${i}-${j}`)) edges.push([i, j]);
for (let i = edges.length - 1; i > 0; i--) {
const j = Math.floor(rnd() * (i + 1));
[edges[i], edges[j]] = [edges[j], edges[i]];
}
const walls: WallDef[] = edges.slice(0, wantWalls).map(([a, b]) => [plain.cells[a].id, plain.cells[b].id]);
// 3b. 转墙:随机顶点 + 相位,要求作者解每一步都不被挡
const spinners: SpinnerDef[] = [];
if (wantSpinners > 0) {
const stepOfCell = new Map<number, number>();
path.forEach((c, i) => stepOfCell.set(c, i + 1));
for (let attempt = 0; attempt < 60 && spinners.length < wantSpinners; attempt++) {
const cell = plain.cells[Math.floor(rnd() * plain.n)];
const def: SpinnerDef = { cell: cell.id, corner: Math.floor(rnd() * 6), phase: Math.floor(rnd() * 3) };
let b: HexBoard;
try {
b = HexBoard.fromCells(cells, walls, [...spinners, def]);
} catch {
continue;
}
// 作者解是否仍合法
let legal = true;
for (let i = 0; i + 1 < path.length && legal; i++) legal = b.canStep(path[i], path[i + 1], i + 1);
if (!legal) continue;
// 至少要挡到作者解经过的某个格周围的边才有意义:三条边至少两条在盘上
spinners.push(def);
}
if (spinners.length < wantSpinners) continue;
}
// 4. 挑线索:从含 1 开始,逐个加最“区分”的线索直到唯一
const stepOf = new Map<number, number>();
path.forEach((c, i) => stepOf.set(c, i + 1));
const chosen = new Set<number>([path[0]]);
const build = (): HexBoard =>
HexBoard.fromCells(
cells.map((c, i) => ({ ...c, clue: chosen.has(i) ? stepOf.get(i) : undefined })),
walls,
spinners,
);
let board = build();
let ok = solve(board, { limit: 2 }).length === 1;
while (!ok && chosen.size < wantClues) {
let best = -1;
let bestCount = Infinity;
const order = cells.map((_, i) => i).sort(() => rnd() - 0.5);
for (const i of order) {
if (chosen.has(i)) continue;
chosen.add(i);
const n = solve(build(), { limit: 50 }).length;
chosen.delete(i);
if (n < bestCount) {
bestCount = n;
best = i;
}
if (n === 1) break;
}
if (best < 0) break;
chosen.add(best);
board = build();
ok = bestCount === 1;
}
if (!ok) continue;
// 5. 去墙不唯一 + 每堵墙都必要
if ((walls.length || spinners.length) && !wallsMatter(board)) continue;
if (redundantWalls(board).length) continue;
if (spinnerReport(board).some((r) => r.removable || r.freezable.length)) continue;
found.push({
cells: board.cells.map((c) => ({ id: c.id, q: c.q, r: c.r, ...(c.clue !== null ? { clue: c.clue } : {}) })),
walls,
spinners,
path,
freePaths: solve(plain, { ignoreClues: true, limit: 500 }).length,
noWallPaths: solve(board.withoutWalls(), { limit: 500 }).length,
board,
});
}
console.log(`尝试 ${tries} 次,得到 ${found.length} 个候选\n`);
for (const c of found) {
console.log(`--- N=${c.cells.length} 墙=${c.walls.length}${c.spinners.length ? '+转' + c.spinners.length : ''} 线索=${c.cells.filter((x) => x.clue).length} 无线索路径=${c.freePaths} 去墙解=${c.noWallPaths}`);
console.log(renderAscii(c.board).replace(/^/gm, ' '));
console.log(renderAscii(c.board, c.path).replace(/^/gm, ' '));
console.log(' 解: ' + pathToIds(c.board, c.path));
console.log(' JSON: ' + JSON.stringify({ cells: c.cells, walls: c.walls, spinners: c.spinners }));
console.log();
}
+73
View File
@@ -0,0 +1,73 @@
// 发布前校验:levels/*.json 每一关必须连通、恰好 1 条满足线索的哈密顿路径;
// 第 3 关起至少 1 堵墙,且去掉全部墙后解不再唯一(GDD §4.6)。
// 用法:npm run validate (全部包)
// npm run validate -- 1-7 (只看某关,并打印解)
import { readdirSync, readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { loadPack, parsePack } from '../src/levels';
import { redundantWalls, solve, spinnerReport, wallsMatter } from '../src/solver';
import { pathToIds, renderAscii } from './ascii';
const here = dirname(fileURLToPath(import.meta.url));
const levelsDir = join(here, '..', 'levels');
const only = process.argv[2];
let failed = 0;
let total = 0;
for (const file of readdirSync(levelsDir).filter((f) => f.endsWith('.json')).sort()) {
const pack = parsePack(JSON.parse(readFileSync(join(levelsDir, file), 'utf8')));
console.log(`\n== ${file} · ${pack.title} · ${pack.levels.length} 关`);
let levels;
try {
levels = loadPack(pack);
} catch (e) {
console.log(` ✗ 解析失败: ${(e as Error).message}`);
failed++;
continue;
}
for (const lv of levels) {
if (only && lv.def.id !== only) continue;
total++;
const b = lv.board;
const clues = b.cells.filter((c) => c.clue !== null).map((c) => c.clue!).sort((a, z) => a - z);
const t0 = performance.now();
const paths = solve(b, { limit: 3 });
const ms = (performance.now() - t0).toFixed(1);
const free = solve(b, { ignoreClues: true, limit: 500 }).length;
const noWall = solve(b.withoutWalls(), { limit: 500 }).length;
const problems: string[] = [];
if (!b.isConnected()) problems.push('去墙后不连通');
if (paths.length === 0) problems.push('无解');
if (paths.length > 1) problems.push(`多解 (≥${paths.length})`);
const wallCount = b.walls.length + b.spinners.length;
if (lv.index >= 3 || pack.pack >= 2) {
if (wallCount === 0) problems.push('第 3 关起每关至少 1 堵墙');
else if (paths.length === 1 && !wallsMatter(b)) problems.push('去墙后仍唯一,墙没参与推理');
}
const redundant = paths.length === 1 ? redundantWalls(b) : [];
if (paths.length === 1) {
for (const r of spinnerReport(b)) {
if (r.removable) problems.push(`转墙 ${r.index} 冗余`);
if (r.freezable.length) problems.push(`转墙 ${r.index} 可换成静态墙(边 ${r.freezable.join('/')})`);
}
}
const ok = problems.length === 0;
if (!ok) failed++;
const mark = ok ? '✓' : '✗';
console.log(
` ${mark} ${lv.def.id.padEnd(5)} N=${String(b.n).padStart(2)} 墙=${b.walls.length}${b.spinners.length ? '+转' + b.spinners.length : ''} 线索=${clues.join(',').padEnd(10)} 无线索路径=${String(free >= 500 ? '500+' : free).padStart(4)} 去墙解=${String(noWall >= 500 ? '500+' : noWall).padStart(4)} ${ms}ms` +
(redundant.length ? ` 可删墙=${redundant.map((i) => `${b.cells[b.walls[i].a].id}|${b.cells[b.walls[i].b].id}`).join(',')}` : '') +
(ok ? '' : ` ← ${problems.join(';')}`),
);
if (only || !ok) {
console.log(renderAscii(b).replace(/^/gm, ' '));
for (const p of paths) console.log(' 解: ' + pathToIds(b, p));
if (only && paths.length === 1) console.log(renderAscii(b, paths[0]).replace(/^/gm, ' '));
}
}
}
console.log(`\n${total - failed}/${total} 关通过`);
process.exit(failed ? 1 : 0);
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"strict": true,
"noUncheckedIndexedAccess": false,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["node"]
},
"include": ["src", "web", "tools", "tests", "scripts"],
"exclude": ["web/dist", "node_modules"]
}
+17
View File
@@ -0,0 +1,17 @@
{
"// 说明": "只检查 src/。lib 不含 DOM:保证规则层与视图逻辑不依赖浏览器 API,可原样搬进 Cocos Creator。",
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2020"],
"strict": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"isolatedModules": true,
"skipLibCheck": true,
"noEmit": true,
"types": []
},
"include": ["src"]
}
+68
View File
@@ -0,0 +1,68 @@
// 音效:WebAudio 合成,不需要资源文件。落子短音(音高随步号上升,12 后循环)、撞墙闷响、冲突、过关和弦。
// Cocos 版换成 AudioSource 播放几段短音即可,接口保持 step/wall/conflict/cleared/reject。
export interface Sfx {
unlock(): void;
step(n: number): void;
wall(): void;
conflict(): void;
cleared(): void;
reject(): void;
enabled: boolean;
}
export class WebAudioSfx implements Sfx {
enabled = true;
private ctx: AudioContext | null = null;
/** 必须在用户手势里调用一次,iOS 才放行 */
unlock(): void {
if (!this.ctx) {
const AC = window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (!AC) return;
this.ctx = new AC();
}
if (this.ctx.state === 'suspended') void this.ctx.resume();
}
private tone(freq: number, dur: number, type: OscillatorType, gain: number, when = 0, slideTo?: number): void {
if (!this.enabled || !this.ctx) return;
const ctx = this.ctx;
const t0 = ctx.currentTime + when;
const osc = ctx.createOscillator();
const g = ctx.createGain();
osc.type = type;
osc.frequency.setValueAtTime(freq, t0);
if (slideTo) osc.frequency.exponentialRampToValueAtTime(slideTo, t0 + dur);
g.gain.setValueAtTime(0.0001, t0);
g.gain.exponentialRampToValueAtTime(gain, t0 + 0.008);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
osc.connect(g).connect(ctx.destination);
osc.start(t0);
osc.stop(t0 + dur + 0.02);
}
step(n: number): void {
const semi = (n - 1) % 12;
const f = 523.25 * Math.pow(2, semi / 12);
this.tone(f, 0.09, 'sine', 0.18);
}
/** 撞墙:短促的低频“咚” */
wall(): void {
this.tone(180, 0.09, 'square', 0.07, 0, 90);
}
conflict(): void {
this.tone(130, 0.16, 'square', 0.08);
}
cleared(): void {
const base = 523.25;
[0, 4, 7, 12].forEach((semi, i) => this.tone(base * Math.pow(2, semi / 12), 0.22, 'triangle', 0.16, i * 0.07));
}
reject(): void {
this.tone(220, 0.06, 'triangle', 0.06);
}
}
+80
View File
@@ -0,0 +1,80 @@
// Painter 的 Canvas 2D 实现。Cocos 版本换成 Graphics + Label 即可,HexBoardView 不用改。
import type { Point } from '../src/geom';
import type { Painter } from '../src/painter';
export class CanvasPainter implements Painter {
private readonly ctx: CanvasRenderingContext2D;
private readonly fontFamily =
'"SF Pro Rounded", "PingFang SC", "Helvetica Neue", "Segoe UI", system-ui, -apple-system, sans-serif';
constructor(ctx: CanvasRenderingContext2D) {
this.ctx = ctx;
}
private path(pts: Point[]): void {
const ctx = this.ctx;
ctx.beginPath();
ctx.moveTo(pts[0].x, pts[0].y);
for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);
ctx.closePath();
}
fillPolygon(pts: Point[], color: string, alpha = 1): void {
if (alpha <= 0 || pts.length < 3) return;
const ctx = this.ctx;
ctx.globalAlpha = alpha;
ctx.fillStyle = color;
this.path(pts);
ctx.fill();
ctx.globalAlpha = 1;
}
strokePolygon(pts: Point[], color: string, lineWidth: number, alpha = 1): void {
if (alpha <= 0 || pts.length < 2) return;
const ctx = this.ctx;
ctx.globalAlpha = alpha;
ctx.strokeStyle = color;
ctx.lineWidth = lineWidth;
ctx.lineJoin = 'round';
this.path(pts);
ctx.stroke();
ctx.globalAlpha = 1;
}
line(x1: number, y1: number, x2: number, y2: number, width: number, color: string, alpha = 1): void {
if (alpha <= 0) return;
const ctx = this.ctx;
ctx.globalAlpha = alpha;
ctx.strokeStyle = color;
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
ctx.globalAlpha = 1;
}
fillCircle(cx: number, cy: number, radius: number, color: string, alpha = 1): void {
if (alpha <= 0) return;
const ctx = this.ctx;
ctx.globalAlpha = alpha;
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1;
}
text(s: string, cx: number, cy: number, size: number, color: string, bold = false, alpha = 1): void {
if (alpha <= 0) return;
const ctx = this.ctx;
ctx.globalAlpha = alpha;
ctx.fillStyle = color;
ctx.font = `${bold ? '700' : '500'} ${size}px ${this.fontFamily}`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(s, cx, cy + size * 0.04);
ctx.globalAlpha = 1;
}
}
+69
View File
@@ -0,0 +1,69 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
/>
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="theme-color" content="#ffffff" />
<title>六边一笔</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div id="app">
<!-- 选关 -->
<section id="screen-select" class="screen">
<header class="select-header">
<div class="logo">
<span class="logo-hex"></span>
<span class="logo-hex on"></span>
<span class="logo-hex wall"></span>
</div>
<h1>六边一笔</h1>
<p class="sub">六边形上一笔走遍,条纹墙挡住那条路</p>
</header>
<div class="pack">
<div class="pack-title"><span id="pack-name">教学</span><span id="pack-progress" class="pack-progress"></span></div>
<div id="level-grid" class="level-grid"></div>
</div>
<button id="btn-continue" class="primary">继续</button>
<footer class="select-footer">
<button id="btn-sound" class="chip">音效</button>
<button id="btn-haptic" class="chip">振动</button>
<button id="btn-reset-progress" class="chip danger">重置进度</button>
</footer>
</section>
<!-- 对局 -->
<section id="screen-play" class="screen hidden">
<header class="topbar">
<button id="btn-back" class="icon" aria-label="返回选关">
<svg viewBox="0 0 24 24" width="26" height="26"><path d="M15 5l-7 7 7 7" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<div id="level-title" class="level-title">1-1</div>
<div id="remaining" class="remaining"><span class="hex-dot"></span><span id="remaining-n">0</span></div>
</header>
<div id="caption" class="caption"></div>
<div id="board-wrap" class="board-wrap">
<canvas id="board"></canvas>
</div>
<footer class="bottombar">
<button id="btn-restart" class="action">
<svg viewBox="0 0 24 24" width="22" height="22"><path d="M4 12a8 8 0 1 0 2.5-5.8" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"/><path d="M4 4v5h5" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg>
<span>重开</span>
</button>
<button id="btn-hint" class="action">
<svg viewBox="0 0 24 24" width="22" height="22"><path d="M9 18h6M10 21h4M12 3a6 6 0 0 0-3.6 10.8c.6.5 1 1.2 1.1 2.2h5c.1-1 .5-1.7 1.1-2.2A6 6 0 0 0 12 3z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
<span>提示</span>
</button>
</footer>
<div id="toast" class="toast" role="status"></div>
</section>
</div>
<script src="dist/app.js"></script>
</body>
</html>
+490
View File
@@ -0,0 +1,490 @@
// Web 原型入口:DOM / Canvas / PointerEvent / localStorage 的粘合层。
// 规则、输入语义、动效状态都在 ../src 里;这里只做“把浏览器事件喂给 src,把 src 的状态画到屏幕上”。
import pack1Json from '../levels/pack-1-tutorial.json';
import pack2Json from '../levels/pack-2-spinner.json';
import type { GameEvent } from '../src/game';
import { Game } from '../src/game';
import { PointerInput } from '../src/input';
import { cellCenter, fitBoard, hitTest, type HexLayout } from '../src/layout';
import { loadPack, parsePack, type LoadedLevel } from '../src/levels';
import { Progress, type KeyValueStore } from '../src/progress';
import { hint as computeHint, uniqueSolution } from '../src/solver';
import { HexBoardView } from '../src/view';
import { WebAudioSfx } from './audio';
import { CanvasPainter } from './canvas-painter';
const $ = <T extends HTMLElement = HTMLElement>(id: string): T => {
const el = document.getElementById(id);
if (!el) throw new Error(`#${id} 不存在`);
return el as T;
};
class LocalStore implements KeyValueStore {
get(k: string): string | null {
try {
return localStorage.getItem(k);
} catch {
return null;
}
}
set(k: string, v: string): void {
try {
localStorage.setItem(k, v);
} catch {
/* 私密模式等 */
}
}
remove(k: string): void {
try {
localStorage.removeItem(k);
} catch {
/* ignore */
}
}
}
interface Session {
level: LoadedLevel;
game: Game;
view: HexBoardView;
input: PointerInput;
solution: number[] | null;
layout: HexLayout | null;
}
class App {
private readonly packs = [parsePack(pack1Json), parsePack(pack2Json)].filter((p) => p.levels.length > 0);
/** 所有包的关卡拼成一条线性序列,跨包线性解锁 */
private readonly levels: LoadedLevel[] = this.packs.flatMap((p) => loadPack(p));
private readonly ids = this.levels.map((l) => l.def.id);
private readonly progress = new Progress(new LocalStore());
private readonly sfx = new WebAudioSfx();
private readonly devUnlock = /[?&]dev\b/.test(location.search);
private readonly canvas = $<HTMLCanvasElement>('board');
private readonly ctx = this.canvas.getContext('2d')!;
private readonly painter = new CanvasPainter(this.ctx);
private readonly boardWrap = $('board-wrap');
private session: Session | null = null;
private rafId = 0;
private lastT = 0;
private activePointer: number | null = null;
private lastPos = { x: 0, y: 0 };
private advanceTimer = 0;
private toastTimer = 0;
private cssSize = { w: 0, h: 0 };
constructor() {
this.sfx.enabled = this.progress.prefs.sound;
this.bindUI();
new ResizeObserver(() => this.resize()).observe(this.boardWrap);
this.renderSelect();
this.showSelect();
if (this.devUnlock) {
(window as unknown as { __hexWalls: App }).__hexWalls = this;
}
}
/** 调试:某格中心的视口坐标(clientX/Y),用于自动化手势测试 */
debugCellCenter(cellId: string): { x: number; y: number } | null {
const s = this.session;
if (!s || !s.layout) return null;
const cell = s.level.board.cellById(cellId);
const r = this.canvas.getBoundingClientRect();
const c = cellCenter(s.level.board, cell.index, s.layout);
return { x: r.left + c.x, y: r.top + c.y };
}
/** 调试:当前路径(格 id)、状态与唯一解 */
debugState(): { id: string; path: string[]; status: string; remaining: number; solution: string[] | null } | null {
const s = this.session;
if (!s) return null;
const ids = (p: number[]) => p.map((i) => s.level.board.cells[i].id);
return {
id: s.level.def.id,
path: ids(s.game.path),
status: s.game.status,
remaining: s.game.remaining,
solution: s.solution ? ids(s.solution) : null,
};
}
/** 调试:直接进某关 */
debugStart(id: string): void {
const i = this.ids.indexOf(id);
if (i >= 0) this.startLevel(i);
}
// ---------- 屏幕切换 ----------
private showSelect(): void {
this.stopLoop();
$('screen-play').classList.add('hidden');
$('screen-select').classList.remove('hidden');
this.renderSelect();
}
private showPlay(): void {
$('screen-select').classList.add('hidden');
$('screen-play').classList.remove('hidden');
this.resize();
this.startLoop();
}
// ---------- 选关 ----------
private renderSelect(): void {
$('pack-name').textContent = this.packs.map((p) => p.title).join(' · ');
$('pack-progress').textContent = `${this.progress.clearedCount()} / ${this.levels.length}`;
const grid = $('level-grid');
grid.innerHTML = '';
const currentId = this.progress.firstUncleared(this.ids);
const allCleared = this.progress.clearedCount() >= this.levels.length;
let lastPack = -1;
this.levels.forEach((lv, i) => {
const id = lv.def.id;
if (lv.pack !== lastPack) {
lastPack = lv.pack;
const head = document.createElement('div');
head.className = 'pack-head';
head.textContent = this.packs.find((p) => p.pack === lv.pack)?.title ?? `包 ${lv.pack}`;
grid.appendChild(head);
}
const tile = document.createElement('button');
tile.className = 'level-tile';
tile.innerHTML = `<span>${lv.index}</span>`;
const unlocked = this.devUnlock || this.progress.isUnlocked(this.ids, i);
if (this.progress.isCleared(id)) tile.classList.add('cleared');
else if (!unlocked) tile.classList.add('locked');
if (id === currentId && !allCleared) tile.classList.add('current');
tile.addEventListener('click', () => {
this.sfx.unlock();
if (!unlocked) {
tile.classList.remove('shake');
void tile.offsetWidth;
tile.classList.add('shake');
return;
}
this.startLevel(i);
});
grid.appendChild(tile);
});
const btn = $('btn-continue');
btn.textContent = allCleared ? `全部通关 · 重玩 ${this.ids[0]}` : `继续 · ${currentId}`;
this.syncPrefChips();
}
private syncPrefChips(): void {
$('btn-sound').classList.toggle('on', this.progress.prefs.sound);
$('btn-haptic').classList.toggle('on', this.progress.prefs.haptics);
}
// ---------- 对局 ----------
private startLevel(i: number): void {
window.clearTimeout(this.advanceTimer);
const level = this.levels[i];
const game = new Game(level.board);
const view = new HexBoardView(game);
this.session = {
level,
game,
view,
input: new PointerInput(game),
solution: uniqueSolution(level.board),
layout: null,
};
this.progress.setCurrent(level.def.id);
$('level-title').textContent = level.def.id;
$('caption').textContent = level.def.caption ?? '';
$('btn-hint').classList.remove('pulse');
$('btn-restart').classList.remove('pulse');
this.setButtonsEnabled(true);
this.updateHud();
this.showPlay();
}
private currentIndex(): number {
return this.session ? this.levels.indexOf(this.session.level) : -1;
}
private nextLevel(): void {
const i = this.currentIndex();
if (i >= 0 && i + 1 < this.levels.length) this.startLevel(i + 1);
else {
this.showSelect();
this.toastOnSelect('已通关全部 ' + this.levels.length + ' 关');
}
}
private updateHud(): void {
if (!this.session) return;
$('remaining-n').textContent = String(this.session.game.remaining);
}
private setButtonsEnabled(on: boolean): void {
($('btn-hint') as HTMLButtonElement).disabled = !on;
($('btn-restart') as HTMLButtonElement).disabled = !on;
}
private handleEvents(events: GameEvent[]): void {
const s = this.session;
if (!s) return;
for (const e of events) {
s.view.onEvent(e);
switch (e.type) {
case 'step':
if (e.conflict) {
this.sfx.conflict();
this.vibrate(40);
} else {
this.sfx.step(e.step);
this.vibrate(e.clueHit ? 18 : 8);
}
break;
case 'wallHit':
this.sfx.wall();
this.vibrate(25);
break;
case 'rejectStart':
this.sfx.reject();
this.vibrate(15);
break;
case 'deadEnd':
this.vibrate([10, 30, 10]);
break;
case 'cleared':
this.onCleared();
break;
default:
break;
}
}
if (events.length) this.updateHud();
}
private onCleared(): void {
const s = this.session;
if (!s) return;
this.sfx.cleared();
this.vibrate([20, 40, 30]);
this.progress.markCleared(s.level.def.id);
this.setButtonsEnabled(false);
this.activePointer = null;
window.clearTimeout(this.advanceTimer);
this.advanceTimer = window.setTimeout(() => this.nextLevel(), s.view.clearedDuration * 1000);
}
private restart(): void {
const s = this.session;
if (!s || s.game.isCleared) return;
s.input.cancel();
this.activePointer = null;
this.handleEvents(s.game.apply({ type: 'reset' }));
$('btn-restart').classList.remove('pulse');
}
private hint(): void {
const s = this.session;
if (!s || s.game.isCleared) return;
if (!s.solution) {
this.toast('本关没有唯一解(关卡数据有误)');
return;
}
const h = computeHint(s.solution, s.game.path);
s.view.showHint(h);
$('btn-restart').classList.remove('pulse');
switch (h.kind) {
case 'start':
this.toast('从这里开始');
break;
case 'next':
this.toast('下一步在这里');
break;
case 'backtrack':
this.toast(`已经偏离了,退回到高亮的格(撤销 ${h.undoCount} 步)`);
break;
case 'restart':
this.toast('起手位置不对,重开试试');
$('btn-restart').classList.add('pulse');
break;
case 'none':
break;
}
}
// ---------- 布局与渲染 ----------
private resize(): void {
const s = this.session;
const rect = this.boardWrap.getBoundingClientRect();
if (rect.width < 10 || rect.height < 10) return;
const dpr = Math.min(3, window.devicePixelRatio || 1);
this.cssSize = { w: rect.width, h: rect.height };
this.canvas.width = Math.round(rect.width * dpr);
this.canvas.height = Math.round(rect.height * dpr);
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
if (s) {
// 小盘不要铺满:六边形半径最大约 58px;大盘按短边缩放
const maxSize = s.level.board.n <= 6 ? 52 : 60;
s.layout = fitBoard(s.level.board, rect.width, rect.height, { padding: 12, maxSize });
s.view.layout = s.layout;
s.view.invalidate();
}
this.render();
}
private startLoop(): void {
if (this.rafId) return;
this.lastT = performance.now();
const tick = (t: number): void => {
const dt = (t - this.lastT) / 1000;
this.lastT = t;
const s = this.session;
if (s) {
s.view.update(dt);
if (s.view.isAnimating) this.render();
}
this.rafId = requestAnimationFrame(tick);
};
this.rafId = requestAnimationFrame(tick);
}
private stopLoop(): void {
if (this.rafId) cancelAnimationFrame(this.rafId);
this.rafId = 0;
}
private render(): void {
const s = this.session;
this.ctx.clearRect(0, 0, this.cssSize.w, this.cssSize.h);
if (!s || !s.layout) return;
s.view.draw(this.painter);
}
// ---------- 输入 ----------
private pointerPos(e: PointerEvent): { x: number; y: number } {
const r = this.canvas.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
}
private cellAt(p: { x: number; y: number }): number {
const s = this.session;
if (!s || !s.layout) return -1;
return hitTest(s.level.board, s.layout, p.x, p.y);
}
private onPointerDown = (e: PointerEvent): void => {
if (!e.isPrimary || this.activePointer !== null) return;
if (!this.session || this.session.game.isCleared) return;
this.sfx.unlock();
this.activePointer = e.pointerId;
try {
this.canvas.setPointerCapture(e.pointerId);
} catch {
/* 某些 WebView 不支持 */
}
const p = this.pointerPos(e);
this.lastPos = p;
this.handleEvents(this.session.input.down(this.cellAt(p)));
e.preventDefault();
};
private onPointerMove = (e: PointerEvent): void => {
if (e.pointerId !== this.activePointer || !this.session || !this.session.layout) return;
const p = this.pointerPos(e);
// 快速滑动时在两帧之间插值采样,避免跳过中间格
const step = this.session.layout.size * 0.4;
const dx = p.x - this.lastPos.x;
const dy = p.y - this.lastPos.y;
const n = Math.max(1, Math.ceil(Math.hypot(dx, dy) / step));
const events: GameEvent[] = [];
for (let i = 1; i <= n; i++) {
const q = { x: this.lastPos.x + (dx * i) / n, y: this.lastPos.y + (dy * i) / n };
events.push(...this.session.input.move(this.cellAt(q)));
}
this.lastPos = p;
this.handleEvents(events);
e.preventDefault();
};
private onPointerUp = (e: PointerEvent): void => {
if (e.pointerId !== this.activePointer) return;
this.activePointer = null;
if (this.session) this.handleEvents(this.session.input.up());
};
private vibrate(pattern: number | number[]): void {
if (!this.progress.prefs.haptics) return;
try {
navigator.vibrate?.(pattern);
} catch {
/* ignore */
}
}
// ---------- 提示条 ----------
private toast(msg: string): void {
const el = $('toast');
el.textContent = msg;
el.classList.add('show');
window.clearTimeout(this.toastTimer);
this.toastTimer = window.setTimeout(() => el.classList.remove('show'), 1800);
}
private toastOnSelect(msg: string): void {
const el = $('pack-progress');
const old = el.textContent;
el.textContent = msg;
window.setTimeout(() => (el.textContent = old), 2200);
}
// ---------- 事件绑定 ----------
private bindUI(): void {
this.canvas.addEventListener('pointerdown', this.onPointerDown);
this.canvas.addEventListener('pointermove', this.onPointerMove);
this.canvas.addEventListener('pointerup', this.onPointerUp);
this.canvas.addEventListener('pointercancel', this.onPointerUp);
this.canvas.addEventListener('lostpointercapture', this.onPointerUp);
this.canvas.addEventListener('contextmenu', (e) => e.preventDefault());
$('btn-back').addEventListener('click', () => {
window.clearTimeout(this.advanceTimer);
this.showSelect();
});
$('btn-restart').addEventListener('click', () => this.restart());
$('btn-hint').addEventListener('click', () => this.hint());
$('btn-continue').addEventListener('click', () => {
this.sfx.unlock();
const id = this.progress.clearedCount() >= this.levels.length ? this.ids[0] : this.progress.firstUncleared(this.ids);
this.startLevel(this.ids.indexOf(id));
});
$('btn-sound').addEventListener('click', () => {
this.progress.setPrefs({ sound: !this.progress.prefs.sound });
this.sfx.enabled = this.progress.prefs.sound;
if (this.sfx.enabled) {
this.sfx.unlock();
this.sfx.step(1);
}
this.syncPrefChips();
});
$('btn-haptic').addEventListener('click', () => {
this.progress.setPrefs({ haptics: !this.progress.prefs.haptics });
if (this.progress.prefs.haptics) this.vibrate(15);
this.syncPrefChips();
});
$('btn-reset-progress').addEventListener('click', () => {
if (window.confirm('清空本玩法的通关记录?')) {
this.progress.resetAll();
this.renderSelect();
}
});
window.addEventListener('resize', () => this.resize());
}
}
new App();
+444
View File
@@ -0,0 +1,444 @@
:root {
--bg: #ffffff;
--ink: #20223a;
--ink-2: #686a80;
--ink-3: #a3a5b5;
--cell: #e4e6ee;
--primary: #4f5bd5;
--primary-soft: #e9ebfb;
--danger: #e5484d;
--wall: #1f1f27;
--wall-stripe: #ffc826;
--hex: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%);
--safe-top: env(safe-area-inset-top, 0px);
--safe-bottom: env(safe-area-inset-bottom, 0px);
--safe-left: env(safe-area-inset-left, 0px);
--safe-right: env(safe-area-inset-right, 0px);
}
* {
box-sizing: border-box;
-webkit-tap-highlight-color: transparent;
}
html,
body {
margin: 0;
height: 100%;
background: #f2f2f6;
color: var(--ink);
font-family: "SF Pro Rounded", "PingFang SC", "Helvetica Neue", "Segoe UI", system-ui, -apple-system, sans-serif;
-webkit-font-smoothing: antialiased;
user-select: none;
-webkit-user-select: none;
touch-action: manipulation;
overscroll-behavior: none;
}
#app {
position: relative;
margin: 0 auto;
width: 100%;
max-width: 480px;
height: 100vh;
height: 100dvh;
background: var(--bg);
overflow: hidden;
}
.screen {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
padding: calc(var(--safe-top) + 12px) calc(var(--safe-right) + 20px) calc(var(--safe-bottom) + 16px)
calc(var(--safe-left) + 20px);
}
.screen.hidden {
display: none;
}
button {
font: inherit;
color: inherit;
background: none;
border: 0;
padding: 0;
cursor: pointer;
-webkit-touch-callout: none;
}
button:active {
transform: scale(0.97);
}
/* ---------- 选关 ---------- */
.select-header {
padding: 24px 4px 8px;
}
.logo {
display: flex;
gap: 4px;
margin-bottom: 14px;
}
.logo-hex {
width: 26px;
height: 30px;
background: var(--cell);
clip-path: var(--hex);
}
.logo-hex.on {
background: var(--primary);
}
.logo-hex.wall {
background: repeating-linear-gradient(135deg, var(--wall) 0 5px, var(--wall-stripe) 5px 10px);
}
.select-header h1 {
margin: 0;
font-size: 34px;
font-weight: 800;
letter-spacing: 0.02em;
}
.select-header .sub {
margin: 8px 0 0;
color: var(--ink-2);
font-size: 15px;
}
.pack {
margin-top: 24px;
}
.pack-title {
display: flex;
align-items: baseline;
justify-content: space-between;
padding: 0 4px 12px;
font-size: 17px;
font-weight: 700;
}
.pack-progress {
color: var(--ink-3);
font-size: 14px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.level-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 10px 12px;
}
.pack-head {
grid-column: 1 / -1;
padding: 10px 4px 0;
color: var(--ink-3);
font-size: 13px;
font-weight: 700;
letter-spacing: 0.06em;
}
.pack-head:first-child {
padding-top: 0;
}
.level-tile {
position: relative;
aspect-ratio: 0.9;
clip-path: var(--hex);
background: var(--cell);
color: var(--ink);
font-size: 20px;
font-weight: 700;
font-variant-numeric: tabular-nums;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.12s ease;
}
.level-tile.cleared {
background: var(--primary);
color: #fff;
}
.level-tile.cleared::after {
content: "";
position: absolute;
right: 22%;
top: 22%;
width: 7px;
height: 7px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.85);
}
.level-tile.current {
background: var(--primary-soft);
color: var(--primary);
}
.level-tile.current::before {
content: "";
position: absolute;
inset: 0;
clip-path: var(--hex);
background: var(--primary);
z-index: -1;
}
.level-tile.current span {
position: relative;
}
.level-tile.current {
isolation: isolate;
}
.level-tile.current::after {
content: "";
position: absolute;
inset: 3px;
clip-path: var(--hex);
background: var(--primary-soft);
z-index: -1;
}
.level-tile.locked {
color: var(--ink-3);
background: #f0f0f4;
}
.level-tile.locked::before {
content: "";
width: 10px;
height: 12px;
border: 2px solid var(--ink-3);
border-radius: 3px;
border-top-width: 3px;
}
.level-tile.locked span {
display: none;
}
.level-tile.shake {
animation: shake 0.3s ease;
}
.primary {
margin-top: auto;
height: 54px;
border-radius: 16px;
background: var(--primary);
color: #fff;
font-size: 18px;
font-weight: 700;
box-shadow: 0 6px 18px rgba(79, 91, 213, 0.28);
}
.select-footer {
display: flex;
justify-content: center;
gap: 10px;
padding-top: 18px;
}
.chip {
height: 34px;
padding: 0 14px;
border-radius: 17px;
background: #f0f0f4;
color: var(--ink-2);
font-size: 13px;
font-weight: 600;
}
.chip.on {
background: var(--primary-soft);
color: var(--primary);
}
.chip.danger {
color: var(--danger);
}
/* ---------- 对局 ---------- */
.topbar {
display: grid;
grid-template-columns: 44px 1fr 44px;
align-items: center;
height: 48px;
}
.topbar .icon {
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
color: var(--ink-2);
border-radius: 22px;
}
.level-title {
text-align: center;
font-size: 20px;
font-weight: 800;
letter-spacing: 0.04em;
font-variant-numeric: tabular-nums;
}
.remaining {
justify-self: end;
display: flex;
align-items: center;
gap: 6px;
color: var(--ink-2);
font-size: 15px;
font-weight: 700;
font-variant-numeric: tabular-nums;
min-width: 44px;
justify-content: flex-end;
}
.remaining .hex-dot {
width: 13px;
height: 15px;
clip-path: var(--hex);
background: var(--ink-3);
}
.caption {
height: 40px;
margin-top: 4px;
display: flex;
align-items: center;
justify-content: center;
color: var(--ink-2);
font-size: 15px;
text-align: center;
transition: opacity 0.2s;
}
.caption:empty {
opacity: 0;
}
.board-wrap {
flex: 1;
min-height: 0;
position: relative;
}
#board {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
touch-action: none;
display: block;
}
.bottombar {
display: flex;
justify-content: center;
gap: 14px;
padding-top: 12px;
}
.action {
flex: 1;
max-width: 170px;
height: 56px;
border-radius: 18px;
background: #f0f0f4;
color: var(--ink);
font-size: 16px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: background 0.15s;
}
.action.pulse {
animation: pulse 0.9s ease infinite;
}
.action:disabled {
opacity: 0.4;
pointer-events: none;
}
.toast {
position: absolute;
left: 50%;
bottom: calc(var(--safe-bottom) + 92px);
transform: translate(-50%, 8px);
padding: 10px 16px;
border-radius: 12px;
background: rgba(32, 34, 58, 0.92);
color: #fff;
font-size: 14px;
font-weight: 600;
white-space: nowrap;
opacity: 0;
pointer-events: none;
transition: opacity 0.18s, transform 0.18s;
}
.toast.show {
opacity: 1;
transform: translate(-50%, 0);
}
@keyframes shake {
0%,
100% {
transform: translateX(0);
}
25% {
transform: translateX(-5px);
}
50% {
transform: translateX(5px);
}
75% {
transform: translateX(-3px);
}
}
@keyframes pulse {
0%,
100% {
box-shadow: 0 0 0 0 rgba(255, 122, 26, 0);
}
50% {
box-shadow: 0 0 0 5px rgba(255, 122, 26, 0.35);
}
}
@media (min-width: 520px) {
body {
padding: 24px 0;
}
#app {
height: calc(100vh - 48px);
height: calc(100dvh - 48px);
border-radius: 28px;
box-shadow: 0 20px 60px rgba(20, 20, 40, 0.14);
}
}