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

39 KiB
Raw Blame History

玩法一 · 数字一笔 — 交接文档

面向接手的人。读完这份文档 + 跑一遍 npm run check,应该能不问人就继续往下做。 规格权威是 GDD.md,目录/运行方式的速查在 ../README.md,本文讲“做了什么、怎么做的、为什么、还差什么”。

交接日期:2026-09-16。状态:M1/M2 达成(Web 原型可玩、三包 26 关唯一解、奇偶线索/存档/提示/音效/振动齐全),未进 Cocos。


0. 一分钟上手

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(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 关卡格式与校验流水线

{ "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 环境):

// 视口 + 触摸仿真
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"]:

{ "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 已够用一阵)。

附:常用命令速查

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 ...   # 临时关卡文件也行