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

16 KiB
Raw Blame History

玩法三 · 六边一笔 — 交接文档

面向接手的人。读完本文 + 跑一遍 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 已知不足

  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/)。

附:常用命令

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