初始提交:三玩法 Web 原型与设计文档

- 玩法一 数字一笔:规则库、求解器、26 关、竖屏触摸 Web 原型、测试
- 玩法二 连连气球:连消畅玩版核心、Web 原型、Cocos 球槽组件与导出脚本
- 玩法三 六边一笔:规则库、求解器、10 关 + 转墙实验包、Web 原型、测试
- 产品文档、UI 设计规范、各玩法 GDD / HANDOFF
- 文档移除全部外部参考作引用;删除玩法二 v0 旧稿
This commit is contained in:
cabinw
2026-09-16 14:30:59 +08:00
commit 8026e4628d
169 changed files with 22162 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
node_modules/
web/dist/
+159
View File
@@ -0,0 +1,159 @@
# 玩法一 · 数字一笔
按数字顺序,一笔画走完盘面上每一格。大格只计一步。
本目录自包含:规格、关卡、规则代码、求解器和 Web 原型。不要依赖 `modes/02` 或 `modes/03`。壳需要的东西通过 `shared/` 的玩法入口对接,见 [`docs/PRODUCT.md`](../../docs/PRODUCT.md)。
关卡、资源、文案全部原创,不使用第三方题库或素材。
## 状态
M1/M2 已达:竖屏触摸 Web 原型可玩,三包 26 关全部经求解器断言唯一解,含 6 关「奇与偶」体验包;存档、补数字 / 指路双提示、音效、振动齐全。68 个单元测试通过。下一步为试玩调校与 M4 迁 Cocos。
## 目录
| 路径 | 用途 |
|---|---|
| [`docs/GDD.md`](docs/GDD.md) | 本玩法规格(权威) |
| [`docs/HANDOFF.md`](docs/HANDOFF.md) | 交接文档:实现了什么、怎么实现、与 GDD 的偏差、待办与 Cocos 迁移顺序 |
| [`../../docs/design.md`](../../docs/design.md) | UI 设计规范(色彩 / 字阶 / 组件 / 动效)。`web/style.css :root` 与 `src/theme.ts` 由 `tests/theme.test.ts` 校验一致 |
| [`levels/pack-1-tutorial.json`](levels/pack-1-tutorial.json) | 第一包 10 关(教学) |
| [`levels/pack-2-advanced.json`](levels/pack-2-advanced.json) | 第二包 10 关(进阶,生成器产出 + 人工过目) |
| [`levels/pack-3-parity.json`](levels/pack-3-parity.json) | 第三包 6 关(奇与偶,每条奇偶线索都参与唯一性) |
| [`src/`](src/) | **纯 TS 规则层,零 DOM / 零 Cocos 依赖**。Web 与 Cocos 共用 |
| [`web/`](web/) | 浏览器原型:DOM / Canvas / PointerEvent / localStorage 粘合层 |
| [`tools/`](tools/) | 求解器命令行:全关卡校验、关卡设计辅助 |
| [`tests/`](tests/) | 规则层单元测试(node:test) |
`src/` 分层:
```
types.ts 关卡 JSON 结构
board.ts Cell / Board:单位网格、四向邻接、共享边
clue.ts 数字 / 奇偶线索统一判定与显示文字
game.ts 对局状态机:Command → GameEvent(含 rewind 多步回退、reveal 补数字)
input.ts 手势语义:按下/移动/松手 + “当前压在哪个格” → Command
solver.ts 枚举满足线索的哈密顿路径、唯一解、提示(下一步 / 退回分叉)
clue-hint.ts 在尚未解开的数字区间补中间数,不替玩家画路线
layout.ts 盘面适配、格矩形、桥矩形、命中测试(带死区)
view.ts BoardView:格/桥/数字/冲突/提示/过关动效,只调用 Painter
painter.ts 绘制原语接口(圆角矩形、圆、居中文字)
theme.ts 配色
progress.ts 存档(KeyValueStore 抽象,桶 mode:01-number-stroke)
levels.ts 关卡包解析
```
## 运行
```bash
cd modes/01-number-stroke
npm install
npm run dev # 起本地服务 + 监听改动,终端会打印手机可访问的局域网地址
npm run build # 打包到 web/dist/app.js,之后可直接双击 web/index.html
npm run build:standalone # 导出项目根目录 releases/数字一笔-试玩版.html
npm run check # 类型检查(含 src/ 无 DOM 校验)+ 单元测试 + 全关卡唯一解校验
```
`?dev=1` 解锁全部关卡并暴露 `window.__numberStroke` 调试句柄(自动化手势测试用)。
直接体验奇偶:`http://localhost:8000/?dev=1&level=3-1`。`level` 跳关参数仅在开发模式下生效;不清空已有存档。
**发给朋友测试**:运行 `npm run build:standalone`,发送生成的 [`数字一笔-试玩版.html`](../../releases/数字一笔-试玩版.html) 一个文件即可。脚本、样式、26 关数据与合成音效全部内置,无需安装或启动服务,可离线用浏览器打开。全部 26 关直接可选,无需解锁或添加参数;通关记录仍保存在试玩者的浏览器中。
关卡工具:
```bash
npm run validate # 每关:连通、恰好 1 条满足线索的哈密顿路径
npm run validate -- 1-7 # 只看某关,并打印解
npm run design -- 1-7 --minimize # 当前线索里哪些可删
npm run design -- 1-7 --suggest 3 --path a,b,c,... # 给作者解找最少线索
npm run generate -- --seed 3 --out levels/pack-2-advanced.json # 随机铺盘+作者解+挑线索,批量产关
```
选关页按包分区渲染,跨包线性解锁(2-1 需先过 1-10);`web/main.ts` 顶部的 `packs` 数组登记包文件,加新包时往数组里加 import 即可。
## 第一包 · 10 关
每关只加一个旋钮(GDD §7)。线索数已按 §4.6 收到教学所需的最少。
| 关 | N | 教什么 | 大格 | 线索 |
|---|---|---|---|---|
| 1-1 | 3 | 滑动,邻格自动出步号 | – | 1 |
| 1-2 | 4 | 必须走完所有格 | – | 1, 4 |
| 1-3 | 5 | 走错可以退(第一次死路) | – | 1, 3 |
| 1-4 | 4 | 大格只算一步 | 2×2 | 1, 3 |
| 1-5 | 7 | 中段线索 | – | 1, 4 |
| 1-6 | 9 | 空洞:隔洞不相邻,先看终点再起手 | – | 1, 9 |
| 1-7 | 12 | 先推再画:三处分叉各需一次推理 | – | 1, 4, 8 |
| 1-8 | 11 | 2×2 当枢纽:6 个出口只能选远角 | 2×2 | 1, 8 |
| 1-9 | 11 | 横长条:b 紧挨 1 却是第 6 步,只能从长条下边进上边出 | 2×1 | 1, 6 |
| 1-10 | 14 | 小考:顶行直走只有 5 步,要借 2×2 垫一步;竖长条收尾 | 2×2 + 1×2 | 1, 6 |
GDD §7.1 的 12 关教学梯度压缩到 10 关:竖长条与“两个大格”并入 1-10;无线索 `1` 的关按 §7 留在第 13 关之后,本包不含。
## 第二包 · 10 关(进阶)
`tools/generate.ts` 自动生成(seed=43)+ 人工过目。面向已经会玩的人,三个设计原则(来自试玩反馈,已写进 GDD §4/§7/§8.2):
- **难度的主旋钮是相邻线索的步号差**。每关开局固定「线索 1 + 4~7 步内的引导线索」,玩家先有起手方向;难度靠后面的差距拉开(包内最大差从 8 升到 16)。
- **大连块高频出现**:每关 2~3 个多单位格,`2×2` 在 7 关出现,`3×3` 在 2-2、2-4 出现。大连块只计 1 步但出口多,是推理的主战场。
- **格子可以是俄罗斯方块式异形**(L/I/S/T,随机旋转镜像)。异形格占 2~4 个单位格、仍只计 1 步,凹口让邻接关系更不规则;渲染上拼块连成一整块(单位矩形 + 延展接缝),与 `2×2` 观感一致。
| 关 | N | 大连块与异形 | 空洞 | 线索 | 步号差 |
|---|---|---|---|---|---|
| 2-1 | 16 | 异形×2 + 2×2 | – | 1, 7, 14 | 6/7/2 |
| 2-2 | 14 | **3×3** + 异形 | – | 1, 4, 12 | 3/8/2 |
| 2-3 | 18 | 异形×2 | 1 | 1, 4, 8, 16 | 3/4/8/2 |
| 2-4 | 15 | **3×3** | 1 | 1, 6, 15 | 5/9/0 |
| 2-5 | 16 | 2×2×2 + 异形 | – | 1, 6, 15 | 5/9/1 |
| 2-6 | 18 | 2×2×2 | 1 | 1, 4, 8 | 3/4/10 |
| 2-7 | 21 | 2×2 + 条 | – | 1, 7, 17 | 6/10/4 |
| 2-8 | 19 | 异形×2 | – | 3, 5, 15, 17 | 2/10/2/2 |
| 2-9 | 21 | 2×2 + 条 | – | 1, 7, 20 | 6/13/1 |
| 2-10 | 21 | 2×2 + 条 | – | 1, 5, 21 | 4/16/0 |
2-8 **无线索 1**(全游戏第一关):起点也要自己找。
每关 JSON 里的 `teach` 字段存了生成参数与作者解(剧透,永不渲染)。想换一批:`npm run generate -- --seed <新种子> --out levels/pack-2-advanced.json`,然后 `npm run check`。
## 第三包 · 6 关(奇与偶)
「奇」要求走到该格时的步数为 1、3、5…,「偶」要求 2、4、6…。整块仍只计一步,数字与奇偶条件要同时满足。走过后显示实际步号并保留奇偶标记;走错可撤回,提示也考虑奇偶要求。
| 关 | 格数 | 内容 |
|---|---|---|
| 3-1 | 4 | 「偶」入门,借 2×2 调整步数 |
| 3-2 | 6 | 「奇」写在 3×3 上,整块只计一步 |
| 3-3 | 11 | 用奇数条件决定绕行方向 |
| 3-4 | 16 | 异形格上的「偶」+「奇」+数字线索 |
| 3-5 | 18 | 双异形、奇偶与中段数字共同推理 |
| 3-6 | 18 | 两个 2×2、两个「奇」,仅固定起点 1 |
3-2 为新盘,其余复用既有关卡的几何并重新设计线索(来源及作者解记在 `teach`)。每关唯一解;逐条删去任意奇偶条件都会出现多解,确保新规则有实际作用。此包先用于体验规则,难度手感仍待真人试玩。
关卡中写 `"parity": "odd"` / `"parity": "even"`,与同格 `clue` 互斥;可与 `shape` 或 `3×3` 共用。`npm run design -- 3-4 --minimize` 会同时检查数字和奇偶线索。现有随机生成器仍产出数字关,奇偶包采用人工配线索并用求解器筛选。
## 交互(已实现,对齐 GDD §5)
- 有线索 `1` 时只能从它起手,点别处 `1` 抖一下
- 按住滑到相邻未走格追加;点按或滑到任意已走格,直接撤掉该格之后的路径,不松手就能换方向继续画
- 松手保留路径;从末端按下继续;点末端旁的空格仍可追加;不相邻的空格忽略
- 快速滑动在两帧之间插值采样,避免漏掉中间格;直接落到历史格时优先回退。触屏死区收至 8%,鼠标维持 14%;只跟踪一个指针
- 踩错线索,或第 n 步走到预定数字 n 所在格以外的位置,立即让该格变红并轻晃,连接线保持紫色,不加叉号;本格要求不符时仍显示原数字 / 奇偶要求。原始数字与补出的数字都生效;退回后清除冲突。死路当前格抖动,不弹失败
- 补数字:优先当前尚未解开的数字区间,1~9 补 5;每次只给一个确定数字,遵守原来的奇偶规则。重开保留补数,退出重进则恢复原关卡
- 指路:无路径指起点;是解的前缀给下一步;已偏离则高亮分叉格并压暗错段,点高亮格即可直接回退
- 过关:路径提亮、按步序扫光,约 0.9s 后自动下一关;最后一关回选关
## 移植到 Cocos Creator
技术选型就是为这一步做的:
1. **`src/` 原样拷进 `assets/modes/01-number-stroke/scripts/`。** 它用 `tsconfig.src.json`(`lib: ["ES2020"]`,无 DOM)编译通过;import 全部无后缀,符合 Cocos 模块规范(Cocos 不允许 `import './foo.ts'`)。
2. **Painter 换实现。** `web/canvas-painter.ts` 对应一个 `GraphicsPainter`:`fillRoundRect/strokeRoundRect/fillCircle` 用 `Graphics`,`text` 用一组按需复用的 `Label` 节点。`BoardView.draw()` 不用改。Cocos 的 y 轴向上,在 Painter 里翻一次 y 即可。异形格不用新原语:`layout.cellShapeRects()` 把拼块拆成单位矩形 + 接缝矩形,复用同一套 `fillRoundRect`。
3. **输入换事件源。** `Node.on(TOUCH_START/MOVE/END)` 把触点转成 Canvas 节点局部坐标 → `layout.hitTest()` → `PointerInput.down/move/up()`。插值采样那几行照搬 `web/main.ts`。
4. **存档换 store。** `Progress` 接收 `KeyValueStore`,Cocos 用 `sys.localStorage` 包一层。
5. **关卡 JSON 放 `resources/`**,`resources.load` 成 `JsonAsset` 后走同一个 `parsePack/loadPack`。
6. **HUD / 选关是 UI 节点。** 对应 `web/index.html` 里那几个元素:顶栏(返回、关号、剩余格)、字幕、底栏(重开、补数字、指路)、选关网格。
7. **音效**:`web/audio.ts` 是 WebAudio 合成;Cocos 用 `AudioSource` 播 4 段短音(step / conflict / cleared / reject),接口一致。
8. 每帧:`view.update(dt)` 放 `update(dt)`,`view.draw(painter)` 在 `isAnimating` 为真时清空 Graphics 重画。
不搬进 Cocos 的东西:`web/`、`tools/`、`tests/`(测试可以继续在 Node 跑,它们只依赖 `src/`)。
+464
View File
@@ -0,0 +1,464 @@
# 玩法一 · 数字一笔
本文只描述**玩法一**。玩法二(连号1234)、玩法三(六边一笔)有自己的目录和 GDD,见仓库 [`docs/PRODUCT.md`](../../../docs/PRODUCT.md)。玩法三虽然也是顺序一笔画,几何和墙都独立,不要把墙或六边形补进本玩法。
用一次滑动,按数字规定的顺序,把盘面上每一块都走遍。规则、交互、关卡、竖直切面都以能直接实现为准。关卡、资源、文案全部原创。
---
## 1. 一句话
在不规则拼板上画一条哈密顿路径:格子可大可小,大格只算一步;盘上预先写好的数字,必须刚好在那一步走到。
## 2. 产品定位
| 项 | 决定 |
|---|---|
| 类型 | 单机、离线、无体力、无计时的逻辑 puzzle |
| 平台 | 手机竖屏。本玩法 V1 先在 `modes/01-number-stroke/web/` 可玩,再进 Cocos 本玩法场景 |
| 单局 | 15–90 秒想,3–8 秒画完 |
| 会话 | 通勤 3 关,睡前 15 关 |
| 对象 | 喜欢 Flow Free、Numberlink、一笔画、数回的人;不靠剧情、不靠养成 |
| 付费 | 竖直切面免费无广告。上线后再加去广告 + 提示视频 |
卖点不是“又一个一笔画”,而是:**数字把无限路径收成唯一解,大格子把几何邻接和步数拆开。**
没有数字时,一笔画路径往往很多。加上两三个序号,解立刻变成一条。难度结构就是这三条:数字少、解唯一、横长/纵长格让难度陡起来。
## 3. 核心循环
1. 看盘:哪些格写了数字,哪些是大格,洞在哪。
2. 推:从已知序号往两边挤,排除走不通的岔路。
3. 画:手指从当前格滑到邻格,沿途自动填序号。
4. 错了就沿原路退,或一键重开。不会死局锁死。
5. 全填满且数字全部对上 → 过关,下一题。
没有分数、连击、限时。爽感来自“这条路只能是这样”和滑过时格子亮起来。
---
## 4. 规则(权威)
### 4.1 盘面
- 盘面落在整数网格上。每个**格**是轴对齐矩形,占 `w×h` 个单位格,`w,h ≥ 1`。
- V1 允许的尺寸:`1×1`、`1×2`、`2×1`、`2×2`。V1.1 追加 `3×3` 大连块。
- **V1.1 起追加异形格**(俄罗斯方块式拼块):2~4 个单位格四向连通,目录为 I/L/S/T/O(含旋转与镜像),JSON 里用 `shape` 偏移列表表达。规则与矩形完全一致:占多个单位格、仍只计 1 步、按单位边算邻接。
- 每个格无论面积,都是图上的**一个节点**,路径里只计 **1 步**。
- 单位格要么属于恰好一个格,要么是空洞(不可走、不显示底板)。
- 盘面可以不规则,可以有洞。
### 4.2 邻接
两个格相邻,当且仅当它们共享至少 1 个单位长度的边(异形格按它占的每个单位格逐对判断)。只认四向,不认角对角。
推论:`2×2` 可以同时和它四边贴着的多个 `1×1` 相邻。这是难度的主开关——步数是 1,出口却有 4~8 个。异形格的凹口让「贴着几个格」更不规则,是 V1.1 的难度来源之一。
### 4.3 路径
目标:在格图上找一条**哈密顿路径**。
- 每个格恰好经过一次。
- 只能走到相邻且尚未走过的格。
- 经过第 `k` 个格时,该格显示数字 `k`(从 1 起)。
- 路径是一条线,不是树,不能分叉,不能跳。
### 4.4 线索格
部分格预先写了正整数 `c`,带描边,开局就看得见。
- 路径走到这个格时,步号必须等于 `c`;反过来,第 `c` 步也必须走到这个格。
- 盘上可以没有 `1`:起点未给定,玩家自己判断从哪起手。这是中后期难度,不进前 12 关。
- 线索可以在路径中段,不必是起点或终点。
- 同一盘线索互不相同,且 `1 ≤ c ≤ N`(`N` 为格数)。
- 线索可以不包含 `N`(终点未给定)。
**V1.2 奇偶线索**:格子也可以写「奇」或「偶」,代替确定的数字。
- 「奇」要求该格的路径步号是 `1、3、5…`;「偶」要求 `2、4、6…`。按整格计步,和占用面积无关,支持 `3×3` 与异形拼块。
- 同一格只填一种线索:数字或奇偶;同一盘允许多个「奇」和「偶」,并可与数字线索混用。
- 未走时居中显示「奇/偶」和整格描边。走对后显示实际步号,在下方保留小字「奇/偶」;走错时红底显示实际步号,角标保留「奇/偶」要求。
- 起点规则不变:有数字 `1` 时只能从它起手;没有 `1` 时可从任意格尝试,从「偶」起手会产生冲突。
- 第三包「奇与偶」先提供 6 关体验(`pack-3-parity.json`);每条奇偶线索都必须参与唯一性,不能只是普通方格盘上天然成立的奇偶条件。
### 4.5 对错
走的过程允许“暂时错”:
- 踩到线索格但当前步号不满足数字或奇偶要求:该格变冲突态(红底),数字显示当前步号,角标显示原线索,避免只靠颜色。
- 当第 `c` 步走到预定数字 `c` 所在格以外的任意格,也立即冲突。错误格红底显示当前步号并轻晃,不显示叉号;无需等到踩上预定格再判错。
- 原始预定数字与「补数字」新增的数字都适用;补数后即时检查现有路径。奇偶要求只约束本格,不占用其他同类步号。
- 错误格单独轻晃 280ms,连接线保持紫色。补数字造成已有路径冲突时也轻晃;退回 / 重开立即停止。减弱动态效果开启时仅标红。
- 冲突不打断手势,可以继续画,也可以退回。
- **过关条件(同时满足)**:所有格都走过,且全部数字线索和奇偶线索都满足。
- 未过关时没有失败弹窗。死路的反馈是:没格可走了,但还没填满或还在冲突。
### 4.6 内容铁律
每一关在发布前必须通过求解器验证:
1. 至少 1 条合法哈密顿路径满足全部线索。
2. 恰好 1 条。
3. 线索数量尽量少:在仍唯一的前提下,优先少线索、优先去掉 `1` 和 `N` 这类“端点暗示”。
4. 有奇偶线索时,逐条删除后都应出现多解;`validate` 会拒绝冗余奇偶线索。
前 8 关可以故意多给线索,用来教学,不追求最简线索。
---
## 5. 交互
### 5.1 手势
单指。
| 手势 | 行为 |
|---|---|
| 点在合法起点 | 该格变为路径第 1 步。若该格是线索且 `c ≠ 1`,进入冲突态,但仍算第 1 步 |
| 按住滑向相邻未走格 | 追加一步,画连接短边,格子填色并写步号 |
| 点按或滑到任意已走格 | 保留该格及之前的路径,一次撤掉后面的填色、短边、步号与冲突;不松手即可换方向继续画 |
| 滑到不相邻的未走格 | 忽略;前进仍只允许四向邻接 |
| 在空白处松手 | 路径保留,下次从末端继续,或点更早的已走格直接回退 |
| 点末端旁的未走格 | 追加一步,兼容点按操作 |
触屏命中死区为格四周 8%,鼠标为 14%。快速滑动插值补采样;实际触点落到历史格时优先回退,避免跳跃采样误追加途中的格。仅跟踪一个主指针。
合法起点:
- 若存在线索 `1`:只能从该格起手。点别的格无效果,线索 `1` 轻抖一次。
- 若不存在线索 `1`:任意非空洞格都可起手。
### 5.2 按钮
盘面下方三个按钮:
1. **重开**:清空路径,本局已补出的数字保留。
2. **补数字**:在当前待解区间补一个中间数字,让玩家继续推路线。规则见 §10。
3. **指路**:指出下一步或应退回的位置,保留原来的提示方案。
顶部:`{pack}-{index}`、剩余未走格 `□ × n`、返回选关。音效 / 振动在选关页设置;正式壳接管通用设置。
没有倒计时,没有步数惩罚。
### 5.3 输入状态机
```
Empty
→ 合法起点按下 → Drawing
Drawing
→ 滑到相邻未走格 → Drawing(N 增加)
→ 滑到任意已走格 → Drawing(截短路径,保留目标格)
→ 松手 → Idle(保留路径)
Idle
→ 从末端按下 → Drawing
→ 点更早的已走格 → Drawing(截短路径,可继续滑动)
→ 重开 → Empty
→ 全填满且无冲突 → Cleared
Cleared
→ 短庆祝后自动进下一关,或点下一关
```
任何时候点重开都回到 Empty。Cleared 后本关路径锁住,不能再改。
---
## 6. 反馈与手感
逻辑 puzzle 的板要干净,动效只服务三件事:我走到哪了、哪错了、过没过。
| 事件 | 反馈 |
|---|---|
| 追加一步 | 格 80ms 弹性放大回 1.0;相邻短边弹出;步号 fade-in;轻振动 |
| 撤销一步 | 格褪回未走色;短边收回;无振动 |
| 踩中线索且步号正确 | 描边闪白一次。这是“推对了”的主奖励 |
| 冲突 | 单格变红并轻晃,连接线保持紫色;短振动,不弹窗 |
| 死路未过关 | 当前格轻轻抖。不弹“失败” |
| 过关 | 全盘路径色提到更亮,短边顺次扫过 200–300ms,然后切下一关。不要大烟花、不要全屏撒纸片 |
| 非法起手 | 线索 `1` 抖动 |
视觉与动效的权威规格已迁到仓库 [`docs/design.md`](../../../docs/design.md)(令牌、字阶、组件、动效四档、过关时间线)。下面的配色与反馈表保留为规则口径,数值以设计规范为准。
配色 V1(浅色主题):
- 未走:中灰
- 已走:单一主色(蓝紫)
- 线索描边:未走线索 / 奇偶格与线索数字同色(墨 `#23243A`);起点和已走线索为灰白 `#DFE1EC`
- 起点数字 `1`:开局蓝紫底、白字、灰白描边;数字放大为单位格的 62%(原为 42%),描边收细至 4.5%;只是视觉提示,按下后才计入路径。重开和撤销至空盘时恢复此样式。
- 冲突:红
- 背景:白
- 数字:已走格用深色;未走线索格用白字
不要每关换主题色。识别数字和冲突优先于换皮。
音效:落子短音(音高随步号轻微上升,到 12 后循环)、冲突闷音、过关和弦。可关。
---
## 7. 关卡语言
难度不靠计时,靠这五个旋钮。一次只加一个。
| 旋钮 | 作用 | 何时引入 |
|---|---|---|
| 格数 `N` | 8–12 教学,12–16 常规,17–22 后期 | 全程 |
| 大格 | `2×2`/`3×3` 出口多、只计 1 步;`1×2`/`2×1` 让难度突然抬升 | 第 4 关起 `2×2`,第 9 关起长条,第二包起 `3×3` |
| 异形格 | L/I/S/T 拼块:邻接更不规则,凹口改变分叉结构(V1.1) | 第 11 关起(第二包) |
| 空洞 | 切断看似相邻的格,强迫绕路 | 第 6 关 |
| 线索位置 | 中段线索比起点线索更要推理 | 第 5 关 |
| **线索步号差** | **难度的主旋钮**:相邻线索差得越远越难。开局 5~7 步内给一个近距引导线索,玩家就有起手方向;难度靠后面拉开差距 | 全程(V1.1 起作为生成约束) |
| 无线索 `1` | 起点未知 | 第 13 关之后 |
| 奇偶线索 | 只约束步号奇偶,结合大块改变走法;需证明参与唯一性 | V1.2 第三包 |
不做的旋钮(V1):斜走、传送、单向边、冰格、颜色门、限时。先把主规则做到手熟。
1-1 到 1-5 的坐标已经写在 `levels/pack-1-tutorial.json`。实现时直接读这个文件。
### 7.1 教学 12 关(必须手做,不生成)
| 关 | 教什么 | 盘面约束 |
|---|---|---|
| 1-1 | 滑动、自动出数字 | 3 个 `1×1` 一横,只标 `1` |
| 1-2 | 必须走完所有格 | L 形 4 格,标 `1` 和 `4` |
| 1-3 | 可以退回 | 小岔路,标 `1`。第一次死路用抖提示退 |
| 1-4 | 大格 = 1 步 | 一个 `2×2` + 三个 `1×1`,标 `1` |
| 1-5 | 中段线索 | 约 7 格,标 `1` 和 `4` |
| 1-6 | 空洞 | 明显的缺口,看起来近其实不相邻 |
| 1-7 | 先推再画 | ~10 格,两个中段线索。正解需要先排除一条岔路 |
| 1-8 | `2×2` 当枢纽 | 一个 `2×2` 连接三个方向 |
| 1-9 | 横长条 | 第一次 `1×2`,出口在长边上 |
| 1-10 | 竖长条 | `2×1` |
| 1-11 | 两个大格 | `2×2` + `1×2` |
| 1-12 | 综合小考 | ~12 格,3 个线索,唯一解,不过分绕 |
1-4 是关键:玩家必须理解“这块很大,但数字只加 1”。过不了这关,后面全废。
### 7.2 结构
- 一包 8 关。包号就是顶部的 `pack`。
- 包内由易到难,最后一关是本包小 Boss。
- V1 内容量:教学 12 关 + 4 包 × 8 = **44 关**。够验证循环,不够上架。上架目标另定 200+。
- 正常版本选关按通关进度线性解锁;独立 HTML 试玩版全部 26 关开放,可直接选择任意关卡,不需要通关前置关。
- 没有每日挑战、没有联机(V1)。
### 7.3 规则样例盘(只用于讲规则,不上架)
一块 15 格的原创样例,用来对齐邻接和大格,不进资源表。`tests/solver.test.ts` 用同一块盘做求解器回归。
单位网格 5×5,行从上到下 0–4,列从左到右 0–4:
```
A A a b c
A A . d e
f g h B B
. i j B B
1 l m . .
```
- `A` = `2×2` 于 (0,0)
- `B` = `2×2` 于 (3,2),带线索 `6`
- `1` 是线索,位于 (0,4)
- 其余字母是 `1×1`
- `.` 是空洞
- `N = 15`
邻接检查:`A` 贴 `a`(右边)、`f`、`g`(底边),不贴 `h`(只碰角)。`B` 贴 `d`、`e`(顶边)、`h`、`j`(左边),底边全是空洞。`1` 只贴 `l`,度为 1,所以必是路径端点。
唯一解:`1 → l → m → j → h → B → d → e → c → b → a → A → f → g → i`。`B` 面积 4 格,只占第 6 步。
错误走法演示:`1 → l → m → j` 之后往左进 `i` 看起来顺手,但那样 `B` 最早也要到第 8 步,踩上去显示红 `8`:这是冲突态的标准演示。正确走法是 `j` 之后先进 `h` 当第 5 步,再进 `B` 当第 6 步。
---
## 8. 生成器与求解器
手做教学关。第 13 关起允许生成,但每关必须人过一眼:解是否“漂亮”(没有无意义绕圈、线索是否读得清)。
### 8.1 求解器
输入:格列表(矩形 / 异形 + 可选数字或奇偶线索)。输出:满足全部线索的哈密顿路径。
- `N ≤ 22`,回溯即可。先走度小的节点,线索格在步号到达前剪枝。
- 线索格 `c`:若当前步号已超过 `c` 而该格未走,剪枝;若走到时步号 ≠ `c`,剪枝。
- 奇偶格:每次尝试进入(包括起点)都按实际步号检查奇偶,不符则剪枝。`ignoreClues` 同时忽略数字与奇偶条件。
- 发布流水线:`paths.length === 1` 才写入关卡表。
### 8.2 生成流程
1. 随机或用模板铺格 + 空洞,保证格图连通。V1.1 起模板含异形拼块(I/L/S/T/O,随机旋转镜像),并保证每个多单位格都被作者解真正穿过(不当端点)。
2. 随机找一条哈密顿路径作为“作者解”。
3. 从路径上抽 2~4 个位置当线索(偏中段,少用 `1`/`N`)。V1.1 起带线索 `1` 的关先在 `1` 之后 4~7 步放一个**引导线索**(开局近距指引),再用其余线索拉开步号差上难度。
4. 跑求解器。若解 > 1,加线索或换位置;若解 = 0,丢弃。
5. 尝试删线索,能删到仍唯一就删。
6. 导出 JSON。
### 8.3 关卡文件
```json
{
"id": "2-3",
"pack": 2,
"index": 3,
"cellSize": 64,
"cells": [
{ "id": "a", "x": 0, "y": 0, "w": 1, "h": 1, "clue": 1 },
{ "id": "b", "x": 1, "y": 0, "w": 2, "h": 2 }
]
}
```
- `x,y` 是单位格坐标,原点左上。
- V1.2 可写 `"parity": "odd"` 或 `"parity": "even"`,与 `clue` 互斥;省略表示没有奇偶要求。例:`{ "id": "big", "x": 1, "y": 1, "w": 3, "h": 3, "parity": "odd" }`。
- 不存解。解由求解器在编辑器里验证,运行时只做规则模拟。
- 提示用的“下一步”在编辑器烘焙进可选字段 `hintPath`,避免端上枚举。若缺失,运行时现算(N 小,可接受)。
---
## 9. 界面与流程
```
(壳:选玩法)→ 本玩法选关(默认停在第一关未通) → 对局 → 过关 → 下一关
↘ 菜单:本玩法关卡列表 / 返回壳
```
设置、广告、返回首页属于壳,见 `shared/`。本玩法对局页不实现去广告购买。
对局层从上到下:
1. 安全区
2. 顶栏:关号、剩余格、汉堡菜单
3. 盘面(水平垂直居中,按短边缩放,四周留指宽)
4. 手势说明「按住滑动连线 · 点已走格回退」;底栏:重开、补数字、指路
5. 底 banner 广告位:竖直切面放占位,正式包未去广告时才显示
过关不进独立结算页。短动画后自动下一关。最后一关回选关。
文案尽量少。规则用 1-1~1-4 教完,不写长教程。1-1 顶部一行:“从一个数字滑到邻格”。1-4 一行:“大格也只算一步”。之后无文案。
---
## 10. 提示、存档、设置
**补数字**
- 从唯一解中取当前尚未解开的数字区间,选择靠近中点的一个步号。例如已有 `1` 与 `9`,补出 `5`;再请求可继续缩小区间。
- 正确前缀已走过的区域不再补数;走错时仍按真实解补数,不沿错误路线给数字。
- 每次只增加一个固定数字,不自动追加路径。该数字参与冲突与通关判定;若补在奇偶格,数字与奇偶要求同时保留。
- 补出的数字先用琥珀外圈提示位置,之后以普通数字线索显示。重开保留;退出重进、刷新或切关恢复原始关卡。补数不写回关卡文件,也不存档。
- 没有可补数字时提示尝试「指路」。
**指路**
- 当前无路径:指出合法起点(有 `1` 就指它;否则指解的真实起点)。
- 当前有路径且与唯一解的前缀一致:指出下一步。
- 当前有路径但已偏离唯一解:高亮最后一个正确格,压暗错段;点高亮格即可一次退回。若起点就错了,提示重开。
- 竖直切面:无限次、无广告。
- 上线:每天 1 次免费,之后激励视频。
**存档**
本地,键前缀 `mode:01-number-stroke`。存 `clearedId[]`、`currentId`。不存半途路径。不得读写其他玩法的桶。
高对比(冲突改成红底+斜纹)读壳的设置项,本玩法视图遵守即可。
---
## 11. 设计边界
V1 只做主规则(§4)。明确不做,留给后续版本或其它玩法:
- 对角邻接、斜走、传送、单向边
- 用户造关、关卡分享
- 计时、体力、分数
关卡、命名、视觉、音效全部原创,不使用第三方题库或素材。教学关按教学节拍手做(§7.1)。
---
## 12. 技术
全部落在 `modes/01-number-stroke/` 内。不要把本玩法的 Board / 邻接 / 求解器放到 `shared/` 或其它 `modes/`。
| 项 | 决定 |
|---|---|
| 竖直切面 | `web/` 单页,手机浏览器可玩,不经过壳首页 |
| 正式工程 | Cocos Creator 3.x,场景与脚本在 `assets/modes/01-number-stroke/` |
| 逻辑 | 纯 TypeScript,与渲染解耦。同一套规则跑在 `web/` 和 Cocos |
| 求解器 | `tools/solver.ts`,只服务本玩法 |
| 关卡 | `levels/*.json`,见 §8.3 |
核心代码模块(`src/`):
- `model`:Cell、Board、Path、clue/conflict
- `rules`:邻接、合法下一步、过关判定
- `input`:手势 → 命令(Start / Append / Rewind / Reset)
- `solver`:枚举唯一解
- `clue-hint`:从唯一解选择待解区间的中间数字
- `view`:格、短边、数字、冲突、过关动画
对壳只暴露玩法入口 `id + mount/unmount`,见产品文档。
---
## 13. 竖直切面(做完才能说“玩法成立”)
可玩标准,全部要在真机或浏览器里点过:
1. 1-1 到 1-4 能独立教会滑动、走完、撤销、大格计 1 步。
2. 1-5 到 1-8 需要先想再画,不是闭眼滑。
3. 冲突态肉眼可辨,撤销后恢复。
4. 有线索 `1` 时,点其他格起手无效。
5. 无线索 `1` 的样例关至少 1 个(可放在 VS 附录关,不进前 12)。
6. 过关自动进下一关,进度刷新后还在。
7. 求解器对 VS 全部关断言 `paths.length === 1`。
8. 提示在偏离解时高亮分叉,不继续给错下一步。
VS 不做:广告、IAP、每日、动画角色、换皮、关卡编辑器 UI。
---
## 14. 里程碑
| 阶段 | 交付 |
|---|---|
| M0 | 本 GDD 锁定 |
| M1 | 规则库 + 求解器 + 1-1~1-4 可玩 |
| M2 | 1-1~1-12 + 输入手感 + 存档 |
| M3 | 再 32 关(生成+人工验收)+ 选关 |
| M4 | 迁 Cocos 本玩法场景,真机跑 VS 清单。不负责接其它玩法 |
M1 结束时如果 1-4 的“大格只计一步”教不会,先改教学盘,再加内容。
---
## 15. 关键决定
1. **关卡与资源全原创。** 不使用第三方题库或素材。
2. **大格是一等公民,不是皮肤。** 面积不影响步数,只影响邻接。这是和普通一笔画的分界。
3. **解必须唯一。** 休闲 puzzle 靠“想明白就过”,不靠多种走法碰运气。
4. **允许画错,不允许锁死。** 冲突标红,撤销和重开永远在。
5. **无计时、无分、无体力。** 会话长度由关卡大小控制。
6. **先 Web 规则原型,再 Cocos。** 玩法成立之前不上引擎装饰。
7. **V1 不加第二套机构。** 斜走、传送、单向边全部推迟。把 44 关的主规则打满。
---
## 16. 验收标准(玩法)
- 没玩过的人,不看文字,能在 1-4 结束前理解全部 V1 规则。
- 1-7 开始出现“停下来看”的瞬间,而不是滑完才发现错。
- 任意关在提示关闭时仍可人解,不依赖穷举。
- 同一关重开 10 次,手势与判定稳定,无幽灵邻接、无双触追加两步。
---
## 17. 已关闭的问题
- 要不要做成 2048 / 合成数字?不要。本玩法就是顺序一笔画。其它机制留给玩法二、三。
- 要不要 V1 就上差异化机构?不要。先证明主规则。
## 18. 留给实现的缺口
- 本玩法对外名称是否就用「数字一笔 / Number Stroke」,上架前再定。产品总名称另定。
- 主色的最终值(蓝紫只是 VS 默认)。
- M3 的 32 关是全手做还是生成后人工改,看 M2 手感再定。
- 本玩法的 `src/`、`tools/`、`web/` 在 M1 才建。Cocos 场景在 M4 才建。
+536
View File
@@ -0,0 +1,536 @@
# 玩法一 · 数字一笔 — 交接文档
> 面向接手的人。读完这份文档 + 跑一遍 `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 ... # 临时关卡文件也行
```
@@ -0,0 +1,152 @@
{
"mode": "01-number-stroke",
"pack": 1,
"title": "入门",
"notes": "10 关。1-1 到 1-4 是竖直切面必须手做的关;每关只加一个旋钮。坐标原点左上,单位格。全部经 tools/validate.ts 断言唯一解。",
"levels": [
{
"id": "1-1",
"teach": "滑动,邻格自动出步号",
"caption": "从 1 开始,滑到旁边的格",
"cells": [
{ "id": "a", "x": 0, "y": 0, "w": 1, "h": 1, "clue": 1 },
{ "id": "b", "x": 1, "y": 0, "w": 1, "h": 1 },
{ "id": "c", "x": 2, "y": 0, "w": 1, "h": 1 }
]
},
{
"id": "1-2",
"teach": "必须走完所有格",
"caption": "走完每一格,数字要对上",
"cells": [
{ "id": "a", "x": 0, "y": 0, "w": 1, "h": 1, "clue": 1 },
{ "id": "b", "x": 0, "y": 1, "w": 1, "h": 1 },
{ "id": "c", "x": 0, "y": 2, "w": 1, "h": 1 },
{ "id": "d", "x": 1, "y": 2, "w": 1, "h": 1, "clue": 4 }
]
},
{
"id": "1-3",
"teach": "走错可以退。先往下会错过 3。",
"caption": "走错了,点已走格就能退回",
"cells": [
{ "id": "start", "x": 0, "y": 0, "w": 1, "h": 1, "clue": 1 },
{ "id": "a", "x": 1, "y": 0, "w": 1, "h": 1 },
{ "id": "b", "x": 2, "y": 0, "w": 1, "h": 1, "clue": 3 },
{ "id": "d", "x": 1, "y": 1, "w": 1, "h": 1 },
{ "id": "c", "x": 2, "y": 1, "w": 1, "h": 1 }
]
},
{
"id": "1-4",
"teach": "大格只算一步",
"caption": "大格也只算一步",
"cells": [
{ "id": "start", "x": 0, "y": 0, "w": 1, "h": 1, "clue": 1 },
{ "id": "a", "x": 1, "y": 0, "w": 1, "h": 1 },
{ "id": "b", "x": 2, "y": 0, "w": 1, "h": 1 },
{ "id": "big", "x": 1, "y": 1, "w": 2, "h": 2, "clue": 3 }
]
},
{
"id": "1-5",
"teach": "中段线索。必须先走完顶行,4 才落在右上角。",
"cells": [
{ "id": "start", "x": 0, "y": 0, "w": 1, "h": 1, "clue": 1 },
{ "id": "a", "x": 1, "y": 0, "w": 1, "h": 1 },
{ "id": "b", "x": 2, "y": 0, "w": 1, "h": 1 },
{ "id": "c", "x": 3, "y": 0, "w": 1, "h": 1, "clue": 4 },
{ "id": "d", "x": 2, "y": 1, "w": 1, "h": 1 },
{ "id": "e", "x": 3, "y": 1, "w": 1, "h": 1 },
{ "id": "f", "x": 2, "y": 2, "w": 1, "h": 1 }
]
},
{
"id": "1-6",
"teach": "空洞。中间缺一格,a 与 f 隔洞不相邻;9 在左边,必须绕环一圈最后到它。先往下走会把 9 提前踩成 3。",
"cells": [
{ "id": "s", "x": 1, "y": 0, "w": 1, "h": 1, "clue": 1 },
{ "id": "a", "x": 2, "y": 0, "w": 1, "h": 1 },
{ "id": "b", "x": 3, "y": 0, "w": 1, "h": 1 },
{ "id": "t", "x": 0, "y": 1, "w": 1, "h": 1, "clue": 9 },
{ "id": "c", "x": 1, "y": 1, "w": 1, "h": 1 },
{ "id": "d", "x": 3, "y": 1, "w": 1, "h": 1 },
{ "id": "e", "x": 1, "y": 2, "w": 1, "h": 1 },
{ "id": "f", "x": 2, "y": 2, "w": 1, "h": 1 },
{ "id": "g", "x": 3, "y": 2, "w": 1, "h": 1 }
]
},
{
"id": "1-7",
"teach": "先推再画。三处分叉:b 处要走 f 才能让 e=4;k 处要走 g 才能让 g=8;g 处要先取 c,否则 l 被孤立。",
"cells": [
{ "id": "a", "x": 0, "y": 0, "w": 1, "h": 1, "clue": 1 },
{ "id": "b", "x": 1, "y": 0, "w": 1, "h": 1 },
{ "id": "c", "x": 2, "y": 0, "w": 1, "h": 1 },
{ "id": "d", "x": 3, "y": 0, "w": 1, "h": 1 },
{ "id": "e", "x": 0, "y": 1, "w": 1, "h": 1, "clue": 4 },
{ "id": "f", "x": 1, "y": 1, "w": 1, "h": 1 },
{ "id": "g", "x": 2, "y": 1, "w": 1, "h": 1, "clue": 8 },
{ "id": "h", "x": 3, "y": 1, "w": 1, "h": 1 },
{ "id": "i", "x": 0, "y": 2, "w": 1, "h": 1 },
{ "id": "j", "x": 1, "y": 2, "w": 1, "h": 1 },
{ "id": "k", "x": 2, "y": 2, "w": 1, "h": 1 },
{ "id": "l", "x": 3, "y": 2, "w": 1, "h": 1 }
]
},
{
"id": "1-8",
"teach": "2×2 当枢纽。B 有 6 个出口,但只能进出各一次:从 B 出去必须挑 c 或 d(远角),挑 e/f/h/i 都会孤立一侧。h=8 决定顺时针。",
"cells": [
{ "id": "a", "x": 1, "y": 0, "w": 1, "h": 1, "clue": 1 },
{ "id": "b", "x": 1, "y": 1, "w": 1, "h": 1 },
{ "id": "c", "x": 0, "y": 2, "w": 1, "h": 1 },
{ "id": "B", "x": 1, "y": 2, "w": 2, "h": 2 },
{ "id": "d", "x": 3, "y": 2, "w": 1, "h": 1 },
{ "id": "e", "x": 0, "y": 3, "w": 1, "h": 1 },
{ "id": "f", "x": 3, "y": 3, "w": 1, "h": 1 },
{ "id": "g", "x": 0, "y": 4, "w": 1, "h": 1 },
{ "id": "h", "x": 1, "y": 4, "w": 1, "h": 1, "clue": 8 },
{ "id": "i", "x": 2, "y": 4, "w": 1, "h": 1 },
{ "id": "j", "x": 3, "y": 4, "w": 1, "h": 1 }
]
},
{
"id": "1-9",
"teach": "横长条,出口在长边上。b 紧挨着 1 却要第 6 步才到:只能从长条 H 的下边进、上边出(h→H→b),先绕左下角。",
"cells": [
{ "id": "a", "x": 0, "y": 0, "w": 1, "h": 1, "clue": 1 },
{ "id": "b", "x": 1, "y": 0, "w": 1, "h": 1, "clue": 6 },
{ "id": "c", "x": 2, "y": 0, "w": 1, "h": 1 },
{ "id": "d", "x": 3, "y": 0, "w": 1, "h": 1 },
{ "id": "e", "x": 0, "y": 1, "w": 1, "h": 1 },
{ "id": "H", "x": 1, "y": 1, "w": 2, "h": 1 },
{ "id": "f", "x": 3, "y": 1, "w": 1, "h": 1 },
{ "id": "g", "x": 0, "y": 2, "w": 1, "h": 1 },
{ "id": "h", "x": 1, "y": 2, "w": 1, "h": 1 },
{ "id": "i", "x": 2, "y": 2, "w": 1, "h": 1 },
{ "id": "j", "x": 3, "y": 2, "w": 1, "h": 1 }
]
},
{
"id": "1-10",
"teach": "小考:2×2 + 竖长条 + 空洞。顶行直走到 e 只有 5 步,e=6 要求多垫一步——从 b 下到 B 再回 c(a,b,B,c,d,e)。之后绕右下回到左侧,V 收尾。陷阱:a,V,B,c 也能让 e=6,但 b 会被孤立。",
"cells": [
{ "id": "a", "x": 0, "y": 0, "w": 1, "h": 1, "clue": 1 },
{ "id": "b", "x": 1, "y": 0, "w": 1, "h": 1 },
{ "id": "c", "x": 2, "y": 0, "w": 1, "h": 1 },
{ "id": "d", "x": 3, "y": 0, "w": 1, "h": 1 },
{ "id": "e", "x": 4, "y": 0, "w": 1, "h": 1, "clue": 6 },
{ "id": "V", "x": 0, "y": 1, "w": 1, "h": 2 },
{ "id": "B", "x": 1, "y": 1, "w": 2, "h": 2 },
{ "id": "f", "x": 4, "y": 1, "w": 1, "h": 1 },
{ "id": "g", "x": 3, "y": 2, "w": 1, "h": 1 },
{ "id": "h", "x": 4, "y": 2, "w": 1, "h": 1 },
{ "id": "i", "x": 0, "y": 3, "w": 1, "h": 1 },
{ "id": "j", "x": 1, "y": 3, "w": 1, "h": 1 },
{ "id": "k", "x": 2, "y": 3, "w": 1, "h": 1 },
{ "id": "l", "x": 3, "y": 3, "w": 1, "h": 1 }
]
}
]
}
@@ -0,0 +1,247 @@
{
"mode": "01-number-stroke",
"pack": 2,
"title": "进阶",
"notes": "10 关,tools/generate.ts 自动生成(seed=43)+ 人工过目。15+ 格、俄罗斯方块异形格、开局 5~7 步内有引导线索、之后靠步号差上难度,部分关不带线索 1。",
"levels": [
{
"id": "2-1",
"teach": "自动生成 seed=43。N=16 多单位格=3(异形2) 空洞=0 无线索路径=230 线索=1/7/14 步号差=6/7/2 难度分=75.5。作者解: d → c → b → a → e → A → f → B → g → C → j → m → l → i → h → k",
"cells": [
{ "id": "A", "x": 0, "y": 0, "w": 2, "h": 3, "shape": [[0,0],[0,1],[0,2],[1,2]] },
{ "id": "a", "x": 1, "y": 0, "w": 1, "h": 1 },
{ "id": "b", "x": 2, "y": 0, "w": 1, "h": 1 },
{ "id": "c", "x": 3, "y": 0, "w": 1, "h": 1 },
{ "id": "d", "x": 4, "y": 0, "w": 1, "h": 1, "clue": 1 },
{ "id": "e", "x": 1, "y": 1, "w": 1, "h": 1 },
{ "id": "B", "x": 2, "y": 1, "w": 3, "h": 2, "shape": [[2,0],[1,0],[0,0],[1,1]] },
{ "id": "f", "x": 2, "y": 2, "w": 1, "h": 1, "clue": 7 },
{ "id": "g", "x": 4, "y": 2, "w": 1, "h": 1 },
{ "id": "h", "x": 0, "y": 3, "w": 1, "h": 1 },
{ "id": "i", "x": 1, "y": 3, "w": 1, "h": 1, "clue": 14 },
{ "id": "j", "x": 2, "y": 3, "w": 1, "h": 1 },
{ "id": "C", "x": 3, "y": 3, "w": 2, "h": 2 },
{ "id": "k", "x": 0, "y": 4, "w": 1, "h": 1 },
{ "id": "l", "x": 1, "y": 4, "w": 1, "h": 1 },
{ "id": "m", "x": 2, "y": 4, "w": 1, "h": 1 }
]
},
{
"id": "2-2",
"teach": "自动生成 seed=43。N=14 多单位格=2(异形1) 空洞=0 无线索路径=296 线索=1/4/12 步号差=3/8/2 难度分=79.2。作者解: e → d → h → i → j → k → l → B → b → a → A → c → g → f",
"cells": [
{ "id": "A", "x": 0, "y": 0, "w": 3, "h": 3 },
{ "id": "a", "x": 3, "y": 0, "w": 1, "h": 1 },
{ "id": "b", "x": 4, "y": 0, "w": 1, "h": 1 },
{ "id": "B", "x": 3, "y": 1, "w": 2, "h": 3, "shape": [[1,2],[1,1],[1,0],[0,0]] },
{ "id": "c", "x": 3, "y": 2, "w": 1, "h": 1, "clue": 12 },
{ "id": "d", "x": 0, "y": 3, "w": 1, "h": 1 },
{ "id": "e", "x": 1, "y": 3, "w": 1, "h": 1, "clue": 1 },
{ "id": "f", "x": 2, "y": 3, "w": 1, "h": 1 },
{ "id": "g", "x": 3, "y": 3, "w": 1, "h": 1 },
{ "id": "h", "x": 0, "y": 4, "w": 1, "h": 1 },
{ "id": "i", "x": 1, "y": 4, "w": 1, "h": 1, "clue": 4 },
{ "id": "j", "x": 2, "y": 4, "w": 1, "h": 1 },
{ "id": "k", "x": 3, "y": 4, "w": 1, "h": 1 },
{ "id": "l", "x": 4, "y": 4, "w": 1, "h": 1 }
]
},
{
"id": "2-3",
"teach": "自动生成 seed=43。N=18 多单位格=2(异形2) 空洞=1 无线索路径=600+ 线索=1/4/8/16 步号差=3/4/8/2 难度分=82.5。作者解: k → o → p → l → h → g → e → A → b → a → c → B → d → f → j → i → m → n",
"cells": [
{ "id": "a", "x": 0, "y": 0, "w": 1, "h": 1 },
{ "id": "b", "x": 1, "y": 0, "w": 1, "h": 1 },
{ "id": "A", "x": 2, "y": 0, "w": 3, "h": 2, "shape": [[0,0],[1,0],[2,0],[2,1]], "clue": 8 },
{ "id": "B", "x": 0, "y": 1, "w": 2, "h": 3, "shape": [[0,1],[0,2],[1,0],[1,1]] },
{ "id": "c", "x": 0, "y": 1, "w": 1, "h": 1 },
{ "id": "d", "x": 2, "y": 1, "w": 1, "h": 1 },
{ "id": "e", "x": 3, "y": 1, "w": 1, "h": 1 },
{ "id": "f", "x": 2, "y": 2, "w": 1, "h": 1 },
{ "id": "g", "x": 3, "y": 2, "w": 1, "h": 1 },
{ "id": "h", "x": 4, "y": 2, "w": 1, "h": 1 },
{ "id": "i", "x": 1, "y": 3, "w": 1, "h": 1, "clue": 16 },
{ "id": "j", "x": 2, "y": 3, "w": 1, "h": 1 },
{ "id": "k", "x": 3, "y": 3, "w": 1, "h": 1, "clue": 1 },
{ "id": "l", "x": 4, "y": 3, "w": 1, "h": 1, "clue": 4 },
{ "id": "m", "x": 1, "y": 4, "w": 1, "h": 1 },
{ "id": "n", "x": 2, "y": 4, "w": 1, "h": 1 },
{ "id": "o", "x": 3, "y": 4, "w": 1, "h": 1 },
{ "id": "p", "x": 4, "y": 4, "w": 1, "h": 1 }
]
},
{
"id": "2-4",
"teach": "自动生成 seed=43。N=15 多单位格=2(异形0) 空洞=1 无线索路径=144 线索=1/6/15 步号差=5/9/0 难度分=85.8。作者解: a → b → c → d → A → m → l → k → j → B → i → h → f → e → g",
"cells": [
{ "id": "a", "x": 1, "y": 0, "w": 1, "h": 1, "clue": 1 },
{ "id": "b", "x": 2, "y": 0, "w": 1, "h": 1 },
{ "id": "c", "x": 3, "y": 0, "w": 1, "h": 1 },
{ "id": "d", "x": 4, "y": 0, "w": 1, "h": 1 },
{ "id": "e", "x": 0, "y": 1, "w": 1, "h": 1 },
{ "id": "f", "x": 1, "y": 1, "w": 1, "h": 1 },
{ "id": "A", "x": 2, "y": 1, "w": 3, "h": 3 },
{ "id": "g", "x": 0, "y": 2, "w": 1, "h": 1, "clue": 15 },
{ "id": "h", "x": 1, "y": 2, "w": 1, "h": 1 },
{ "id": "B", "x": 0, "y": 3, "w": 1, "h": 2 },
{ "id": "i", "x": 1, "y": 3, "w": 1, "h": 1 },
{ "id": "j", "x": 1, "y": 4, "w": 1, "h": 1 },
{ "id": "k", "x": 2, "y": 4, "w": 1, "h": 1 },
{ "id": "l", "x": 3, "y": 4, "w": 1, "h": 1 },
{ "id": "m", "x": 4, "y": 4, "w": 1, "h": 1, "clue": 6 }
]
},
{
"id": "2-5",
"teach": "自动生成 seed=43。N=16 多单位格=3(异形1) 空洞=0 无线索路径=600+ 线索=1/6/15 步号差=5/9/1 难度分=91.6。作者解: j → i → f → b → c → A → a → B → e → h → m → l → k → C → g → d",
"cells": [
{ "id": "A", "x": 0, "y": 0, "w": 2, "h": 2, "clue": 6 },
{ "id": "a", "x": 2, "y": 0, "w": 1, "h": 1 },
{ "id": "B", "x": 3, "y": 0, "w": 2, "h": 2 },
{ "id": "C", "x": 1, "y": 1, "w": 2, "h": 3, "shape": [[1,0],[1,1],[1,2],[0,2]] },
{ "id": "b", "x": 0, "y": 2, "w": 1, "h": 1 },
{ "id": "c", "x": 1, "y": 2, "w": 1, "h": 1 },
{ "id": "d", "x": 3, "y": 2, "w": 1, "h": 1 },
{ "id": "e", "x": 4, "y": 2, "w": 1, "h": 1 },
{ "id": "f", "x": 0, "y": 3, "w": 1, "h": 1 },
{ "id": "g", "x": 3, "y": 3, "w": 1, "h": 1, "clue": 15 },
{ "id": "h", "x": 4, "y": 3, "w": 1, "h": 1 },
{ "id": "i", "x": 0, "y": 4, "w": 1, "h": 1 },
{ "id": "j", "x": 1, "y": 4, "w": 1, "h": 1, "clue": 1 },
{ "id": "k", "x": 2, "y": 4, "w": 1, "h": 1 },
{ "id": "l", "x": 3, "y": 4, "w": 1, "h": 1 },
{ "id": "m", "x": 4, "y": 4, "w": 1, "h": 1 }
]
},
{
"id": "2-6",
"teach": "自动生成 seed=43。N=18 多单位格=2(异形0) 空洞=1 无线索路径=128 线索=1/4/8 步号差=3/4/10 难度分=96.2。作者解: e → d → c → g → A → j → k → l → m → p → o → n → B → h → i → f → b → a",
"cells": [
{ "id": "a", "x": 0, "y": 0, "w": 1, "h": 1 },
{ "id": "b", "x": 1, "y": 0, "w": 1, "h": 1 },
{ "id": "c", "x": 2, "y": 0, "w": 1, "h": 1 },
{ "id": "d", "x": 3, "y": 0, "w": 1, "h": 1 },
{ "id": "e", "x": 4, "y": 0, "w": 1, "h": 1, "clue": 1 },
{ "id": "f", "x": 1, "y": 1, "w": 1, "h": 1 },
{ "id": "g", "x": 2, "y": 1, "w": 1, "h": 1, "clue": 4 },
{ "id": "A", "x": 3, "y": 1, "w": 2, "h": 2 },
{ "id": "h", "x": 0, "y": 2, "w": 1, "h": 1 },
{ "id": "i", "x": 1, "y": 2, "w": 1, "h": 1 },
{ "id": "j", "x": 2, "y": 2, "w": 1, "h": 1 },
{ "id": "B", "x": 0, "y": 3, "w": 2, "h": 2 },
{ "id": "k", "x": 2, "y": 3, "w": 1, "h": 1 },
{ "id": "l", "x": 3, "y": 3, "w": 1, "h": 1, "clue": 8 },
{ "id": "m", "x": 4, "y": 3, "w": 1, "h": 1 },
{ "id": "n", "x": 2, "y": 4, "w": 1, "h": 1 },
{ "id": "o", "x": 3, "y": 4, "w": 1, "h": 1 },
{ "id": "p", "x": 4, "y": 4, "w": 1, "h": 1 }
]
},
{
"id": "2-7",
"teach": "自动生成 seed=43。N=21 多单位格=2(异形0) 空洞=0 无线索路径=600+ 线索=1/7/17 步号差=6/10/4 难度分=103.1。作者解: q → p → o → B → k → l → h → g → A → d → a → b → c → f → e → i → m → r → s → n → j",
"cells": [
{ "id": "A", "x": 0, "y": 0, "w": 2, "h": 2 },
{ "id": "a", "x": 2, "y": 0, "w": 1, "h": 1 },
{ "id": "b", "x": 3, "y": 0, "w": 1, "h": 1 },
{ "id": "c", "x": 4, "y": 0, "w": 1, "h": 1 },
{ "id": "d", "x": 2, "y": 1, "w": 1, "h": 1 },
{ "id": "e", "x": 3, "y": 1, "w": 1, "h": 1 },
{ "id": "f", "x": 4, "y": 1, "w": 1, "h": 1 },
{ "id": "B", "x": 0, "y": 2, "w": 1, "h": 2 },
{ "id": "g", "x": 1, "y": 2, "w": 1, "h": 1 },
{ "id": "h", "x": 2, "y": 2, "w": 1, "h": 1, "clue": 7 },
{ "id": "i", "x": 3, "y": 2, "w": 1, "h": 1 },
{ "id": "j", "x": 4, "y": 2, "w": 1, "h": 1 },
{ "id": "k", "x": 1, "y": 3, "w": 1, "h": 1 },
{ "id": "l", "x": 2, "y": 3, "w": 1, "h": 1 },
{ "id": "m", "x": 3, "y": 3, "w": 1, "h": 1, "clue": 17 },
{ "id": "n", "x": 4, "y": 3, "w": 1, "h": 1 },
{ "id": "o", "x": 0, "y": 4, "w": 1, "h": 1 },
{ "id": "p", "x": 1, "y": 4, "w": 1, "h": 1 },
{ "id": "q", "x": 2, "y": 4, "w": 1, "h": 1, "clue": 1 },
{ "id": "r", "x": 3, "y": 4, "w": 1, "h": 1 },
{ "id": "s", "x": 4, "y": 4, "w": 1, "h": 1 }
]
},
{
"id": "2-8",
"teach": "自动生成 seed=43。N=19 多单位格=2(异形2) 空洞=0 无线索路径=600+ 线索=3/5/15/17(无线索1) 步号差=2/10/2/2 难度分=109.7。作者解: i → d → e → j → l → k → n → o → q → p → B → m → A → h → c → b → a → f → g",
"cells": [
{ "id": "a", "x": 0, "y": 0, "w": 1, "h": 1, "clue": 17 },
{ "id": "b", "x": 1, "y": 0, "w": 1, "h": 1 },
{ "id": "c", "x": 2, "y": 0, "w": 1, "h": 1, "clue": 15 },
{ "id": "d", "x": 3, "y": 0, "w": 1, "h": 1 },
{ "id": "e", "x": 4, "y": 0, "w": 1, "h": 1, "clue": 3 },
{ "id": "f", "x": 0, "y": 1, "w": 1, "h": 1 },
{ "id": "g", "x": 1, "y": 1, "w": 1, "h": 1 },
{ "id": "h", "x": 2, "y": 1, "w": 1, "h": 1 },
{ "id": "i", "x": 3, "y": 1, "w": 1, "h": 1 },
{ "id": "j", "x": 4, "y": 1, "w": 1, "h": 1 },
{ "id": "A", "x": 0, "y": 2, "w": 3, "h": 2, "shape": [[0,0],[1,0],[2,0],[2,1]] },
{ "id": "k", "x": 3, "y": 2, "w": 1, "h": 1 },
{ "id": "l", "x": 4, "y": 2, "w": 1, "h": 1, "clue": 5 },
{ "id": "B", "x": 0, "y": 3, "w": 3, "h": 2, "shape": [[2,1],[1,1],[0,1],[1,0]] },
{ "id": "m", "x": 0, "y": 3, "w": 1, "h": 1 },
{ "id": "n", "x": 3, "y": 3, "w": 1, "h": 1 },
{ "id": "o", "x": 4, "y": 3, "w": 1, "h": 1 },
{ "id": "p", "x": 3, "y": 4, "w": 1, "h": 1 },
{ "id": "q", "x": 4, "y": 4, "w": 1, "h": 1 }
]
},
{
"id": "2-9",
"teach": "自动生成 seed=43。N=21 多单位格=2(异形0) 空洞=0 无线索路径=600+ 线索=1/7/20 步号差=6/13/1 难度分=124.1。作者解: f → c → b → e → A → a → d → g → j → o → p → q → r → s → n → i → B → m → l → k → h",
"cells": [
{ "id": "a", "x": 0, "y": 0, "w": 1, "h": 1 },
{ "id": "A", "x": 1, "y": 0, "w": 2, "h": 2 },
{ "id": "b", "x": 3, "y": 0, "w": 1, "h": 1 },
{ "id": "c", "x": 4, "y": 0, "w": 1, "h": 1 },
{ "id": "d", "x": 0, "y": 1, "w": 1, "h": 1, "clue": 7 },
{ "id": "e", "x": 3, "y": 1, "w": 1, "h": 1 },
{ "id": "f", "x": 4, "y": 1, "w": 1, "h": 1, "clue": 1 },
{ "id": "g", "x": 0, "y": 2, "w": 1, "h": 1 },
{ "id": "h", "x": 1, "y": 2, "w": 1, "h": 1 },
{ "id": "B", "x": 2, "y": 2, "w": 2, "h": 1 },
{ "id": "i", "x": 4, "y": 2, "w": 1, "h": 1 },
{ "id": "j", "x": 0, "y": 3, "w": 1, "h": 1 },
{ "id": "k", "x": 1, "y": 3, "w": 1, "h": 1, "clue": 20 },
{ "id": "l", "x": 2, "y": 3, "w": 1, "h": 1 },
{ "id": "m", "x": 3, "y": 3, "w": 1, "h": 1 },
{ "id": "n", "x": 4, "y": 3, "w": 1, "h": 1 },
{ "id": "o", "x": 0, "y": 4, "w": 1, "h": 1 },
{ "id": "p", "x": 1, "y": 4, "w": 1, "h": 1 },
{ "id": "q", "x": 2, "y": 4, "w": 1, "h": 1 },
{ "id": "r", "x": 3, "y": 4, "w": 1, "h": 1 },
{ "id": "s", "x": 4, "y": 4, "w": 1, "h": 1 }
]
},
{
"id": "2-10",
"teach": "自动生成 seed=43。N=21 多单位格=2(异形0) 空洞=0 无线索路径=600+ 线索=1/5/21 步号差=4/16/0 难度分=145.1。作者解: q → l → m → r → B → s → p → k → h → e → d → c → b → a → A → f → g → j → o → n → i",
"cells": [
{ "id": "a", "x": 0, "y": 0, "w": 1, "h": 1 },
{ "id": "b", "x": 1, "y": 0, "w": 1, "h": 1 },
{ "id": "c", "x": 2, "y": 0, "w": 1, "h": 1 },
{ "id": "d", "x": 3, "y": 0, "w": 1, "h": 1 },
{ "id": "e", "x": 4, "y": 0, "w": 1, "h": 1 },
{ "id": "A", "x": 0, "y": 1, "w": 2, "h": 2 },
{ "id": "f", "x": 2, "y": 1, "w": 1, "h": 1 },
{ "id": "g", "x": 3, "y": 1, "w": 1, "h": 1 },
{ "id": "h", "x": 4, "y": 1, "w": 1, "h": 1 },
{ "id": "i", "x": 2, "y": 2, "w": 1, "h": 1, "clue": 21 },
{ "id": "j", "x": 3, "y": 2, "w": 1, "h": 1 },
{ "id": "k", "x": 4, "y": 2, "w": 1, "h": 1 },
{ "id": "l", "x": 0, "y": 3, "w": 1, "h": 1 },
{ "id": "m", "x": 1, "y": 3, "w": 1, "h": 1 },
{ "id": "n", "x": 2, "y": 3, "w": 1, "h": 1 },
{ "id": "o", "x": 3, "y": 3, "w": 1, "h": 1 },
{ "id": "p", "x": 4, "y": 3, "w": 1, "h": 1 },
{ "id": "q", "x": 0, "y": 4, "w": 1, "h": 1, "clue": 1 },
{ "id": "r", "x": 1, "y": 4, "w": 1, "h": 1 },
{ "id": "B", "x": 2, "y": 4, "w": 2, "h": 1, "clue": 5 },
{ "id": "s", "x": 4, "y": 4, "w": 1, "h": 1 }
]
}
]
}
@@ -0,0 +1,650 @@
{
"mode": "01-number-stroke",
"pack": 3,
"title": "奇与偶",
"notes": "6 关体验包。奇/偶约束整块的路径步号,不是面积;每条奇偶线索都经删除校验证明参与唯一性。",
"levels": [
{
"id": "3-1",
"caption": "偶:走到这里时,步数要是 2、4、6…",
"teach": "奇偶体验;复用 1-4 的几何,重新配置线索。作者解: start → a → big → b",
"cells": [
{
"id": "start",
"x": 0,
"y": 0,
"w": 1,
"h": 1,
"clue": 1
},
{
"id": "a",
"x": 1,
"y": 0,
"w": 1,
"h": 1
},
{
"id": "b",
"x": 2,
"y": 0,
"w": 1,
"h": 1,
"parity": "even"
},
{
"id": "big",
"x": 1,
"y": 1,
"w": 2,
"h": 2
}
]
},
{
"id": "3-2",
"caption": "奇:步数要是 1、3、5…;大块只算一步",
"teach": "奇偶体验;原创 3×3 教学盘。作者解: start → a → big → b → c → end",
"cells": [
{
"id": "start",
"x": 0,
"y": 0,
"w": 1,
"h": 1,
"clue": 1
},
{
"id": "a",
"x": 1,
"y": 0,
"w": 1,
"h": 1
},
{
"id": "b",
"x": 2,
"y": 0,
"w": 1,
"h": 1
},
{
"id": "c",
"x": 3,
"y": 0,
"w": 1,
"h": 1
},
{
"id": "big",
"x": 1,
"y": 1,
"w": 3,
"h": 3,
"parity": "odd"
},
{
"id": "end",
"x": 4,
"y": 0,
"w": 1,
"h": 1
}
]
},
{
"id": "3-3",
"caption": "先数步数,再决定从大块哪边出去",
"teach": "奇偶体验;复用 1-8 的几何,重新配置线索。作者解: a → b → B → d → f → j → i → h → g → e → c",
"cells": [
{
"id": "a",
"x": 1,
"y": 0,
"w": 1,
"h": 1,
"clue": 1
},
{
"id": "b",
"x": 1,
"y": 1,
"w": 1,
"h": 1
},
{
"id": "c",
"x": 0,
"y": 2,
"w": 1,
"h": 1,
"parity": "odd"
},
{
"id": "B",
"x": 1,
"y": 2,
"w": 2,
"h": 2
},
{
"id": "d",
"x": 3,
"y": 2,
"w": 1,
"h": 1
},
{
"id": "e",
"x": 0,
"y": 3,
"w": 1,
"h": 1
},
{
"id": "f",
"x": 3,
"y": 3,
"w": 1,
"h": 1
},
{
"id": "g",
"x": 0,
"y": 4,
"w": 1,
"h": 1
},
{
"id": "h",
"x": 1,
"y": 4,
"w": 1,
"h": 1
},
{
"id": "i",
"x": 2,
"y": 4,
"w": 1,
"h": 1
},
{
"id": "j",
"x": 3,
"y": 4,
"w": 1,
"h": 1
}
]
},
{
"id": "3-4",
"caption": "奇偶线索和数字线索要同时满足",
"teach": "奇偶体验;复用 2-1 的几何,重新配置线索。作者解: d → c → b → a → e → A → f → B → g → C → j → m → l → i → h → k",
"cells": [
{
"id": "A",
"x": 0,
"y": 0,
"w": 2,
"h": 3,
"shape": [
[
0,
0
],
[
0,
1
],
[
0,
2
],
[
1,
2
]
],
"parity": "even"
},
{
"id": "a",
"x": 1,
"y": 0,
"w": 1,
"h": 1
},
{
"id": "b",
"x": 2,
"y": 0,
"w": 1,
"h": 1
},
{
"id": "c",
"x": 3,
"y": 0,
"w": 1,
"h": 1
},
{
"id": "d",
"x": 4,
"y": 0,
"w": 1,
"h": 1,
"clue": 1
},
{
"id": "e",
"x": 1,
"y": 1,
"w": 1,
"h": 1
},
{
"id": "B",
"x": 2,
"y": 1,
"w": 3,
"h": 2,
"shape": [
[
2,
0
],
[
1,
0
],
[
0,
0
],
[
1,
1
]
]
},
{
"id": "f",
"x": 2,
"y": 2,
"w": 1,
"h": 1
},
{
"id": "g",
"x": 4,
"y": 2,
"w": 1,
"h": 1
},
{
"id": "h",
"x": 0,
"y": 3,
"w": 1,
"h": 1
},
{
"id": "i",
"x": 1,
"y": 3,
"w": 1,
"h": 1,
"clue": 14
},
{
"id": "j",
"x": 2,
"y": 3,
"w": 1,
"h": 1,
"parity": "odd"
},
{
"id": "C",
"x": 3,
"y": 3,
"w": 2,
"h": 2
},
{
"id": "k",
"x": 0,
"y": 4,
"w": 1,
"h": 1
},
{
"id": "l",
"x": 1,
"y": 4,
"w": 1,
"h": 1
},
{
"id": "m",
"x": 2,
"y": 4,
"w": 1,
"h": 1
}
]
},
{
"id": "3-5",
"caption": "先看奇偶,再安排绕行的顺序",
"teach": "奇偶体验;复用 2-3 的几何,重新配置线索。作者解: k → o → p → l → h → g → e → A → b → a → c → B → d → f → j → i → m → n",
"cells": [
{
"id": "a",
"x": 0,
"y": 0,
"w": 1,
"h": 1,
"parity": "even"
},
{
"id": "b",
"x": 1,
"y": 0,
"w": 1,
"h": 1
},
{
"id": "A",
"x": 2,
"y": 0,
"w": 3,
"h": 2,
"shape": [
[
0,
0
],
[
1,
0
],
[
2,
0
],
[
2,
1
]
]
},
{
"id": "B",
"x": 0,
"y": 1,
"w": 2,
"h": 3,
"shape": [
[
0,
1
],
[
0,
2
],
[
1,
0
],
[
1,
1
]
]
},
{
"id": "c",
"x": 0,
"y": 1,
"w": 1,
"h": 1
},
{
"id": "d",
"x": 2,
"y": 1,
"w": 1,
"h": 1
},
{
"id": "e",
"x": 3,
"y": 1,
"w": 1,
"h": 1,
"parity": "odd"
},
{
"id": "f",
"x": 2,
"y": 2,
"w": 1,
"h": 1
},
{
"id": "g",
"x": 3,
"y": 2,
"w": 1,
"h": 1
},
{
"id": "h",
"x": 4,
"y": 2,
"w": 1,
"h": 1
},
{
"id": "i",
"x": 1,
"y": 3,
"w": 1,
"h": 1,
"clue": 16
},
{
"id": "j",
"x": 2,
"y": 3,
"w": 1,
"h": 1
},
{
"id": "k",
"x": 3,
"y": 3,
"w": 1,
"h": 1,
"clue": 1
},
{
"id": "l",
"x": 4,
"y": 3,
"w": 1,
"h": 1
},
{
"id": "m",
"x": 1,
"y": 4,
"w": 1,
"h": 1
},
{
"id": "n",
"x": 2,
"y": 4,
"w": 1,
"h": 1
},
{
"id": "o",
"x": 3,
"y": 4,
"w": 1,
"h": 1
},
{
"id": "p",
"x": 4,
"y": 4,
"w": 1,
"h": 1
}
]
},
{
"id": "3-6",
"caption": "两个奇数格,中间要隔几步?",
"teach": "奇偶体验;复用 2-6 的几何,重新配置线索。作者解: e → d → c → g → A → j → k → l → m → p → o → n → B → h → i → f → b → a",
"cells": [
{
"id": "a",
"x": 0,
"y": 0,
"w": 1,
"h": 1
},
{
"id": "b",
"x": 1,
"y": 0,
"w": 1,
"h": 1
},
{
"id": "c",
"x": 2,
"y": 0,
"w": 1,
"h": 1,
"parity": "odd"
},
{
"id": "d",
"x": 3,
"y": 0,
"w": 1,
"h": 1
},
{
"id": "e",
"x": 4,
"y": 0,
"w": 1,
"h": 1,
"clue": 1
},
{
"id": "f",
"x": 1,
"y": 1,
"w": 1,
"h": 1
},
{
"id": "g",
"x": 2,
"y": 1,
"w": 1,
"h": 1
},
{
"id": "A",
"x": 3,
"y": 1,
"w": 2,
"h": 2,
"parity": "odd"
},
{
"id": "h",
"x": 0,
"y": 2,
"w": 1,
"h": 1
},
{
"id": "i",
"x": 1,
"y": 2,
"w": 1,
"h": 1
},
{
"id": "j",
"x": 2,
"y": 2,
"w": 1,
"h": 1
},
{
"id": "B",
"x": 0,
"y": 3,
"w": 2,
"h": 2
},
{
"id": "k",
"x": 2,
"y": 3,
"w": 1,
"h": 1
},
{
"id": "l",
"x": 3,
"y": 3,
"w": 1,
"h": 1
},
{
"id": "m",
"x": 4,
"y": 3,
"w": 1,
"h": 1
},
{
"id": "n",
"x": 2,
"y": 4,
"w": 1,
"h": 1
},
{
"id": "o",
"x": 3,
"y": 4,
"w": 1,
"h": 1
},
{
"id": "p",
"x": 4,
"y": 4,
"w": 1,
"h": 1
}
]
}
]
}
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
{
"name": "number-stroke",
"private": true,
"version": "0.1.0",
"description": "玩法一 · 数字一笔 — 竖屏触摸 Web 原型(纯 TS 规则层,可迁 Cocos Creator)",
"type": "module",
"scripts": {
"dev": "node scripts/build.mjs --serve",
"build": "node scripts/build.mjs",
"build:standalone": "node scripts/standalone.mjs",
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.src.json --noEmit",
"test": "node --import tsx --test tests/*.test.ts",
"validate": "node --import tsx tools/validate.ts",
"design": "node --import tsx tools/design.ts",
"generate": "node --import tsx tools/generate.ts",
"check": "npm run typecheck && npm run test && npm run validate"
},
"devDependencies": {
"@types/node": "^22.7.0",
"esbuild": "^0.24.0",
"tsx": "^4.19.0",
"typescript": "^5.6.0"
}
}
+42
View File
@@ -0,0 +1,42 @@
// 打包 web/main.ts → web/dist/app.js(IIFE,可 file:// 直接打开)。
// --serve 时起本地服务并监听改动,手机同一 Wi-Fi 下访问启动时打印的地址。
// 端口:--port 4000 或 PORT=4000,不给就从 8000 起自动找空闲端口。
import * as esbuild from 'esbuild';
import { networkInterfaces } from 'node:os';
const argv = process.argv.slice(2);
const serve = argv.includes('--serve');
const portArg = argv[argv.indexOf('--port') + 1];
const port = argv.includes('--port') ? Number(portArg) : process.env.PORT ? Number(process.env.PORT) : undefined;
const ctx = await esbuild.context({
entryPoints: ['web/main.ts'],
bundle: true,
format: 'iife',
target: ['es2020'],
outfile: 'web/dist/app.js',
sourcemap: serve ? 'inline' : false,
minify: !serve,
logLevel: 'info',
loader: { '.json': 'json' },
});
if (!serve) {
await ctx.rebuild();
await ctx.dispose();
} else {
await ctx.watch();
const served = await ctx.serve({ servedir: 'web', host: '0.0.0.0', ...(port ? { port } : {}) });
let lan = [];
try {
lan = Object.values(networkInterfaces())
.flat()
.filter((i) => i && i.family === 'IPv4' && !i.internal)
.map((i) => i.address);
} catch {
// 受限环境拿不到网卡列表,只打印 localhost
}
console.log(`\n 本机: http://localhost:${served.port}/`);
for (const ip of lan) console.log(` 手机: http://${ip}:${served.port}/`);
console.log(` (监听 ${served.host}:${served.port},改动自动重新打包,刷新页面即可)\n`);
}
@@ -0,0 +1,45 @@
// 单文件离线版:直接从源码打包,不依赖开发服务器或现有 web/dist。
// npm run build:standalone [-- /绝对路径/试玩版.html]
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { build, transform } from 'esbuild';
const root = fileURLToPath(new URL('../', import.meta.url));
const out = process.argv[2]
? resolve(process.argv[2])
: resolve(root, '../../releases/数字一笔-试玩版.html');
const [template, css, bundle] = await Promise.all([
readFile(resolve(root, 'web/index.html'), 'utf8'),
readFile(resolve(root, 'web/style.css'), 'utf8'),
build({
absWorkingDir: root,
entryPoints: ['web/main.ts'],
bundle: true,
format: 'iife',
target: ['es2020'],
minify: true,
write: false,
legalComments: 'none',
loader: { '.json': 'json' },
}),
]);
const style = (await transform(css, { loader: 'css', minify: true })).code;
const script = bundle.outputFiles[0].text;
function inlineOnce(html, needle, replacement) {
if (html.split(needle).length !== 2) throw new Error(`HTML 入口已变动,需要更新单文件构建脚本:${needle}`);
// 回调替换,避免 JS/CSS 内的 $& 等字符被解释为替换指令。
return html.replace(needle, () => replacement);
}
let html = inlineOnce(template, '<link rel="stylesheet" href="style.css" />', `<style>${style.replace(/<\/style/gi, '<\\/style')}</style>`);
html = inlineOnce(html, '<html lang="zh-CN">', '<html lang="zh-CN" data-unlock-all="true">');
html = inlineOnce(html, '按数字顺序,一笔走遍每一格', '全部关卡开放,自由选关试玩');
html = inlineOnce(html, '<script src="dist/app.js"></script>', `<script>${script.replace(/<\/script/gi, '<\\/script')}</script>`);
html = html.replace('<title>数字一笔</title>', '<title>数字一笔 · 试玩版</title>');
await mkdir(dirname(out), { recursive: true });
await writeFile(out, html, 'utf8');
console.log(`独立 HTML:${out}`);
console.log(`大小:${(Buffer.byteLength(html) / 1024).toFixed(1)} KB;脚本、样式及全部关卡已内嵌,所有关卡可直接选择。`);
+379
View File
@@ -0,0 +1,379 @@
// 盘面模型:矩形格 + 俄罗斯方块式异形格、单位网格、四向邻接。规则来源 GDD §4.1–4.2(V1.1 起含异形格)。
import type { CellDef, LevelDef } from './types';
export interface Cell {
/** 在 Board.cells 中的下标,运行时统一用下标引用格 */
index: number;
id: string;
/** 紧凑包围盒(异形格 = shape 的 bbox) */
x: number;
y: number;
w: number;
h: number;
clue: number | null;
parity: 'odd' | 'even' | null;
/** 占用的全部单位格绝对坐标(矩形格 = 铺满 bbox) */
units: ReadonlyArray<readonly [number, number]>;
/** units 是否铺满 bbox(true = 普通矩形,视图单矩形画法;false = 异形,按单位拼画) */
isRect: boolean;
}
/** 两格共享边的线段(单位坐标),用于画连接桥 */
export interface SharedEdge {
x1: number;
y1: number;
x2: number;
y2: number;
/** 'v' = 竖直共享边(左右相邻),'h' = 水平共享边(上下相邻) */
dir: 'v' | 'h';
}
export const ALLOWED_SIZES: ReadonlyArray<readonly [number, number]> = [
[1, 1],
[2, 1],
[1, 2],
[2, 2],
[3, 3], // V1.1:大连块
];
/**
* 异形格目录(GDD §4.2 V1.1):I3、L3、I4、O4、L4/J4、S4/Z4、T4。
* 存归一化形式(旋转/镜像后平移到原点、坐标排序拼串),校验时同样归一化再比对。
*/
export const ALLOWED_SHAPES: ReadonlySet<string> = buildShapeCatalog();
type Offset = readonly [number, number];
function normalizeShape(pts: ReadonlyArray<Offset>): string {
let minX = Infinity;
let minY = Infinity;
for (const [x, y] of pts) {
minX = Math.min(minX, x);
minY = Math.min(minY, y);
}
return pts
.map(([x, y]) => `${x - minX},${y - minY}`)
.sort()
.join(';');
}
function buildShapeCatalog(): Set<string> {
const bases: Offset[][] = [
[[0, 0], [1, 0]], // I2(与 2×1/1×2 矩形同形,shape 写法也放行)
[[0, 0], [1, 0], [2, 0]], // I3
[[0, 0], [0, 1], [1, 1]], // L3
[[0, 0], [1, 0], [2, 0], [3, 0]], // I4
[[0, 0], [1, 0], [0, 1], [1, 1]], // O4
[[0, 0], [0, 1], [0, 2], [1, 2]], // L4
[[1, 0], [2, 0], [0, 1], [1, 1]], // S4
[[0, 0], [1, 0], [2, 0], [1, 1]], // T4
];
const out = new Set<string>();
for (const base of bases) {
let pts = base;
for (let mirror = 0; mirror < 2; mirror++) {
let cur = pts;
for (let rot = 0; rot < 4; rot++) {
out.add(normalizeShape(cur));
cur = cur.map(([x, y]) => [y, -x] as Offset); // 逆时针 90°
}
pts = pts.map(([x, y]) => [-x, y] as Offset); // 镜像
}
}
return out;
}
export class Board {
readonly cells: ReadonlyArray<Cell>;
readonly n: number;
/** 单位网格尺寸(已把最小 x/y 平移到 0) */
readonly cols: number;
readonly rows: number;
/** adj[i] = 与 i 相邻的格下标 */
readonly adj: ReadonlyArray<ReadonlyArray<number>>;
/** 线索步号 → 格下标 */
readonly clueCellOfStep: ReadonlyMap<number, number>;
/** 线索 1 所在格,没有则 -1 */
readonly startClueCell: number;
private readonly unitMap: Int16Array;
private constructor(cells: Cell[]) {
this.cells = cells;
this.n = cells.length;
let cols = 0;
let rows = 0;
for (const c of cells) {
cols = Math.max(cols, c.x + c.w);
rows = Math.max(rows, c.y + c.h);
}
this.cols = cols;
this.rows = rows;
const unitMap = new Int16Array(cols * rows).fill(-1);
for (const c of cells) {
for (const [ux, uy] of c.units) {
const k = uy * cols + ux;
if (unitMap[k] !== -1) {
throw new Error(`格重叠: ${cells[unitMap[k]].id} 与 ${c.id} 于 (${ux},${uy})`);
}
unitMap[k] = c.index;
}
}
this.unitMap = unitMap;
const adj: number[][] = cells.map(() => []);
for (let i = 0; i < cells.length; i++) {
for (let j = i + 1; j < cells.length; j++) {
if (cellsShareEdge(cells[i], cells[j])) {
adj[i].push(j);
adj[j].push(i);
}
}
}
this.adj = adj;
const clueMap = new Map<number, number>();
for (const c of cells) {
if (c.clue !== null) {
if (clueMap.has(c.clue)) throw new Error(`线索重复: ${c.clue}`);
clueMap.set(c.clue, c.index);
}
}
this.clueCellOfStep = clueMap;
this.startClueCell = clueMap.get(1) ?? -1;
}
static fromLevel(level: LevelDef): Board {
return Board.fromCells(level.cells);
}
static fromCells(defs: CellDef[]): Board {
if (defs.length === 0) throw new Error('盘面为空');
let minX = Infinity;
let minY = Infinity;
for (const d of defs) {
if (d.shape) validateShape(d);
else if (!ALLOWED_SIZES.some(([w, h]) => w === d.w && h === d.h)) {
throw new Error(`格 ${d.id} 尺寸 ${d.w}×${d.h} 不在 V1 允许范围`);
}
minX = Math.min(minX, d.x);
minY = Math.min(minY, d.y);
}
const ids = new Set<string>();
const cells: Cell[] = defs.map((d, index) => {
if (ids.has(d.id)) throw new Error(`格 id 重复: ${d.id}`);
ids.add(d.id);
const clue = d.clue ?? null;
if (d.parity !== undefined && d.parity !== 'odd' && d.parity !== 'even') {
throw new Error(`格 ${d.id} parity 必须为 odd 或 even`);
}
const parity = d.parity ?? null;
if (clue !== null && parity !== null) {
throw new Error(`格 ${d.id} 数字线索与奇偶线索不能同时填写`);
}
if (clue !== null && (!Number.isInteger(clue) || clue < 1 || clue > defs.length)) {
throw new Error(`格 ${d.id} 线索 ${clue} 超出 1..${defs.length}`);
}
const x = d.x - minX;
const y = d.y - minY;
const units: Array<readonly [number, number]> = [];
if (d.shape) {
for (const [dx, dy] of d.shape) units.push([x + dx, y + dy]);
} else {
for (let uy = y; uy < y + d.h; uy++) for (let ux = x; ux < x + d.w; ux++) units.push([ux, uy]);
}
return { index, id: d.id, x, y, w: d.w, h: d.h, clue, parity, units, isRect: units.length === d.w * d.h };
});
return new Board(cells);
}
/** 单位坐标 → 格下标;空洞 / 越界返回 -1 */
cellAtUnit(ux: number, uy: number): number {
if (ux < 0 || uy < 0 || ux >= this.cols || uy >= this.rows) return -1;
return this.unitMap[uy * this.cols + ux];
}
areAdjacent(a: number, b: number): boolean {
return this.adj[a].includes(b);
}
cellById(id: string): Cell {
const c = this.cells.find((x) => x.id === id);
if (!c) throw new Error(`没有格 ${id}`);
return c;
}
/** 数字 / 角标的锚点单位格:离形心最近的单位(L 型的 bbox 中心可能落在形外) */
anchorUnit(cell: number): readonly [number, number] {
const c = this.cells[cell];
if (c.isRect) return [c.x, c.y];
let cx = 0;
let cy = 0;
for (const [ux, uy] of c.units) {
cx += ux + 0.5;
cy += uy + 0.5;
}
cx /= c.units.length;
cy /= c.units.length;
let best = c.units[0];
let bestD = Infinity;
for (const u of c.units) {
const d = (u[0] + 0.5 - cx) ** 2 + (u[1] + 0.5 - cy) ** 2;
if (d < bestD) {
bestD = d;
best = u;
}
}
return best;
}
/** 视觉锚点的单位坐标(小数):矩形 = bbox 中心,异形 = anchorUnit 中心 */
anchorCenter(cell: number): readonly [number, number] {
const c = this.cells[cell];
if (c.isRect) return [c.x + c.w / 2, c.y + c.h / 2];
const [ux, uy] = this.anchorUnit(cell);
return [ux + 0.5, uy + 0.5];
}
/**
* 相邻两格的共享边线段:把共线的单位边合并成一整段(与旧矩形实现行为一致),
* 取最长的一段;不相邻或同一格返回 null。
*/
sharedEdge(a: number, b: number): SharedEdge | null {
if (a === b) return null;
const A = this.cells[a];
const B = this.cells[b];
const vEdges: Array<[number, number]> = []; // [x, y] = 边 (x, y..y+1),左右相邻
const hEdges: Array<[number, number]> = []; // [x, y] = 边 (x..x+1, y),上下相邻
for (const [ax, ay] of A.units) {
for (const [bx, by] of B.units) {
if (by === ay && bx - ax === 1) vEdges.push([ax + 1, ay]);
else if (by === ay && ax - bx === 1) vEdges.push([bx + 1, by]);
else if (bx === ax && by - ay === 1) hEdges.push([ax, ay + 1]);
else if (bx === ax && ay - by === 1) hEdges.push([bx, by + 1]);
}
}
// 按固定坐标分组,找最长连续段
const longest = (edges: Array<[number, number]>, vertical: boolean): SharedEdge | null => {
const groups = new Map<number, number[]>();
for (const [u, v] of edges) {
const key = vertical ? u : v;
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(vertical ? v : u);
}
let best: SharedEdge | null = null;
for (const [fixed, positions] of groups) {
positions.sort((p, q) => p - q);
let start = positions[0];
let prev = positions[0];
const flush = (): void => {
const len = prev - start + 1;
const cand: SharedEdge = vertical
? { x1: fixed, y1: start, x2: fixed, y2: prev + 1, dir: 'v' }
: { x1: start, y1: fixed, x2: prev + 1, y2: fixed, dir: 'h' };
if (!best || len > best.x2 - best.x1 + (best.y2 - best.y1)) best = cand;
};
for (let i = 1; i <= positions.length; i++) {
if (i < positions.length && positions[i] === prev + 1) {
prev = positions[i];
} else {
flush();
if (i < positions.length) {
start = positions[i];
prev = positions[i];
}
}
}
}
return best;
};
const v = longest(vEdges, true);
const h = longest(hEdges, false);
if (!v) return h;
if (!h) return v;
const lenOf = (e: SharedEdge): number => e.x2 - e.x1 + (e.y2 - e.y1);
return lenOf(v) >= lenOf(h) ? v : h;
}
/** 是否连通(任意格都能到任意格)。不连通的盘不可能有解 */
isConnected(): boolean {
const seen = new Uint8Array(this.n);
const stack = [0];
seen[0] = 1;
let count = 1;
while (stack.length) {
const i = stack.pop()!;
for (const j of this.adj[i]) {
if (!seen[j]) {
seen[j] = 1;
count++;
stack.push(j);
}
}
}
return count === this.n;
}
}
/** 异形格 shape 校验:2~4 单位、无重复、四向连通、bbox 与 w/h 一致、在形状目录内 */
function validateShape(d: CellDef): void {
const shape = d.shape!;
const fail = (why: string): never => {
throw new Error(`格 ${d.id} shape 非法: ${why}`);
};
if (shape.length < 2 || shape.length > 4) fail(`单位数 ${shape.length} 不在 2..4`);
const seen = new Set<string>();
let maxX = -1;
let maxY = -1;
for (const [dx, dy] of shape) {
if (dx < 0 || dy < 0) fail(`偏移 (${dx},${dy}) 为负(锚点必须是 bbox 左上)`);
const k = `${dx},${dy}`;
if (seen.has(k)) fail(`单位 (${dx},${dy}) 重复`);
seen.add(k);
maxX = Math.max(maxX, dx);
maxY = Math.max(maxY, dy);
}
if (maxX + 1 !== d.w || maxY + 1 !== d.h) fail(`bbox ${maxX + 1}×${maxY + 1} 与 w/h ${d.w}×${d.h} 不符`);
// 四向连通
const reached = new Set<string>([shape[0].join(',')]);
const stack = [shape[0]];
while (stack.length) {
const [x, y] = stack.pop()!;
for (const [nx, ny] of [[x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]] as Offset[]) {
const k = `${nx},${ny}`;
if (seen.has(k) && !reached.has(k)) {
reached.add(k);
stack.push([nx, ny]);
}
}
}
if (reached.size !== shape.length) fail('不连通');
if (!ALLOWED_SHAPES.has(normalizeShape(shape))) fail('不在形状目录(I/L/S/T/O,2~4 格)');
}
/** 两格是否相邻:任一对单位共享 1 条单位边(只认四向) */
function cellsShareEdge(a: Cell, b: Cell): boolean {
for (const [ax, ay] of a.units) {
for (const [bx, by] of b.units) {
if (Math.abs(ax - bx) + Math.abs(ay - by) === 1) return true;
}
}
return false;
}
/** 两个轴对齐矩形共享至少 1 个单位长度的边(只认四向)。保留给旧测试;运行时邻接走 cellsShareEdge */
export function rectsShareEdge(
a: { x: number; y: number; w: number; h: number },
b: { x: number; y: number; w: number; h: number },
): boolean {
if (a.x + a.w === b.x || b.x + b.w === a.x) {
const overlap = Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y);
if (overlap >= 1) return true;
}
if (a.y + a.h === b.y || b.y + b.h === a.y) {
const overlap = Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x);
if (overlap >= 1) return true;
}
return false;
}
+33
View File
@@ -0,0 +1,33 @@
// 用盘面上的一个确定数字缩短待解区间,不替玩家画路线。
import type { Game } from './game';
export interface ClueHint {
cell: number;
step: number;
}
/** 优先当前尚未解开的区间:1…9 补 5,继续请求可把 1…5 再拆开。 */
export function pickClueHint(game: Game, solution: readonly number[]): ClueHint | null {
if (game.isCleared || solution.length !== game.n) return null;
let common = 0;
while (common < game.path.length && game.path[common] === solution[common]) common++;
const anchors = [0, game.n + 1]; // 无起点 / 终点数字时的虚拟边界
for (const c of game.board.cells) {
const clue = game.clueAt(c.index).clue;
if (clue !== null) anchors.push(clue);
}
anchors.sort((a, b) => a - b);
for (let i = 0; i < anchors.length - 1; i++) {
const left = Math.max(anchors[i], common);
const right = anchors[i + 1];
const middle = (Math.max(1, left) + Math.min(game.n, right)) / 2;
let best: ClueHint | null = null;
for (let step = left + 1; step < right; step++) {
const cell = solution[step - 1];
if (game.clueAt(cell).clue !== null || game.stepOf(cell) === step) continue;
if (!best || Math.abs(step - middle) < Math.abs(best.step - middle)) best = { cell, step };
}
if (best) return best;
}
return null;
}
+20
View File
@@ -0,0 +1,20 @@
// 数字与奇偶线索的共同语义;规则、求解器、视图与设计工具共用。
import type { Cell } from './board';
type Clue = Pick<Cell, 'clue' | 'parity'>;
export function hasClue(cell: Clue): boolean {
return cell.clue !== null || cell.parity !== null;
}
/** step 从 1 起。未走过的格由调用方先检查,不应传 0。 */
export function matchesClue(cell: Clue, step: number): boolean {
if (cell.clue !== null && step !== cell.clue) return false;
if (cell.parity !== null && step % 2 !== (cell.parity === 'odd' ? 1 : 0)) return false;
return true;
}
export function clueLabel(cell: Clue): string {
if (cell.clue !== null) return String(cell.clue);
return cell.parity === 'odd' ? '奇' : cell.parity === 'even' ? '偶' : '';
}
+230
View File
@@ -0,0 +1,230 @@
// 对局状态机:路径、冲突、过关。规则来源 GDD §4.3–4.5、§5.3。
// 输入层把手势翻译成 Command,视图层消费 GameEvent;本文件不关心两者的实现。
import type { Board, Cell } from './board';
import { hasClue, matchesClue } from './clue';
export type Status = 'empty' | 'drawing' | 'idle' | 'cleared';
export type Command =
| { type: 'start'; cell: number }
| { type: 'append'; cell: number }
| { type: 'undo' }
/** 一次回到任意已走格,保留该格及之前的路径 */
| { type: 'rewind'; cell: number }
/** 求解器给出的额外数字线索,仅作用于本次对局 */
| { type: 'reveal'; cell: number; step: number }
| { type: 'release' }
| { type: 'resume' }
| { type: 'reset' };
export type GameEvent =
/** 追加一步 */
| { type: 'step'; cell: number; step: number; conflict: boolean; clueHit: boolean }
/** 撤销一步 */
| { type: 'unstep'; cell: number; step: number }
| { type: 'rewound'; cell: number; removed: number }
| { type: 'clueRevealed'; cell: number; step: number }
| { type: 'reset' }
/** 起手被拒:有线索 1 时只能从它起手 */
| { type: 'rejectStart'; cell: number; clueCell: number }
/** 无格可走,但还没过关(信息性,不是失败) */
| { type: 'deadEnd'; cell: number }
| { type: 'cleared' };
export class Game {
readonly board: Board;
/** 已走格下标,按步序 */
readonly path: number[] = [];
status: Status = 'empty';
/** stepOfCell[i] = 第几步走到格 i,0 表示未走 */
private readonly stepOfCell: Int16Array;
private readonly extraClues = new Map<number, number>();
constructor(board: Board) {
this.board = board;
this.stepOfCell = new Int16Array(board.n);
}
get n(): number {
return this.board.n;
}
get last(): number {
return this.path.length ? this.path[this.path.length - 1] : -1;
}
get remaining(): number {
return this.board.n - this.path.length;
}
get isCleared(): boolean {
return this.status === 'cleared';
}
stepOf(cell: number): number {
return this.stepOfCell[cell];
}
isVisited(cell: number): boolean {
return this.stepOfCell[cell] !== 0;
}
/** 原始关卡不变,补充数字可与原来的奇偶要求同时生效。 */
clueAt(cell: number): Pick<Cell, 'clue' | 'parity'> {
const c = this.board.cells[cell];
return { clue: this.extraClues.get(cell) ?? c.clue, parity: c.parity };
}
get revealedClues(): ReadonlyMap<number, number> {
return this.extraClues;
}
get startClueCell(): number {
return this.clueCellAtStep(1);
}
/** 固定数字占用的步号:第 step 步必须走到这个格,包括本局补出的数字。 */
private clueCellAtStep(step: number): number {
const original = this.board.clueCellOfStep.get(step);
if (original !== undefined) return original;
for (const [cell, clue] of this.extraClues) if (clue === step) return cell;
return -1;
}
/** 本格要求不符,或该步号已被另一个固定数字格占用。 */
isConflict(cell: number): boolean {
const step = this.stepOfCell[cell];
if (step === 0) return false;
const required = this.clueCellAtStep(step);
return !matchesClue(this.clueAt(cell), step) || (required !== -1 && required !== cell);
}
hasConflict(): boolean {
for (const c of this.path) if (this.isConflict(c)) return true;
return false;
}
/** 合法起点:有线索 1 只能是它;否则任意格 */
isLegalStart(cell: number): boolean {
const s = this.startClueCell;
return s === -1 || s === cell;
}
/** 从当前末端能否追加到 cell */
canAppend(cell: number): boolean {
const last = this.last;
if (last === -1 || this.status === 'cleared') return false;
return !this.isVisited(cell) && this.board.areAdjacent(last, cell);
}
/** 当前末端所有可追加的格 */
legalMoves(): number[] {
const last = this.last;
if (last === -1) return [];
return this.board.adj[last].filter((c) => !this.isVisited(c));
}
isSolved(): boolean {
return this.path.length === this.board.n && !this.hasConflict();
}
apply(cmd: Command): GameEvent[] {
switch (cmd.type) {
case 'start':
return this.start(cmd.cell);
case 'append':
return this.append(cmd.cell);
case 'undo':
return this.undo();
case 'rewind':
return this.rewind(cmd.cell);
case 'reveal':
return this.reveal(cmd.cell, cmd.step);
case 'release':
if (this.status === 'drawing') this.status = 'idle';
return [];
case 'resume':
if (this.status === 'idle') this.status = 'drawing';
return [];
case 'reset':
return this.reset();
}
}
private start(cell: number): GameEvent[] {
if (this.status === 'cleared' || this.path.length !== 0) return [];
if (!this.isLegalStart(cell)) {
return [{ type: 'rejectStart', cell, clueCell: this.startClueCell }];
}
this.status = 'drawing';
return this.push(cell);
}
private append(cell: number): GameEvent[] {
if (!this.canAppend(cell)) return [];
this.status = 'drawing';
return this.push(cell);
}
private push(cell: number): GameEvent[] {
this.path.push(cell);
const step = this.path.length;
this.stepOfCell[cell] = step;
const c = this.clueAt(cell);
const conflict = this.isConflict(cell);
const events: GameEvent[] = [
{
type: 'step',
cell,
step,
conflict,
clueHit: hasClue(c) && !conflict,
},
];
if (this.isSolved()) {
this.status = 'cleared';
events.push({ type: 'cleared' });
} else if (this.legalMoves().length === 0) {
events.push({ type: 'deadEnd', cell });
}
return events;
}
private undo(): GameEvent[] {
if (this.status === 'cleared' || this.path.length === 0) return [];
const cell = this.path.pop()!;
const step = this.stepOfCell[cell];
this.stepOfCell[cell] = 0;
if (this.path.length === 0) this.status = 'empty';
return [{ type: 'unstep', cell, step }];
}
private rewind(cell: number): GameEvent[] {
const index = this.path.indexOf(cell);
if (this.isCleared || index < 0 || index === this.path.length - 1) return [];
const removed = this.path.length - index - 1;
const events: GameEvent[] = [];
while (this.path.length > index + 1) events.push(...this.undo());
events.push({ type: 'rewound', cell, removed });
return events;
}
private reveal(cell: number, step: number): GameEvent[] {
const c = this.board.cells[cell];
if (this.isCleared || !Number.isInteger(cell) || !c || !Number.isInteger(step) || step < 1 || step > this.n) return [];
if (this.clueAt(cell).clue !== null || !matchesClue(c, step)) return [];
if (this.board.clueCellOfStep.has(step) || [...this.extraClues.values()].includes(step)) return [];
this.extraClues.set(cell, step);
return [{ type: 'clueRevealed', cell, step }];
}
private reset(): GameEvent[] {
// 重开只清路径,已经补出的数字继续帮助玩家,不修改原始关卡。
if (this.path.length === 0 && this.status === 'empty') return [];
this.path.length = 0;
this.stepOfCell.fill(0);
this.status = 'empty';
return [{ type: 'reset' }];
}
}
+95
View File
@@ -0,0 +1,95 @@
// 手势 → 命令。GDD §5.1。
// 这里只认“指针现在压在哪个格上”(由 layout.hitTest 算出),不碰 DOM 事件;
// Web 用 PointerEvent 喂,Cocos 用 Node TOUCH_START/MOVE/END 喂,逻辑完全一致。
import type { Command, GameEvent } from './game';
import { Game } from './game';
export class PointerInput {
private readonly game: Game;
/** 手指是否在“画”的状态(压着且已接上路径) */
private drawing = false;
/** 指针上一次落在的格,避免同一格重复触发 */
private hoverCell = -1;
constructor(game: Game) {
this.game = game;
}
get isDrawing(): boolean {
return this.drawing;
}
/** 按下。cell < 0 表示按在空白处 */
down(cell: number): GameEvent[] {
this.drawing = false;
this.hoverCell = cell;
const g = this.game;
if (g.isCleared || cell < 0) return [];
if (g.path.length === 0) {
const ev = this.run({ type: 'start', cell });
this.drawing = g.status === 'drawing';
return ev;
}
const last = g.last;
if (cell === last) {
// 从末端继续画
this.drawing = true;
return this.run({ type: 'resume' });
}
if (g.isVisited(cell)) {
// 点路径上的任意格:直接回到这里,不必沿原路线逐格退。
const ev = this.run({ type: 'rewind', cell });
this.drawing = true;
this.run({ type: 'resume' });
return ev;
}
if (g.canAppend(cell)) {
// 直接点末端旁边的空格:追加(点着走)
const ev = this.run({ type: 'append', cell });
this.drawing = g.status === 'drawing';
return ev;
}
return [];
}
/** 移动到某格(或空白 -1)。只在 drawing 时生效 */
move(cell: number): GameEvent[] {
if (cell === this.hoverCell) return [];
this.hoverCell = cell;
if (!this.drawing || cell < 0) return [];
const g = this.game;
if (g.isCleared) {
this.drawing = false;
return [];
}
if (cell !== g.last && g.isVisited(cell)) {
return this.run({ type: 'rewind', cell });
}
if (g.canAppend(cell)) {
const ev = this.run({ type: 'append', cell });
if (g.isCleared) this.drawing = false;
return ev;
}
// 不相邻的空格:忽略。只能沿相邻格前进,但可直接回到任意已走格。
return [];
}
/** 松手 */
up(): GameEvent[] {
this.hoverCell = -1;
if (!this.drawing) return [];
this.drawing = false;
return this.run({ type: 'release' });
}
/** 外部(按钮)触发重开时同步状态 */
cancel(): void {
this.drawing = false;
this.hoverCell = -1;
}
private run(cmd: Command): GameEvent[] {
return this.game.apply(cmd);
}
}
+166
View File
@@ -0,0 +1,166 @@
// 布局:把单位网格铺进给定区域;格矩形、桥矩形、命中测试。GDD §9「按短边缩放,四周留指宽」。
// 纯数学,Web 与 Cocos 共用(Cocos 侧 y 轴向上,换算时翻一下 y 即可)。
import type { Board } from './board';
export interface Rect {
x: number;
y: number;
w: number;
h: number;
}
export interface BoardLayout {
/** 一个单位格的像素边长 */
unit: number;
/** 盘面左上角在容器里的偏移 */
originX: number;
originY: number;
/** 相邻格之间的缝 */
gap: number;
/** 圆角 */
radius: number;
/** 盘面总像素尺寸 */
width: number;
height: number;
}
export interface FitOptions {
/** 单位格最大像素,避免 3 格的教学关铺满全屏 */
maxUnit?: number;
/** 容器四周留白 */
padding?: number;
/** 缝占单位格的比例 */
gapRatio?: number;
radiusRatio?: number;
}
export function fitBoard(board: Board, availW: number, availH: number, opts: FitOptions = {}): BoardLayout {
const padding = opts.padding ?? 16;
const maxUnit = opts.maxUnit ?? 104;
const w = Math.max(0, availW - padding * 2);
const h = Math.max(0, availH - padding * 2);
const unit = Math.max(8, Math.min(maxUnit, w / board.cols, h / board.rows));
const width = unit * board.cols;
const height = unit * board.rows;
return {
unit,
originX: (availW - width) / 2,
originY: (availH - height) / 2,
gap: unit * (opts.gapRatio ?? 0.1),
radius: unit * (opts.radiusRatio ?? 0.18),
width,
height,
};
}
/** 格的像素矩形(已扣掉缝的一半,格与格之间留出 gap) */
export function cellRect(board: Board, cell: number, L: BoardLayout): Rect {
const c = board.cells[cell];
const half = L.gap / 2;
return {
x: L.originX + c.x * L.unit + half,
y: L.originY + c.y * L.unit + half,
w: c.w * L.unit - L.gap,
h: c.h * L.unit - L.gap,
};
}
/** 格的完整占位矩形(不扣缝),用于命中测试 */
export function cellBounds(board: Board, cell: number, L: BoardLayout): Rect {
const c = board.cells[cell];
return {
x: L.originX + c.x * L.unit,
y: L.originY + c.y * L.unit,
w: c.w * L.unit,
h: c.h * L.unit,
};
}
/**
* 异形格的绘制矩形集:每个单位一个矩形(扣半缝),加上相邻单位之间的接缝矩形。
* 接缝矩形沿拼接方向向两侧单位内各延长 overlap(= 圆角半径),把单位圆角在接缝两端
* 咬出的缺口填满——单位 + 接缝同色填充后,视觉上是连续的一整块(像 2×2 那样),
* 圆角只留在凸外角,凹角自然为直角。
*/
export function cellShapeRects(
board: Board,
cell: number,
L: BoardLayout,
overlap = L.radius,
): { units: Rect[]; seams: Rect[] } {
const c = board.cells[cell];
const half = L.gap / 2;
const unitRect = (ux: number, uy: number): Rect => ({
x: L.originX + ux * L.unit + half,
y: L.originY + uy * L.unit + half,
w: L.unit - L.gap,
h: L.unit - L.gap,
});
const set = new Set(c.units.map(([ux, uy]) => `${ux},${uy}`));
const units: Rect[] = [];
const seams: Rect[] = [];
for (const [ux, uy] of c.units) {
units.push(unitRect(ux, uy));
if (set.has(`${ux + 1},${uy}`)) {
seams.push({
x: L.originX + (ux + 1) * L.unit - half - overlap,
y: L.originY + uy * L.unit + half,
w: L.gap + overlap * 2,
h: L.unit - L.gap,
});
}
if (set.has(`${ux},${uy + 1}`)) {
seams.push({
x: L.originX + ux * L.unit + half,
y: L.originY + (uy + 1) * L.unit - half - overlap,
w: L.unit - L.gap,
h: L.gap + overlap * 2,
});
}
}
return { units, seams };
}
/**
* 连接两相邻格的“桥”矩形:横跨缝,沿共享边居中,宽度为共享边长的一部分。
* 与两侧格同色时,视觉上就是一条连续的路径。
*/
export function bridgeRect(board: Board, a: number, b: number, L: BoardLayout, thicknessRatio = 0.46): Rect | null {
const e = board.sharedEdge(a, b);
if (!e) return null;
const thick = L.unit * thicknessRatio;
const span = L.gap + 2; // 多盖 1px,避免抗锯齿露缝
if (e.dir === 'v') {
const x = L.originX + e.x1 * L.unit;
const cy = L.originY + ((e.y1 + e.y2) / 2) * L.unit;
return { x: x - span / 2, y: cy - thick / 2, w: span, h: thick };
}
const y = L.originY + e.y1 * L.unit;
const cx = L.originX + ((e.x1 + e.x2) / 2) * L.unit;
return { x: cx - thick / 2, y: y - span / 2, w: thick, h: span };
}
/**
* 像素点 → 格下标。inset 是每个格四周的“死区”比例(相对单位格),
* 手指扫过格与格的交界时不会误触;落在死区返回 -1(调用方保持上一格)。
*/
export function hitTest(board: Board, L: BoardLayout, px: number, py: number, insetRatio = 0.14): number {
const ux = Math.floor((px - L.originX) / L.unit);
const uy = Math.floor((py - L.originY) / L.unit);
const cell = board.cellAtUnit(ux, uy);
if (cell < 0) return -1;
const b = cellBounds(board, cell, L);
const inset = L.unit * insetRatio;
if (px < b.x + inset || px > b.x + b.w - inset || py < b.y + inset || py > b.y + b.h - inset) return -1;
return cell;
}
export function rectCenter(r: Rect): { x: number; y: number } {
return { x: r.x + r.w / 2, y: r.y + r.h / 2 };
}
export function scaleRect(r: Rect, s: number): Rect {
const cx = r.x + r.w / 2;
const cy = r.y + r.h / 2;
return { x: cx - (r.w * s) / 2, y: cy - (r.h * s) / 2, w: r.w * s, h: r.h * s };
}
+37
View File
@@ -0,0 +1,37 @@
// 关卡包读取与校验。运行时(Web / Cocos)和工具链共用同一份解析。
import { Board } from './board';
import type { LevelDef, PackDef } from './types';
export interface LoadedLevel {
def: LevelDef;
board: Board;
pack: number;
/** 包内序号,从 1 起 */
index: number;
}
export function parsePack(raw: unknown): PackDef {
if (!raw || typeof raw !== 'object') throw new Error('关卡包不是对象');
const p = raw as Partial<PackDef>;
if (typeof p.pack !== 'number' || !Array.isArray(p.levels)) throw new Error('关卡包缺少 pack / levels');
for (const lv of p.levels) {
if (typeof lv.id !== 'string' || !Array.isArray(lv.cells)) throw new Error(`关卡格式错误: ${JSON.stringify(lv).slice(0, 60)}`);
}
return {
mode: String(p.mode ?? '01-number-stroke'),
pack: p.pack,
title: String(p.title ?? `包 ${p.pack}`),
notes: p.notes,
levels: p.levels,
};
}
/** 把包展开成可玩关卡(构建 Board 时会校验格尺寸 / 重叠 / 线索范围) */
export function loadPack(pack: PackDef): LoadedLevel[] {
return pack.levels.map((def, i) => ({
def,
board: Board.fromLevel(def),
pack: pack.pack,
index: i + 1,
}));
}
+11
View File
@@ -0,0 +1,11 @@
// 绘制接口:BoardView 只调用这几个原语,不知道底下是 Canvas 2D 还是 Cocos Graphics。
// Web 实现见 web/canvas-painter.ts;Cocos 实现用 Graphics(矩形/圆)+ Label(文字)即可对应。
import type { Rect } from './layout';
export interface Painter {
fillRoundRect(r: Rect, radius: number, color: string, alpha?: number): void;
strokeRoundRect(r: Rect, radius: number, color: string, lineWidth: number, alpha?: number): void;
fillCircle(cx: number, cy: number, radius: number, color: string, alpha?: number): void;
/** 水平垂直居中的文字 */
text(s: string, cx: number, cy: number, size: number, color: string, bold?: boolean, alpha?: number): void;
}
+114
View File
@@ -0,0 +1,114 @@
// 存档:本地、按玩法分桶(GDD §10)。只存已通关 id 与当前关 id,不存半途路径。
// KeyValueStore 由宿主提供:Web 用 localStorage,Cocos 用 sys.localStorage。
export interface KeyValueStore {
get(key: string): string | null;
set(key: string, value: string): void;
remove(key: string): void;
}
export const STORAGE_KEY = 'mode:01-number-stroke';
interface Saved {
cleared: string[];
currentId: string | null;
prefs?: Prefs;
}
export interface Prefs {
sound: boolean;
haptics: boolean;
}
export class Progress {
private readonly store: KeyValueStore;
private readonly key: string;
private cleared = new Set<string>();
currentId: string | null = null;
prefs: Prefs = { sound: true, haptics: true };
constructor(store: KeyValueStore, key: string = STORAGE_KEY) {
this.store = store;
this.key = key;
this.load();
}
isCleared(id: string): boolean {
return this.cleared.has(id);
}
clearedCount(): number {
return this.cleared.size;
}
markCleared(id: string): void {
this.cleared.add(id);
this.save();
}
setCurrent(id: string): void {
this.currentId = id;
this.save();
}
setPrefs(p: Partial<Prefs>): void {
this.prefs = { ...this.prefs, ...p };
this.save();
}
/**
* 线性解锁:第 i 关(0 起)解锁 ⇔ i === 0 或前一关已通。
* ids 为包内顺序。
*/
isUnlocked(ids: string[], i: number): boolean {
return i === 0 || this.cleared.has(ids[i - 1]);
}
/** 默认停在第一关未通的关;全通则最后一关 */
firstUncleared(ids: string[]): string {
for (const id of ids) if (!this.cleared.has(id)) return id;
return ids[ids.length - 1];
}
resetAll(): void {
this.cleared.clear();
this.currentId = null;
this.save();
}
private load(): void {
try {
const raw = this.store.get(this.key);
if (!raw) return;
const s = JSON.parse(raw) as Partial<Saved>;
if (Array.isArray(s.cleared)) this.cleared = new Set(s.cleared.filter((x) => typeof x === 'string'));
if (typeof s.currentId === 'string') this.currentId = s.currentId;
if (s.prefs) this.prefs = { ...this.prefs, ...s.prefs };
} catch {
// 存档损坏就当没有
}
}
private save(): void {
const s: Saved = { cleared: [...this.cleared], currentId: this.currentId, prefs: this.prefs };
try {
this.store.set(this.key, JSON.stringify(s));
} catch {
// 隐私模式等写不进去,忽略
}
}
}
/** 纯内存实现:测试与无存储环境用 */
export class MemoryStore implements KeyValueStore {
private m = new Map<string, string>();
get(k: string): string | null {
return this.m.get(k) ?? null;
}
set(k: string, v: string): void {
this.m.set(k, v);
}
remove(k: string): void {
this.m.delete(k);
}
}
+157
View File
@@ -0,0 +1,157 @@
// 求解器:枚举满足全部线索的哈密顿路径;提示逻辑。GDD §8.1、§10。
// N ≤ 22,回溯 + 连通性 / 线索距离剪枝,毫秒级。
import type { Board } from './board';
import { matchesClue } from './clue';
export interface SolveOptions {
/** 最多找几条就停(唯一性检查用 2) */
limit?: number;
/** 忽略线索,枚举全部哈密顿路径(设计工具用) */
ignoreClues?: boolean;
/** 只从这些格起手(默认按线索 1 / 任意) */
starts?: number[];
}
export function solve(board: Board, opts: SolveOptions = {}): number[][] {
const limit = opts.limit ?? Infinity;
const n = board.n;
const adj = board.adj;
const clueOf = new Int16Array(n).fill(0);
const cellOfStep = new Int16Array(n + 2).fill(-1);
if (!opts.ignoreClues) {
for (const c of board.cells) {
if (c.clue !== null) {
clueOf[c.index] = c.clue;
cellOfStep[c.clue] = c.index;
}
}
}
const futureClues: number[] = [];
for (let s = 1; s <= n; s++) if (cellOfStep[s] !== -1) futureClues.push(s);
const results: number[][] = [];
const visited = new Uint8Array(n);
const path: number[] = [];
const dist = new Int16Array(n);
const queue = new Int16Array(n);
/**
* 从 cur 出发,只经未走格做 BFS。
* 返回可达的未走格数;dist[] 填距离,用于线索距离剪枝。
*/
function bfs(cur: number): number {
dist.fill(-1);
dist[cur] = 0;
let head = 0;
let tail = 0;
queue[tail++] = cur;
let reached = 0;
while (head < tail) {
const u = queue[head++];
for (const v of adj[u]) {
if (!visited[v] && dist[v] === -1) {
dist[v] = dist[u] + 1;
reached++;
queue[tail++] = v;
}
}
}
return reached;
}
function prune(cur: number, step: number): boolean {
const remaining = n - step;
if (remaining === 0) return false;
if (bfs(cur) < remaining) return true;
// 未来每个线索格必须在“剩余步数”内够得着
for (const s of futureClues) {
if (s <= step) continue;
const c = cellOfStep[s];
if (visited[c]) return true; // 线索格已被提前走过(步号不对)
const d = dist[c];
if (d === -1 || d > s - step) return true;
}
return false;
}
function dfs(cur: number, step: number): boolean {
if (step === n) {
results.push(path.slice());
return results.length >= limit;
}
if (prune(cur, step)) return false;
const nextStep = step + 1;
const forced = cellOfStep[nextStep];
if (forced !== -1) {
if (visited[forced] || !adj[cur].includes(forced)) return false;
return visit(forced, nextStep);
}
// 出口少的先走(Warnsdorff),找第一条解更快
const cands = adj[cur].filter((v) => !visited[v] && clueOf[v] === 0);
if (cands.length > 1) {
cands.sort((a, b) => freeDegree(a) - freeDegree(b));
}
for (const v of cands) {
if (visit(v, nextStep)) return true;
}
return false;
}
function freeDegree(v: number): number {
let d = 0;
for (const w of adj[v]) if (!visited[w]) d++;
return d;
}
function visit(v: number, step: number): boolean {
if (!opts.ignoreClues && !matchesClue(board.cells[v], step)) return false;
visited[v] = 1;
path.push(v);
const stop = dfs(v, step);
path.pop();
visited[v] = 0;
return stop;
}
let starts: number[];
if (opts.starts) starts = opts.starts;
else if (cellOfStep[1] !== -1) starts = [cellOfStep[1]];
else starts = board.cells.map((c) => c.index);
for (const s of starts) {
if (clueOf[s] !== 0 && clueOf[s] !== 1) continue;
if (visit(s, 1)) break;
}
return results;
}
/** 唯一解;无解或多解返回 null */
export function uniqueSolution(board: Board): number[] | null {
const paths = solve(board, { limit: 2 });
return paths.length === 1 ? paths[0] : null;
}
export type Hint =
/** 还没起手:指出起点 */
| { kind: 'start'; cell: number }
/** 路径是解的前缀:下一步 */
| { kind: 'next'; cell: number }
/** 已偏离:退回到 cell(路径中最后一个仍在解上的格) */
| { kind: 'backtrack'; cell: number; undoCount: number }
/** 起手就错了(只在无线索 1 的盘出现):需要重开 */
| { kind: 'restart'; cell: number }
| { kind: 'none' };
export function hint(solution: number[], path: number[]): Hint {
if (path.length === 0) return { kind: 'start', cell: solution[0] };
let common = 0;
while (common < path.length && common < solution.length && path[common] === solution[common]) {
common++;
}
if (common === path.length) {
if (common === solution.length) return { kind: 'none' };
return { kind: 'next', cell: solution[common] };
}
if (common === 0) return { kind: 'restart', cell: solution[0] };
return { kind: 'backtrack', cell: path[common - 1], undoCount: path.length - common };
}
+90
View File
@@ -0,0 +1,90 @@
// 设计令牌(浅色单主题)。权威说明见仓库 docs/design.md;改这里必须同步改 web/style.css 的 :root 与设计文档。
// tests/theme.test.ts 会校验 style.css 里同名令牌与本文件一致。
// GDD §6:识别数字和冲突优先于换皮,不要每关换主题色。
export const THEME = {
/** 页面背景 */
bg: '#FFFFFF',
/** 未走格 */
cell: '#E6E7EC',
/** 未走线索格的数字 */
clueInk: '#23243A',
/** 未走线索 / 奇偶格的描边:与线索数字同色(墨),像印在盘上的一圈,简洁不跳脱 */
clueRing: '#23243A',
/** 起点 / 已走线索的灰白描边,在紫底外留一圈轮廓 */
activeClueRing: '#DFE1EC',
/** 已走主色(蓝紫) */
visited: '#6558F5',
/** 过关后提亮 */
visitedBright: '#7D72FF',
/** 已走格上的数字 */
visitedInk: '#FFFFFF',
/** 冲突 */
conflict: '#E5484D',
conflictInk: '#FFFFFF',
/** 提示高亮(琥珀) */
hint: '#F5A524',
/** 线索踩对 / 过关扫光的闪色 */
flash: '#FFFFFF',
} as const;
export type Theme = typeof THEME;
/**
* 盘面字号:相对单位格边长的比例。数字用 800 字重,汉字(奇/偶)用 700。
* 与 docs/design.md「盘面字阶」一致。
*/
export const BOARD_TYPE = {
/** 起点 1(未按下时的视觉提示,以及已走的第 1 步) */
start: 0.6,
/** 单位数步号 / 线索 */
single: 0.52,
/** 两位数步号 / 线索 */
double: 0.46,
/** 未走奇偶格的「奇 / 偶」 */
parity: 0.44,
/** 已走奇偶格下方的小字「奇 / 偶」 */
paritySub: 0.19,
/** 冲突角标半径(相对单位格)与角标字号(相对角标半径) */
badge: 0.15,
badgeText: 1.25,
/** 线索描边线宽(相对单位格),最小 2px */
ring: 0.05,
/** 提示描边线宽(相对单位格),最小 3px */
hintRing: 0.08,
} as const;
/**
* 动效常量(秒)。四档时长:fast 0.12 / base 0.2 / slow 0.32 / hold 0.5,
* 与 web/style.css 的 --t-fast / --t-base / --t-slow / --t-hold 对应。
*/
export const MOTION = {
fast: 0.12,
base: 0.2,
slow: 0.32,
hold: 0.5,
/** 落子弹簧:给缩放的初速度、刚度、阻尼(约 200ms 内回到 1) */
popImpulse: 2.4,
springK: 260,
springDamp: 18,
/** 线索踩对闪白:slow 内线性衰减 */
flashTime: 0.32,
/** 抖动:base 稍长,六个来回 */
shakeTime: 0.28,
/** 步号淡入 = fast */
numberFade: 0.12,
/** 桥生长 = fast(只长厚度,跨缝方向始终盖满) */
bridgeGrow: 0.12,
/** 提示高亮总时长与脉冲角频率(约 1.1Hz) */
hintTime: 2.4,
hintPulse: 7,
/** 过关:按步序扫光 slow,停留 hold,再退场 fast */
clearedSweep: 0.32,
clearedSweepCell: 0.25,
clearedHold: 0.5,
boardExit: 0.16,
/** 开局:格子按阅读顺序错落进场,每格 base,相邻延迟 14ms,总延迟封顶 0.2s */
boardEnter: 0.2,
boardEnterStagger: 0.014,
boardEnterMaxDelay: 0.2,
} as const;
+38
View File
@@ -0,0 +1,38 @@
// 关卡文件结构(levels/*.json),见 GDD §8.3。
// 本文件与整个 src/ 都不依赖 DOM / Cocos,可原样搬入 Cocos Creator。
export interface CellDef {
id: string;
/** 单位格坐标,原点左上(shape 的锚点) */
x: number;
y: number;
/** 宽高(单位格数)。矩形格允许 1×1、2×1、1×2、2×2、3×3;给 shape 时必须等于 shape 的紧凑包围盒 */
w: number;
h: number;
/**
* 异形格(俄罗斯方块式拼块):相对 (x,y) 的单位格偏移列表,2~4 格、四向连通,
* 目录见 board.ts 的 ALLOWED_SHAPES(I/L/S/T/O,含旋转镜像)。缺省 = w×h 矩形。
*/
shape?: ReadonlyArray<readonly [number, number]>;
/** 线索:路径走到本格时步号必须等于它 */
clue?: number;
/** 奇偶线索:本格步号必须为奇数 / 偶数,与 clue 互斥;整块仍只计一步 */
parity?: 'odd' | 'even';
}
export interface LevelDef {
id: string;
/** 教学备注(设计用,不显示) */
teach?: string;
/** 对局顶部一行文案(只有教学关有) */
caption?: string;
cells: CellDef[];
}
export interface PackDef {
mode: string;
pack: number;
title: string;
notes?: string;
levels: LevelDef[];
}
+421
View File
@@ -0,0 +1,421 @@
// 盘面视图:格、桥、数字、冲突、提示、开局进场、过关动画。GDD §6,动效口径见 docs/design.md。
// 纯 TS:只依赖 Game 状态和 Painter 接口。宿主每帧调用 update(dt) + draw(painter)。
import type { Board } from './board';
import { clueLabel, hasClue, matchesClue } from './clue';
import type { Game, GameEvent } from './game';
import { bridgeRect, cellRect, cellShapeRects, scaleRect, type BoardLayout, type Rect } from './layout';
import type { Painter } from './painter';
import type { Hint } from './solver';
import { BOARD_TYPE, MOTION, THEME } from './theme';
/** 单格动效状态 */
interface CellAnim {
/** 弹性缩放,回落到 1 */
scale: number;
scaleVel: number;
/** 描边闪白 0..1,线性衰减 */
flash: number;
/** 抖动剩余秒数 */
shake: number;
/** 步号淡入 0..1 */
numberAlpha: number;
/** 过关扫光 0..1 */
sweep: number;
/** 开局进场 0..1(缩放 0.92→1 + 淡入) */
enter: number;
/** 进场延迟(秒),按阅读顺序错落 */
enterDelay: number;
}
export interface BoardViewOptions {
/** 系统“减弱动态效果”:去掉进场错落、弹簧和扫光,只保留状态变化 */
reducedMotion?: boolean;
}
/** ease-out(quart),与 CSS --ease-out 观感一致 */
function easeOut(t: number): number {
const u = 1 - Math.max(0, Math.min(1, t));
return 1 - u * u * u * u;
}
/** 格的静态绘制形状(无动效):矩形格 = 单个 cellRect;异形格 = 单位矩形 + 接缝矩形(连成一整块) */
function staticShape(board: Board, cell: number, L: BoardLayout): { units: Rect[]; seams: Rect[]; radius: number } {
const c = board.cells[cell];
if (c.isRect) return { units: [cellRect(board, cell, L)], seams: [], radius: L.radius };
const { units, seams } = cellShapeRects(board, cell, L);
return { units, seams, radius: L.radius };
}
export class BoardView {
readonly game: Game;
layout: BoardLayout | null = null;
private readonly reduced: boolean;
private readonly cells: CellAnim[];
/** bridges[i] = 第 i 步与第 i+1 步之间的桥的出现进度 0..1 */
private bridges: number[] = [];
private hintState: { hint: Hint | { kind: 'clue'; cell: number }; t: number } | null = null;
private cleared = false;
private clearedT = 0;
/** 退场进度 0..1(整盘淡出,宿主在切关前调用 beginExit) */
private exit = 0;
private exiting = false;
/** 是否有动画在跑(宿主可据此决定要不要重绘) */
private animating = true;
constructor(game: Game, opts: BoardViewOptions = {}) {
this.game = game;
this.reduced = opts.reducedMotion === true;
// 进场顺序:按阅读顺序(先行后列)错落,最长延迟封顶
const order = game.board.cells
.map((c) => ({ i: c.index, key: c.y * 1000 + c.x }))
.sort((a, b) => a.key - b.key)
.map((o) => o.i);
const delay = new Array<number>(order.length);
order.forEach((cell, rank) => {
delay[cell] = Math.min(MOTION.boardEnterMaxDelay, rank * MOTION.boardEnterStagger);
});
this.cells = game.board.cells.map((c) => ({
scale: 1,
scaleVel: 0,
flash: 0,
shake: 0,
numberAlpha: 0,
sweep: 0,
enter: this.reduced ? 1 : 0,
enterDelay: this.reduced ? 0 : delay[c.index],
}));
}
get isAnimating(): boolean {
return this.animating;
}
/** 过关动画时长(秒):扫光 + 停留 + 退场。宿主据此安排跳下一关 */
get clearedDuration(): number {
return MOTION.clearedSweep + MOTION.clearedHold + MOTION.boardExit;
}
/** 过关后到开始退场的时刻(秒),宿主据此调用 beginExit */
get clearedHoldEnd(): number {
return MOTION.clearedSweep + MOTION.clearedHold;
}
invalidate(): void {
this.animating = true;
}
/** 整盘淡出(切关 / 返回选关前) */
beginExit(): void {
if (this.reduced) {
this.exit = 1;
} else {
this.exiting = true;
}
this.animating = true;
}
onEvent(e: GameEvent): void {
this.animating = true;
this.hintState = null;
switch (e.type) {
case 'step': {
const a = this.cells[e.cell];
a.scaleVel = this.reduced || e.conflict ? 0 : MOTION.popImpulse;
a.shake = !this.reduced && e.conflict ? MOTION.shakeTime : 0;
a.numberAlpha = this.reduced ? 1 : 0;
if (e.clueHit) a.flash = 1;
this.bridges.length = Math.max(0, e.step - 1);
if (e.step >= 2) this.bridges[e.step - 2] = this.reduced ? 1 : 0;
break;
}
case 'unstep': {
const a = this.cells[e.cell];
a.numberAlpha = 0;
a.flash = 0;
a.scale = 1;
a.scaleVel = 0;
a.shake = 0;
this.bridges.length = Math.max(0, e.step - 2);
break;
}
case 'rewound':
this.cells[e.cell].scaleVel = this.reduced ? 0 : MOTION.popImpulse;
break;
case 'clueRevealed': {
this.cells[e.cell].scaleVel = this.reduced ? 0 : MOTION.popImpulse;
// 新数字可能让已画的格变成冲突:线索格本身,以及占用了该步号的格。
for (const cell of [e.cell, this.game.path[e.step - 1]]) {
if (cell === undefined || !this.game.isConflict(cell)) continue;
const a = this.cells[cell];
a.scale = 1;
a.scaleVel = 0;
a.shake = this.reduced ? 0 : MOTION.shakeTime;
}
this.hintState = { hint: { kind: 'clue', cell: e.cell }, t: MOTION.hintTime };
break;
}
case 'reset':
for (const a of this.cells) {
a.flash = 0;
a.numberAlpha = 0;
a.scale = 1;
a.scaleVel = 0;
a.shake = 0;
a.sweep = 0;
}
this.bridges.length = 0;
this.cleared = false;
this.clearedT = 0;
break;
case 'rejectStart':
if (e.clueCell >= 0) this.cells[e.clueCell].shake = this.reduced ? 0 : MOTION.shakeTime;
break;
case 'deadEnd':
this.cells[e.cell].shake = this.reduced ? 0 : MOTION.shakeTime;
break;
case 'cleared':
this.cleared = true;
this.clearedT = 0;
break;
}
}
showHint(h: Hint): void {
this.hintState = h.kind === 'none' ? null : { hint: h, t: MOTION.hintTime };
this.animating = true;
}
update(dt: number): void {
if (dt <= 0) return;
dt = Math.min(dt, 0.05);
let busy = false;
for (const a of this.cells) {
// 进场:先等自己的延迟,再在 boardEnter 内走到 1
if (a.enter < 1) {
if (a.enterDelay > 0) a.enterDelay = Math.max(0, a.enterDelay - dt);
else a.enter = Math.min(1, a.enter + dt / MOTION.boardEnter);
busy = true;
}
// 弹簧回 1
if (a.scaleVel !== 0 || Math.abs(a.scale - 1) > 0.0005) {
const acc = -MOTION.springK * (a.scale - 1) - MOTION.springDamp * a.scaleVel;
a.scaleVel += acc * dt;
a.scale += a.scaleVel * dt;
if (Math.abs(a.scale - 1) < 0.0005 && Math.abs(a.scaleVel) < 0.01) {
a.scale = 1;
a.scaleVel = 0;
} else busy = true;
}
if (a.flash > 0) {
a.flash = Math.max(0, a.flash - dt / MOTION.flashTime);
busy = true;
}
if (a.shake > 0) {
a.shake = Math.max(0, a.shake - dt);
busy = true;
}
if (a.numberAlpha < 1) {
a.numberAlpha = Math.min(1, a.numberAlpha + dt / MOTION.numberFade);
busy = true;
}
}
for (let i = 0; i < this.bridges.length; i++) {
if (this.bridges[i] < 1) {
this.bridges[i] = Math.min(1, this.bridges[i] + dt / MOTION.bridgeGrow);
busy = true;
}
}
if (this.hintState) {
this.hintState.t -= dt;
if (this.hintState.t <= 0) this.hintState = null;
busy = true;
}
if (this.cleared) {
this.clearedT += dt;
const path = this.game.path;
for (let i = 0; i < path.length; i++) {
// 按步序依次扫过
const start = (i / path.length) * MOTION.clearedSweep;
const local = this.reduced ? 1 : (this.clearedT - start) / MOTION.clearedSweepCell;
this.cells[path[i]].sweep = Math.max(0, Math.min(1, local));
}
if (this.clearedT < this.clearedDuration) busy = true;
}
if (this.exiting && this.exit < 1) {
this.exit = Math.min(1, this.exit + dt / MOTION.boardExit);
busy = true;
}
this.animating = busy;
}
/** 单格的绘制矩形集:已套用抖动、弹性缩放与进场缩放。异形格绕锚点整体缩放,拼块不会散架 */
private cellDraw(cell: number): { units: Rect[]; seams: Rect[]; radius: number; anchor: Rect; badge: Rect; alpha: number } {
const L = this.layout!;
const board = this.game.board;
const c = board.cells[cell];
const a = this.cells[cell];
const d = staticShape(board, cell, L);
const k = a.shake / MOTION.shakeTime;
const shakeDx = a.shake > 0 ? Math.sin(k * Math.PI * 6) * L.unit * 0.06 * k : 0;
const enter = easeOut(a.enter);
const scale = a.scale * (0.92 + 0.08 * enter);
const [acx, acy] = board.anchorCenter(cell);
const pivot = { x: L.originX + acx * L.unit, y: L.originY + acy * L.unit };
const tf = (r: Rect): Rect => {
let o = r;
if (shakeDx) o = { ...o, x: o.x + shakeDx };
if (scale !== 1) {
o = c.isRect
? scaleRect(o, scale)
: { x: pivot.x + (o.x - pivot.x) * scale, y: pivot.y + (o.y - pivot.y) * scale, w: o.w * scale, h: o.h * scale };
}
return o;
};
const units = d.units.map(tf);
const seams = d.seams.map(tf);
const [aux, auy] = board.anchorUnit(cell);
const anchorIdx = c.units.findIndex(([ux, uy]) => ux === aux && uy === auy);
let badgeIdx = 0; // 最上排里最右的单位,冲突角标放它右上角
c.units.forEach(([ux, uy], i) => {
const [bx, by] = c.units[badgeIdx];
if (uy < by || (uy === by && ux > bx)) badgeIdx = i;
});
return {
units,
seams,
radius: d.radius,
anchor: units[c.isRect ? 0 : anchorIdx],
badge: units[c.isRect ? 0 : badgeIdx],
alpha: enter * (1 - this.exit),
};
}
draw(p: Painter): void {
const L = this.layout;
if (!L) return;
const g = this.game;
const board = g.board;
const path = g.path;
const boardAlpha = 1 - this.exit;
if (boardAlpha <= 0) return;
const pathColor = this.cleared ? THEME.visitedBright : THEME.visited;
// 1. 底板:所有格(异形格 = 单位矩形 + 接缝矩形,同色即连成一块)
for (const c of board.cells) {
const a = this.cells[c.index];
const d = this.cellDraw(c.index);
const step = g.stepOf(c.index);
const visited = step !== 0;
const conflict = g.isConflict(c.index);
let fill: string = THEME.cell;
if (conflict) fill = THEME.conflict;
else if (visited || g.clueAt(c.index).clue === 1) fill = pathColor;
for (const r of d.units) p.fillRoundRect(r, d.radius, fill, d.alpha);
for (const r of d.seams) p.fillRoundRect(r, 0, fill, d.alpha);
// 过关扫光
if (visited && a.sweep > 0 && a.sweep < 1) {
const alpha = 0.55 * Math.sin(a.sweep * Math.PI) * d.alpha;
for (const r of d.units) p.fillRoundRect(r, d.radius, THEME.flash, alpha);
for (const r of d.seams) p.fillRoundRect(r, 0, THEME.flash, alpha);
}
}
// 2. 桥:连接相邻两步(画在格上面,同色即融为一体)。只长厚度,跨缝方向始终盖满,不露缝
for (let i = 0; i < path.length - 1; i++) {
const k = easeOut(this.bridges[i] ?? 1);
if (k <= 0) continue;
const br = bridgeRect(board, path[i], path[i + 1], L);
if (!br) continue;
const horizontalGap = br.w < br.h; // 竖向共享边:桥横跨 x 方向的缝,厚度沿 y
const grown: Rect = horizontalGap
? { x: br.x, y: br.y + (br.h * (1 - k)) / 2, w: br.w, h: br.h * k }
: { x: br.x + (br.w * (1 - k)) / 2, y: br.y, w: br.w * k, h: br.h };
p.fillRoundRect(grown, 0, pathColor, boardAlpha);
}
// 3. 线索描边、数字、冲突标记
for (const c of board.cells) {
const a = this.cells[c.index];
const d = this.cellDraw(c.index);
const cx = d.anchor.x + d.anchor.w / 2;
const cy = d.anchor.y + d.anchor.h / 2;
const step = g.stepOf(c.index);
const visited = step !== 0;
const conflict = g.isConflict(c.index);
const clue = g.clueAt(c.index);
const isClue = hasClue(clue);
const isStart = clue.clue === 1 || step === 1;
// 起点更醒目;双位数留出横向空间;汉字与数字按视觉大小分别取字阶(docs/design.md 盘面字阶)
const label = visited ? String(step) : clueLabel(clue);
const ratio = isStart && !conflict ? BOARD_TYPE.start : !visited && clue.clue === null && c.parity !== null ? BOARD_TYPE.parity : label.length > 1 ? BOARD_TYPE.double : BOARD_TYPE.single;
const fontSize = L.unit * ratio;
if (isClue) {
const lw = Math.max(2, L.unit * BOARD_TYPE.ring);
const ringColor = visited || clue.clue === 1 ? THEME.activeClueRing : THEME.clueRing;
// 外层剪影 + 内层填充:矩形和异形用同一种轮廓画法,避免描边和底色
// 抗锯齿不重合时在圆角外留下零散的紫点。仍只用原有 Painter 原语。
const silhouette = (color: string, inset: number, alpha = 1): void => {
for (const r of d.units) {
p.fillRoundRect({ x: r.x + inset, y: r.y + inset, w: r.w - 2 * inset, h: r.h - 2 * inset }, Math.max(0, d.radius - inset), color, alpha * d.alpha);
}
for (const r of d.seams) {
const horizontal = r.w < r.h;
p.fillRoundRect(horizontal
? { x: r.x, y: r.y + inset, w: r.w, h: r.h - 2 * inset }
: { x: r.x + inset, y: r.y, w: r.w - 2 * inset, h: r.h }, 0, color, alpha * d.alpha);
}
};
silhouette(ringColor, 0);
if (a.flash > 0) silhouette(THEME.flash, 0, a.flash);
silhouette(conflict ? THEME.conflict : visited || clue.clue === 1 ? pathColor : THEME.cell, lw);
if (visited && a.sweep > 0 && a.sweep < 1) silhouette(THEME.flash, lw, 0.55 * Math.sin(a.sweep * Math.PI));
}
const showParity = c.parity !== null && !conflict && (visited || clue.clue !== null);
if (visited) {
const ink = conflict ? THEME.conflictInk : THEME.visitedInk;
p.text(String(step), cx, cy - (showParity ? L.unit * 0.09 : 0), fontSize, ink, true, a.numberAlpha * d.alpha);
if (showParity) p.text(clueLabel(c), cx, cy + L.unit * 0.23, L.unit * BOARD_TYPE.paritySub, ink, true, d.alpha);
if (conflict && !matchesClue(clue, step)) {
// 本格要求不符时保留原要求;仅步号位置错误时只标红、轻晃,不加 ×。
const br = Math.max(7, L.unit * BOARD_TYPE.badge);
const bx = d.badge.x + d.badge.w - br - L.unit * 0.08;
const by = d.badge.y + br + L.unit * 0.08;
p.fillCircle(bx, by, br, THEME.clueInk, d.alpha);
p.text(clueLabel(clue), bx, by + br * 0.03, br * BOARD_TYPE.badgeText, THEME.conflictInk, true, d.alpha);
}
} else if (isClue) {
const ink = clue.clue === 1 ? THEME.visitedInk : THEME.clueInk;
p.text(label, cx, cy - (showParity ? L.unit * 0.09 : 0), fontSize, ink, true, d.alpha);
if (showParity) p.text(clueLabel(c), cx, cy + L.unit * 0.23, L.unit * BOARD_TYPE.paritySub, ink, true, d.alpha);
}
}
// 4. 提示
if (this.hintState) {
const { hint, t } = this.hintState;
const pulse = this.reduced ? 1 : 0.55 + 0.45 * Math.sin((MOTION.hintTime - t) * MOTION.hintPulse);
const alpha = Math.min(1, t / 0.4) * pulse * boardAlpha;
const target = hint.kind === 'none' ? -1 : hint.cell;
if (target >= 0) {
const d = staticShape(board, target, L);
const lw = Math.max(3, L.unit * BOARD_TYPE.hintRing);
for (const r of d.units) {
const outer: Rect = { x: r.x - lw, y: r.y - lw, w: r.w + lw * 2, h: r.h + lw * 2 };
p.strokeRoundRect(outer, d.radius + lw, THEME.hint, lw, alpha);
}
}
if (hint.kind === 'backtrack') {
// 要退掉的那一段压暗
const from = path.indexOf(hint.cell) + 1;
for (let i = from; i < path.length; i++) {
const d = staticShape(board, path[i], L);
for (const r of d.units) p.fillRoundRect(r, d.radius, THEME.bg, 0.45 * pulse * boardAlpha);
for (const r of d.seams) p.fillRoundRect(r, 0, THEME.bg, 0.45 * pulse * boardAlpha);
}
}
}
}
}
+140
View File
@@ -0,0 +1,140 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { Board } from '../src/board';
import type { CellDef } from '../src/types';
const c = (id: string, x: number, y: number, w = 1, h = 1, clue?: number): CellDef => ({ id, x, y, w, h, clue });
test('只认四向邻接,角对角不算', () => {
const b = Board.fromCells([c('a', 0, 0), c('b', 1, 1), c('c', 1, 0)]);
assert.equal(b.areAdjacent(0, 1), false);
assert.equal(b.areAdjacent(0, 2), true);
assert.equal(b.areAdjacent(2, 1), true);
});
test('2×2 可以同时贴多个 1×1;只碰角的不算', () => {
// 1-4 的盘:start(0,0) 与 big(1,1,2,2) 只碰角
const b = Board.fromCells([c('start', 0, 0, 1, 1, 1), c('a', 1, 0), c('b', 2, 0), c('big', 1, 1, 2, 2, 3)]);
const big = b.cellById('big').index;
assert.deepEqual([...b.adj[big]].sort(), [1, 2]);
assert.equal(b.areAdjacent(0, big), false);
});
test('横长条与其长边上的两个格都相邻', () => {
const b = Board.fromCells([c('H', 1, 1, 2, 1), c('p', 1, 0), c('q', 2, 0), c('r', 3, 1), c('s', 0, 2)]);
const H = b.cellById('H').index;
assert.equal(b.areAdjacent(H, b.cellById('p').index), true);
assert.equal(b.areAdjacent(H, b.cellById('q').index), true);
assert.equal(b.areAdjacent(H, b.cellById('r').index), true);
assert.equal(b.areAdjacent(H, b.cellById('s').index), false);
});
test('空洞返回 -1,坐标会平移到原点', () => {
const b = Board.fromCells([c('a', 2, 3), c('b', 3, 3), c('d', 3, 4)]);
assert.equal(b.cols, 2);
assert.equal(b.rows, 2);
assert.equal(b.cellAtUnit(0, 0), 0);
assert.equal(b.cellAtUnit(0, 1), -1);
assert.equal(b.cellAtUnit(5, 5), -1);
});
test('共享边线段', () => {
const b = Board.fromCells([c('B', 0, 0, 2, 2), c('x', 2, 1), c('y', 1, 2)]);
assert.deepEqual(b.sharedEdge(0, 1), { x1: 2, y1: 1, x2: 2, y2: 2, dir: 'v' });
assert.deepEqual(b.sharedEdge(0, 2), { x1: 1, y1: 2, x2: 2, y2: 2, dir: 'h' });
assert.equal(b.sharedEdge(1, 2), null);
});
test('3×3 大连块:合法、长边邻接、共享边合并', () => {
const b = Board.fromCells([c('O', 0, 0, 3, 3), c('a', 3, 1), c('d', 1, 3), c('e', 3, 3)]);
const O = b.cellById('O');
assert.equal(O.isRect, true);
assert.equal(b.areAdjacent(O.index, b.cellById('a').index), true);
assert.equal(b.areAdjacent(O.index, b.cellById('d').index), true);
assert.equal(b.areAdjacent(O.index, b.cellById('e').index), false, '角对角不算');
assert.equal(b.cellAtUnit(2, 2), O.index);
assert.deepEqual(b.sharedEdge(O.index, b.cellById('a').index), { x1: 3, y1: 1, x2: 3, y2: 2, dir: 'v' });
assert.deepEqual(b.sharedEdge(O.index, b.cellById('d').index), { x1: 1, y1: 3, x2: 2, y2: 3, dir: 'h' });
});
test('非法盘面直接抛错', () => {
assert.throws(() => Board.fromCells([c('a', 0, 0), c('b', 0, 0)]), /重叠/);
assert.throws(() => Board.fromCells([c('a', 0, 0, 3, 1)]), /尺寸/);
assert.throws(() => Board.fromCells([c('a', 0, 0, 1, 1, 5)]), /线索/);
assert.throws(() => Board.fromCells([c('a', 0, 0, 1, 1, 1), c('b', 1, 0, 1, 1, 1)]), /重复/);
});
// ---------- 异形格(俄罗斯方块拼块) ----------
const s = (id: string, x: number, y: number, w: number, h: number, shape: Array<[number, number]>, clue?: number): CellDef => ({
id,
x,
y,
w,
h,
shape,
clue,
});
test('异形格:L4 展开成单位集合,缺口是空洞', () => {
const b = Board.fromCells([s('L', 1, 1, 2, 3, [[0, 0], [0, 1], [0, 2], [1, 2]]), c('a', 0, 0)]);
const L = b.cellById('L');
assert.equal(L.isRect, false);
assert.deepEqual([...L.units].sort(), [[1, 1], [1, 2], [1, 3], [2, 3]].sort());
assert.equal(b.cellAtUnit(2, 1), -1, 'bbox 里缺口 (2,1) 是空洞');
assert.equal(b.cellAtUnit(2, 3), L.index);
assert.equal(b.cellById('a').isRect, true);
});
test('异形格:旋转与镜像都在目录内', () => {
const variants: Array<Array<[number, number]>> = [
[[0, 0], [0, 1], [0, 2], [1, 2]], // L
[[0, 0], [1, 0], [2, 0], [0, 1]], // J(L 的镜像)
[[0, 0], [1, 0], [1, 1], [2, 1]], // L 旋转 90°
[[1, 0], [2, 0], [0, 1], [1, 1]], // S
[[0, 0], [0, 1], [1, 1], [1, 2]], // Z(S 的镜像)
[[0, 0], [1, 0], [2, 0], [1, 1]], // T
[[0, 0], [0, 1], [1, 0]], // L3
];
for (const shape of variants) {
const w = Math.max(...shape.map(([x]) => x)) + 1;
const h = Math.max(...shape.map(([, y]) => y)) + 1;
assert.doesNotThrow(() => Board.fromCells([s('P', 0, 1, w, h, shape), c('a', 0, 0)]), JSON.stringify(shape));
}
});
test('异形格:非法 shape 抛错', () => {
assert.throws(() => Board.fromCells([s('L', 0, 0, 2, 2, [[0, 0], [1, 1]])]), /不连通/);
assert.throws(() => Board.fromCells([s('L', 0, 0, 2, 2, [[0, 0], [0, 0]])]), /重复/);
assert.throws(() => Board.fromCells([s('L', 0, 0, 2, 2, [[0, 0], [-1, 0]])]), /为负/);
assert.throws(() => Board.fromCells([s('L', 0, 0, 2, 2, [[0, 0], [0, 1], [0, 2]])]), /不符/);
assert.throws(() => Board.fromCells([s('L', 0, 0, 3, 3, [[0, 0], [1, 0], [2, 0], [2, 1], [0, 1]])]), /单位数/);
});
test('异形格邻接:认每一条共享单位边', () => {
// L 占 (1,1)(1,2)(1,3)(2,3),缺口在 (2,1)(2,2)
const b = Board.fromCells([
s('L', 1, 1, 2, 3, [[0, 0], [0, 1], [0, 2], [1, 2]]),
c('a', 2, 1), // 缺口里的格:与 (1,1) 共边 → 相邻
c('d', 2, 2), // 与 (1,2) 共边 → 相邻
c('e', 3, 1), // 只挨到缺口,不与任何 L 单位共边 → 不相邻
]);
const L = b.cellById('L').index;
assert.equal(b.areAdjacent(L, b.cellById('a').index), true);
assert.equal(b.areAdjacent(L, b.cellById('d').index), true);
assert.equal(b.areAdjacent(L, b.cellById('e').index), false);
});
test('异形格共享边:共线单位边合并成一整段;自身返回 null', () => {
const b = Board.fromCells([c('B', 0, 0, 2, 2), c('V', 2, 0, 1, 2)]);
assert.deepEqual(b.sharedEdge(0, 1), { x1: 2, y1: 0, x2: 2, y2: 2, dir: 'v' }, '2×2 与竖条的两条单位边合并');
assert.equal(b.sharedEdge(0, 0), null);
});
test('异形格锚点在形内', () => {
const b = Board.fromCells([s('L', 1, 1, 2, 3, [[0, 0], [0, 1], [0, 2], [1, 2]]), c('a', 0, 0)]);
const [ux, uy] = b.anchorUnit(b.cellById('L').index);
assert.ok(b.cellById('L').units.some(([x, y]) => x === ux && y === uy), `锚点 (${ux},${uy}) 必须在 L 内`);
const [cx, cy] = b.anchorCenter(b.cellById('a').index);
assert.deepEqual([cx, cy], [0.5, 0.5], '矩形格锚点 = bbox 中心');
});
@@ -0,0 +1,146 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { test } from 'node:test';
import { Board } from '../src/board';
import { pickClueHint } from '../src/clue-hint';
import { Game } from '../src/game';
import { fitBoard } from '../src/layout';
import { loadPack, parsePack } from '../src/levels';
import type { Painter } from '../src/painter';
import { uniqueSolution } from '../src/solver';
import { THEME } from '../src/theme';
import { BoardView } from '../src/view';
function line() {
return new Game(Board.fromCells(Array.from({ length: 9 }, (_, i) => ({
id: String(i), x: i, y: 0, w: 1, h: 1, clue: i === 0 || i === 8 ? i + 1 : undefined,
}))));
}
const solution = [0, 1, 2, 3, 4, 5, 6, 7, 8];
test('1~9 补 5,再补 3;只增加线索,不代画路线', () => {
const game = line();
const h = pickClueHint(game, solution)!;
assert.deepEqual(h, { cell: 4, step: 5 });
assert.deepEqual(game.apply({ type: 'reveal', ...h }), [{ type: 'clueRevealed', ...h }]);
assert.deepEqual(pickClueHint(game, solution), { cell: 2, step: 3 });
assert.deepEqual(game.path, []);
assert.equal(game.status, 'empty');
assert.equal(game.remaining, 9);
});
test('按已解出的前缀推进区间,跳过已经走对的数字', () => {
const game = line();
for (const cell of solution.slice(0, 4)) game.apply({ type: game.path.length ? 'append' : 'start', cell });
assert.deepEqual(pickClueHint(game, solution), { cell: 5, step: 6 });
game.apply({ type: 'reveal', cell: 4, step: 5 });
game.apply({ type: 'append', cell: 4 });
assert.deepEqual(pickClueHint(game, solution), { cell: 6, step: 7 });
});
test('新数字独立于错误路线,并立即约束已走错的格;回退后冲突消失', () => {
const board = Board.fromCells([
{ id: 'a', x: 0, y: 0, w: 1, h: 1, clue: 1 },
{ id: 'b', x: 1, y: 0, w: 1, h: 1 },
{ id: 'c', x: 1, y: 1, w: 1, h: 1 },
{ id: 'd', x: 0, y: 1, w: 1, h: 1, clue: 4 },
]);
const game = new Game(board);
for (const cell of [0, 3, 2]) game.apply({ type: game.path.length ? 'append' : 'start', cell });
assert.deepEqual(pickClueHint(game, [0, 1, 2, 3]), { cell: 1, step: 2 });
game.apply({ type: 'append', cell: 1 });
game.apply({ type: 'reveal', cell: 1, step: 2 });
assert.equal(game.isConflict(1), true);
assert.equal(game.isCleared, false);
game.apply({ type: 'rewind', cell: 0 });
assert.equal(game.hasConflict(), false);
assert.equal(game.clueAt(1).clue, 2);
});
test('重开保留补出的数字,原始关卡和新对局不变', () => {
const game = line();
game.apply({ type: 'reveal', cell: 4, step: 5 });
game.apply({ type: 'start', cell: 0 });
game.apply({ type: 'reset' });
assert.equal(game.clueAt(4).clue, 5);
assert.equal(game.board.cells[4].clue, null);
assert.equal(new Game(game.board).clueAt(4).clue, null);
assert.deepEqual(game.path, []);
});
test('补出的 1 成为起手约束,奇偶要求同时保留', () => {
const game = new Game(Board.fromCells([
{ id: 'a', x: 0, y: 0, w: 1, h: 1, parity: 'odd' },
{ id: 'b', x: 1, y: 0, w: 1, h: 1, clue: 2 },
]));
assert.deepEqual(pickClueHint(game, [0, 1]), { cell: 0, step: 1 });
game.apply({ type: 'reveal', cell: 0, step: 1 });
assert.deepEqual(game.clueAt(0), { clue: 1, parity: 'odd' });
assert.deepEqual(game.apply({ type: 'start', cell: 1 }), [{ type: 'rejectStart', cell: 1, clueCell: 0 }]);
game.apply({ type: 'start', cell: 0 });
game.apply({ type: 'append', cell: 1 });
assert.equal(game.isCleared, true);
assert.equal(pickClueHint(game, [0, 1]), null);
});
test('不覆盖原数字、不重复、不接受越界或奇偶不符的补充数字', () => {
const game = line();
for (const [cell, step] of [[0, 2], [1, 1], [1, 9], [1, 0], [1, 10], [-1, 2], [9, 2], [1.5, 2], [1, 2.5]]) {
assert.deepEqual(game.apply({ type: 'reveal', cell, step }), []);
}
game.apply({ type: 'reveal', cell: 4, step: 5 });
assert.deepEqual(game.apply({ type: 'reveal', cell: 4, step: 6 }), []);
assert.deepEqual(game.apply({ type: 'reveal', cell: 3, step: 5 }), []);
assert.equal(pickClueHint(game, []), null);
const parity = new Game(Board.fromCells([
{ id: 'a', x: 0, y: 0, w: 1, h: 1, parity: 'even' },
{ id: 'b', x: 1, y: 0, w: 1, h: 1 },
]));
assert.deepEqual(parity.apply({ type: 'reveal', cell: 0, step: 1 }), []);
});
test('补数字的渲染:数字与原奇偶同在,走错时显示要求并变红', () => {
const game = new Game(Board.fromCells([
{ id: 'a', x: 0, y: 0, w: 1, h: 1 },
{ id: 'b', x: 1, y: 0, w: 1, h: 1, parity: 'even' },
]));
const view = new BoardView(game, { reducedMotion: true });
view.layout = fitBoard(game.board, 320, 400);
const labels: string[] = [];
const fills: string[] = [];
const painter: Painter = {
text: s => { labels.push(s); }, fillRoundRect: (_r, _radius, color) => { fills.push(color); },
strokeRoundRect: () => {}, fillCircle: () => {},
};
game.apply({ type: 'reveal', cell: 1, step: 2 }).forEach(e => view.onEvent(e));
view.draw(painter);
assert.ok(labels.includes('2') && labels.includes('偶'));
labels.length = 0;
game.apply({ type: 'start', cell: 1 }).forEach(e => view.onEvent(e));
view.draw(painter);
assert.ok(labels.includes('1') && labels.includes('2'));
assert.ok(fills.includes(THEME.conflict));
});
test('26 关反复补数最终用尽且每个数符合唯一解,重开仍可正常通关', () => {
for (const file of ['pack-1-tutorial.json', 'pack-2-advanced.json', 'pack-3-parity.json']) {
const levels = loadPack(parsePack(JSON.parse(readFileSync(new URL(`../levels/${file}`, import.meta.url), 'utf8'))));
for (const level of levels) {
const game = new Game(level.board);
const path = uniqueSolution(level.board)!;
for (let i = 0; i <= game.n; i++) {
const h = pickClueHint(game, path);
if (!h) break;
assert.ok(i < game.n, `${level.def.id} 提示应有界`);
assert.equal(path[h.step - 1], h.cell);
assert.equal(game.apply({ type: 'reveal', ...h }).length, 1);
}
assert.equal(pickClueHint(game, path), null);
assert.deepEqual(game.path, []);
game.apply({ type: 'start', cell: path[0] });
game.apply({ type: 'reset' });
path.forEach((cell, i) => game.apply({ type: i === 0 ? 'start' : 'append', cell }));
assert.equal(game.isCleared, true, level.def.id);
}
}
});
+99
View File
@@ -0,0 +1,99 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { Board } from '../src/board';
import { Game } from '../src/game';
import type { CellDef } from '../src/types';
const c = (id: string, x: number, y: number, w = 1, h = 1, clue?: number): CellDef => ({ id, x, y, w, h, clue });
// 1-3:start(1) a b(3) / d c
const level13 = () =>
Board.fromCells([c('start', 0, 0, 1, 1, 1), c('a', 1, 0), c('b', 2, 0, 1, 1, 3), c('d', 1, 1), c('c', 2, 1)]);
test('有线索 1 时只能从它起手', () => {
const g = new Game(level13());
const ev = g.apply({ type: 'start', cell: 1 });
assert.deepEqual(ev, [{ type: 'rejectStart', cell: 1, clueCell: 0 }]);
assert.equal(g.status, 'empty');
assert.equal(g.apply({ type: 'start', cell: 0 })[0].type, 'step');
assert.equal(g.status, 'drawing');
});
test('无线索 1 时任意格可起手', () => {
const g = new Game(Board.fromCells([c('a', 0, 0), c('b', 1, 0, 1, 1, 2)]));
assert.equal(g.apply({ type: 'start', cell: 0 })[0].type, 'step');
});
test('追加只能到相邻未走格;冲突不打断;撤销恢复', () => {
const b = level13();
const g = new Game(b);
const id = (s: string) => b.cellById(s).index;
g.apply({ type: 'start', cell: id('start') });
assert.deepEqual(g.apply({ type: 'append', cell: id('c') }), []); // 不相邻
g.apply({ type: 'append', cell: id('a') });
const ev = g.apply({ type: 'append', cell: id('d') }); // 第 3 步没落到预定的 b,立即冲突
assert.equal(ev[0].type, 'step');
assert.ok(ev.some(e => e.type === 'step' && e.conflict && !e.clueHit));
assert.equal(g.isConflict(id('d')), true);
assert.equal(g.stepOf(id('b')), 0, '无需先走到线索格,就能判断该步位置错误');
g.apply({ type: 'append', cell: id('c') });
const conflict = g.apply({ type: 'append', cell: id('b') });
assert.equal(conflict[0].type, 'step');
assert.equal((conflict[0] as { conflict: boolean }).conflict, true);
assert.equal(g.isConflict(id('b')), true);
assert.equal(g.path.length, 5);
assert.equal(g.status, 'drawing', '填满但有冲突不算过关');
// 走到头了但没过关 → deadEnd 事件
assert.ok(conflict.some((e) => e.type === 'deadEnd'));
const un = g.apply({ type: 'undo' });
assert.deepEqual(un, [{ type: 'unstep', cell: id('b'), step: 5 }]);
assert.equal(g.isConflict(id('b')), false);
assert.equal(g.stepOf(id('b')), 0);
});
test('全走完且线索全对 → cleared,之后锁住', () => {
const b = level13();
const g = new Game(b);
const id = (s: string) => b.cellById(s).index;
for (const s of ['start', 'a', 'b', 'c']) g.apply(g.path.length ? { type: 'append', cell: id(s) } : { type: 'start', cell: id(s) });
const ev = g.apply({ type: 'append', cell: id('d') });
assert.ok(ev.some((e) => e.type === 'cleared'));
assert.equal(g.status, 'cleared');
assert.deepEqual(g.apply({ type: 'undo' }), []);
assert.equal(g.path.length, 5);
});
test('release / resume / reset 状态流转', () => {
const g = new Game(level13());
g.apply({ type: 'start', cell: 0 });
g.apply({ type: 'release' });
assert.equal(g.status, 'idle');
g.apply({ type: 'resume' });
assert.equal(g.status, 'drawing');
assert.deepEqual(g.apply({ type: 'reset' }), [{ type: 'reset' }]);
assert.equal(g.status, 'empty');
assert.equal(g.remaining, 5);
assert.deepEqual(g.apply({ type: 'reset' }), [], '空盘重开不产生事件');
});
test('撤销到空盘回到 empty', () => {
const g = new Game(level13());
g.apply({ type: 'start', cell: 0 });
g.apply({ type: 'undo' });
assert.equal(g.status, 'empty');
assert.equal(g.last, -1);
});
test('直接回退移除后缀冲突,保留前缀;非法目标、末端和过关后均忽略', () => {
const g = new Game(level13());
for (const cell of [0, 1, 3, 4, 2]) g.apply({ type: g.path.length ? 'append' : 'start', cell });
assert.equal(g.hasConflict(), true);
g.apply({ type: 'rewind', cell: 1 });
assert.deepEqual(g.path, [0, 1]);
assert.equal(g.hasConflict(), false);
for (const cell of [-1, 4, 1]) assert.deepEqual(g.apply({ type: 'rewind', cell }), []);
for (const cell of [2, 4, 3]) g.apply({ type: 'append', cell });
assert.equal(g.isCleared, true);
assert.deepEqual(g.apply({ type: 'rewind', cell: 0 }), []);
assert.equal(g.path.length, 5);
});
+140
View File
@@ -0,0 +1,140 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { Board } from '../src/board';
import { Game } from '../src/game';
import { PointerInput } from '../src/input';
import type { CellDef } from '../src/types';
const c = (id: string, x: number, y: number, w = 1, h = 1, clue?: number): CellDef => ({ id, x, y, w, h, clue });
// 一横四格:a(1) b c d
function setup() {
const b = Board.fromCells([c('a', 0, 0, 1, 1, 1), c('b', 1, 0), c('c', 2, 0), c('d', 3, 0)]);
const g = new Game(b);
return { b, g, input: new PointerInput(g) };
}
test('按下起点后滑动追加;滑回上一格撤销;松手保留路径', () => {
const { g, input } = setup();
input.down(0);
assert.equal(g.path.length, 1);
input.move(1);
input.move(2);
assert.deepEqual([...g.path], [0, 1, 2]);
input.move(1); // 滑回上一格 = 撤销
assert.deepEqual([...g.path], [0, 1]);
input.up();
assert.equal(g.status, 'idle');
assert.deepEqual([...g.path], [0, 1]);
});
test('松手后从末端按下可继续;按不相邻的空格不生效', () => {
const { g, input } = setup();
input.down(0);
input.move(1);
input.up();
input.down(3); // 不相邻的空格
assert.equal(input.isDrawing, false);
input.move(2);
assert.equal(g.path.length, 2);
input.up();
input.down(1); // 末端
assert.equal(input.isDrawing, true);
input.move(2);
assert.deepEqual([...g.path], [0, 1, 2]);
});
test('点末端的前一格 = 撤销一步,且可以接着滑', () => {
const { g, input } = setup();
input.down(0);
input.move(1);
input.move(2);
input.up();
input.down(1);
assert.deepEqual([...g.path], [0, 1]);
assert.equal(input.isDrawing, true);
input.move(0);
assert.deepEqual([...g.path], [0]);
});
test('点末端旁边的空格直接追加(点着走)', () => {
const { g, input } = setup();
input.down(0);
input.up();
input.down(1);
assert.deepEqual([...g.path], [0, 1]);
});
test('有线索 1 时点别的格起手无效并产生 rejectStart', () => {
const { g, input } = setup();
const ev = input.down(2);
assert.equal(ev[0].type, 'rejectStart');
assert.equal(g.path.length, 0);
assert.equal(input.isDrawing, false);
assert.deepEqual(input.move(1), []);
});
test('滑到不相邻格忽略;空白 -1 不影响;同一格不重复触发', () => {
const { g, input } = setup();
input.down(0);
input.move(-1);
input.move(2); // 不相邻
assert.deepEqual([...g.path], [0]);
input.move(1);
input.move(1);
assert.deepEqual([...g.path], [0, 1]);
});
test('过关后停止响应', () => {
const { g, input } = setup();
input.down(0);
input.move(1);
input.move(2);
const ev = input.move(3);
assert.ok(ev.some((e) => e.type === 'cleared'));
assert.equal(input.isDrawing, false);
assert.deepEqual(input.down(3), []);
assert.deepEqual(input.move(2), []);
assert.equal(g.path.length, 4);
});
test('点更早的历史格一次撤掉多步,不松手即可沿新分支继续', () => {
const b = Board.fromCells([
c('a', 0, 0, 1, 1, 1), c('b', 1, 0), c('c', 2, 0),
c('d', 2, 1), c('e', 1, 1), c('f', 0, 1),
]);
const g = new Game(b);
const input = new PointerInput(g);
input.down(0);
[1, 2, 3, 4].forEach(cell => input.move(cell));
input.up();
const events = input.down(1);
assert.deepEqual(g.path, [0, 1]);
assert.deepEqual(events, [
{ type: 'unstep', cell: 4, step: 5 },
{ type: 'unstep', cell: 3, step: 4 },
{ type: 'unstep', cell: 2, step: 3 },
{ type: 'rewound', cell: 1, removed: 3 },
]);
assert.equal(input.isDrawing, true);
assert.equal(g.status, 'drawing');
input.move(4); // 从第 2 格直接转入之前的第 5 格
input.move(5);
input.up();
assert.deepEqual(g.path, [0, 1, 4, 5]);
assert.equal(g.stepOf(4), 3);
assert.equal(g.stepOf(2), 0);
assert.equal(g.status, 'idle');
});
test('不松手滑到不相邻历史格可直接回退;同格抖动不会重复撤销', () => {
const { g, input } = setup();
input.down(0);
input.move(1);
input.move(2);
input.move(0); // 与末端不相邻,仍能一次回到起点
assert.deepEqual(g.path, [0]);
assert.deepEqual(input.move(0), []);
input.move(1);
assert.deepEqual(g.path, [0, 1]);
});
@@ -0,0 +1,76 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { Board } from '../src/board';
import { bridgeRect, cellRect, cellShapeRects, fitBoard, hitTest } from '../src/layout';
import type { CellDef } from '../src/types';
const c = (id: string, x: number, y: number, w = 1, h = 1, clue?: number): CellDef => ({ id, x, y, w, h, clue });
test('按短边缩放并居中,受 maxUnit 限制', () => {
const b = Board.fromCells([c('a', 0, 0), c('b', 1, 0), c('c', 2, 0)]);
const L = fitBoard(b, 390, 600, { padding: 10, maxUnit: 96 });
assert.equal(L.unit, 96);
assert.equal(L.width, 288);
assert.equal(L.originX, (390 - 288) / 2);
const tall = Board.fromCells([c('a', 0, 0), c('b', 0, 1), c('c', 0, 2), c('d', 0, 3), c('e', 0, 4), c('f', 0, 5)]);
const L2 = fitBoard(tall, 390, 300, { padding: 0, maxUnit: 200 });
assert.equal(L2.unit, 50);
});
test('命中测试:格中心命中、缝与死区不命中、空洞不命中', () => {
const b = Board.fromCells([c('a', 0, 0), c('B', 1, 0, 2, 2), c('h', 0, 2)]);
const L = fitBoard(b, 300, 300, { padding: 0, maxUnit: 100 });
const ra = cellRect(b, 0, L);
assert.equal(hitTest(b, L, ra.x + ra.w / 2, ra.y + ra.h / 2), 0);
assert.equal(hitTest(b, L, L.originX + 2, L.originY + 2), -1, '角落死区');
assert.equal(hitTest(b, L, L.originX + 150, L.originY + 150), 1, '2×2 中心');
assert.equal(hitTest(b, L, L.originX + 50, L.originY + 150), -1, '空洞 (0,1)');
assert.equal(hitTest(b, L, -5, -5), -1);
});
test('桥矩形横跨缝并落在共享边中点', () => {
const b = Board.fromCells([c('a', 0, 0), c('B', 1, 0, 2, 2)]);
const L = fitBoard(b, 300, 200, { padding: 0, maxUnit: 100 });
const br = bridgeRect(b, 0, 1, L)!;
assert.ok(br);
assert.ok(Math.abs(br.x + br.w / 2 - (L.originX + 100)) < 1e-6, '中心在 x=1 的边上');
assert.ok(Math.abs(br.y + br.h / 2 - (L.originY + 50)) < 1e-6, '共享边 y∈[0,1] 的中点');
assert.equal(bridgeRect(b, 1, 1, L), null);
});
test('异形格绘制矩形:单位 + 接缝', () => {
// L4 占 (1,1)(1,2)(1,3)(2,3):4 个单位矩形 + 3 条内部缝(两竖一横)
// 补一个 (0,0) 的格钉住原点,免得 L 被平移
const b = Board.fromCells([
{ id: 'L', x: 1, y: 1, w: 2, h: 3, shape: [[0, 0], [0, 1], [0, 2], [1, 2]] },
c('z', 0, 0),
]);
const L = fitBoard(b, 300, 300, { padding: 0, maxUnit: 100 });
const { units, seams } = cellShapeRects(b, 0, L);
assert.equal(units.length, 4);
assert.equal(seams.length, 3);
const half = L.gap / 2;
// 横缝:(1,3)→(2,3),跨过 x=2 的竖缝,向两侧单位内各延长一个圆角半径(盖住圆角缺口)
assert.deepEqual(
seams.find((r) => r.w === L.gap + 2 * L.radius),
{
x: L.originX + 2 * L.unit - half - L.radius,
y: L.originY + 3 * L.unit + half,
w: L.gap + 2 * L.radius,
h: L.unit - L.gap,
},
);
// 竖缝:(1,1)→(1,2),跨过 y=2 的横缝,同样上下延长
assert.deepEqual(
seams.find((r) => r.h === L.gap + 2 * L.radius && r.y === L.originY + 2 * L.unit - half - L.radius),
{
x: L.originX + L.unit + half,
y: L.originY + 2 * L.unit - half - L.radius,
w: L.unit - L.gap,
h: L.gap + 2 * L.radius,
},
);
// 单位矩形与 cellRect 对单个 1×1 的算法一致
const u0 = units[0];
assert.deepEqual(u0, { x: L.originX + L.unit + half, y: L.originY + L.unit + half, w: L.unit - L.gap, h: L.unit - L.gap });
});
+160
View File
@@ -0,0 +1,160 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { test } from 'node:test';
import { Board } from '../src/board';
import { Game } from '../src/game';
import { PointerInput } from '../src/input';
import { fitBoard } from '../src/layout';
import { loadPack, parsePack } from '../src/levels';
import type { Painter } from '../src/painter';
import { hint, solve } from '../src/solver';
import { THEME } from '../src/theme';
import type { CellDef } from '../src/types';
import { BoardView } from '../src/view';
const pack = parsePack(JSON.parse(readFileSync(new URL('../levels/pack-3-parity.json', import.meta.url), 'utf8')));
const levels = loadPack(pack);
const c = (id: string, x: number, parity?: 'odd' | 'even'): CellDef => ({ id, x, y: 0, w: 1, h: 1, parity });
test('奇偶字段校验:仅接受 odd/even,不能同时填数字线索', () => {
for (const invalid of ['奇', 'ODD', 1, null]) {
assert.throws(() => Board.fromCells([{ ...c('a', 0), parity: invalid } as CellDef]), /parity 必须/);
}
assert.throws(() => Board.fromCells([{ ...c('a', 0, 'odd'), clue: 1 }]), /不能同时填写/);
assert.equal(Board.fromCells([c('a', 0)]).cells[0].parity, null);
});
test('无线索 1:偶格也允许试起手,但第 1 步必定冲突;撤销后清除', () => {
const b = Board.fromCells([c('a', 0, 'even'), c('b', 1, 'odd')]);
const game = new Game(b);
const events = game.apply({ type: 'start', cell: 0 });
assert.ok(events.some(e => e.type === 'step' && e.conflict && !e.clueHit));
assert.equal(game.isConflict(0), true);
game.apply({ type: 'undo' });
assert.equal(game.hasConflict(), false);
assert.equal(game.status, 'empty');
assert.deepEqual(solve(b), [[1, 0]], '求解器只给满足奇偶的起点');
assert.deepEqual(solve(b, { starts: [0] }), []);
assert.equal(solve(b, { ignoreClues: true }).length, 2);
});
test('奇偶步号独立校验:与完整路径逐条过滤结果一致', () => {
// 三角邻接来自大块,防止测试仅覆盖奇偶天然交替的小方格。
const geometry = pack.levels[0].cells.map(({ clue, parity, ...cell }) => cell);
const free = solve(Board.fromCells(geometry), { ignoreClues: true });
// 穷举每格 空/奇/偶 的 3^4 组条件,加一个固定数字交叉验证。
for (let mask = 0; mask < 3 ** geometry.length; mask++) {
let value = mask;
const defs = geometry.map(cell => {
const digit = value % 3;
value = Math.floor(value / 3);
return { ...cell, ...(digit ? { parity: digit === 1 ? 'odd' as const : 'even' as const } : {}) };
});
for (const fixed of [false, true]) {
const cells: CellDef[] = defs.map((cell, i) => fixed && i === 0 ? { ...geometry[i], clue: 1 } : cell);
const expected = free.filter(path => cells.every((cell, i) => {
const step = path.indexOf(i) + 1;
return (cell.clue === undefined || step === cell.clue) &&
(cell.parity === undefined || step % 2 === (cell.parity === 'odd' ? 1 : 0));
}));
const keys = (paths: number[][]) => paths.map(p => p.join(',')).sort();
assert.deepEqual(keys(solve(Board.fromCells(cells))), keys(expected), `${mask}/${fixed}`);
}
}
});
test('体验关:每条奇偶线索都必要,唯一解能经真实规则过关', () => {
for (const lv of levels) {
const [path] = solve(lv.board, { limit: 2 });
const game = new Game(lv.board);
const parityCells = lv.def.cells.filter(cell => cell.parity);
assert.ok(parityCells.length > 0);
for (const cell of parityCells) {
const defs = lv.def.cells.map(({ parity, ...rest }) => rest.id === cell.id ? rest : { ...rest, parity });
assert.equal(solve(Board.fromCells(defs), { limit: 2 }).length, 2, `${lv.def.id}/${cell.id} 必须参与唯一性`);
}
path.forEach((cell, i) => {
const events = game.apply({ type: i === 0 ? 'start' : 'append', cell });
if (lv.board.cells[cell].parity !== null) {
assert.ok(events.some(e => e.type === 'step' && e.clueHit && !e.conflict));
}
});
assert.equal(game.isCleared, true, lv.def.id);
}
});
test('走错偶格不会过关;滑回撤销、提示退回分叉后能重走通关', () => {
const b = levels[0].board;
const game = new Game(b);
const input = new PointerInput(game);
const idx = (id: string) => b.cellById(id).index;
input.down(idx('start'));
input.move(idx('a'));
const events = input.move(idx('b')); // 第 3 步踩偶
assert.ok(events.some(e => e.type === 'step' && e.conflict));
assert.equal(game.isConflict(idx('b')), true);
const solution = solve(b, { limit: 1 })[0];
assert.deepEqual(hint(solution, game.path), { kind: 'backtrack', cell: idx('a'), undoCount: 1 });
input.move(idx('big')); // 填满仍有奇偶冲突
assert.equal(game.remaining, 0);
assert.equal(game.isCleared, false);
input.move(idx('b'));
input.move(idx('a'));
assert.equal(game.hasConflict(), false);
assert.deepEqual(hint(solution, game.path), { kind: 'next', cell: idx('big') });
input.move(idx('big'));
input.move(idx('b'));
assert.equal(game.isCleared, true);
});
function drawing(game: Game) {
const view = new BoardView(game);
view.layout = fitBoard(game.board, 350, 400);
const labels: { text: string; x: number; y: number; size: number }[] = [];
const fills: string[] = [];
const painter: Painter = {
fillRoundRect: (_r, _radius, color) => { fills.push(color); },
strokeRoundRect: () => {},
fillCircle: () => {},
text: (text, x, y, size) => { labels.push({ text, x, y, size }); },
};
view.update(0.05);
view.draw(painter);
assert.ok(labels.every(l => [l.x, l.y, l.size].every(Number.isFinite)));
return { labels: labels.map(l => l.text), fills };
}
test('渲染:奇偶未走显示文字、走对保留标记、撤销恢复;冲突标明要求', () => {
const b = levels[0].board;
const g = new Game(b);
assert.ok(drawing(g).labels.includes('偶'));
for (const id of ['start', 'a', 'b']) g.apply({ type: g.path.length ? 'append' : 'start', cell: b.cellById(id).index });
const conflict = drawing(g);
assert.ok(conflict.labels.includes('3') && conflict.labels.includes('偶'));
assert.ok(conflict.fills.includes(THEME.conflict));
assert.ok(!conflict.labels.includes('null'));
g.apply({ type: 'undo' });
assert.ok(drawing(g).labels.includes('偶'));
g.apply({ type: 'append', cell: b.cellById('big').index });
g.apply({ type: 'append', cell: b.cellById('b').index });
assert.ok(drawing(g).labels.includes('偶'));
assert.ok(!drawing(g).fills.includes(THEME.conflict));
});
test('3×3 大连块与异形:奇偶冲突角标在形内,绘制不崩溃', () => {
for (const cell of [
{ id: 'big', x: 0, y: 0, w: 3, h: 3, parity: 'even' as const },
{ id: 'poly', x: 0, y: 0, w: 2, h: 2, shape: [[0, 0], [0, 1], [1, 1]] as [number, number][], parity: 'even' as const },
]) {
const game = new Game(Board.fromCells([cell]));
game.apply({ type: 'start', cell: 0 });
assert.equal(game.isConflict(0), true);
assert.equal(game.stepOf(0), 1, '计步与占用面积无关');
const result = drawing(game);
assert.ok(result.labels.includes('1') && result.labels.includes('偶'));
assert.ok(result.fills.includes(THEME.conflict));
game.apply({ type: 'reset' });
assert.equal(game.hasConflict(), false);
assert.ok(drawing(game).labels.includes('偶'));
}
});
@@ -0,0 +1,51 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { MemoryStore, Progress, STORAGE_KEY } from '../src/progress';
const ids = ['1-1', '1-2', '1-3'];
test('线性解锁与首个未通关', () => {
const p = new Progress(new MemoryStore());
assert.equal(p.isUnlocked(ids, 0), true);
assert.equal(p.isUnlocked(ids, 1), false);
assert.equal(p.firstUncleared(ids), '1-1');
p.markCleared('1-1');
assert.equal(p.isUnlocked(ids, 1), true);
assert.equal(p.isUnlocked(ids, 2), false);
assert.equal(p.firstUncleared(ids), '1-2');
p.markCleared('1-2');
p.markCleared('1-3');
assert.equal(p.firstUncleared(ids), '1-3');
});
test('存档落在本玩法的桶里并可恢复', () => {
const store = new MemoryStore();
const p = new Progress(store);
p.markCleared('1-1');
p.setCurrent('1-2');
p.setPrefs({ sound: false });
assert.ok(store.get(STORAGE_KEY));
const q = new Progress(store);
assert.equal(q.isCleared('1-1'), true);
assert.equal(q.currentId, '1-2');
assert.equal(q.prefs.sound, false);
assert.equal(q.prefs.haptics, true);
q.resetAll();
assert.equal(new Progress(store).clearedCount(), 0);
});
test('跨包链式解锁:2-1 要先过 1-10', () => {
const chained = ['1-1', '1-2', '2-1', '2-2'];
const p = new Progress(new MemoryStore());
assert.equal(p.isUnlocked(chained, 2), false);
p.markCleared('1-1');
p.markCleared('1-2');
assert.equal(p.isUnlocked(chained, 2), true);
assert.equal(p.isUnlocked(chained, 3), false);
});
test('损坏的存档不炸', () => {
const store = new MemoryStore();
store.set(STORAGE_KEY, '{not json');
assert.doesNotThrow(() => new Progress(store));
});
@@ -0,0 +1,159 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { Board } from '../src/board';
import { Game } from '../src/game';
import { cellRect, fitBoard, type Rect } from '../src/layout';
import type { Painter } from '../src/painter';
import { THEME } from '../src/theme';
import type { CellDef } from '../src/types';
import { BoardView } from '../src/view';
// a(1) b c(3)
// d e f
// 正解可为 a b c f e d;错误分支 a d e 在第 3 步使用了 c 预定的数字。
function setup(withNumber = true, parity?: 'odd' | 'even') {
const cells: CellDef[] = ['a', 'b', 'c', 'd', 'e', 'f'].map((id, i) => ({
id, x: i % 3, y: Math.floor(i / 3), w: 1, h: 1,
clue: i === 0 ? 1 : withNumber && i === 2 ? 3 : undefined,
parity: i === 4 ? parity : undefined,
}));
return new Game(Board.fromCells(cells));
}
function startWrongBranch(game: Game) {
game.apply({ type: 'start', cell: 0 });
game.apply({ type: 'append', cell: 3 });
return game.apply({ type: 'append', cell: 4 });
}
test('固定数字出现于普通格时立即标错;未走的固定数字格保持未走状态', () => {
const game = setup();
const events = startWrongBranch(game);
assert.ok(events.some(e => e.type === 'step' && e.conflict && !e.clueHit));
assert.equal(game.isConflict(4), true);
assert.equal(game.hasConflict(), true);
assert.equal(game.isConflict(3), false, '第 2 步没有固定位置,不按隐藏答案标错');
assert.equal(game.stepOf(2), 0);
assert.equal(game.isConflict(2), false);
assert.equal(game.isCleared, false);
});
test('数字位置错误优先于奇偶命中,不播放踩对线索反馈', () => {
const game = setup(true, 'odd');
const events = startWrongBranch(game);
assert.equal(game.clueAt(4).parity, 'odd');
assert.equal(game.stepOf(4), 3);
assert.ok(events.some(e => e.type === 'step' && e.conflict && !e.clueHit));
assert.equal(game.isConflict(4), true);
});
test('撤销或多步回退后清除占号冲突,可改走正确数字格通关', () => {
for (const action of ['undo', 'rewind'] as const) {
const game = setup();
startWrongBranch(game);
game.apply(action === 'undo' ? { type: 'undo' } : { type: 'rewind', cell: 0 });
assert.equal(game.hasConflict(), false);
game.apply({ type: 'rewind', cell: 0 });
for (const cell of [1, 2, 5, 4, 3]) {
const events = game.apply({ type: 'append', cell });
assert.ok(events.every(e => e.type !== 'step' || !e.conflict));
}
assert.equal(game.isCleared, true);
}
});
test('补数字会立即检查已有路线,并在重开后继续约束该步的位置', () => {
const game = setup(false);
startWrongBranch(game);
assert.equal(game.hasConflict(), false);
game.apply({ type: 'reveal', cell: 2, step: 3 });
assert.equal(game.stepOf(2), 0);
assert.equal(game.isConflict(4), true, '补出的 3 尚未被踩到,别处的第 3 步已应判错');
game.apply({ type: 'reset' });
assert.equal(game.hasConflict(), false);
assert.ok(startWrongBranch(game).some(e => e.type === 'step' && e.conflict));
game.apply({ type: 'reset' });
for (const cell of [0, 1, 2, 5, 4, 3]) game.apply({ type: game.path.length ? 'append' : 'start', cell });
assert.equal(game.isCleared, true);
assert.equal(new Game(game.board).clueAt(2).clue, null);
});
test('非固定数字的奇偶要求不占用所有同类步号', () => {
const game = setup(false, 'odd');
game.apply({ type: 'start', cell: 0 });
game.apply({ type: 'append', cell: 1 });
game.apply({ type: 'append', cell: 2 });
assert.equal(game.hasConflict(), false, '另一个普通格上出现奇数 3 是允许的');
});
test('冲突只标红格,进出连线保持紫色,不显示 ×;本格线索不符仍显示原要求', () => {
for (const parity of [undefined, 'odd', 'even'] as const) {
const game = setup(true, parity);
const view = new BoardView(game, { reducedMotion: true });
view.layout = fitBoard(game.board, 350, 400);
for (const cell of [0, 3, 4, 5]) game.apply({ type: game.path.length ? 'append' : 'start', cell }).forEach(e => view.onEvent(e));
const texts: string[] = [];
const fills: string[] = [];
const bridges: string[] = [];
let badges = 0;
const painter: Painter = {
fillRoundRect: (_r, radius, color) => { fills.push(color); if (radius === 0) bridges.push(color); },
strokeRoundRect: () => {}, fillCircle: () => { badges++; }, text: text => { texts.push(text); },
};
view.draw(painter);
assert.ok(!texts.includes('×'));
if (parity === 'even') assert.ok(texts.includes('偶'));
assert.equal(badges, parity === 'even' ? 1 : 0);
assert.ok(!texts.includes(''), '新增冲突不能留下空角标');
assert.ok(fills.includes(THEME.conflict));
assert.deepEqual(bridges, [THEME.visited, THEME.visited, THEME.visited], '错误格进出两侧连线均保持紫色');
}
});
function frame(view: BoardView): Rect[] {
const cells: Rect[] = [];
view.draw({
fillRoundRect: rect => { cells.push(rect); },
strokeRoundRect: () => {}, fillCircle: () => {}, text: () => {},
});
return cells.slice(0, view.game.n); // 底板首先按格序绘制
}
function settle(view: BoardView): void {
for (let i = 0; i < 120; i++) view.update(1 / 60);
}
test('落错时只晃动出错格,动画结束复位;撤销和重开立即停止晃动', () => {
for (const cancel of ['undo', 'reset', 'finish'] as const) {
const game = setup();
const view = new BoardView(game);
view.layout = fitBoard(game.board, 350, 400);
for (const cell of [0, 3]) game.apply({ type: game.path.length ? 'append' : 'start', cell }).forEach(e => view.onEvent(e));
settle(view);
const before = frame(view);
game.apply({ type: 'append', cell: 4 }).forEach(e => view.onEvent(e));
view.update(1 / 60);
const shaken = frame(view);
assert.ok(Math.abs(shaken[4].x - before[4].x) > 0.1, '错误格应横向晃动');
assert.equal(shaken[4].w, before[4].w, '错误反馈只晃动,不叠加落子缩放');
for (const cell of [0, 1, 2, 3, 5]) assert.deepEqual(shaken[cell], before[cell], `其他格 ${cell} 不晃动`);
if (cancel === 'finish') settle(view);
else game.apply({ type: cancel }).forEach(e => view.onEvent(e));
assert.deepEqual(frame(view)[4], before[4], `${cancel} 后立即恢复原位置`);
}
});
test('补数字发现旧路径的错误也会晃动;减弱动态时保留红色但不晃动', () => {
for (const reducedMotion of [false, true]) {
const game = setup(false);
const view = new BoardView(game, { reducedMotion });
view.layout = fitBoard(game.board, 350, 400);
for (const cell of [0, 3, 4]) game.apply({ type: game.path.length ? 'append' : 'start', cell }).forEach(e => view.onEvent(e));
settle(view);
game.apply({ type: 'reveal', cell: 2, step: 3 }).forEach(e => view.onEvent(e));
view.update(1 / 60);
const original = cellRect(game.board, 4, view.layout);
assert.equal(game.isConflict(4), true);
if (reducedMotion) assert.deepEqual(frame(view)[4], original);
else assert.ok(Math.abs(frame(view)[4].x - original.x) > 0.1);
}
});
@@ -0,0 +1,76 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { test } from 'node:test';
import { Board } from '../src/board';
import { loadPack, parsePack } from '../src/levels';
import { hint, solve, uniqueSolution } from '../src/solver';
import type { CellDef } from '../src/types';
const c = (id: string, x: number, y: number, w = 1, h = 1, clue?: number): CellDef => ({ id, x, y, w, h, clue });
test('关卡包每一关恰好一个解', () => {
for (const file of ['pack-1-tutorial.json', 'pack-2-advanced.json', 'pack-3-parity.json']) {
const pack = parsePack(JSON.parse(readFileSync(new URL(`../levels/${file}`, import.meta.url), 'utf8')));
const levels = loadPack(pack);
assert.equal(levels.length, file === 'pack-3-parity.json' ? 6 : 10, `${file} 关数不符`);
for (const lv of levels) {
const paths = solve(lv.board, { limit: 3 });
assert.equal(paths.length, 1, `${lv.def.id} 应唯一解,实际 ${paths.length}`);
assert.equal(paths[0].length, lv.board.n);
}
}
});
test('GDD §7.3 样例盘:15 格、两块 2×2、线索落在大格上,唯一解且 B 是第 6 步', () => {
// A A a b c / A A . d e / f g h B B / . i j B B / 1 l m . .
const b = Board.fromCells([
c('A', 0, 0, 2, 2),
c('a', 2, 0),
c('b', 3, 0),
c('c', 4, 0),
c('d', 3, 1),
c('e', 4, 1),
c('f', 0, 2),
c('g', 1, 2),
c('h', 2, 2),
c('B', 3, 2, 2, 2, 6),
c('i', 1, 3),
c('j', 2, 3),
c('one', 0, 4, 1, 1, 1),
c('l', 1, 4),
c('m', 2, 4),
]);
assert.equal(b.n, 15);
const paths = solve(b);
assert.equal(paths.length, 1, '样例盘恰好唯一解');
const p = paths[0];
assert.deepEqual(
p.map((i) => b.cells[i].id),
['one', 'l', 'm', 'j', 'h', 'B', 'd', 'e', 'c', 'b', 'a', 'A', 'f', 'g', 'i'],
);
// 从 j 往左绕 i 会让 B 最早第 8 步才到(红 8),所以第 5 步必须是 h、第 6 步进 B
assert.equal(b.cells[p[4]].id, 'h');
assert.equal(b.cells[p[5]].id, 'B');
});
test('无线索 1 的盘:任意起点都会被枚举', () => {
const b = Board.fromCells([c('a', 0, 0), c('b', 1, 0), c('d', 1, 1, 1, 1, 3)]);
const paths = solve(b);
assert.equal(paths.length, 1);
assert.equal(b.cells[paths[0][0]].id, 'a');
});
test('多解与无解', () => {
assert.equal(solve(Board.fromCells([c('a', 0, 0), c('b', 1, 0), c('c', 2, 0)])).length, 2);
assert.equal(uniqueSolution(Board.fromCells([c('a', 0, 0), c('b', 1, 0), c('c', 2, 0)])), null);
assert.equal(solve(Board.fromCells([c('a', 0, 0, 1, 1, 1), c('b', 1, 0), c('c', 2, 0, 1, 1, 2)])).length, 0);
});
test('提示:起点 / 下一步 / 退回分叉 / 起手错', () => {
const sol = [0, 1, 2, 3, 4];
assert.deepEqual(hint(sol, []), { kind: 'start', cell: 0 });
assert.deepEqual(hint(sol, [0, 1]), { kind: 'next', cell: 2 });
assert.deepEqual(hint(sol, [0, 1, 7, 8]), { kind: 'backtrack', cell: 1, undoCount: 2 });
assert.deepEqual(hint(sol, [3, 2]), { kind: 'restart', cell: 0 });
assert.deepEqual(hint(sol, sol), { kind: 'none' });
});
@@ -0,0 +1,65 @@
// 设计令牌单一来源守门:web/style.css 的 :root 与 src/theme.ts、web/canvas-painter.ts 必须一致。
// 改任一处而不同步另一处,这里会红。规格文档见仓库 docs/design.md。
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { test } from 'node:test';
import { fileURLToPath } from 'node:url';
import { MOTION, THEME } from '../src/theme';
import { BOARD_FONT_FAMILY } from '../web/canvas-painter';
const here = dirname(fileURLToPath(import.meta.url));
const css = readFileSync(resolve(here, '../web/style.css'), 'utf8');
function rootTokens(source: string): Map<string, string> {
const m = /:root\s*\{([\s\S]*?)\n\}/.exec(source);
assert.ok(m, 'style.css 缺少 :root 块');
const out = new Map<string, string>();
for (const line of m[1].split('\n')) {
const t = /^\s*(--[\w-]+)\s*:\s*(.+?);\s*$/.exec(line);
if (t) out.set(t[1], t[2]);
}
return out;
}
const tokens = rootTokens(css);
const hex = (s: string | undefined): string => (s ?? '').trim().toUpperCase();
test('颜色令牌:style.css :root 与 THEME 一致', () => {
const pairs: Array<[string, string]> = [
['--bg', THEME.bg],
['--cell', THEME.cell],
['--ink', THEME.clueInk],
['--clue-ring', THEME.clueRing],
['--primary', THEME.visited],
['--primary-bright', THEME.visitedBright],
['--on-primary', THEME.visitedInk],
['--danger', THEME.conflict],
['--hint', THEME.hint],
];
for (const [name, value] of pairs) {
assert.equal(hex(tokens.get(name)), hex(value), `${name} 与 theme.ts 不一致`);
}
});
test('动效时长令牌:--t-* 与 MOTION 一致', () => {
const pairs: Array<[string, number]> = [
['--t-fast', MOTION.fast],
['--t-base', MOTION.base],
['--t-slow', MOTION.slow],
['--t-hold', MOTION.hold],
];
for (const [name, seconds] of pairs) {
assert.equal(tokens.get(name), `${Math.round(seconds * 1000)}ms`, `${name} 与 theme.ts MOTION 不一致`);
}
assert.equal(MOTION.numberFade, MOTION.fast, '步号淡入应等于 fast 档');
assert.equal(MOTION.bridgeGrow, MOTION.fast, '桥生长应等于 fast 档');
assert.equal(MOTION.flashTime, MOTION.slow, '闪白应等于 slow 档');
assert.equal(MOTION.clearedSweep, MOTION.slow, '过关扫光应等于 slow 档');
assert.equal(MOTION.clearedHold, MOTION.hold, '过关停留应等于 hold 档');
assert.equal(MOTION.boardEnter, MOTION.base, '进场应等于 base 档');
});
test('字体栈:canvas 与 CSS --font 完全一致', () => {
assert.equal(tokens.get('--font'), BOARD_FONT_FAMILY);
});
+44
View File
@@ -0,0 +1,44 @@
// 终端里把盘面画成字符图,方便设计时看形状。只给工具用。
import type { Board } from '../src/board';
import { clueLabel, hasClue } from '../src/clue';
/**
* 每个单位格 4 字符宽。1×1 显示 id(或线索),大格用同一个标记铺满,空洞是 '.'。
* 传 path 时改为显示步号。
*/
export function renderAscii(board: Board, path?: number[]): string {
const stepOf = new Map<number, number>();
path?.forEach((c, i) => stepOf.set(c, i + 1));
const lines: string[] = [];
for (let y = 0; y < board.rows; y++) {
let line = '';
for (let x = 0; x < board.cols; x++) {
const ci = board.cellAtUnit(x, y);
if (ci < 0) {
line += ' . ';
continue;
}
const c = board.cells[ci];
let label: string;
if (path) {
const s = stepOf.get(ci);
label = s === undefined ? '?' : String(s);
} else if (hasClue(c)) {
label = `[${clueLabel(c)}]`;
} else {
label = c.id;
}
const big = c.w > 1 || c.h > 1;
const [ax, ay] = board.anchorUnit(ci);
const isOrigin = x === ax && y === ay;
const text = big && !isOrigin ? (path ? '·' : '·') : label;
line += text.padStart(3, ' ') + ' ';
}
lines.push(line.replace(/\s+$/, ''));
}
return lines.join('\n');
}
export function pathToIds(board: Board, path: number[]): string {
return path.map((c) => board.cells[c].id).join(' → ');
}
+144
View File
@@ -0,0 +1,144 @@
// 关卡设计辅助。GDD §8.2 的第 3–5 步:挑线索、验唯一、删到最少。
//
// npm run design -- 1-7 看这关:全部无线索路径、当前线索下的解
// npm run design -- 1-7 --minimize 当前线索里哪些可以删掉仍唯一
// npm run design -- 1-7 --suggest 2 以当前解为“作者解”,找 2 个线索(含 1)就唯一的方案
// npm run design -- file.json 也可以直接指定一个临时关卡文件(单关 LevelDef 或整包)
import { existsSync, readFileSync, readdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { Board } from '../src/board';
import { clueLabel, hasClue } from '../src/clue';
import { parsePack } from '../src/levels';
import { solve } from '../src/solver';
import type { CellDef, LevelDef } from '../src/types';
import { pathToIds, renderAscii } from './ascii';
const here = dirname(fileURLToPath(import.meta.url));
const args = process.argv.slice(2);
const target = args[0];
if (!target) {
console.log('用法: npm run design -- <关卡id|json文件> [--minimize] [--suggest k] [--path a,b,c]');
process.exit(1);
}
function findLevel(idOrFile: string): LevelDef {
if (idOrFile.endsWith('.json') && existsSync(idOrFile)) {
const raw = JSON.parse(readFileSync(idOrFile, 'utf8'));
if (Array.isArray(raw.levels)) return parsePack(raw).levels[0];
return raw as LevelDef;
}
const dir = join(here, '..', 'levels');
for (const f of readdirSync(dir).filter((x) => x.endsWith('.json'))) {
const pack = parsePack(JSON.parse(readFileSync(join(dir, f), 'utf8')));
const lv = pack.levels.find((l) => l.id === idOrFile);
if (lv) return lv;
}
throw new Error(`找不到关卡 ${idOrFile}`);
}
const level = findLevel(target);
const board = Board.fromLevel(level);
const clueCells = board.cells.filter((c) => c.clue !== null);
console.log(`\n${level.id} N=${board.n} 线索: ${board.cells.filter(hasClue).map((c) => `${c.id}=${clueLabel(c)}`).join(' ') || '无'}`);
console.log(renderAscii(board));
const allPaths = solve(board, { ignoreClues: true, limit: 2000 });
console.log(`\n无线索哈密顿路径: ${allPaths.length >= 2000 ? '2000+' : allPaths.length}`);
const withClues = solve(board, { limit: 50 });
console.log(`当前线索下的解: ${withClues.length}${withClues.length >= 50 ? '+' : ''}`);
for (const p of withClues.slice(0, 8)) console.log(' ' + pathToIds(board, p));
if (withClues.length === 1) console.log(renderAscii(board, withClues[0]));
// 作者解:--path 指定,否则用当前唯一解
let author: number[] | null = null;
const pathArg = args.indexOf('--path');
if (pathArg !== -1) {
author = args[pathArg + 1].split(',').map((id) => board.cellById(id.trim()).index);
} else if (withClues.length === 1) {
author = withClues[0];
}
function countWithClues(clues: Map<number, number>, limit = 2): number {
const defs: CellDef[] = level.cells.map(({ clue, ...c }, index) => ({
...c,
...(clues.has(index) ? { clue: clues.get(index)! } : {}),
}));
return solve(Board.fromCells(defs), { limit }).length;
}
if (args.includes('--minimize') && withClues.length === 1) {
console.log('\n[minimize] 尝试删线索:');
const current = new Map(clueCells.map((c) => [c.index, c.clue!]));
const removable: string[] = [];
for (const c of clueCells) {
const trial = new Map(current);
trial.delete(c.index);
const n = countWithClues(trial);
console.log(` 去掉 ${c.id}=${c.clue} → ${n === 1 ? '仍唯一 ✓' : n === 0 ? '无解' : '多解'}`);
if (n === 1) removable.push(`${c.id}=${c.clue}`);
}
for (const c of board.cells.filter((cell) => cell.parity !== null)) {
const defs = level.cells.map((d) => {
const { parity, ...rest } = d;
return d.id === c.id ? rest : d;
});
const n = solve(Board.fromCells(defs), { limit: 2 }).length;
console.log(` 去掉 ${c.id}=${clueLabel(c)} → ${n === 1 ? '仍唯一 ✓' : '多解'}`);
if (n === 1) removable.push(`${c.id}=${clueLabel(c)}`);
}
console.log(removable.length ? ` 单独可删: ${removable.join(', ')}` : ' 每个线索都必要');
}
const suggestArg = args.indexOf('--suggest');
if (suggestArg !== -1) {
if (!author) {
console.log('\n[suggest] 需要一条作者解:先让当前线索唯一,或用 --path 指定');
} else {
const k = Number(args[suggestArg + 1] ?? 2);
const n = author.length;
const keepStart = clueCells.some((c) => c.clue === 1);
console.log(`\n[suggest] 作者解: ${pathToIds(board, author)}`);
console.log(` 找 ${k} 个线索${keepStart ? '(含起点 1)' : ''} 使其唯一,按“少用端点、分布均匀”排序:`);
const positions: number[] = [];
for (let s = 1; s <= n; s++) if (board.cells[author[s - 1]].parity === null) positions.push(s);
const results: { clues: number[]; score: number }[] = [];
const combo: number[] = [];
const startFixed = keepStart ? [1] : [];
const pool = positions.filter((s) => !startFixed.includes(s));
const need = k - startFixed.length;
function rec(from: number): void {
if (combo.length === need) {
const steps = [...startFixed, ...combo];
const clues = new Map(steps.map((s) => [author![s - 1], s]));
if (countWithClues(clues) === 1) {
// 端点扣分;线索之间间距越均匀越好
const sorted = steps.slice().sort((a, b) => a - b);
let score = 0;
for (const s of sorted) if (s === 1 || s === n) score += 3;
for (let i = 1; i < sorted.length; i++) score += Math.abs(sorted[i] - sorted[i - 1] - n / (sorted.length + 1)) * 0.2;
results.push({ clues: steps, score });
}
return;
}
for (let i = from; i < pool.length; i++) {
combo.push(pool[i]);
rec(i + 1);
combo.pop();
}
}
rec(0);
results.sort((a, b) => a.score - b.score);
if (!results.length) console.log(' 没有方案,试试 k+1');
for (const r of results.slice(0, 12)) {
const desc = r.clues
.slice()
.sort((a, b) => a - b)
.map((s) => `${board.cells[author![s - 1]].id}=${s}`)
.join(' ');
console.log(` ${desc}`);
}
if (results.length > 12) console.log(` … 共 ${results.length} 个方案`);
}
}
+599
View File
@@ -0,0 +1,599 @@
// 关卡生成器。GDD §8.2 的第 1–2 步:随机铺盘(1×1 + 矩形/异形大格 + 空洞,保证连通且有哈密顿路径),
// 随机取一条作者解,再挑线索使其唯一。
//
// npm run generate 生成一包候选并打印盘面,不写文件
// npm run generate -- --seed 7 --out levels/pack-2-advanced.json
// npm run generate -- --levels 12 --pool 80 --attempts 600
//
// 流程:铺盘(矩形 45% / 俄罗斯方块异形 55%:L/I/S/T)→ 美感过滤(多单位格都要被路径真正穿过、
// 不能蛇形到底、无线索路径要够多)→ 挑线索:带线索 1 的关固定「1 + 引导步(4~7)」给开局指引,
// 再补 1~2 个拉开差距的线索凑唯一(难度的主旋钮是相邻线索的步号差);约 18% 的候选不带线索 1。
// → 难度分(以最大步号差为主)排序,从偏难的一段里等距挑 --levels 关。
import { writeFileSync } from 'node:fs';
import { Board } from '../src/board';
import { solve } from '../src/solver';
import type { CellDef, LevelDef, PackDef } from '../src/types';
import { pathToIds, renderAscii } from './ascii';
// ---------- 参数 ----------
interface Opts {
seed: number;
out: string | null;
levels: number;
pool: number;
attempts: number;
minFree: number;
pack: number;
title: string;
}
function parseOpts(): Opts {
const a = process.argv.slice(2);
const num = (flag: string, dflt: number): number => {
const i = a.indexOf(flag);
return i === -1 ? dflt : Number(a[i + 1]);
};
const str = (flag: string): string | null => {
const i = a.indexOf(flag);
return i === -1 ? null : a[i + 1];
};
if (a.includes('--help')) {
console.log('用法: npm run generate -- [--seed N] [--levels N] [--pool N] [--attempts N] [--min-free N] [--pack N] [--title 名] [--out file.json]');
process.exit(0);
}
return {
seed: num('--seed', Date.now() % 100000),
out: str('--out'),
levels: num('--levels', 10),
pool: num('--pool', 60),
attempts: num('--attempts', 400),
minFree: num('--min-free', 120),
pack: num('--pack', 2),
title: str('--title') ?? '进阶',
};
}
// ---------- 随机数 ----------
function mulberry32(seed: number): () => number {
let s = seed >>> 0;
return () => {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// ---------- 铺盘 ----------
const GRIDS: Array<[number, number, number]> = [
// cols, rows, 权重
[5, 5, 0.45],
[4, 5, 0.2],
[5, 4, 0.15],
[4, 4, 0.2],
];
function pickGrid(rand: () => number): [number, number] {
let r = rand();
for (const [c, rr, w] of GRIDS) {
if ((r -= w) < 0) return [c, rr];
}
return [5, 5];
}
type Offset = readonly [number, number];
/**
* 大格模板:大连块为主(2×2 + 3×3 占 40%),俄罗斯方块异形(L/I/S/T)44%,条 16%。
* 摆放时随机旋转 × 随机镜像,所以 L 含 J、S 含 Z、条含横竖。
*/
const TEMPLATES: Array<{ name: string; base: Offset[]; weight: number }> = [
{ name: 'O4', base: [[0, 0], [1, 0], [0, 1], [1, 1]], weight: 28 },
{ name: 'O9', base: [[0, 0], [1, 0], [2, 0], [0, 1], [1, 1], [2, 1], [0, 2], [1, 2], [2, 2]], weight: 18 },
{ name: 'I2', base: [[0, 0], [1, 0]], weight: 16 },
{ name: 'L4', base: [[0, 0], [0, 1], [0, 2], [1, 2]], weight: 16 },
{ name: 'I3', base: [[0, 0], [1, 0], [2, 0]], weight: 6 },
{ name: 'I4', base: [[0, 0], [1, 0], [2, 0], [3, 0]], weight: 6 },
{ name: 'S4', base: [[1, 0], [2, 0], [0, 1], [1, 1]], weight: 9 },
{ name: 'T4', base: [[0, 0], [1, 0], [2, 0], [1, 1]], weight: 7 },
];
function pickTemplate(rand: () => number): (typeof TEMPLATES)[number] {
let r = rand() * TEMPLATES.reduce((s, t) => s + t.weight, 0);
for (const t of TEMPLATES) {
if ((r -= t.weight) < 0) return t;
}
return TEMPLATES[0];
}
/** 旋转 rot×90° + 可选镜像,然后平移到原点 */
function transformShape(base: Offset[], rot: number, mirror: boolean, rand: () => number): Offset[] {
void rand;
let pts = base.map(([x, y]) => [x, y] as Offset);
if (mirror) pts = pts.map(([x, y]) => [-x, y] as Offset);
for (let i = 0; i < rot; i++) pts = pts.map(([x, y]) => [y, -x] as Offset);
let minX = Infinity;
let minY = Infinity;
for (const [x, y] of pts) {
minX = Math.min(minX, x);
minY = Math.min(minY, y);
}
return pts.map(([x, y]) => [x - minX, y - minY] as Offset);
}
/** 随机铺一盘(只含几何,id 后补)。格数不在 [nMin, nMax] 返回 null。 */
function randomBoardCells(rand: () => number, nMin: number, nMax: number): { cells: CellDef[]; holes: number } | null {
const [cols, rows] = pickGrid(rand);
const taken = new Uint8Array(cols * rows);
const cells: CellDef[] = [];
const area = cols * rows;
// 大连块多放一些:大盘 2~3 个多单位格,小盘 1~2 个
const bigTarget = area >= 20 ? 2 + Math.floor(rand() * 2) : 1 + Math.floor(rand() * 2);
// 先抽模板再按面积降序放:3×3 需要整片空地,后放容易摆不下
const bigPicks = Array.from({ length: bigTarget }, () => {
const t = pickTemplate(rand);
return transformShape(t.base, Math.floor(rand() * 4), rand() < 0.5, rand);
}).sort((a, b) => b.length - a.length);
for (const pts of bigPicks) {
const bw = Math.max(...pts.map(([x]) => x)) + 1;
const bh = Math.max(...pts.map(([, y]) => y)) + 1;
for (let tries = 0; tries < 40; tries++) {
const x = Math.floor(rand() * (cols - bw + 1));
const y = Math.floor(rand() * (rows - bh + 1));
if (pts.every(([dx, dy]) => !taken[(y + dy) * cols + (x + dx)])) {
for (const [dx, dy] of pts) taken[(y + dy) * cols + (x + dx)] = 1;
// 铺满 bbox 的就是矩形格(不写 shape,保持 JSON 简洁)
const full = pts.length === bw * bh;
cells.push(full ? { id: '?', x, y, w: bw, h: bh } : { id: '?', x, y, w: bw, h: bh, shape: pts });
break;
}
}
}
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
if (!taken[y * cols + x]) cells.push({ id: '?', x, y, w: 1, h: 1 });
}
}
// 挖洞:只挖 1×1,挖完必须还连通(后面统一验)
let holes = 0;
const holeTarget = Math.floor(rand() * 3);
const smalls = cells.filter((c) => !c.shape && c.w === 1 && c.h === 1);
for (let h = 0; h < holeTarget && smalls.length; h++) {
const i = Math.floor(rand() * smalls.length);
const [victim] = smalls.splice(i, 1);
cells.splice(cells.indexOf(victim), 1);
holes++;
}
const n = cells.length;
if (n < nMin || n > nMax) return null;
return { cells, holes };
}
/** 按阅读顺序(y 后 x)起名:多单位格大写、1×1 小写,与 pack-1 一致。 */
function assignIds(cells: CellDef[]): CellDef[] {
const sorted = cells.slice().sort((a, b) => a.y - b.y || a.x - b.x);
let big = 0;
let small = 0;
const multi = (c: CellDef): boolean => (c.shape ? c.shape.length > 1 : c.w * c.h > 1);
return sorted.map((c) => ({
...c,
id: multi(c) ? String.fromCharCode(65 + big++) : String.fromCharCode(97 + small++),
}));
}
// ---------- 随机作者解 ----------
/** 随机化 Warnsdorff DFS 找一条哈密顿路径。多次换起点重试。 */
function randomHamilton(board: Board, rand: () => number): number[] | null {
const n = board.n;
const visited = new Uint8Array(n);
const path: number[] = [];
const free = (v: number): number => {
let d = 0;
for (const w of board.adj[v]) if (!visited[w]) d++;
return d;
};
const dfs = (cur: number): boolean => {
if (path.length === n) return true;
const cands = board.adj[cur]
.filter((v) => !visited[v])
.map((v) => ({ v, k: free(v) + rand() * 1.2 }))
.sort((a, b) => a.k - b.k);
for (const { v } of cands) {
visited[v] = 1;
path.push(v);
if (dfs(v)) return true;
path.pop();
visited[v] = 0;
}
return false;
};
for (let t = 0; t < 24; t++) {
visited.fill(0);
path.length = 0;
const start = Math.floor(rand() * n);
visited[start] = 1;
path.push(start);
if (dfs(start)) return path.slice();
}
return null;
}
// ---------- 美感过滤 ----------
interface Beauty {
ok: boolean;
reason: string;
big: number;
poly: number;
maxRunRatio: number;
}
function beauty(board: Board, path: number[]): Beauty {
const n = board.n;
let big = 0;
let poly = 0;
for (const c of board.cells) {
if (c.units.length > 1) {
big++;
if (!c.isRect) poly++;
// 多单位格必须在路径中段(进出各一次),不许当端点——每个拼块都要被真正穿过
const p = path.indexOf(c.index);
if (p === 0 || p === n - 1) return { ok: false, reason: `${c.id} 当了端点`, big, poly, maxRunRatio: 0 };
}
}
if (big === 0) return { ok: false, reason: '没有大格', big, poly, maxRunRatio: 0 };
// 最长直线段占比:蛇形盘一眼望穿,太顺不好看
let maxRun = 1;
let run = 1;
const heading = (a: number, b: number): string => {
const A = board.cells[a];
const B = board.cells[b];
const dx = B.x + B.w / 2 - (A.x + A.w / 2);
const dy = B.y + B.h / 2 - (A.y + A.h / 2);
return Math.abs(dx) > Math.abs(dy) ? 'h' : 'v';
};
for (let i = 1; i < path.length; i++) {
if (i > 1 && heading(path[i - 1], path[i]) === heading(path[i - 2], path[i - 1])) run++;
else run = 1;
maxRun = Math.max(maxRun, run);
}
const maxRunRatio = maxRun / (n - 1);
if (maxRunRatio > 0.5) return { ok: false, reason: `最长直线 ${maxRun}/${n - 1} 太蛇形`, big, poly, maxRunRatio };
return { ok: true, reason: '', big, poly, maxRunRatio };
}
// ---------- 挑线索 ----------
/** CellDef 需要 shape 字段时从运行时 Cell 的 units 还原(uniqueWithClues 重建 Board 用) */
function toCellDef(c: Board['cells'][number], clue?: number): CellDef {
return {
id: c.id,
x: c.x,
y: c.y,
w: c.w,
h: c.h,
...(c.isRect ? {} : { shape: c.units.map(([ux, uy]) => [ux - c.x, uy - c.y] as [number, number]) }),
...(clue !== undefined ? { clue } : {}),
};
}
function uniqueWithClues(board: Board, clues: Map<number, number>): boolean {
const defs = board.cells.map((c) => toCellDef(c, clues.get(c.index)));
return solve(Board.fromCells(defs), { limit: 2 }).length === 1;
}
function clueScore(board: Board, author: number[], steps: number[]): number {
const n = author.length;
const sorted = steps.slice().sort((a, b) => a - b);
let score = 0;
for (const s of sorted) if (s === n) score += 3; // 少用终点
const ideal = n / (sorted.length + 1);
for (let i = 1; i < sorted.length; i++) score += Math.abs(sorted[i] - sorted[i - 1] - ideal) * 0.2;
// 线索格在盘面上两两相邻 → 读起来挤,扣分
const cells = sorted.map((s) => author[s - 1]);
for (let i = 0; i < cells.length; i++) {
for (let j = i + 1; j < cells.length; j++) if (board.areAdjacent(cells[i], cells[j])) score += 1.5;
}
return score;
}
function combinations(pool: number[], k: number, cap: number, rand: () => number): number[][] {
const total = pool.length;
// 组合数不大就全枚举,太大就随机采样
let count = 1;
for (let i = 0; i < k; i++) count = (count * (total - i)) / (i + 1);
if (count <= cap) {
const out: number[][] = [];
const combo: number[] = [];
const rec = (from: number): void => {
if (combo.length === k) {
out.push(combo.slice());
return;
}
for (let i = from; i < pool.length; i++) {
combo.push(pool[i]);
rec(i + 1);
combo.pop();
}
};
rec(0);
return out;
}
const seen = new Set<string>();
const out: number[][] = [];
while (out.length < cap) {
const combo = pool.slice().sort(() => rand() - 0.5).slice(0, k).sort((a, b) => a - b);
const key = combo.join(',');
if (!seen.has(key)) {
seen.add(key);
out.push(combo);
}
}
return out;
}
/**
* 挑线索。
* 带线索 1:固定「1 + 引导步 guide∈[4,7]」——开局 5~6 步内有近距指引,玩家知道从哪起手;
* 再从 guide+2..n 补 1~2 个线索凑唯一(难度由后面的差距承担)。
* 无线索 1:k 从 2 升到 4 纯搜索。
*/
function pickClues(
board: Board,
author: number[],
withStart: boolean,
rand: () => number,
): { steps: number[]; score: number; guide: number | null } | null {
const n = author.length;
const fixed = withStart ? [1, 4 + Math.floor(rand() * 4)] : [];
const pool: number[] = [];
for (let s = withStart ? fixed[1] + 2 : 2; s <= n; s++) pool.push(s);
const maxK = 4;
for (let k = Math.max(2, fixed.length + 1); k <= maxK; k++) {
const need = k - fixed.length;
if (need > pool.length) continue;
let best: { steps: number[]; score: number } | null = null;
for (const combo of combinations(pool, need, 2000, rand)) {
const steps = [...fixed, ...combo];
const clues = new Map(steps.map((s) => [author[s - 1], s]));
if (uniqueWithClues(board, clues)) {
const sc = clueScore(board, author, steps);
if (!best || sc < best.score) best = { steps, score: sc };
}
}
if (best) return { ...best, guide: withStart ? fixed[1] : null };
}
return null;
}
// ---------- 难度分 ----------
/** 相邻线索步号差(含最后一条到终点 N 的尾段) */
export function clueGaps(steps: number[], n: number): number[] {
const sorted = steps.slice().sort((a, b) => a - b);
const gaps: number[] = [];
for (let i = 1; i < sorted.length; i++) gaps.push(sorted[i] - sorted[i - 1]);
gaps.push(n - sorted[sorted.length - 1]);
return gaps;
}
function difficulty(c: Candidate): number {
const maxGap = Math.max(...clueGaps(c.clueSteps, c.n));
return (
maxGap * 7 +
c.n * 1.2 +
Math.log10(c.clueFree + 1) * 5 -
c.clueSteps.length * 3 +
c.big * 1.5 +
(c.noStart ? 12 : 0)
);
}
interface Candidate {
cells: CellDef[];
n: number;
big: number;
poly: number;
holes: number;
clueFree: number;
clueSteps: number[];
guide: number | null;
noStart: boolean;
maxRunRatio: number;
solution: number[];
score: number;
}
// ---------- 主流程 ----------
const opts = parseOpts();
const rand = mulberry32(opts.seed);
const pool: Candidate[] = [];
const rejections = new Map<string, number>();
const reject = (why: string): void => {
rejections.set(why, (rejections.get(why) ?? 0) + 1);
};
console.log(`seed=${opts.seed} 目标: ${opts.levels} 关,候选池 ${opts.pool},最多尝试 ${opts.attempts} 次`);
for (let attempt = 0; attempt < opts.attempts && pool.length < opts.pool; attempt++) {
if (attempt % 25 === 24) console.log(` … 已尝试 ${attempt + 1} 次,候选 ${pool.length}`);
const raw = randomBoardCells(rand, 13, 21);
if (!raw) {
reject('格数不在 13..21');
continue;
}
const cells = assignIds(raw.cells);
let board: Board;
try {
board = Board.fromCells(cells);
} catch {
reject('盘面非法');
continue;
}
if (!board.isConnected()) {
reject('不连通');
continue;
}
const author = randomHamilton(board, rand);
if (!author) {
reject('没有哈密顿路径');
continue;
}
const b = beauty(board, author);
if (!b.ok) {
reject(b.reason);
continue;
}
const clueFree = solve(board, { ignoreClues: true, limit: 600 }).length;
if (clueFree < opts.minFree) {
reject(`无线索路径太少 (${clueFree})`);
continue;
}
const noStart = rand() < 0.18;
const picked = pickClues(board, author, !noStart, rand);
if (!picked) {
reject('4 个线索仍不唯一');
continue;
}
pool.push({
cells,
n: board.n,
big: b.big,
poly: b.poly,
holes: raw.holes,
clueFree,
clueSteps: picked.steps.slice().sort((x, y) => x - y),
guide: picked.guide,
noStart,
maxRunRatio: b.maxRunRatio,
solution: author,
score: 0,
});
}
for (const c of pool) c.score = difficulty(c);
console.log(`\n候选 ${pool.length} 个。淘汰原因:`);
for (const [why, cnt] of [...rejections.entries()].sort((a, b) => b[1] - a[1])) console.log(` ${why} × ${cnt}`);
if (pool.length < opts.levels) {
console.error(`\n候选不够(${pool.length} < ${opts.levels}),放宽过滤或加 --attempts`);
process.exit(1);
}
// ---------- 选拔:偏难 65% 里等距取,保证花样 ----------
pool.sort((a, b) => a.score - b.score);
const hard = pool.slice(Math.floor(pool.length * 0.35));
const picks: Candidate[] = [];
const used = new Set<Candidate>();
for (let i = 0; i < opts.levels; i++) {
const idx = Math.round((i * (hard.length - 1)) / (opts.levels - 1));
picks.push(hard[idx]);
used.add(hard[idx]);
}
// 无线索 1:至少 1 关、至多 2 关(稀缺才新鲜),且必须在第 3 关之后(GDD:13 关之后才出现)
const MAX_NOSTART = 2;
if (!picks.some((c) => c.noStart)) {
const alt = [...pool].reverse().find((c) => c.noStart && !used.has(c));
if (alt) {
picks[Math.max(3, opts.levels - 3)] = alt;
used.add(alt);
} else {
console.log('\n⚠ 池里没有无线索 1 的候选,本包全带线索 1');
}
}
for (let i = picks.length - 1; i >= 0 && picks.filter((c) => c.noStart).length > MAX_NOSTART; i--) {
if (!picks[i].noStart) continue;
const alt = [...pool].reverse().find((c) => !c.noStart && !used.has(c));
if (!alt) break;
used.delete(picks[i]);
picks[i] = alt;
used.add(alt);
}
picks.sort((a, b) => a.score - b.score);
// 无线索 1 不许落在前 3(GDD:13 关之后);用搬移而不是交换,保持其余关的难度顺序
for (let i = 0; i < Math.min(3, picks.length); i++) {
if (picks[i].noStart) {
const [c] = picks.splice(i, 1);
picks.splice(Math.min(3, picks.length), 0, c);
}
}
// ---------- 输出 ----------
const levels: LevelDef[] = picks.map((c, i) => {
const clueOf = new Map<number, number>();
for (const s of c.clueSteps) clueOf.set(c.solution[s - 1], s);
const cells: CellDef[] = c.cells.map((cell, idx) => ({
...cell,
...(clueOf.has(idx) ? { clue: clueOf.get(idx)! } : {}),
}));
// cells 顺序变了,solution 下标基于 Board.fromCells(cells) 的顺序 —— assignIds 已排序,这里保持一致
const board = Board.fromCells(cells);
const gaps = clueGaps(c.clueSteps, c.n);
return {
id: `${opts.pack}-${i + 1}`,
teach:
`自动生成 seed=${opts.seed}。N=${c.n} 多单位格=${c.big}(异形${c.poly}) 空洞=${c.holes} ` +
`无线索路径=${c.clueFree >= 600 ? '600+' : c.clueFree} 线索=${c.clueSteps.join('/')}${c.noStart ? '(无线索1)' : ''} ` +
`步号差=${gaps.join('/')} 难度分=${c.score.toFixed(1)}。作者解: ${pathToIds(board, c.solution)}`,
cells,
};
});
console.log(`\n== 选出 ${levels.length} 关(按难度分升序)==`);
levels.forEach((lv, i) => {
const c = picks[i];
const board = Board.fromCells(lv.cells);
const check = solve(board, { limit: 2 });
console.log(
`\n${lv.id} N=${c.n} 多单位格=${c.big}(异形${c.poly}) 空洞=${c.holes} 无线索=${c.clueFree >= 600 ? '600+' : c.clueFree} ` +
`线索=${c.clueSteps.join('/')}${c.noStart ? '(无1)' : ''} 步号差=${clueGaps(c.clueSteps, c.n).join('/')} 分=${c.score.toFixed(1)} 解数=${check.length}`,
);
console.log(renderAscii(board, c.solution));
});
if (opts.out) {
const cellJson = (cell: CellDef): string => {
const parts = [`"id": ${JSON.stringify(cell.id)}`, `"x": ${cell.x}`, `"y": ${cell.y}`, `"w": ${cell.w}`, `"h": ${cell.h}`];
if (cell.shape) parts.push(`"shape": ${JSON.stringify(cell.shape)}`);
if (cell.clue !== undefined) parts.push(`"clue": ${cell.clue}`);
return `{ ${parts.join(', ')} }`;
};
const levelJson = (lv: LevelDef): string =>
[
` {`,
` "id": ${JSON.stringify(lv.id)},`,
` "teach": ${JSON.stringify(lv.teach)},`,
` "cells": [`,
lv.cells.map((cell) => ` ${cellJson(cell)}`).join(',\n'),
` ]`,
` }`,
].join('\n');
const pack: PackDef = {
mode: '01-number-stroke',
pack: opts.pack,
title: opts.title,
notes:
`${opts.levels} 关,tools/generate.ts 自动生成(seed=${opts.seed})+ 人工过目。15+ 格、俄罗斯方块异形格、` +
`开局 5~7 步内有引导线索、之后靠步号差上难度,部分关不带线索 1。`,
levels,
};
const json =
`{\n "mode": ${JSON.stringify(pack.mode)},\n "pack": ${pack.pack},\n "title": ${JSON.stringify(pack.title)},\n` +
` "notes": ${JSON.stringify(pack.notes)},\n "levels": [\n${levels.map(levelJson).join(',\n')}\n ]\n}\n`;
writeFileSync(opts.out, json);
console.log(`\n已写入 ${opts.out}`);
} else {
console.log('\n(未指定 --out,只打印不写文件)');
}
+73
View File
@@ -0,0 +1,73 @@
// 发布前校验:levels/*.json 每一关必须连通、恰好 1 条满足线索的哈密顿路径。GDD §4.6。
// 用法:npm run validate (全部包)
// npm run validate -- 1-7 (只看某关,并打印解)
import { readdirSync, readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { loadPack, parsePack } from '../src/levels';
import { Board } from '../src/board';
import { clueLabel } from '../src/clue';
import { solve } from '../src/solver';
import { pathToIds, renderAscii } from './ascii';
const here = dirname(fileURLToPath(import.meta.url));
const levelsDir = join(here, '..', 'levels');
const only = process.argv[2];
let failed = 0;
let total = 0;
for (const file of readdirSync(levelsDir).filter((f) => f.endsWith('.json')).sort()) {
const pack = parsePack(JSON.parse(readFileSync(join(levelsDir, file), 'utf8')));
console.log(`\n== ${file} · ${pack.title} · ${pack.levels.length} 关`);
let levels;
try {
levels = loadPack(pack);
} catch (e) {
console.log(` ✗ 解析失败: ${(e as Error).message}`);
failed++;
continue;
}
for (const lv of levels) {
if (only && lv.def.id !== only) continue;
total++;
const b = lv.board;
const big = b.cells.filter((c) => c.w * c.h > 1).length;
const clues = b.cells.filter((c) => c.clue !== null).map((c) => c.clue!).sort((a, z) => a - z);
const parityCells = b.cells.filter((c) => c.parity !== null);
const t0 = performance.now();
const paths = solve(b, { limit: 3 });
const ms = (performance.now() - t0).toFixed(1);
const all = solve(b, { ignoreClues: true, limit: 500 }).length;
const problems: string[] = [];
if (!b.isConnected()) problems.push('盘面不连通');
if (paths.length === 0) problems.push('无解');
if (paths.length > 1) problems.push(`多解 (≥${paths.length})`);
// 奇偶线索必须能排除错误路线,不能只是普通棋盘上的天然黑白奇偶。
for (const cell of parityCells) {
const defs = lv.def.cells.map((c) => {
const { parity, ...rest } = c;
return c.id === cell.id ? rest : c;
});
if (solve(Board.fromCells(defs), { limit: 2 }).length === 1) {
problems.push(`冗余奇偶线索 ${cell.id}=${clueLabel(cell)}`);
}
}
const ok = problems.length === 0;
if (!ok) failed++;
const mark = ok ? '✓' : '✗';
console.log(
` ${mark} ${lv.def.id.padEnd(5)} N=${String(b.n).padStart(2)} 大格=${big} 线索=${clues.join(',').padEnd(10)} 无线索路径=${String(all >= 500 ? '500+' : all).padStart(4)} ${ms}ms` +
(parityCells.length ? ` 奇偶=${parityCells.map((c) => `${c.id}:${clueLabel(c)}`).join(',')}` : '') +
(ok ? '' : ` ← ${problems.join(';')}`),
);
if (only || !ok) {
console.log(renderAscii(b).replace(/^/gm, ' '));
for (const p of paths) console.log(' 解: ' + pathToIds(b, p));
if (only && paths.length === 1) console.log(renderAscii(b, paths[0]).replace(/^/gm, ' '));
}
}
}
console.log(`\n${total - failed}/${total} 关通过`);
process.exit(failed ? 1 : 0);
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"strict": true,
"noUncheckedIndexedAccess": false,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["node"]
},
"include": ["src", "web", "tools", "tests", "scripts"],
"exclude": ["web/dist", "node_modules"]
}
+17
View File
@@ -0,0 +1,17 @@
{
"// 说明": "只检查 src/。lib 不含 DOM:保证规则层与视图逻辑不依赖浏览器 API,可原样搬进 Cocos Creator。",
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2020"],
"strict": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"isolatedModules": true,
"skipLibCheck": true,
"noEmit": true,
"types": []
},
"include": ["src"]
}
+61
View File
@@ -0,0 +1,61 @@
// 音效:WebAudio 合成,不需要资源文件。GDD §6:落子短音(音高随步号上升,12 后循环)、冲突闷音、过关和弦。
// Cocos 版换成 AudioSource 播放几段短音即可,接口保持 step/conflict/cleared/reject。
export interface Sfx {
unlock(): void;
step(n: number): void;
conflict(): void;
cleared(): void;
reject(): void;
enabled: boolean;
}
export class WebAudioSfx implements Sfx {
enabled = true;
private ctx: AudioContext | null = null;
/** 必须在用户手势里调用一次,iOS 才放行 */
unlock(): void {
if (!this.ctx) {
const AC = window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (!AC) return;
this.ctx = new AC();
}
if (this.ctx.state === 'suspended') void this.ctx.resume();
}
private tone(freq: number, dur: number, type: OscillatorType, gain: number, when = 0): void {
if (!this.enabled || !this.ctx) return;
const ctx = this.ctx;
const t0 = ctx.currentTime + when;
const osc = ctx.createOscillator();
const g = ctx.createGain();
osc.type = type;
osc.frequency.setValueAtTime(freq, t0);
g.gain.setValueAtTime(0.0001, t0);
g.gain.exponentialRampToValueAtTime(gain, t0 + 0.008);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
osc.connect(g).connect(ctx.destination);
osc.start(t0);
osc.stop(t0 + dur + 0.02);
}
step(n: number): void {
const semi = (n - 1) % 12;
const f = 523.25 * Math.pow(2, semi / 12);
this.tone(f, 0.09, 'sine', 0.18);
}
conflict(): void {
this.tone(130, 0.16, 'square', 0.08);
}
cleared(): void {
const base = 523.25;
[0, 4, 7, 12].forEach((semi, i) => this.tone(base * Math.pow(2, semi / 12), 0.22, 'triangle', 0.16, i * 0.07));
}
reject(): void {
this.tone(220, 0.06, 'triangle', 0.06);
}
}
@@ -0,0 +1,81 @@
// Painter 的 Canvas 2D 实现。Cocos 版本换成 Graphics + Label 即可,BoardView 不用改。
import type { Rect } from '../src/layout';
import type { Painter } from '../src/painter';
/**
* 盘面字体栈,与 web/style.css 的 --font 完全一致(tests/theme.test.ts 校验)。
* ui-rounded 让 iOS / macOS 直接拿到系统圆体;其余按平台回退到 PingFang / Noto。
*/
export const BOARD_FONT_FAMILY =
'ui-rounded, "SF Pro Rounded", "PingFang SC", "Hiragino Sans GB", "Noto Sans SC", system-ui, -apple-system, "Segoe UI", sans-serif';
export class CanvasPainter implements Painter {
private readonly ctx: CanvasRenderingContext2D;
constructor(ctx: CanvasRenderingContext2D) {
this.ctx = ctx;
}
private path(r: Rect, radius: number): void {
const ctx = this.ctx;
const rad = Math.max(0, Math.min(radius, r.w / 2, r.h / 2));
ctx.beginPath();
if (typeof ctx.roundRect === 'function') {
ctx.roundRect(r.x, r.y, r.w, r.h, rad);
} else {
// 老 WebView 兜底
const { x, y, w, h } = r;
ctx.moveTo(x + rad, y);
ctx.arcTo(x + w, y, x + w, y + h, rad);
ctx.arcTo(x + w, y + h, x, y + h, rad);
ctx.arcTo(x, y + h, x, y, rad);
ctx.arcTo(x, y, x + w, y, rad);
ctx.closePath();
}
}
fillRoundRect(r: Rect, radius: number, color: string, alpha = 1): void {
if (alpha <= 0 || r.w <= 0 || r.h <= 0) return;
const ctx = this.ctx;
ctx.globalAlpha = Math.min(1, alpha);
ctx.fillStyle = color;
this.path(r, radius);
ctx.fill();
ctx.globalAlpha = 1;
}
strokeRoundRect(r: Rect, radius: number, color: string, lineWidth: number, alpha = 1): void {
if (alpha <= 0) return;
const ctx = this.ctx;
ctx.globalAlpha = Math.min(1, alpha);
ctx.strokeStyle = color;
ctx.lineWidth = lineWidth;
this.path(r, radius);
ctx.stroke();
ctx.globalAlpha = 1;
}
fillCircle(cx: number, cy: number, radius: number, color: string, alpha = 1): void {
if (alpha <= 0) return;
const ctx = this.ctx;
ctx.globalAlpha = Math.min(1, alpha);
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1;
}
/** 数字 800、汉字 700(docs/design.md 盘面字阶);不加粗时 500 */
text(s: string, cx: number, cy: number, size: number, color: string, bold = false, alpha = 1): void {
if (alpha <= 0) return;
const ctx = this.ctx;
ctx.globalAlpha = Math.min(1, alpha);
ctx.fillStyle = color;
ctx.font = `${bold ? (/^\d+$/.test(s) ? '800' : '700') : '500'} ${size}px ${BOARD_FONT_FAMILY}`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(s, cx, cy + size * 0.04);
ctx.globalAlpha = 1;
}
}
+78
View File
@@ -0,0 +1,78 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
/>
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="theme-color" content="#ffffff" />
<title>数字一笔</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div id="app">
<!-- 选关 -->
<section id="screen-select" class="screen">
<header class="select-header">
<h1>数字一笔</h1>
<p class="sub">按数字顺序,一笔走遍每一格</p>
<div class="overall" aria-label="总进度">
<div class="overall-bar"><i id="overall-fill"></i></div>
<span id="overall-text" class="overall-text">0 / 0</span>
</div>
</header>
<div id="pack-list" class="pack-list"></div>
<button id="btn-continue" class="primary">继续</button>
<footer class="select-footer">
<button id="btn-sound" class="chip">音效</button>
<button id="btn-haptic" class="chip">振动</button>
<button id="btn-reset-progress" class="chip danger">重置进度</button>
</footer>
</section>
<!-- 对局 -->
<section id="screen-play" class="screen hidden">
<header class="topbar">
<button id="btn-back" class="icon" aria-label="返回选关">
<svg viewBox="0 0 24 24" width="26" height="26" aria-hidden="true"><path d="M15 5l-7 7 7 7" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<div class="level-info">
<div id="level-title" class="level-title">1-1</div>
<div id="level-sub" class="level-sub">入门 · 第 1 关</div>
</div>
<div id="remaining" class="remaining" aria-live="polite" aria-label="剩余格数">
<svg class="remaining-icon tile" viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><rect x="4" y="4" width="16" height="16" rx="4.5" fill="none" stroke="currentColor" stroke-width="2.4"/></svg>
<svg class="remaining-icon check" viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5" fill="none" stroke="currentColor" stroke-width="2.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
<span id="remaining-n">0</span>
</div>
</header>
<div id="level-progress" class="level-progress" aria-hidden="true"></div>
<div id="caption" class="caption"></div>
<div id="board-wrap" class="board-wrap">
<canvas id="board"></canvas>
</div>
<p class="gesture-help">按住滑动连线 · 点已走格回退</p>
<footer class="bottombar">
<button id="btn-restart" class="action">
<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true"><path d="M4 12a8 8 0 1 0 2.5-5.8" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"/><path d="M4 4v5h5" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg>
<span>重开</span>
</button>
<button id="btn-hint" class="action">
<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true"><path d="M9 18h6M10 21h4M12 3a6 6 0 0 0-3.6 10.8c.6.5 1 1.2 1.1 2.2h5c.1-1 .5-1.7 1.1-2.2A6 6 0 0 0 12 3z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
<span>补数字</span>
</button>
<button id="btn-route-hint" class="action">
<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true"><path d="M5 19v-7a4 4 0 0 1 4-4h10m-4-4 4 4-4 4" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg>
<span>指路</span>
</button>
</footer>
</section>
<div id="toast" class="toast" role="status"></div>
</div>
<script src="dist/app.js"></script>
</body>
</html>
+604
View File
@@ -0,0 +1,604 @@
// Web 原型入口:DOM / Canvas / PointerEvent / localStorage 的粘合层。
// 规则、输入语义、动效状态都在 ../src 里;这里只做“把浏览器事件喂给 src,把 src 的状态画到屏幕上”。
import pack1Json from '../levels/pack-1-tutorial.json';
import pack2Json from '../levels/pack-2-advanced.json';
import pack3Json from '../levels/pack-3-parity.json';
import type { GameEvent } from '../src/game';
import { Game } from '../src/game';
import { pickClueHint } from '../src/clue-hint';
import { PointerInput } from '../src/input';
import { fitBoard, hitTest, type BoardLayout } from '../src/layout';
import { loadPack, parsePack, type LoadedLevel } from '../src/levels';
import { Progress, type KeyValueStore } from '../src/progress';
import { hint as computeHint, uniqueSolution } from '../src/solver';
import { BoardView } from '../src/view';
import { WebAudioSfx } from './audio';
import { CanvasPainter } from './canvas-painter';
const $ = <T extends HTMLElement = HTMLElement>(id: string): T => {
const el = document.getElementById(id);
if (!el) throw new Error(`#${id} 不存在`);
return el as T;
};
class LocalStore implements KeyValueStore {
get(k: string): string | null {
try {
return localStorage.getItem(k);
} catch {
return null;
}
}
set(k: string, v: string): void {
try {
localStorage.setItem(k, v);
} catch {
/* 私密模式等 */
}
}
remove(k: string): void {
try {
localStorage.removeItem(k);
} catch {
/* ignore */
}
}
}
interface Session {
level: LoadedLevel;
game: Game;
view: BoardView;
input: PointerInput;
solution: number[] | null;
layout: BoardLayout | null;
}
class App {
private readonly packs = [pack1Json, pack2Json, pack3Json].map((j) => parsePack(j));
private readonly packLevels = this.packs.map((p) => loadPack(p));
/** 全部关卡按包序拼平;解锁、继续、过关跳关都基于这个跨包线性序列 */
private readonly levels: LoadedLevel[] = this.packLevels.flat();
private readonly ids = this.levels.map((l) => l.def.id);
private readonly progress = new Progress(new LocalStore());
private readonly sfx = new WebAudioSfx();
private readonly devUnlock = /[?&]dev\b/.test(location.search);
/** 单文件试玩版开放选关,不改通关记录,也不需要开启开发模式。 */
private readonly unlockAll = this.devUnlock || document.documentElement.dataset.unlockAll === 'true';
/** 系统“减弱动态效果”:盘面去掉进场错落 / 弹簧 / 扫光,CSS 侧由 media query 处理 */
private readonly reducedMotion = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
private readonly canvas = $<HTMLCanvasElement>('board');
private readonly ctx = this.canvas.getContext('2d')!;
private readonly painter = new CanvasPainter(this.ctx);
private readonly boardWrap = $('board-wrap');
private session: Session | null = null;
private rafId = 0;
private lastT = 0;
private activePointer: number | null = null;
private touchPointer = false;
private lastPos = { x: 0, y: 0 };
private advanceTimer = 0;
private exitTimer = 0;
private toastTimer = 0;
private cssSize = { w: 0, h: 0 };
private lastRemaining = -1;
constructor() {
this.sfx.enabled = this.progress.prefs.sound;
this.bindUI();
new ResizeObserver(() => this.resize()).observe(this.boardWrap);
this.renderSelect();
this.showSelect();
if (this.devUnlock) {
// ?dev:解锁全部关卡,并暴露调试句柄给自动化测试
(window as unknown as { __numberStroke: App }).__numberStroke = this;
const requested = new URLSearchParams(location.search).get('level');
const i = requested === null ? -1 : this.ids.indexOf(requested);
if (i >= 0) this.startLevel(i);
}
}
/** 调试:某格视觉锚点中心的视口坐标(clientX/Y),用于自动化手势测试。异形格取形心最近单位 */
debugCellCenter(cellId: string): { x: number; y: number } | null {
const s = this.session;
if (!s || !s.layout) return null;
const cell = s.level.board.cellById(cellId);
const [ax, ay] = s.level.board.anchorCenter(cell.index);
const r = this.canvas.getBoundingClientRect();
return {
x: r.left + s.layout.originX + ax * s.layout.unit,
y: r.top + s.layout.originY + ay * s.layout.unit,
};
}
/** 调试:当前路径(格 id)、状态与唯一解 */
debugState(): { id: string; path: string[]; status: string; remaining: number; solution: string[] | null; conflicts: string[]; revealed: { cell: string; step: number }[] } | null {
const s = this.session;
if (!s) return null;
const ids = (p: number[]) => p.map((i) => s.level.board.cells[i].id);
return {
id: s.level.def.id,
path: ids(s.game.path),
status: s.game.status,
remaining: s.game.remaining,
solution: s.solution ? ids(s.solution) : null,
conflicts: ids(s.game.path.filter((i) => s.game.isConflict(i))),
revealed: [...s.game.revealedClues].map(([cell, step]) => ({ cell: s.level.board.cells[cell].id, step })),
};
}
// ---------- 屏幕切换 ----------
private showSelect(): void {
this.cancelGesture();
this.stopLoop();
window.clearTimeout(this.exitTimer);
$('screen-play').classList.add('hidden');
this.renderSelect();
this.enterScreen($('screen-select'));
}
private showPlay(): void {
$('screen-select').classList.add('hidden');
const play = $('screen-play');
// 关与关之间只有盘面自己的进场 / 退场,整屏淡入只在从选关进来时做一次
if (play.classList.contains('hidden')) this.enterScreen(play);
this.resize();
this.startLoop();
}
/** 整屏进场:淡入 + 上移 8px(--t-base)。动画结束后摘掉类,便于下次重放 */
private enterScreen(el: HTMLElement): void {
el.classList.remove('hidden');
el.classList.remove('enter');
void el.offsetWidth;
el.classList.add('enter');
el.addEventListener('animationend', () => el.classList.remove('enter'), { once: true });
}
// ---------- 选关 ----------
private renderSelect(): void {
const list = $('pack-list');
list.innerHTML = '';
const currentId = this.progress.firstUncleared(this.ids);
const clearedTotal = this.progress.clearedCount();
const allCleared = clearedTotal >= this.levels.length;
$('overall-fill').style.width = `${(100 * clearedTotal) / this.levels.length}%`;
$('overall-text').textContent = `${clearedTotal} / ${this.levels.length}`;
let gi = 0; // 跨包全局序号:包间线性解锁(2-1 需先过 1-10)
this.packs.forEach((pack, pi) => {
const levels = this.packLevels[pi];
const section = document.createElement('div');
section.className = 'pack';
const clearedInPack = levels.filter((l) => this.progress.isCleared(l.def.id)).length;
if (clearedInPack === levels.length) section.classList.add('done');
const title = document.createElement('div');
title.className = 'pack-title';
title.innerHTML = `<span></span><span class="pack-progress">${clearedInPack} / ${levels.length}</span>`;
title.firstElementChild!.textContent = pack.title;
const grid = document.createElement('div');
grid.className = 'level-grid';
for (const lv of levels) {
const i = gi++;
const id = lv.def.id;
const tile = document.createElement('button');
tile.className = 'level-tile';
tile.innerHTML = `<span>${lv.index}</span>`;
const unlocked = this.unlockAll || this.progress.isUnlocked(this.ids, i);
if (this.progress.isCleared(id)) tile.classList.add('cleared');
else if (!unlocked) tile.classList.add('locked');
if (id === currentId && !allCleared) tile.classList.add('current');
tile.addEventListener('click', () => {
this.sfx.unlock();
if (!unlocked) {
tile.classList.remove('shake');
void tile.offsetWidth;
tile.classList.add('shake');
return;
}
this.startLevel(i);
});
grid.appendChild(tile);
}
section.appendChild(title);
section.appendChild(grid);
list.appendChild(section);
});
const btn = $('btn-continue');
btn.textContent = allCleared ? `全部通关 · 重玩 ${this.ids[0]}` : `继续 · ${currentId}`;
this.syncPrefChips();
}
private syncPrefChips(): void {
$('btn-sound').classList.toggle('on', this.progress.prefs.sound);
$('btn-haptic').classList.toggle('on', this.progress.prefs.haptics);
}
// ---------- 对局 ----------
private startLevel(i: number): void {
this.cancelGesture();
window.clearTimeout(this.advanceTimer);
window.clearTimeout(this.exitTimer);
const level = this.levels[i];
const game = new Game(level.board);
const view = new BoardView(game, { reducedMotion: this.reducedMotion });
this.session = {
level,
game,
view,
input: new PointerInput(game),
solution: uniqueSolution(level.board),
layout: null,
};
this.progress.setCurrent(level.def.id);
const pi = this.packLevels.findIndex((levels) => levels.includes(level));
$('level-title').textContent = level.def.id;
$('level-sub').textContent = `${this.packs[pi].title} · 第 ${level.index} 关`;
this.renderProgressStrip(pi, level.def.id);
$('caption').textContent = level.def.caption ?? '';
$('btn-hint').classList.remove('pulse');
$('btn-restart').classList.remove('pulse');
$('remaining').classList.remove('done', 'bump');
this.lastRemaining = -1;
this.setButtonsEnabled(true);
this.updateHud();
this.showPlay();
}
/** 顶栏下的本包进度条:每关一段,已过淡紫、当前实紫、未到灰 */
private renderProgressStrip(pi: number, currentId: string): void {
const strip = $('level-progress');
strip.innerHTML = '';
for (const lv of this.packLevels[pi]) {
const seg = document.createElement('i');
if (lv.def.id === currentId) seg.className = 'current';
else if (this.progress.isCleared(lv.def.id)) seg.className = 'cleared';
strip.appendChild(seg);
}
}
private currentIndex(): number {
return this.session ? this.levels.indexOf(this.session.level) : -1;
}
private nextLevel(): void {
const i = this.currentIndex();
if (i >= 0 && i + 1 < this.levels.length) this.startLevel(i + 1);
else {
this.showSelect();
this.toast(this.ids.every((id) => this.progress.isCleared(id)) ? '已通关全部 ' + this.levels.length + ' 关' : '本包已完成');
}
}
private updateHud(): void {
if (!this.session) return;
const n = this.session.game.remaining;
if (n === this.lastRemaining) return;
const pill = $('remaining');
if (this.session.game.isCleared) {
$('remaining-n').textContent = '完成';
pill.classList.add('done');
} else {
$('remaining-n').textContent = String(n);
}
// 数字变化时轻弹一下(--t-base,spring 缓动);开局第一次不弹
if (this.lastRemaining >= 0) {
pill.classList.remove('bump');
void pill.offsetWidth;
pill.classList.add('bump');
}
this.lastRemaining = n;
}
private setButtonsEnabled(on: boolean): void {
($('btn-hint') as HTMLButtonElement).disabled = !on;
($('btn-route-hint') as HTMLButtonElement).disabled = !on;
($('btn-restart') as HTMLButtonElement).disabled = !on;
}
private handleEvents(events: GameEvent[]): void {
const s = this.session;
if (!s) return;
for (const e of events) {
s.view.onEvent(e);
switch (e.type) {
case 'step':
if (e.conflict) {
this.sfx.conflict();
this.vibrate(40);
} else {
this.sfx.step(e.step);
this.vibrate(e.clueHit ? 18 : 8);
}
break;
case 'rejectStart':
this.sfx.reject();
this.vibrate(15);
break;
case 'deadEnd':
this.vibrate([10, 30, 10]);
break;
case 'rewound':
if (e.removed > 1) {
this.vibrate(12);
this.toast(`已回到第 ${s.game.stepOf(e.cell)} 步,可继续滑动`);
}
break;
case 'clueRevealed':
this.vibrate(18);
break;
case 'cleared':
this.onCleared();
break;
default:
break;
}
}
if (events.length) this.updateHud();
}
private onCleared(): void {
const s = this.session;
if (!s) return;
this.sfx.cleared();
this.vibrate([20, 40, 30]);
this.progress.markCleared(s.level.def.id);
this.setButtonsEnabled(false);
this.activePointer = null;
window.clearTimeout(this.advanceTimer);
window.clearTimeout(this.exitTimer);
// 扫光 → 停留 → 盘面淡出 → 下一关进场
this.exitTimer = window.setTimeout(() => s.view.beginExit(), s.view.clearedHoldEnd * 1000);
this.advanceTimer = window.setTimeout(() => this.nextLevel(), s.view.clearedDuration * 1000);
}
private restart(): void {
const s = this.session;
if (!s || s.game.isCleared) return;
this.cancelGesture();
this.handleEvents(s.game.apply({ type: 'reset' }));
$('btn-restart').classList.remove('pulse');
}
private hint(): void {
const s = this.session;
if (!s || s.game.isCleared) return;
this.cancelGesture();
if (!s.solution) {
this.toast('本关暂时无法补充数字');
return;
}
const h = pickClueHint(s.game, s.solution);
if (!h) {
this.toast('可补的数字已给出,试试「指路」');
return;
}
this.handleEvents(s.game.apply({ type: 'reveal', ...h }));
$('btn-restart').classList.remove('pulse');
this.toast(`补上了 ${h.step},第 ${h.step} 步要到这里`);
}
private routeHint(): void {
const s = this.session;
if (!s || s.game.isCleared) return;
this.cancelGesture();
if (!s.solution) {
this.toast('本关没有唯一解(关卡数据有误)');
return;
}
const h = computeHint(s.solution, s.game.path);
s.view.showHint(h);
$('btn-restart').classList.remove('pulse');
switch (h.kind) {
case 'start':
this.toast('从这里开始');
break;
case 'next':
this.toast('下一步在这里');
break;
case 'backtrack':
this.toast(`点高亮格,直接退回 ${h.undoCount} 步`);
break;
case 'restart':
this.toast('起手位置不对,重开试试');
$('btn-restart').classList.add('pulse');
break;
case 'none':
break;
}
}
// ---------- 布局与渲染 ----------
private resize(): void {
const s = this.session;
const rect = this.boardWrap.getBoundingClientRect();
if (rect.width < 10 || rect.height < 10) return;
const dpr = Math.min(3, window.devicePixelRatio || 1);
this.cssSize = { w: rect.width, h: rect.height };
this.canvas.width = Math.round(rect.width * dpr);
this.canvas.height = Math.round(rect.height * dpr);
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
if (s) {
const cols = s.level.board.cols;
const rows = s.level.board.rows;
// 小盘不要铺满:格子最大约 100px;大盘按短边缩放
const maxUnit = cols * rows <= 6 ? 96 : 110;
s.layout = fitBoard(s.level.board, rect.width, rect.height, { padding: 10, maxUnit });
// 视觉中心略高于几何中心:整盘上提 3%,小盘不会显得往下坠
s.layout.originY = Math.max(10, s.layout.originY - rect.height * 0.03);
s.view.layout = s.layout;
s.view.invalidate();
}
this.render();
}
private startLoop(): void {
if (this.rafId) return;
this.lastT = performance.now();
const tick = (t: number): void => {
const dt = (t - this.lastT) / 1000;
this.lastT = t;
const s = this.session;
if (s) {
s.view.update(dt);
if (s.view.isAnimating) this.render();
}
this.rafId = requestAnimationFrame(tick);
};
this.rafId = requestAnimationFrame(tick);
}
private stopLoop(): void {
if (this.rafId) cancelAnimationFrame(this.rafId);
this.rafId = 0;
}
private render(): void {
const s = this.session;
this.ctx.clearRect(0, 0, this.cssSize.w, this.cssSize.h);
if (!s || !s.layout) return;
s.view.draw(this.painter);
}
// ---------- 输入 ----------
private pointerPos(e: PointerEvent): { x: number; y: number } {
const r = this.canvas.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
}
private cellAt(p: { x: number; y: number }): number {
const s = this.session;
if (!s || !s.layout) return -1;
return hitTest(s.level.board, s.layout, p.x, p.y, this.touchPointer ? 0.08 : 0.14);
}
private cancelGesture(): void {
this.session?.input.up();
this.session?.input.cancel();
const pointer = this.activePointer;
this.activePointer = null;
if (pointer !== null && this.canvas.hasPointerCapture(pointer)) this.canvas.releasePointerCapture(pointer);
}
private onPointerDown = (e: PointerEvent): void => {
if (!e.isPrimary || this.activePointer !== null) return;
if (!this.session || this.session.game.isCleared) return;
this.sfx.unlock();
this.activePointer = e.pointerId;
this.touchPointer = e.pointerType === 'touch';
try {
this.canvas.setPointerCapture(e.pointerId);
} catch {
/* 某些 WebView 不支持 */
}
const p = this.pointerPos(e);
this.lastPos = p;
this.handleEvents(this.session.input.down(this.cellAt(p)));
e.preventDefault();
};
private onPointerMove = (e: PointerEvent): void => {
if (e.pointerId !== this.activePointer || !this.session || !this.session.layout) return;
const p = this.pointerPos(e);
const target = this.cellAt(p);
// 直接落到历史格时,优先回退到目标,不把跳跃采样途中经过的空格误加进路径。
// 在同一个大格的不同部位滑动也不应穿出它、误加凹口里的邻格。
if (target >= 0 && this.session.game.isVisited(target)) {
this.lastPos = p;
this.handleEvents(this.session.input.move(target));
e.preventDefault();
return;
}
// 快速滑动时在两帧之间插值采样,避免跳过中间格
const step = this.session.layout.unit * 0.25;
const dx = p.x - this.lastPos.x;
const dy = p.y - this.lastPos.y;
const n = Math.max(1, Math.ceil(Math.hypot(dx, dy) / step));
const events: GameEvent[] = [];
for (let i = 1; i <= n; i++) {
const q = { x: this.lastPos.x + (dx * i) / n, y: this.lastPos.y + (dy * i) / n };
events.push(...this.session.input.move(this.cellAt(q)));
}
this.lastPos = p;
this.handleEvents(events);
e.preventDefault();
};
private onPointerUp = (e: PointerEvent): void => {
if (e.pointerId !== this.activePointer) return;
this.activePointer = null;
if (this.session) this.handleEvents(this.session.input.up());
};
private vibrate(pattern: number | number[]): void {
if (!this.progress.prefs.haptics) return;
try {
navigator.vibrate?.(pattern);
} catch {
/* ignore */
}
}
// ---------- 提示条 ----------
private toast(msg: string): void {
const el = $('toast');
el.textContent = msg;
el.classList.add('show');
window.clearTimeout(this.toastTimer);
this.toastTimer = window.setTimeout(() => el.classList.remove('show'), 1800);
}
// ---------- 事件绑定 ----------
private bindUI(): void {
this.canvas.addEventListener('pointerdown', this.onPointerDown);
this.canvas.addEventListener('pointermove', this.onPointerMove);
this.canvas.addEventListener('pointerup', this.onPointerUp);
this.canvas.addEventListener('pointercancel', this.onPointerUp);
this.canvas.addEventListener('lostpointercapture', this.onPointerUp);
this.canvas.addEventListener('contextmenu', (e) => e.preventDefault());
$('btn-back').addEventListener('click', () => {
window.clearTimeout(this.advanceTimer);
this.showSelect();
});
$('btn-restart').addEventListener('click', () => this.restart());
$('btn-hint').addEventListener('click', () => this.hint());
$('btn-route-hint').addEventListener('click', () => this.routeHint());
$('btn-continue').addEventListener('click', () => {
this.sfx.unlock();
const id = this.progress.clearedCount() >= this.levels.length ? this.ids[0] : this.progress.firstUncleared(this.ids);
this.startLevel(this.ids.indexOf(id));
});
$('btn-sound').addEventListener('click', () => {
this.progress.setPrefs({ sound: !this.progress.prefs.sound });
this.sfx.enabled = this.progress.prefs.sound;
if (this.sfx.enabled) {
this.sfx.unlock();
this.sfx.step(1);
}
this.syncPrefChips();
});
$('btn-haptic').addEventListener('click', () => {
this.progress.setPrefs({ haptics: !this.progress.prefs.haptics });
if (this.progress.prefs.haptics) this.vibrate(15);
this.syncPrefChips();
});
$('btn-reset-progress').addEventListener('click', () => {
if (window.confirm('清空本玩法的通关记录?')) {
this.progress.resetAll();
this.renderSelect();
}
});
window.addEventListener('resize', () => this.resize());
}
}
new App();
+646
View File
@@ -0,0 +1,646 @@
/* 数字一笔 · Web 壳样式。令牌与组件规格见仓库 docs/design.md(改令牌先改文档,再同步 src/theme.ts)。 */
:root {
/* ---- 颜色 ---- */
--bg: #ffffff;
--bg-app: #f2f2f6;
--surface-2: #f3f3f7;
--cell: #e6e7ec;
--line: #e1e2ea;
--ink: #23243a;
--ink-2: #6b6d80;
--ink-3: #a3a5b5;
--primary: #6558f5;
--primary-bright: #7d72ff;
--primary-soft: #eceafe;
--on-primary: #ffffff;
--danger: #e5484d;
--danger-soft: #fdecec;
--hint: #f5a524;
--hint-soft: #fef3dd;
--clue-ring: #23243a;
--overlay: rgba(35, 36, 58, 0.92);
--shadow-primary: 0 6px 18px rgba(101, 88, 245, 0.28);
--shadow-primary-pressed: 0 3px 10px rgba(101, 88, 245, 0.22);
--shadow-frame: 0 20px 60px rgba(20, 20, 40, 0.14);
/* ---- 字体 ---- */
--font: ui-rounded, "SF Pro Rounded", "PingFang SC", "Hiragino Sans GB", "Noto Sans SC", system-ui, -apple-system, "Segoe UI", sans-serif;
--fs-display: 34px;
--fs-title: 22px;
--fs-heading: 17px;
--fs-body: 15px;
--fs-label: 14px;
--fs-caption: 13px;
--fs-micro: 12px;
/* ---- 圆角 ---- */
--r-sm: 10px;
--r-md: 14px;
--r-lg: 18px;
--r-xl: 24px;
--r-pill: 999px;
/* ---- 间距(4pt 网格) ---- */
--sp-1: 4px;
--sp-2: 8px;
--sp-3: 12px;
--sp-4: 16px;
--sp-5: 20px;
--sp-6: 24px;
--sp-7: 28px;
/* ---- 动效 ---- */
--t-fast: 120ms;
--t-base: 200ms;
--t-slow: 320ms;
--t-hold: 500ms;
--ease-out: cubic-bezier(0.22, 1, 0.36, 1);
--ease-in: cubic-bezier(0.4, 0, 1, 1);
--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
/* ---- 尺寸 ---- */
--tap: 44px;
--safe-top: env(safe-area-inset-top, 0px);
--safe-bottom: env(safe-area-inset-bottom, 0px);
--safe-left: env(safe-area-inset-left, 0px);
--safe-right: env(safe-area-inset-right, 0px);
}
* {
box-sizing: border-box;
-webkit-tap-highlight-color: transparent;
}
html,
body {
margin: 0;
height: 100%;
background: var(--bg-app);
color: var(--ink);
font-family: var(--font);
font-size: var(--fs-body);
line-height: 1.3;
-webkit-font-smoothing: antialiased;
font-variant-numeric: tabular-nums;
user-select: none;
-webkit-user-select: none;
touch-action: manipulation;
overscroll-behavior: none;
}
#app {
position: relative;
margin: 0 auto;
width: 100%;
max-width: 480px;
height: 100vh;
height: 100dvh;
background: var(--bg);
overflow: hidden;
}
.screen {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
padding: calc(var(--safe-top) + var(--sp-3)) calc(var(--safe-right) + var(--sp-5)) calc(var(--safe-bottom) + var(--sp-4))
calc(var(--safe-left) + var(--sp-5));
}
.screen.hidden {
display: none;
}
.screen.enter {
animation: screen-in var(--t-base) var(--ease-out) both;
}
button {
font: inherit;
color: inherit;
background: none;
border: 0;
padding: 0;
cursor: pointer;
-webkit-touch-callout: none;
transition: transform var(--t-fast) var(--ease-out), background-color var(--t-fast) var(--ease-out),
box-shadow var(--t-fast) var(--ease-out), opacity var(--t-fast) var(--ease-out);
}
button:active {
transform: scale(0.97);
}
button:focus-visible {
outline: 3px solid var(--primary);
outline-offset: 2px;
}
/* ---------- 选关 ---------- */
.select-header {
padding: var(--sp-7) var(--sp-1) var(--sp-2);
}
.select-header h1 {
margin: 0;
font-size: var(--fs-display);
font-weight: 800;
letter-spacing: 0.02em;
line-height: 1.15;
}
.select-header .sub {
margin: var(--sp-2) 0 0;
color: var(--ink-2);
font-size: var(--fs-body);
font-weight: 500;
}
.overall {
display: flex;
align-items: center;
gap: var(--sp-3);
margin-top: var(--sp-4);
}
.overall-bar {
flex: 1;
height: 6px;
border-radius: var(--r-pill);
background: var(--cell);
overflow: hidden;
}
.overall-bar i {
display: block;
height: 100%;
width: 0;
border-radius: var(--r-pill);
background: var(--primary);
transition: width var(--t-slow) var(--ease-out);
}
.overall-text {
color: var(--ink-3);
font-size: var(--fs-caption);
font-weight: 600;
min-width: 52px;
text-align: right;
}
.pack-list {
flex: 1;
overflow-y: auto;
margin-top: var(--sp-3);
padding-bottom: var(--sp-5);
/* 继续按钮固定在底部,包列表自己滚;底部 20px 渐隐,避免被按钮“切断” */
min-height: 0;
-webkit-mask-image: linear-gradient(to bottom, transparent, #000 12px, #000 calc(100% - 20px), transparent);
mask-image: linear-gradient(to bottom, transparent, #000 12px, #000 calc(100% - 20px), transparent);
}
.pack {
margin-top: var(--sp-7);
}
.pack-list > .pack:first-child {
margin-top: var(--sp-2);
}
.pack-title {
display: flex;
align-items: baseline;
justify-content: space-between;
padding: 0 var(--sp-1) var(--sp-3);
font-size: var(--fs-heading);
font-weight: 700;
}
.pack-progress {
color: var(--ink-3);
font-size: var(--fs-label);
font-weight: 600;
}
.pack.done .pack-progress {
color: var(--primary);
}
.level-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: var(--sp-3);
}
.level-tile {
position: relative;
aspect-ratio: 1;
border-radius: var(--r-md);
background: var(--cell);
color: var(--ink);
font-size: 20px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
}
.level-tile:active {
transform: scale(0.94);
}
.level-tile.cleared {
background: var(--primary);
color: var(--on-primary);
}
/* 已过关:右上角一个小勾(纯 CSS 画,不依赖字体里的 ✓) */
.level-tile.cleared::after {
content: "";
position: absolute;
right: 8px;
top: 6px;
width: 4px;
height: 8px;
border: solid rgba(255, 255, 255, 0.9);
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
.level-tile.current {
box-shadow: inset 0 0 0 3px var(--primary);
background: var(--primary-soft);
color: var(--primary);
animation: breathe 2.4s var(--ease-in-out) infinite;
}
.level-tile.locked {
color: var(--ink-3);
background: var(--surface-2);
}
.level-tile.locked::before {
content: "";
width: 10px;
height: 12px;
border: 2px solid var(--ink-3);
border-radius: 3px;
border-top-width: 3px;
}
.level-tile.locked span {
display: none;
}
.level-tile.shake {
animation: shake var(--t-slow) var(--ease-out);
}
.primary {
margin-top: auto;
height: 54px;
border-radius: var(--r-lg);
background: var(--primary);
color: var(--on-primary);
font-size: var(--fs-heading);
font-weight: 700;
letter-spacing: 0.02em;
box-shadow: var(--shadow-primary);
}
.primary:active {
transform: scale(0.98);
box-shadow: var(--shadow-primary-pressed);
}
.select-footer {
display: flex;
justify-content: center;
gap: var(--sp-2);
padding-top: var(--sp-4);
}
.chip {
height: 34px;
padding: 0 var(--sp-4);
border-radius: var(--r-pill);
background: var(--surface-2);
color: var(--ink-2);
font-size: var(--fs-caption);
font-weight: 600;
}
.chip.on {
background: var(--primary-soft);
color: var(--primary);
}
.chip.danger {
color: var(--danger);
}
/* ---------- 对局 ---------- */
.topbar {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
height: 52px;
}
.topbar .icon {
justify-self: start;
width: var(--tap);
height: var(--tap);
display: flex;
align-items: center;
justify-content: center;
color: var(--ink-2);
border-radius: var(--r-pill);
margin-left: -8px;
}
.topbar .icon:active {
background: var(--surface-2);
}
.level-info {
text-align: center;
line-height: 1.1;
}
.level-title {
font-size: var(--fs-title);
font-weight: 800;
letter-spacing: 0.02em;
}
.level-sub {
margin-top: 3px;
color: var(--ink-3);
font-size: var(--fs-micro);
font-weight: 600;
letter-spacing: 0.04em;
white-space: nowrap;
}
.remaining {
justify-self: end;
display: inline-flex;
align-items: center;
gap: 5px;
height: 32px;
padding: 0 var(--sp-3) 0 10px;
border-radius: var(--r-pill);
background: var(--surface-2);
color: var(--ink-2);
font-size: var(--fs-label);
font-weight: 700;
min-width: 52px;
justify-content: center;
transition: background-color var(--t-base) var(--ease-out), color var(--t-base) var(--ease-out);
}
.remaining-icon {
color: var(--ink-3);
transition: color var(--t-base) var(--ease-out);
}
.remaining-icon.check {
display: none;
}
.remaining.bump {
animation: bump var(--t-base) var(--ease-spring);
}
.remaining.done {
background: var(--primary-soft);
color: var(--primary);
}
.remaining.done .remaining-icon {
color: var(--primary);
}
.remaining.done .remaining-icon.tile {
display: none;
}
.remaining.done .remaining-icon.check {
display: block;
}
/* 本包进度条:每关一段。灰 = 未到,淡紫 = 已过,实紫 = 当前 */
.level-progress {
display: flex;
gap: 3px;
height: 4px;
margin: var(--sp-2) var(--sp-1) 0;
}
.level-progress i {
flex: 1;
border-radius: 2px;
background: var(--cell);
transition: background-color var(--t-slow) var(--ease-out), opacity var(--t-slow) var(--ease-out);
}
.level-progress i.cleared {
background: var(--primary);
opacity: 0.4;
}
.level-progress i.current {
background: var(--primary);
opacity: 1;
}
.caption {
height: 36px;
margin-top: var(--sp-2);
display: flex;
align-items: center;
justify-content: center;
color: var(--ink-2);
font-size: var(--fs-body);
font-weight: 500;
text-align: center;
transition: opacity var(--t-base) var(--ease-out);
}
.caption:empty {
opacity: 0;
}
.board-wrap {
flex: 1;
min-height: 0;
position: relative;
}
#board {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
touch-action: none;
display: block;
}
.bottombar {
display: flex;
justify-content: center;
gap: var(--sp-3);
padding-top: var(--sp-3);
}
.gesture-help {
margin: 0;
padding-top: var(--sp-2);
color: var(--ink-2);
font-size: var(--fs-caption);
text-align: center;
}
.action {
flex: 1;
max-width: 170px;
height: 56px;
border-radius: var(--r-lg);
background: var(--surface-2);
color: var(--ink);
font-size: var(--fs-body);
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
gap: var(--sp-1);
}
.action svg {
color: var(--ink-2);
}
.action:active {
background: var(--cell);
}
.action.pulse {
animation: pulse 0.9s var(--ease-in-out) infinite;
}
.action:disabled {
opacity: 0.4;
pointer-events: none;
}
.toast {
position: absolute;
left: 50%;
bottom: calc(var(--safe-bottom) + 128px);
transform: translate(-50%, 8px);
padding: 10px var(--sp-4);
border-radius: var(--r-md);
background: var(--overlay);
color: #fff;
font-size: var(--fs-label);
font-weight: 600;
width: max-content;
max-width: calc(100% - 40px);
text-align: center;
opacity: 0;
pointer-events: none;
transition: opacity var(--t-base) var(--ease-out), transform var(--t-base) var(--ease-out);
}
.toast.show {
opacity: 1;
transform: translate(-50%, 0);
}
/* ---------- 动效 ---------- */
@keyframes screen-in {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes shake {
0%,
100% {
transform: translateX(0);
}
25% {
transform: translateX(-5px);
}
50% {
transform: translateX(5px);
}
75% {
transform: translateX(-3px);
}
}
@keyframes pulse {
0%,
100% {
box-shadow: 0 0 0 0 rgba(245, 165, 36, 0);
}
50% {
box-shadow: 0 0 0 5px rgba(245, 165, 36, 0.35);
}
}
@keyframes bump {
0% {
transform: scale(1);
}
40% {
transform: scale(1.14);
}
100% {
transform: scale(1);
}
}
@keyframes breathe {
0%,
100% {
box-shadow: inset 0 0 0 3px var(--primary), 0 0 0 0 rgba(101, 88, 245, 0);
}
50% {
box-shadow: inset 0 0 0 3px var(--primary), 0 0 0 6px rgba(101, 88, 245, 0.16);
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation: none !important;
transition: none !important;
}
}
@media (min-width: 520px) {
body {
padding: var(--sp-6) 0;
}
#app {
height: calc(100vh - 48px);
height: calc(100dvh - 48px);
border-radius: 28px;
box-shadow: var(--shadow-frame);
}
}