Files
NumberSwipe_New/modes/03-hex-walls/docs/HANDOFF.md
T
cabinw 8026e4628d 初始提交:三玩法 Web 原型与设计文档
- 玩法一 数字一笔:规则库、求解器、26 关、竖屏触摸 Web 原型、测试
- 玩法二 连连气球:连消畅玩版核心、Web 原型、Cocos 球槽组件与导出脚本
- 玩法三 六边一笔:规则库、求解器、10 关 + 转墙实验包、Web 原型、测试
- 产品文档、UI 设计规范、各玩法 GDD / HANDOFF
- 文档移除全部外部参考作引用;删除玩法二 v0 旧稿
2026-09-16 14:30:59 +08:00

248 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 玩法三 · 六边一笔 — 交接文档
> 面向接手的人。读完本文 + 跑一遍 `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
```