初始提交:三玩法 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
+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
```