Files
NumberSwipe_New/modes/01-number-stroke/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

537 lines
39 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-16。状态:M1/M2 达成(Web 原型可玩、三包 26 关唯一解、奇偶线索/存档/提示/音效/振动齐全),未进 Cocos。
---
## 0. 一分钟上手
```bash
cd modes/01-number-stroke
npm install
npm run dev # 起服务 + 监听改动。终端打印手机可访问的局域网地址(默认从 8000 起找空闲端口)
npm run check # 类型检查(含 src/ 无 DOM 校验)+ 68 个单元测试(含设计令牌一致性)+ 26 关唯一解校验,全绿才算没改坏
```
浏览器打开 `http://localhost:8000/?dev=1`:`?dev=1` 解锁全部关卡并暴露 `window.__numberStroke` 调试句柄(见 §3.6)。去掉参数就是正常的线性解锁。
`npm run build` 会把打包产物写到 `web/dist/app.js`(IIFE),之后直接双击 `web/index.html` 也能玩。
---
## 0.1 本次接手后的最新状态(2026-09-15)
已从 Paseo Agent `62da92b3-72b1-42e9-bf69-229171bc31f1` 的近期会话接手。原会话因 Kimi 周额度耗尽停在用户的新需求:“格子可填奇或偶,要求当前步数符合奇偶”。此前已落地的 3×3、大格权重、异形格、第二包保留。
本次完成:
- `CellDef.parity?: 'odd' | 'even'`,与同格数字 `clue` 互斥。`clue.ts` 统一判定,`Game` 和 `solve` 共用;不符时允许继续但不算通关,撤销/重开恢复,提示基于含奇偶的唯一解。
- 「奇与偶」6 关,登记到 Web 第三包。每关唯一,且删掉任意一条奇偶线索都会出现多解;第一、二包数据未改。3-2 为新的 3×3 教学盘,其余复用旧几何重配线索,具体来源见 `teach`。
- 未走格显示「奇/偶」,走对后显示步号并保留小字要求,走错显示红底及要求角标。异形线索描边改为外/内剪影形成整块轮廓,继续使用原来的四种 Painter 原语。修复大矩形冲突角标索引越界。
- `validate` 增加奇偶冗余校验;`design --minimize` 可检查两类线索,重建候选时保留异形形状和奇偶字段。普通随机生成器仍只生成数字关。
- 修复最后一关回选关时引用不存在的 `pack-progress` 节点:提示条由选关/对局共用。
- 试玩入口:`http://localhost:8000/?dev=1&level=3-1`;指定 `level` 仅在开发模式生效,不清存档。调试状态新增 `conflicts: string[]`。
- 起点样式按后续反馈调整:数字 `1` 开局为蓝紫底、白字、灰白描边(`#DFE1EC`),字号从单位格 42% 调至 62%;普通单字数字 52%、两位数 48%,描边从 6% 收到 4.5%。矩形和异形统一用内外剪影,修掉圆角残色。已检查 390×844 与 320×640 的显示;没有提前计步,撤销/重开为空盘时保持该样式。
验证:`npm run check` 为 46 测试、26/26 唯一解;新增测试用独立枚举对照 81 种奇偶分布(各含有/无数字 1 两种情况),并覆盖起手、冲突、撤销、重开、大块、异形、提示与过关。浏览器 390×844 触摸仿真点通全部 6 个新关卡;3-1 另验证滑错 → 红底与「偶」角标 → 提示退回 → 滑回撤销 → 走通。已目视检查 3×3 已走标记、异形未走及冲突轮廓。
下一步:用户试玩这 6 关后调难度;按需扩充奇偶生成器;Cocos/真机验收仍未做。下文第一包的关卡图与早期架构说明继续保留;关卡总数、异形和奇偶规则以本节与 README/GDD 为准。
---
## 0.2 触屏回退与双提示(2026-09-16,当前实现)
- 点按或滑到任意已走格,`rewind` 一次移除其后缀;保留目标格,不松手即可接着画。触屏命中死区 8%,鼠标 14%;Web 插值前优先检查实际触点是否是历史格,防止跳跃回退误加格。
- 底栏为「重开 / 补数字 / 指路」,并加手势说明。补数字由 `src/clue-hint.ts` 选当前尚未解开的数字区间中点:1~9 补 5。已正确走过的区域不再补。
- `Game` 的 `extraClues` 保存本次对局补数;`clueAt()` 合并数字与原有奇偶条件,冲突和通关都用有效线索。重开保留补数,重新进入关卡或刷新不保留;不修改关卡数据、求解器或存档。
- `reveal` 的数字来自唯一解;渲染时轻弹并用琥珀外圈定位,之后是普通线索。补在奇偶格时保留小字奇偶要求。
- 原 `solver.hint()` 由「指路」调用;偏离时高亮最后一个正确格,可点高亮位置直接回退。调试 `debugState()` 增加 `revealed: {cell, step}[]`。
- 类型检查、60 个测试、26/26 关唯一解通过。浏览器以原生 CDP touch 事件验证:连续画 6 格、点第 3 格一次退 3 步并不松手转向、跨格回退、快速滑动补采样;验证 1~9 补 5、重开保留、旧指路入口及奇偶补数。390×844 与 320×640 检查布局。真机手感仍待试玩。
- `npm run build:standalone` 导出根目录 `releases/数字一笔-试玩版.html`,内含两种提示和触屏操作,可直接发给朋友用浏览器打开。
上一节为接手历史;当前手势和提示以本节及 GDD §5 / §10 为准,视觉以根目录 design.md / design.html v1.3 为准。
---
## 0.3 固定数字步号的双向校验(2026-09-16)
- `Game.isConflict()` 既检查本格数字 / 奇偶要求,也检查当前步号是否被另一个固定数字格占用。例如预定格为 5,而玩家第 5 步落在其他格,则该步立即标错,无需等踩到预定格。
- `push()` 的冲突事件、`clueHit`、盘面渲染与通关统一采用此判断。奇偶命中不能覆盖数字位置错误。原始数字与 `extraClues` 都生效;补数后已有路线也立即重新判断。
- 错位的普通格或满足自身奇偶要求的格显示红底 +「×」角标;自身线索也不符时继续显示原要求。撤销 / 回退后清除对应冲突。
- 新增 6 项回归测试,覆盖即时错位、奇偶命中反馈、撤回重走、补数与重开、奇偶不占号、红格 / 红桥 / 角标。完整检查为 66 测试、26/26 关通过。独立 HTML 同步更新。
---
## 0.4 错格反馈简化、独立试玩版全开放(2026-09-16,当前状态)
- 不再绘制占号冲突的「×」;只有错误格变红并横向轻晃 280ms,进出连接线始终使用路径色。本格数字 / 奇偶要求不符时仍保留原要求角标。
- 新数字让已有路线出错时,同样晃动受影响格;撤销 / 重开立即清除晃动。系统减弱动态时仅保留红色状态。
- `scripts/standalone.mjs` 为导出 HTML 加 `data-unlock-all="true"`,选关不再要求前置通关;无需 `?dev`,不会把关卡伪标记为已通关,也不默认暴露调试对象。正常 Web 的进度规则保持原样。
- 当前完整检查为 68 测试、26/26 关通过;最新交付为根目录 `releases/数字一笔-试玩版.html`。视觉规范为 design.md / design.html v1.5;此前版本的红桥 / × 描述仅作历史记录。
---
## 1. 实现了什么
### 1.1 交付清单(对照 GDD)
| GDD 条目 | 状态 | 备注 |
|---|---|---|
| §4 规则:矩形格、四向邻接、大格计 1 步、线索步号、冲突态、过关判定 | 完成 | `src/board.ts` `src/game.ts`,有单测 |
| §5.1 手势全部行为(含“有 1 只能从 1 起手”) | 完成 | `src/input.ts`,有单测;真实触摸事件跑通 10 关 |
| §5.2 按钮:重开 / 补数字 / 指路 + 返回选关 | 完成 | 菜单做成左上返回箭头,因为壳还没有 |
| §5.3 输入状态机 Empty/Drawing/Idle/Cleared | 完成 | `Game.status` |
| §6 反馈:落子弹性、桥弹出、步号淡入、线索命中闪白、冲突红、死路抖、非法起手抖、过关扫光 | 完成 | `src/view.ts`;音效 `web/audio.ts`;振动 `navigator.vibrate` |
| §7.1 教学关 | 完成 10 关(用户要求 10,见 §3.2) | `levels/pack-1-tutorial.json` |
| §8.1 求解器 | 完成 | `src/solver.ts`,N ≤ 22 毫秒级 |
| §8.2 生成器 | 已实现数字关生成 | `tools/generate.ts` 支持大块/异形与难度筛选;奇偶包当前为手配线索 + 求解器验证 |
| §8.3 关卡文件格式 | 完成 | 未使用可选字段 `hintPath`,运行时现算(N 小) |
| §9 界面流程:选关 → 对局 → 过关自动下一关 → 最后一关回选关 | 完成 | `web/main.ts` |
| §10 补数字 | 完成 | `clue-hint.ts` + `Game.reveal`,重开保留 |
| §10 指路三态(起点 / 下一步 / 退回分叉)+ 起手错 | 完成 | `solver.hint()`,无限次无广告 |
| §10 存档 `mode:01-number-stroke` | 完成 | `src/progress.ts`,`clearedId[]` + `currentId` + 偏好 |
| §10 高对比(冲突加斜纹) | **未做** | 冲突已有非颜色标记(角标),斜纹等壳的设置项 |
| §12 技术:纯 TS 逻辑与渲染解耦 | 完成 | `tsconfig.src.json` 用 `lib: ["ES2020"]` 强制 `src/` 不碰 DOM |
| §13 竖直切面清单 | 见下表 | |
GDD §13 竖直切面 8 条:
| # | 条目 | 状态 |
|---|---|---|
| 1 | 1-1~1-4 能教会滑动、走完、撤销、大格计 1 步 | 关卡与字幕已做;**没有找真人试玩** |
| 2 | 1-5~1-8 需要先想再画 | 求解器层面成立(无线索路径 2~124 条,加线索后唯一);**没有试玩数据** |
| 3 | 冲突态肉眼可辨,撤销后恢复 | 完成(红底 + 白步号 + 深色角标显示应有步号) |
| 4 | 有线索 1 时点其他格无效 | 完成,有单测,浏览器验证过(1 会抖一下) |
| 5 | 无线索 1 的样例关至少 1 个 | 已有 2-8(第二包);奇偶无起点场景另有单测 |
| 6 | 过关自动进下一关,进度刷新后还在 | 完成,浏览器验证过 localStorage 内容 |
| 7 | 求解器对全部关断言 `paths.length === 1` | 完成:`npm run validate` + `tests/solver.test.ts` 双重断言 |
| 8 | 提示在偏离解时高亮分叉,不继续给错下一步 | 完成,浏览器验证过 |
### 1.2 玩家能做什么(交互行为,已实现)
- 有线索 `1`:只能从它起手,点别处 `1` 抖一下 + 轻振动 + 短音。无线索 `1`:任意格起手。
- 按住滑到相邻未走格 → 追加一步(格弹一下、桥长出来、步号淡入、音高随步号上升到 12 后循环)。
- 点按或滑到任意已走格 → 一次撤掉后面的路径,目标格轻弹,多步回退轻振动。
- 滑到不相邻的空格 → 忽略,逻辑指针停在末端。
- 松手 → 路径保留(`idle`)。再从末端按下 → 继续画。点任意已走格 → 回退到它并进入画的状态。点末端旁边的空格 → 直接追加(点着走)。
- 踩到线索但步号不对 → 该格红底,步号照显示,右上角深色圆角标写应有的数字;不中断手势。
- 无格可走但未过关 → 当前格抖一下,不弹窗。
- 全走完且线索全对 → 路径提亮、按步序扫光,约 1 秒后(扫光 → 停留 → 淡出)自动进下一关;包末衔接下一包,全序列最后一关回选关并显示完成提示。过关后本关锁定。
- 重开:清空路径但保留补数。补数字 / 指路:见 §0.2。
- 选关页:10 格瓦片(已通关紫色带点、当前关描边、未解锁灰锁),“继续 · 1-x”大按钮,音效/振动开关,重置进度(有 confirm)。
- 手势健壮性:只跟踪一个指针(`pointerId`),`setPointerCapture`,两帧之间按 1/4 格插值采样防跳格,触屏每格四周 8%、鼠标 14% 死区防误触。
### 1.3 关卡:10 关
坐标原点左上,`.` 是空洞,`·` 是大格的延伸部分,`[n]` 是线索。右边是唯一解的步号图。全部由 `npm run validate` 断言唯一。
**1-1** 滑动,邻格自动出步号(字幕:从 1 开始,滑到旁边的格)
```
[1] b c 1 2 3
```
**1-2** 必须走完所有格(字幕:走完每一格,数字要对上)
```
[1] . 1 .
b . 2 .
c [4] 3 4
```
**1-3** 走错可以退。先往下会错过 3(字幕:走错了,沿原路滑回去)
```
[1] a [3] 1 2 3
. d c . 5 4
```
**1-4** 大格只算一步(字幕:大格也只算一步)。`start` 与 2×2 只碰角,不相邻——这是理解邻接规则的关键盘。
```
[1] a b 1 2 4
. [3] · . 3 ·
. · · . · ·
```
**1-5** 中段线索。必须先走完顶行,4 才落在右上角。
```
[1] a b [4] 1 2 3 4
. . d e . . 6 5
. . f . . . 7 .
```
**1-6** 空洞。中间缺一格,`a` 与 `f` 隔洞不相邻;`9` 在左边,必须绕环一圈最后到它。陷阱:先往下走会把 `9` 提前踩成红 `3`。`9` 对唯一性是冗余的(只留 `1` 也唯一),故意留着教“先看终点再起手”。
```
. [1] a b . 1 2 3
[9] c . d 9 8 . 4
. e f g . 7 6 5
```
**1-7** 先推再画。3×4 满盘,无线索路径 124 条。三处分叉:`b` 处要走 `f` 才能让 `e=4`;`k` 处要走 `g` 才能让 `g=8`;`g` 处要先取 `c`,否则 `l` 被孤立。
```
[1] b c d 1 2 9 10
[4] f [8] h 4 3 8 11
i j k l 5 6 7 12
```
**1-8** 2×2 当枢纽。`B` 有 6 个出口但只能进出各一次:从 `B` 出去必须挑 `c` 或 `d`(远角),挑 `e/f/h/i` 都会孤立一侧。`h=8` 决定顺时针。
```
. [1] . . . 1 . .
. b . . . 2 . .
c B · d 11 3 · 4
e · · f 10 · · 5
g [8] i j 9 8 7 6
```
**1-9** 横长条,出口在长边上。`b` 紧挨着 `1` 却要第 6 步才到:只能从长条 `H` 的下边进、上边出(`h→H→b`),所以先绕左下角。无线索路径 112 条,两个线索即唯一。
```
[1] [6] c d 1 6 7 8
e H · f 2 5 · 9
g h i j 3 4 11 10
```
**1-10** 小考:2×2 + 竖长条 + 空洞。顶行直走到 `e` 只有 5 步,`e=6` 要求多垫一步——从 `b` 下到 `B` 再回 `c`(`a,b,B,c,d,e`)。之后绕右下回到左侧,`V` 收尾。陷阱:`a,V,B,c` 也能让 `e=6`,但 `b` 会被孤立。14 格只有 2 个线索。
```
[1] b c d [6] 1 2 4 5 6
V B · . f 14 3 · . 7
· · · g h · · · 9 8
i j k l . 13 12 11 10 .
```
每关 JSON 里的 `teach` 字段是设计备注(含剧透),**永远不要渲染到界面**;只有 `caption` 会显示在顶部。
难度旋钮引入顺序:格数 → 大格(1-4) → 中段线索(1-5) → 空洞(1-6) → 推理(1-7) → 2×2 枢纽(1-8) → 横条(1-9) → 竖条+综合(1-10)。GDD 的 12 关梯度压成 10 关:竖长条和“两个大格”并进 1-10;无线索 `1` 按 GDD 留到第 13 关之后。
### 1.4 视觉
完整规范见仓库 [`docs/design.md`](../../../docs/design.md)(2026-09-15 起):色彩 / 字阶 / 间距 / 动效四档令牌,CSS `:root` 与 `src/theme.ts`(`THEME` / `BOARD_TYPE` / `MOTION`)由 `tests/theme.test.ts` 校验一致。顶栏含关号、「包名 · 第 n 关」副标题、本包进度条与剩余格胶囊;盘面有错落进场与过关后淡出。
浅色单主题(`src/theme.ts`):背景白、未走格 `#E6E7EC`、已走蓝紫 `#6558F5`(过关提亮 `#7D72FF`)、未走线索描边与数字同为近黑 `#23243A`、冲突红 `#E5484D`、提示琥珀 `#F5A524`。桌面浏览器上以 480px 手机框居中显示,方便调试。
---
## 2. 如何实现
### 2.1 技术选型与理由
目标是“之后迁 Cocos Creator 3.x 不重写”。由此推出的决策:
| 决策 | 理由 |
|---|---|
| `src/` 纯 TypeScript,零 DOM、零 Cocos 依赖 | Cocos 脚本就是 TS;规则/求解器/动效状态/存档逻辑直接拷过去。用 `tsconfig.src.json`(`lib: ["ES2020"]`, `types: []`)编译一遍,谁往 `src/` 里写 `document`/`window` 就报错 |
| import 全部**无后缀**(`./board` 而不是 `./board.ts`) | Cocos 模块规范明确不允许 `import './foo.ts'`(会找不到模块)。Node 原生 type-stripping 反而要求带 `.ts` 后缀,二者冲突,所以放弃 Node 直跑,改用 esbuild 打包 + tsx 跑工具 |
| 渲染走 `Painter` 接口(4 个原语) | Canvas 2D 与 Cocos `Graphics`/`Label` 都能实现;`BoardView.draw()` 是即时模式,每帧全量重画(N ≤ 22 格无压力) |
| 输入层只认“指针现在压在哪个格” | `PointerInput.down/move/up(cellIndex)` 不知道 PointerEvent 或 TOUCH 事件的存在;坐标 → 格由 `layout.hitTest()` 算,Cocos 只需换坐标来源 |
| 存档走 `KeyValueStore` 接口 | Web 用 `localStorage`,Cocos 用 `sys.localStorage`,测试用 `MemoryStore` |
| esbuild 单文件 IIFE 输出 | 不需要 dev server 也能 `file://` 打开;手机测试时 `npm run dev` 走局域网 |
| 关卡 JSON 打进 bundle | esbuild `loader: { '.json': 'json' }`;Cocos 侧放 `resources/` 用 `JsonAsset` 加载,解析函数相同 |
不选的东西:Vite/React(多余的运行时和 DOM 心智)、SVG/DOM 渲染(迁 Cocos 全废)、Node 原生 `--experimental-strip-types`(后缀冲突)。
### 2.2 目录与模块职责
```
modes/01-number-stroke/
package.json tsconfig.json tsconfig.src.json .gitignore
scripts/build.mjs esbuild 打包/监听/服务
levels/pack-1-tutorial.json
src/ ── 纯逻辑层,Web/Cocos 共用 ──
types.ts CellDef / LevelDef / PackDef(关卡 JSON 的类型)
board.ts Board:单位网格、四向邻接、共享边、连通性;rectsShareEdge()
game.ts Game:Command → GameEvent[] 的状态机
input.ts PointerInput:手势语义 → Command
solver.ts solve() / uniqueSolution() / hint()
layout.ts fitBoard() / cellRect() / cellBounds() / bridgeRect() / hitTest()
view.ts BoardView:动效状态 + draw(painter)
painter.ts Painter 接口
theme.ts 配色常量
progress.ts Progress + KeyValueStore + MemoryStore
levels.ts parsePack() / loadPack()
web/ ── 浏览器粘合层 ──
index.html style.css 两屏 DOM(选关 / 对局)
main.ts App:屏幕切换、HUD、PointerEvent → PointerInput、rAF 循环、存档、调试句柄
canvas-painter.ts Painter 的 Canvas 2D 实现
audio.ts WebAudio 合成音效(step/conflict/cleared/reject)
dist/app.js 打包产物(gitignore)
tools/ ── Node 命令行 ──
validate.ts 全包校验:连通 + 恰好 1 解;-- <id> 打印盘面与解
design.ts 设计辅助:枚举无线索路径、--minimize、--suggest k --path ...
ascii.ts 终端字符盘面
tests/*.test.ts node:test,30 个用例
docs/GDD.md docs/HANDOFF.md(本文)
```
### 2.3 数据流:一次触摸从事件到画面
```
PointerEvent (web/main.ts)
│ getBoundingClientRect → 容器像素坐标;pointermove 时按 unit/4 插值采样
▼
layout.hitTest(board, layout, x, y) → cellIndex | -1(缝/死区/空洞/越界)
▼
PointerInput.down/move/up(cellIndex) → 根据路径状态决定 Command
▼
Game.apply(Command) → GameEvent[] (start/append/undo/rewind/reveal/release/resume/reset)
▼
handleEvents(events):
├─ BoardView.onEvent(e) 置动效状态(弹簧脉冲、桥进度、闪白、抖动、过关扫光)
├─ Sfx / vibrate step(n)、conflict、reject、cleared
├─ HUD 剩余格数
└─ cleared → Progress.markCleared → setTimeout(clearedDuration) → 下一关
每帧 (rAF):view.update(dt);若 view.isAnimating 则 clearRect + view.draw(painter)
```
Cocos 里只换最上面一行(TOUCH 事件 → 节点局部坐标)和 `Painter` 实现,中间不动。
### 2.4 各模块要点
**board.ts**
- `Board.fromCells(defs)`:校验尺寸只允许 1×1/2×1/1×2/2×2(`ALLOWED_SIZES`)、id 不重复、线索 ∈ [1, N] 且不重复、单位格不重叠;把最小 x/y 平移到 0。
- 邻接:两矩形共享 ≥ 1 单位长度的边(`rectsShareEdge`),只认四向。2×2 与只碰角的 1×1 不相邻(1-4 就靠这个)。
- `unitMap: Int16Array` 做单位格 → 格下标的 O(1) 查询,空洞为 -1。
- `sharedEdge(a,b)` 返回共享线段和方向,画桥用。
- 运行时统一用**格下标**(`index`)引用格;`id` 只在 JSON 和工具输出里用。
**game.ts**
- 状态 `empty | drawing | idle | cleared`;`stepOfCell: Int16Array`(0 = 未走)。
- `start`:路径非空或已过关 → 无事件;不是合法起点 → `rejectStart{cell, clueCell}`。
- `append`:必须 `canAppend`(相邻且未走且未过关)。每次 `push` 后:若 `isSolved()`(填满且无冲突)→ `cleared` 并锁定;否则若 `legalMoves()` 为空 → 附带 `deadEnd`。
- 冲突判定是派生的:`isConflict(cell) = clue !== null && step !== 0 && step !== clue`,撤销即自动恢复。
- `reset` 在空盘上不产生事件(避免视图无谓刷新)。
**input.ts**
- 内部只有两个字段:`drawing`(手指是否处于画的状态)和 `hoverCell`(去抖,同一格不重复触发)。
- `down` 的分支顺序:路径空 → start;末端 → resume;任意更早历史格 → rewind + resume;可追加 → append;否则忽略。
- `move` 只在 `drawing` 时生效;`up` → release;`cancel()` 给重开按钮同步状态用。
- 空白 `-1` 会被记为 hover,但不改路径。所以手指在缝里滑不会触发任何事。
**solver.ts**
- 回溯 DFS。下一步若有线索格 → 只能走它(且必须相邻);否则候选 = 相邻、未走、**不是线索格**(线索格只能在它的步号进入)。
- 剪枝:每个节点做一次 BFS(只经未走格)——可达未走格数 < 剩余数 → 剪;每个未来线索 `s` 的 BFS 距离 > `s - step` → 剪。
- Warnsdorff 排序(空邻居少的先走)让 `limit: 2` 的唯一性检查很快停。
- 起点:有线索 1 只从它起;否则全部格(跳过 clue ≠ 1 的格)。`opts.starts` 可指定,`opts.ignoreClues` 给设计工具枚举全部哈密顿路径。
- 全部 10 关求解 < 0.2ms;GDD §7.3 的 15 格样例盘也毫秒级。上限 N ≤ 22 是 GDD 定的,没测过更大。
- `hint(solution, path)`:算最长公共前缀 L。`path` 空 → `start`;L = path 长度 → `next`(或 `none`);L = 0 → `restart`(只在无线索 1 的盘可能发生);否则 `backtrack{cell: path[L-1], undoCount}`。
**layout.ts**
- `fitBoard(board, availW, availH, {padding, maxUnit, gapRatio=0.1, radiusRatio=0.18})`:`unit = min(maxUnit, (W-2p)/cols, (H-2p)/rows)`,居中。`web/main.ts` 传 `padding: 10`,`maxUnit` 在 `cols*rows ≤ 6` 时 96 否则 110(防止 3 格的教学关铺满屏)。
- `cellRect` 是扣掉缝的绘制矩形,`cellBounds` 是不扣缝的命中矩形。
- `bridgeRect(a, b, thickness=0.46)`:横跨缝(`gap + 2px` 防抗锯齿露缝),沿共享边中点居中,宽 0.46 unit。与格同色即成连续路径。
- `hitTest(..., inset=0.14)`:先按单位格查,再检查死区,是则返回 -1。Web 触屏传 0.08,鼠标传 0.14。
**view.ts**
- 每格一个 `CellAnim {scale, scaleVel, flash, shake, numberAlpha, sweep}`,桥一个 `bridges[i]` 进度数组。
- 常量:`POP_IMPULSE 2.4 / SPRING_K 260 / SPRING_DAMP 18`(落子弹簧),`FLASH_DECAY 3.2/s`,`SHAKE_TIME 0.28s`,`NUMBER_FADE 8/s`,`BRIDGE_GROW 10/s`,`HINT_TIME 2.4s`,`CLEARED_SWEEP_TIME 0.32s`,`clearedDuration = 0.32 + 0.55 s`(宿主据此跳关)。
- `draw` 四遍:底板 → 桥 → 线索描边/数字/冲突角标 → 提示(琥珀外框脉冲;`backtrack` 时把要退的段用背景色压暗 45%)。
- `update(dt)` 把 dt 截到 50ms 防切后台回来爆炸;`isAnimating` 为假时宿主不重绘。
- `onEvent` 会清掉当前提示(任何一步操作都让提示消失)。
**progress.ts**
- 键 `mode:01-number-stroke`,值 `{"cleared":[...],"currentId":"1-3","prefs":{"sound":true,"haptics":true}}`。
- 线性解锁 `isUnlocked(ids, i) = i === 0 || cleared.has(ids[i-1])`;`firstUncleared` 用于“继续”按钮。
- 读档 try/catch,坏 JSON 当没有。写档失败(私密模式)静默。
- 没有 schema 版本号(见 §4.1)。
**web/main.ts**
- `App` 单例。`Session` 持有 `level/game/view/input/solution/layout`,每进一关重建。
- `resize()`:`ResizeObserver` 盯 `#board-wrap`,canvas 按 `devicePixelRatio`(上限 3)设物理尺寸并 `setTransform`。
- 音频在第一次 `pointerdown` / 按钮点击时 `sfx.unlock()`(iOS 需要用户手势)。
- 过关后禁用重开/提示按钮,`advanceTimer` 到点跳关;返回按钮会 `clearTimeout`。
- 提示的 `restart` 态会让重开按钮加 `.pulse` 动画。
**web/style.css**
- `100dvh`(带 `100vh` 兜底)、`env(safe-area-inset-*)`、`touch-action: none`(canvas)/`manipulation`(body 防双击缩放)、`user-select: none`。≥ 520px 宽时显示手机框。
### 2.5 关卡格式与校验流水线
```json
{ "id": "1-9", "teach": "设计备注,不显示", "caption": "可选,显示在顶部",
"cells": [ { "id": "a", "x": 0, "y": 0, "w": 1, "h": 1, "clue": 1 }, ... ] }
```
改关卡的标准流程:
1. 编辑 `levels/pack-1-tutorial.json`(或新建包文件,`validate` 会扫 `levels/*.json`)。
2. `npm run validate -- 1-9` 看盘面、解、无线索路径数。多解/无解会打 ✗ 并退出码 1。
3. `npm run design -- 1-9 --minimize` 看哪些线索可删;`--suggest 3 --path a,e,g,...` 让工具给一条作者解找最少线索(排序偏好:少用 1/N 端点、线索分布均匀)。
4. `npm run dev` + `?dev=1` 实际滑一遍。
5. `npm run check`。
`tests/solver.test.ts` 断言第一、二包各 10 关、第三包 6 关且全部唯一解;增删关卡记得同步包内数量。
### 2.6 构建与脚本
| 脚本 | 做什么 |
|---|---|
| `npm run dev` | `node scripts/build.mjs --serve`:esbuild context,watch + serve `web/`。`--port N` 或 `PORT=N` 指定端口,否则从 8000 起找空闲 |
| `npm run build` | 同一入口,minify,输出 `web/dist/app.js` |
| `npm run typecheck` | `tsc -p tsconfig.json`(全项目,含 DOM)+ `tsc -p tsconfig.src.json`(仅 src,无 DOM) |
| `npm run test` | `node --import tsx --test tests/*.test.ts` |
| `npm run validate` / `npm run design` | 同上,`node --import tsx tools/xxx.ts` |
| `npm run check` | 以上三项串起来 |
依赖只有 4 个 devDependencies:`esbuild`、`tsx`、`typescript`、`@types/node`。Node 24 开发,Node ≥ 20 应该都行。
为什么是 `node --import tsx` 而不是 `npx tsx`:tsx 的 CLI 会在系统临时目录建一个 IPC 管道,在受限(沙箱)环境里会 EPERM;`--import tsx` 只挂 loader,不建管道。正常机器上 `npx tsx file.ts` 也能用。
### 2.7 测试
**单元测试(46 个,`tests/`;奇偶覆盖见 §0.1)**
- `board`:四向邻接、角对角不相邻、2×2 多邻接、长条长边邻接、空洞、共享边、非法盘抛错。
- `game`:起手合法性(有/无线索 1)、追加限制、冲突不中断、死路事件、过关锁定、release/resume/reset 流转、撤销到空。
- `input`:滑动追加/滑回撤销/松手保留、从末端继续、点前一格撤销、点着走、非法起手、忽略不相邻、过关后失效。
- `solver`:10 关唯一解、GDD §7.3 样例盘、无线索 1 枚举起点、多解/无解、提示四态。
- `layout`:适配与 maxUnit、命中/死区/空洞、桥矩形位置。
- `progress`:线性解锁、存档恢复与偏好、坏存档不炸。
**真实触摸自动化(已验证但脚本没入库)**
用带 CDP 的 Chromium(我用的是本机 ego-browser,Playwright/Puppeteer 同理)在 390×844、触摸仿真下把 10 关全部用 `Input.dispatchTouchEvent` 滑通,检查 `debugState().status === 'cleared'`、路径与解一致、localStorage 内容正确;另测了 1-6 的故意走错 → 冲突 → 提示回退 → 滑回 → 正解。核心片段(可直接搬进任何 CDP 环境):
```js
// 视口 + 触摸仿真
await cdp("Emulation.setDeviceMetricsOverride", { width: 390, height: 844, deviceScaleFactor: 2, mobile: true });
await cdp("Emulation.setTouchEmulationEnabled", { enabled: true, maxTouchPoints: 1 });
// 进关:打开 /?dev=1,点第 n 个 .level-tile
// 拿格中心(视口坐标)与状态
const pts = await evaluate((ids) => ids.map((id) => window.__numberStroke.debugCellCenter(id)), ids);
const st = await evaluate(() => window.__numberStroke.debugState()); // {id, path, status, remaining, solution}
// 沿格中心序列滑动,两格之间插 4 个点
const touch = (type, x, y) => cdp("Input.dispatchTouchEvent", { type,
touchPoints: type === "touchEnd" ? [] : [{ x, y, radiusX: 8, radiusY: 8, force: 1, id: 1 }] });
await touch("touchStart", pts[0].x, pts[0].y);
for (let i = 1; i < pts.length; i++) for (let k = 1; k <= 4; k++) {
const t = k / 4, a = pts[i - 1], b = pts[i];
await touch("touchMove", a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t);
}
await touch("touchEnd", 0, 0);
```
建议把它做成 `tests/e2e/` 下的 Playwright 脚本入库(见 §4.2)。
---
## 3. 与这次实现相关的其他内容
### 3.1 本次改动的仓库文件
- 新建:`modes/01-number-stroke/` 下除 `docs/GDD.md` 以外的全部文件。
- 重写:`levels/pack-1-tutorial.json`(原 5 关 → 10 关;1-1~1-5 坐标未动,只给 1-2/1-3 补了 `caption`,包标题改“入门”)。
- 重写:`modes/01-number-stroke/README.md`(目录、运行、关卡表、交互清单、Cocos 移植 8 步)。
- 改一行:根 `README.md` 与 `docs/PRODUCT.md` 里玩法一的状态 → “Web 原型可玩,10 关”。
- **未改** `docs/GDD.md`。
### 3.2 与 GDD 的偏差与补充(接手人需要知道的口径)
| 项 | GDD | 实际 | 原因 |
|---|---|---|---|
| 包大小 | 一包 8 关,教学 12 关 | 第一包 10 关 | 用户要求“自己设计 10 关”。GDD 文本未同步,是否改由负责人定 |
| 字幕 | 只有 1-1、1-4 有一行文案 | 1-1~1-4 都有 | 帮第一次玩的人;仍在“1-1~1-4 教完规则”的窗口内 |
| 未走线索格样式 | “未走线索格用白字” | 灰底 + 近黑粗描边 + 近黑数字 | 白字在灰底上对比度不够;已走后变紫底白字白描边 |
| 冲突“小标记” | 叠一个小标记 | 右上角深色圆角标,写应有步号 | 顺便告诉玩家“应该是几” |
| 点末端旁的空格 | 未规定 | 追加一步 | 让“点着走”成立,降低滑动门槛 |
| 菜单按钮 | 汉堡菜单:关卡列表 / 返回壳 | 左上返回箭头回选关 | 壳不存在 |
| 音效/振动开关 | 属于壳 | 暂放在选关页底部 | 原型没壳;进壳后删掉这两个 chip,从 `ShellContext` 读 |
| `hintPath` | 可在编辑器烘焙 | 未用,进关时 `uniqueSolution()` 现算 | 毫秒级,没必要 |
| 输入细节 | 未规定 | 插值采样、触屏 8% / 鼠标 14% 死区、单指针 | 手感需要;参数见 §2.4 |
| 无线索 1 的样例关 | VS 第 5 条要至少 1 个 | 没出 | 见 §4.2 |
### 3.3 GDD 里发现的问题(建议负责人修文)
- §7.1/7.2 的 12/8 关与当前 10 关不一致(见上表)。
### 3.4 环境坑
- **Cocos 不允许 `import './x.ts'`**。`src/` 里任何新文件都保持无后缀 import,否则迁移时全要改。
- 本机 5173 被别的服务占着,所以 dev server 默认自动找端口;看终端打印的地址。
- `os.networkInterfaces()` 在受限环境会抛错,`build.mjs` 已 try/catch,只是打不出局域网地址。
- Cursor 内置浏览器打不开 `file://`;要么走 dev server,要么用系统浏览器双击 `web/index.html`。
- 想杀 dev server 用 PID,不要 `pkill -f "build.mjs --serve"`——如果你所在的 shell 命令行本身包含这个字符串,会把自己一起杀掉(我踩过)。
- `.gitignore` 排除了 `node_modules/` 和 `web/dist/`。仓库目前**不是 git 仓库**,需要的话在根目录 `git init`。
- iOS Safari 没有 `navigator.vibrate`,振动开关在 iOS 上无效属正常;Android Chrome 有。
- iOS 音频必须在用户手势里 `AudioContext.resume()`,已在首次 `pointerdown` 处理;静音键打开时 WebAudio 也会被静音,这是系统行为。
### 3.5 存档格式
`localStorage["mode:01-number-stroke"]`:
```json
{ "cleared": ["1-1", "1-2"], "currentId": "1-3", "prefs": { "sound": true, "haptics": true } }
```
只存 id,不存半途路径(GDD §10)。不得读写其他玩法的桶。
### 3.6 调试句柄(仅 `?dev=1`)
- `window.__numberStroke.debugCellCenter("B")` → 该格中心的 `clientX/Y`,自动化滑动用。
- `window.__numberStroke.debugState()` → `{ id, path: string[], status, remaining, solution: string[] | null }`。
- 选关页所有关解锁(不写存档,只是渲染时放行)。
---
## 4. 可优化的点,以及接下来要做的事
### 4.1 已知不足 / 风险(按我认为的优先级)
1. **没有真机、真人数据。** 所有手势验证来自触摸仿真。触屏死区 8% / 鼠标 14%、插值步长 unit/4、`maxUnit` 96/110、过关 0.87s 跳关,这些数字都要在真机上摸过再定。GDD §16 的验收(不看字在 1-4 前学会、1-7 出现停顿)需要 3~5 个没玩过的人。
2. **无线索 1 已有 2-8**;后续可补奇偶与未知起点组合的正式关卡。
3. **存档没有版本号。** 上线前加 `"v": 1`,`Progress.load()` 里按版本迁移。
4. **多包已完成。** 选关按包分组,包间线性解锁,目前三包 26 关。
5. **奇偶生成器待补。** 现有 `generate.ts` 已产出第二包数字关;奇偶条件当前经人工选择和求解器验证。
6. **视觉细节**:线索命中的闪白描边会溢出格外一点(已收窄到 1.6 倍线宽,仍可再调);过关扫光是“白色 sin 脉冲”,比较朴素;数字字体依赖系统字体栈,不同机器观感不一。
7. **BoardView 是即时模式全量重画。** Web 上无压力;到 Cocos 若用 `Graphics` 每帧 clear + 重画也能接受(≤ 22 格 + ≤ 21 桥),但 `Label` 节点需要池化复用,不能每帧新建。若性能不理想,改成“每格一个节点、事件驱动更新”的保留模式——那时 `BoardView` 的动效状态仍可复用,只是 `draw()` 换成 `sync()`。
8. **hint 是运行时求解。** N 一旦上到 22 以上或做用户造关,改成烘焙 `hintPath` 到 JSON(GDD 给了字段)。
9. **提示无限次无广告**是竖直切面的设定;上线要接“每天 1 次免费 + 激励视频”,位置在 `App.hint()` 入口。
10. **文案全是硬编码中文**(`index.html`、`main.ts` 的 toast)。要多语言就抽成表。
11. **高对比模式**(冲突加斜纹)没做;`Painter` 需要加一个斜纹填充原语,`THEME` 加高对比变体,开关从壳读。
12. **选关页在超高屏上中部留白大**,可以把“继续”按钮上移或加规则三行说明;对局页盘面严格垂直居中,拇指够不到顶部大盘时可考虑整体上移 5~8%。
### 4.2 接下来要做(建议顺序)
**A. 收尾 M2(1~2 天)**
- 真机跑一遍(iPhone Safari、Android Chrome 各一台):调 §4.1 第 1 条的参数,看 `100dvh`/安全区/音频解锁/振动。
- 在已有 2-8 上验证未知起点的提示体验,再考虑加入奇偶组合。
- 把 §2.7 的触摸脚本落成 `tests/e2e/play-all.spec.ts`(Playwright),CI 里跑。
- 找 3~5 人试玩,按 GDD §16 记录:在哪一关卡住、是否理解大格、1-10 是否用了提示。只改关卡数据,不改规则。
- 决定 GDD §7.1/7.2/7.3 的文本要不要同步(§3.3)。
**B. M3 内容(32 关)**
- 写 `tools/generate.ts`:随机铺 1×1/2×1/1×2/2×2 + 空洞(保证连通)→ `solve(board, {ignoreClues: true, limit: 1})` 随机取一条作者解(给 DFS 加随机排序)→ 复用 `design.ts` 的 suggest/minimize 挑 2~4 个中段线索 → 导出 JSON。
- “漂亮度”过滤:无意义绕圈(连续 U 形折返次数)、线索读得清(不挤在一起)、大格至少被路径真正利用一次。
- 每关人过一眼(GDD §8 要求),`validate` 全绿。
- 多包 UI 与包间解锁已完成,新增包只需登记并校验。
**C. 壳接入(等第二个玩法有原型后)**
- 把 `web/main.ts` 的 `App` 改造成 `PuzzleMode { id: '01-number-stroke', title, mount(root, ctx), unmount() }`(`docs/PRODUCT.md` 的接口)。`ctx` 提供存档、音效、振动、返回首页——对应现在的 `LocalStore`、`WebAudioSfx`、`vibrate()`、返回按钮。
- 删掉选关页的音效/振动 chip,读壳设置;高对比也从这里来。
- 广告位占位(底 banner)按 GDD §9 加在对局页底部。
**D. M4 迁 Cocos Creator 3.x**(详见 README“移植到 Cocos Creator”,这里补执行顺序)
1. 新建 Cocos 工程,`assets/modes/01-number-stroke/scripts/` ← 拷 `src/` 全部文件,不改一行。先让工程编译过(`tsconfig.src.json` 已保证无 DOM)。
2. 关卡 JSON 放 `assets/modes/01-number-stroke/resources/levels/`,`resources.load('levels/pack-1-tutorial', JsonAsset)` → `parsePack/loadPack`。
3. 写 `GraphicsPainter implements Painter`:一个 `Graphics` 组件画矩形/圆(`roundRect` + `fill/stroke`,注意 Cocos y 轴向上,在 Painter 内 `y' = H - y - h`),一个 `Label` 对象池写数字(每帧先全部隐藏再按需取用)。
4. 写 `BoardController`(组件):`onLoad` 建 `Game/BoardView/PointerInput`,`update(dt)` 调 `view.update(dt)`,`isAnimating` 时 `graphics.clear()` + `view.draw(painter)`;`Node.on(TOUCH_START/MOVE/END/CANCEL)` → `UITransform.convertToNodeSpaceAR` → `hitTest` → `PointerInput`。TOUCH_MOVE 里照搬 `web/main.ts` 的插值采样。
5. `Progress(new CocosStore())`,`CocosStore` 包 `sys.localStorage`。
6. HUD、选关、字幕、toast 用 UI 节点复刻 `index.html` 的结构;音效换 `AudioSource` 四段短音。
7. 用 §2.7 的路径数据在真机上把 10 关滑一遍。
8. 不搬:`web/`、`tools/`、`tests/`(测试继续在 Node 跑,只依赖 `src/`)。
### 4.3 可选的产品/内容方向(不阻塞上面任何一项)
- 每关记录用时与提示次数(本地),给 M3 调难度用。
- 过关动画换成“路径按顺序亮一遍 + 终点格弹一下”,比现在的扫光更有“一笔画完”的感觉。
- 撤销加一个极短的褪色(现在是瞬时),看真机手感再定。
- 关卡编辑器 UI(GDD 明确 VS 不做;`design.ts` 已够用一阵)。
---
## 附:常用命令速查
```bash
npm run dev # 起服务(改动自动重打包)
npm run check # 全部检查
npm run validate -- 1-10 # 看某关盘面/解
npm run design -- 1-10 --minimize # 线索能不能再删
npm run design -- 1-10 --suggest 2 --path a,b,B,c,d,e,f,h,g,l,k,j,i,V
npm run design -- /path/to/draft.json --suggest 3 --path ... # 临时关卡文件也行
```