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