- 玩法一 数字一笔:规则库、求解器、26 关、竖屏触摸 Web 原型、测试 - 玩法二 连连气球:连消畅玩版核心、Web 原型、Cocos 球槽组件与导出脚本 - 玩法三 六边一笔:规则库、求解器、10 关 + 转墙实验包、Web 原型、测试 - 产品文档、UI 设计规范、各玩法 GDD / HANDOFF - 文档移除全部外部参考作引用;删除玩法二 v0 旧稿
16 KiB
玩法三 · 六边一笔 — 交接文档
面向接手的人。读完本文 + 跑一遍
npm run check,应该能不问人就继续往下做。 规格权威是GDD.md,目录/运行方式速查在../README.md,本文讲“做了什么、怎么做的、为什么、还差什么”。交接日期:2026-09-15。状态:M1/M2 达成(Web 原型可玩、10 关唯一解且墙参与推理、存档/提示/音效/振动齐全),另有转墙实验包 9 关(§1.4),未进 Cocos。
0. 一分钟上手
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 已知不足
- 没有真机、真人数据:死区 14%、插值 0.4 size、墙厚 0.3 size、过关 1.06s 跳关,都要真机摸过再定。
- 只支持一个包;M3 的多包选关和包间解锁未做(
Progress.isUnlocked已能按 id 数组判断)。 - 生成器的“漂亮度”过滤没有(重复折返、线索挤在一起),靠人挑。
- 无线索
1的关一个都没有(按 GDD 留到第 13 关后);hint的restart态界面上未真正触发过。 - 高对比模式、多语言、存档迁移(已有
v: 1)未做。 - 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
assets/modes/03-hex-walls/scripts/← 拷src/全部文件,不改一行。- 关卡 JSON 放
resources/levels/,resources.load(..., JsonAsset)→parsePack/loadPack。 - 写
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。 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的插值采样。Progress(new CocosStore()),CocosStore包sys.localStorage。- HUD、选关(六边形瓦片用 Sprite 或 Graphics)、字幕、toast 用 UI 节点复刻
index.html;音效换AudioSource五段短音(step/wall/conflict/cleared/reject)。 - 用 §2.5 的路径数据在真机上把 10 关滑一遍。
- 不搬:
web/、tools/、tests/(测试继续在 Node 跑,只依赖src/)。
附:常用命令
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