初始提交:三玩法 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 @@
.DS_Store
node_modules/
+11
View File
@@ -0,0 +1,11 @@
# number-swipe
多玩法休闲 puzzle。三个玩法共用一个壳,规则和关卡彼此独立。
| 玩法 | 目录 | 一句话 | 状态 |
|---|---|---|---|
| 一 · 数字一笔 | [`modes/01-number-stroke`](modes/01-number-stroke) | 矩形一笔画,大格只计一步,序号必须踩对 | Web 原型可玩,26 关(教学 10 + 进阶 10 + 奇偶 6) |
| 二 · 连连气球 | [`modes/02-connect-1234`](modes/02-connect-1234) | 顺号或同号合成,掉落接力、彩虹双倍、10 号放飞 | Web 竖屏畅玩版,Cocos 迁移包 |
| 三 · 六边一笔 | [`modes/03-hex-walls`](modes/03-hex-walls) | 六边形一笔画,墙贴在边上 | Web 原型可玩,10 关 |
产品级约定在 [`docs/PRODUCT.md`](docs/PRODUCT.md)。UI 设计规范(色彩、字阶、组件、动效)在 [`docs/design.md`](docs/design.md),设计师看 [`docs/design.html`](docs/design.html)。
+78
View File
@@ -0,0 +1,78 @@
# 产品结构
这是一个**多玩法**休闲 puzzle,不是单一机制的游戏。三个玩法共用一个壳,规则、盘面模型和关卡彼此独立。
工作名沿用目录 `number-swipe`。上架名未定。三个玩法各自有对外名称。
## 目录
```
number-swipe/
docs/PRODUCT.md 本文件:壳、隔离规则、玩法清单
docs/design.md UI 设计规范:令牌、组件、动效(玩法一为参考实现)
docs/design.html 同上的设计师版:色板、样张、可回放动效
modes/
01-number-stroke/ 玩法一 · 数字一笔
docs/GDD.md
levels/
02-connect-1234/ 玩法二 · 连连气球
docs/GDD.md
src/flow.ts 连消畅玩状态机
src/supply.ts 成袋补给、落点评价与救场
03-hex-walls/ 玩法三 · 六边一笔
docs/GDD.md
levels/
shared/ 壳:选玩法、设置、存档索引、广告位
```
## 隔离规则
1. **玩法之间禁止互相引用。** 三个 `modes/*` 不得互相 import。
2. **规则、求解器、关卡、对局视图属于玩法。** 盘面模型不默认共用。玩法一是矩形格,玩法三是六边形 + 边墙,即使都是“顺序一笔画”也各写一份。玩法二是圆球 + 物理,更不能复用格子。
3. **`shared/` 只放壳。** 允许:玩法注册表、首页选玩法、设置、存档索引、广告位、安全区、通用按钮皮肤。禁止:过关判定、邻接、提示算法、关卡 JSON、掉落表。
4. **存档按玩法分桶。** `mode:01-number-stroke` / `mode:02-connect-1234` / `mode:03-hex-walls`。
5. **原型按玩法分开跑。** 各玩法的 Web 原型进自己的 `web/`。不要求绑在同一个 index.html。
6. **Cocos 按玩法切场景。** 资源与脚本放 `assets/modes/<id>/`。
壳看到的入口:
```ts
interface PuzzleMode {
id: string;
title: string;
mount(root: HTMLElement, ctx: ShellContext): void;
unmount(): void;
}
```
`ShellContext` 只提供存档读写、音效、振动、返回首页。
## 玩法清单
| id | 目录 | 类型 | 状态 | 规格 |
|---|---|---|---|---|
| `01-number-stroke` | `modes/01-number-stroke` | 关卡逻辑:矩形一笔画 + 序号 | M1/M2:Web 原型可玩,26 关(教学 10 + 进阶 10 + 奇偶 6) | [GDD](../modes/01-number-stroke/docs/GDD.md) |
| `02-connect-1234` | `modes/02-connect-1234` | 气球合成:顺号/同号、掉落接力、彩虹双倍 | Web 可玩,Cocos 迁移包 | [GDD](../modes/02-connect-1234/docs/GDD.md) |
| `03-hex-walls` | `modes/03-hex-walls` | 关卡逻辑:六边形一笔画 + 边墙 | M1/M2:Web 原型可玩,10 关 | [GDD](../modes/03-hex-walls/docs/GDD.md) |
一和三同属路径推理,但几何和障碍不同,**不是 DLC 关系**。二是物理连号合成循环;2026-09-15 根据试玩反馈改成无关卡的持续连消,突出掉落接力和放飞。旧十关作为历史实验保留。
新玩法开工顺序:写该玩法 GDD → 锁规则 → 再写该玩法的 `src/`。
## 共享决策(全产品)
- 手机竖屏,单机,离线可玩
- 默认无体力、无计时。玩法二当前为无关卡、无步数限制的连消畅玩模式
- 竖直切面无广告;上线后的去广告 / 激励视频由壳统一接
- 先 Web 原型验证该玩法,再进 Cocos
- 关卡、掉落表、美术全部原创,不使用第三方题库和素材
## 里程碑(产品级)
| 阶段 | 内容 |
|---|---|
| 现在 | 三个玩法独立可运行;一、三为关卡原型,二为气球连消畅玩版并附 Cocos 迁移包 |
| 各玩法 M1 | 只在自己的 `modes/<id>` 里做可玩原型,互不阻塞 |
| 壳 | 至少两个玩法原型可玩之后,再做选玩法首页 |
不要等三个玩法都做完再写代码。任意一个玩法都可以单独进 M1。
+543
View File
@@ -0,0 +1,543 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>数字一笔 · UI 设计规范 v1.5</title>
<style>
/* ====== 令牌:与 modes/01-number-stroke/web/style.css :root 一致 ====== */
: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; --clue-ring-active: #dfe1ec; --overlay: rgba(35, 36, 58, 0.92);
--shadow-primary: 0 6px 18px rgba(101, 88, 245, 0.28); --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;
--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);
--u: 64px; /* 样张里的单位格 */
}
* { box-sizing: border-box; }
html, body { margin: 0; background: var(--bg-app); color: var(--ink); font-family: var(--font); font-size: 15px; line-height: 1.5; font-variant-numeric: tabular-nums; -webkit-font-smoothing: antialiased; }
a { color: var(--primary); text-decoration: none; }
code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px; background: var(--surface-2); padding: 1px 5px; border-radius: 5px; }
/* ====== 文档骨架 ====== */
.doc { display: grid; grid-template-columns: 220px 1fr; min-height: 100vh; }
nav { position: sticky; top: 0; height: 100vh; padding: 28px 20px; border-right: 1px solid var(--line); background: var(--bg); overflow: auto; }
nav h1 { font-size: 18px; font-weight: 800; margin: 0 0 4px; }
nav .ver { color: var(--ink-3); font-size: 12px; font-weight: 600; margin-bottom: 20px; }
nav a { display: block; padding: 6px 8px; border-radius: 8px; color: var(--ink-2); font-size: 14px; font-weight: 600; }
nav a:hover { background: var(--surface-2); color: var(--ink); }
main { padding: 36px 48px 96px; max-width: 1180px; }
section { margin-bottom: 64px; }
h2 { font-size: 26px; font-weight: 800; margin: 0 0 6px; letter-spacing: 0.01em; }
h3 { font-size: 17px; font-weight: 700; margin: 28px 0 10px; }
.lead { color: var(--ink-2); margin: 0 0 20px; max-width: 720px; }
.card { background: var(--bg); border: 1px solid var(--line); border-radius: 16px; padding: 20px; }
.grid { display: grid; gap: 14px; }
.g2 { grid-template-columns: repeat(2, 1fr); } .g3 { grid-template-columns: repeat(3, 1fr); } .g4 { grid-template-columns: repeat(4, 1fr); }
table { width: 100%; border-collapse: collapse; background: var(--bg); border: 1px solid var(--line); border-radius: 12px; overflow: hidden; font-size: 13.5px; }
th, td { text-align: left; padding: 9px 12px; border-bottom: 1px solid var(--line); vertical-align: middle; }
th { background: var(--surface-2); color: var(--ink-2); font-weight: 700; font-size: 12.5px; }
tr:last-child td { border-bottom: 0; }
.note { font-size: 13px; color: var(--ink-2); }
.tag { display: inline-block; padding: 2px 8px; border-radius: 999px; background: var(--surface-2); color: var(--ink-2); font-size: 12px; font-weight: 700; }
.sync { background: var(--primary-soft); color: var(--primary); border-radius: 12px; padding: 12px 16px; font-size: 13.5px; font-weight: 600; margin-bottom: 28px; }
/* ====== 色板 ====== */
.swatches { display: grid; grid-template-columns: repeat(auto-fill, minmax(168px, 1fr)); gap: 12px; }
.sw { background: var(--bg); border: 1px solid var(--line); border-radius: 12px; overflow: hidden; }
.sw .chip { height: 64px; border-bottom: 1px solid var(--line); }
.sw .meta { padding: 10px 12px; }
.sw b { display: block; font-size: 13.5px; }
.sw code { display: inline-block; margin-top: 3px; }
.sw small { display: block; color: var(--ink-2); font-size: 12px; margin-top: 4px; }
/* ====== 字阶 ====== */
.type-row { display: grid; grid-template-columns: 150px 1fr 220px; align-items: baseline; gap: 16px; padding: 12px 0; border-bottom: 1px solid var(--line); }
.type-row:last-child { border-bottom: 0; }
.type-row .lab { color: var(--ink-3); font-size: 12px; font-weight: 700; }
.type-row .use { color: var(--ink-2); font-size: 13px; }
/* ====== 盘面样张 ====== */
.board { position: relative; display: inline-grid; grid-auto-rows: var(--u); grid-auto-columns: var(--u); background: var(--bg); padding: 14px; border-radius: 16px; border: 1px solid var(--line); }
.bc { position: relative; width: var(--u); height: var(--u); padding: calc(var(--u) * 0.05); }
.bc > i { position: absolute; inset: calc(var(--u) * 0.05); border-radius: calc(var(--u) * 0.18); background: var(--cell); display: flex; align-items: center; justify-content: center; font-style: normal; font-weight: 800; font-size: calc(var(--u) * 0.52); color: var(--ink); }
.bc.two > i { font-size: calc(var(--u) * 0.46); }
.bc.clue > i { box-shadow: inset 0 0 0 calc(var(--u) * 0.05) var(--clue-ring); }
.bc.start > i, .bc.visited > i { background: var(--primary); color: var(--on-primary); }
.bc.start > i { font-size: calc(var(--u) * 0.6); }
.bc.start > i, .bc.visited.clue > i { box-shadow: inset 0 0 0 calc(var(--u) * 0.05) var(--clue-ring-active); }
.bc.bright > i { background: var(--primary-bright); }
.bc.conflict > i { background: var(--danger); color: var(--on-primary); }
.bc.parity > i { font-size: calc(var(--u) * 0.44); font-weight: 700; }
.bc.visited.parity > i { flex-direction: column; font-size: calc(var(--u) * 0.52); font-weight: 800; line-height: 1; }
.bc.visited.parity > i small { font-size: calc(var(--u) * 0.19); font-weight: 700; margin-top: calc(var(--u) * 0.04); }
.bc .badge { position: absolute; right: calc(var(--u) * 0.08); top: calc(var(--u) * 0.08); width: calc(var(--u) * 0.3); height: calc(var(--u) * 0.3); border-radius: 50%; background: var(--ink); color: #fff; font-size: calc(var(--u) * 0.19); font-weight: 800; display: flex; align-items: center; justify-content: center; }
.bc.hint > i { box-shadow: 0 0 0 calc(var(--u) * 0.08) var(--hint); }
.bc.dim > i::after { content: ""; position: absolute; inset: 0; border-radius: inherit; background: rgba(255,255,255,.45); }
.bc.big { width: calc(var(--u) * 2); height: calc(var(--u) * 2); grid-column: span 2; grid-row: span 2; }
.bridge { position: absolute; background: var(--primary); z-index: 1; }
.bridge.h { width: calc(var(--u) * 0.1 + 2px); height: calc(var(--u) * 0.46); }
.bridge.v { height: calc(var(--u) * 0.1 + 2px); width: calc(var(--u) * 0.46); }
.bridge.red { background: var(--danger); }
.legend { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 14px; }
.legend .bc { --u: 72px; }
.legend figure { margin: 0; text-align: center; }
.legend figcaption { font-size: 12.5px; color: var(--ink-2); margin-top: 6px; font-weight: 600; }
/* ====== 组件(与 style.css 同规格) ====== */
.topbar { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; height: 52px; }
.icon-btn { justify-self: start; width: 44px; height: 44px; display: flex; align-items: center; justify-content: center; color: var(--ink-2); border-radius: 999px; margin-left: -8px; background: none; border: 0; }
.level-info { text-align: center; line-height: 1.1; }
.level-title { font-size: var(--fs-title); font-weight: 800; letter-spacing: .02em; }
.level-sub { margin-top: 3px; color: var(--ink-3); font-size: var(--fs-micro); font-weight: 600; letter-spacing: .04em; white-space: nowrap; }
.remaining { justify-self: end; display: inline-flex; align-items: center; gap: 5px; height: 32px; padding: 0 12px 0 10px; border-radius: 999px; background: var(--surface-2); color: var(--ink-2); font-size: 14px; font-weight: 700; min-width: 52px; justify-content: center; }
.remaining svg { color: var(--ink-3); }
.remaining.done { background: var(--primary-soft); color: var(--primary); } .remaining.done svg { color: var(--primary); }
.remaining.bump { animation: bump var(--t-base) var(--ease-spring); }
.level-progress { display: flex; gap: 3px; height: 4px; margin: 8px 4px 0; }
.level-progress i { flex: 1; border-radius: 2px; background: var(--cell); }
.level-progress i.cleared { background: var(--primary); opacity: .4; } .level-progress i.current { background: var(--primary); }
.caption { height: 36px; margin-top: 8px; display: flex; align-items: center; justify-content: center; color: var(--ink-2); font-size: 15px; font-weight: 500; }
.gesture-help { margin: 0; padding-top: 8px; color: var(--ink-2); font-size: 13px; text-align: center; }
.bottombar { display: flex; justify-content: center; gap: 12px; padding-top: 12px; }
.action { flex: 1; max-width: 170px; height: 56px; border-radius: 18px; background: var(--surface-2); color: var(--ink); font: inherit; font-size: 15px; font-weight: 700; display: flex; align-items: center; justify-content: center; gap: 4px; border: 0; cursor: pointer; transition: transform var(--t-fast) var(--ease-out), background-color var(--t-fast) var(--ease-out); }
.action svg { color: var(--ink-2); } .action:active { transform: scale(.97); background: var(--cell); }
.action.pulse { animation: pulse .9s var(--ease-in-out) infinite; } .action:disabled { opacity: .4; }
.toast { display: inline-block; padding: 10px 16px; border-radius: 14px; background: var(--overlay); color: #fff; font-size: 14px; font-weight: 600; max-width: 100%; text-align: center; }
.toast.anim { animation: toast-in var(--t-base) var(--ease-out) both; }
.primary-btn { height: 54px; border-radius: 18px; background: var(--primary); color: #fff; font: inherit; font-size: 17px; font-weight: 700; letter-spacing: .02em; box-shadow: var(--shadow-primary); border: 0; width: 100%; cursor: pointer; transition: transform var(--t-fast) var(--ease-out), box-shadow var(--t-fast) var(--ease-out); }
.primary-btn:active { transform: scale(.98); box-shadow: 0 3px 10px rgba(101,88,245,.22); }
.chip-btn { height: 34px; padding: 0 16px; border-radius: 999px; background: var(--surface-2); color: var(--ink-2); font: inherit; font-size: 13px; font-weight: 600; border: 0; cursor: pointer; }
.chip-btn.on { background: var(--primary-soft); color: var(--primary); } .chip-btn.danger { color: var(--danger); }
.level-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 12px; }
.tile { position: relative; aspect-ratio: 1; border-radius: 14px; background: var(--cell); color: var(--ink); font: inherit; font-size: 20px; font-weight: 700; display: flex; align-items: center; justify-content: center; border: 0; cursor: pointer; transition: transform var(--t-fast) var(--ease-out); }
.tile:active { transform: scale(.94); }
.tile.cleared { background: var(--primary); color: #fff; }
.tile.cleared::after { content: ""; position: absolute; right: 8px; top: 6px; width: 4px; height: 8px; border: solid rgba(255,255,255,.9); border-width: 0 2px 2px 0; transform: rotate(45deg); }
.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; }
.tile.locked { background: var(--surface-2); color: var(--ink-3); } .tile.locked::before { content: ""; width: 10px; height: 12px; border: 2px solid var(--ink-3); border-radius: 3px; border-top-width: 3px; } .tile.locked span { display: none; }
.tile.shake { animation: shake var(--t-slow) var(--ease-out); }
.overall { display: flex; align-items: center; gap: 12px; margin-top: 16px; }
.overall-bar { flex: 1; height: 6px; border-radius: 999px; background: var(--cell); overflow: hidden; } .overall-bar i { display: block; height: 100%; border-radius: 999px; background: var(--primary); width: 62%; }
.overall-text { color: var(--ink-3); font-size: 13px; font-weight: 600; }
/* ====== 手机样机 ====== */
.phones { display: flex; gap: 32px; flex-wrap: wrap; }
.phone { width: 390px; height: 760px; background: var(--bg); border-radius: 28px; box-shadow: var(--shadow-frame); padding: 12px 20px 16px; display: flex; flex-direction: column; overflow: hidden; }
.phone h1 { margin: 0; font-size: 34px; font-weight: 800; letter-spacing: .02em; line-height: 1.15; }
.phone .sub { margin: 8px 0 0; color: var(--ink-2); }
.pack { margin-top: 28px; } .pack-title { display: flex; justify-content: space-between; align-items: baseline; padding: 0 4px 12px; font-size: 17px; font-weight: 700; } .pack-progress { color: var(--ink-3); font-size: 14px; font-weight: 600; } .pack.done .pack-progress { color: var(--primary); }
.phone .list { flex: 1; overflow: hidden; margin-top: 12px; -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); }
.phone .footer { display: flex; justify-content: center; gap: 8px; padding-top: 16px; }
.phone .board-area { flex: 1; display: flex; align-items: center; justify-content: center; padding-bottom: 6%; }
.phone .board { --u: 78px; border: 0; padding: 0; }
/* ====== 动效样张 ====== */
.demo { background: var(--bg); border: 1px solid var(--line); border-radius: 16px; padding: 18px; display: flex; flex-direction: column; gap: 12px; }
.demo header { display: flex; justify-content: space-between; align-items: center; }
.demo header b { font-size: 14px; }
.demo header span { color: var(--ink-3); font-size: 12px; font-weight: 700; }
.demo .stage { height: 120px; display: flex; align-items: center; justify-content: center; gap: 8px; background: var(--surface-2); border-radius: 12px; position: relative; overflow: hidden; }
.demo .stage .bc { --u: 64px; }
.demo p { margin: 0; font-size: 12.5px; color: var(--ink-2); }
.replay { height: 30px; padding: 0 12px; border-radius: 999px; background: var(--primary-soft); color: var(--primary); font: inherit; font-size: 12.5px; font-weight: 700; border: 0; cursor: pointer; }
.a-pop > i { animation: pop 220ms var(--ease-out) both; }
.a-fade > i span { animation: fade var(--t-fast) var(--ease-out) both; }
.a-flash > i::before { content: ""; position: absolute; inset: 0; border-radius: inherit; box-shadow: inset 0 0 0 calc(var(--u) * 0.05) #fff; animation: flash var(--t-slow) linear both; }
.a-shake > i { animation: cell-shake 280ms linear both; }
.a-bridge .bridge { transform-origin: center; animation: bridge-grow var(--t-fast) var(--ease-out) both; }
.a-sweep > i::before { content: ""; position: absolute; inset: 0; border-radius: inherit; background: #fff; opacity: 0; animation: sweep 250ms linear both; animation-delay: calc(var(--i) * 64ms); }
.a-enter > i { animation: enter var(--t-base) var(--ease-out) both; animation-delay: calc(var(--i) * 14ms); }
.a-screen { animation: screen-in var(--t-base) var(--ease-out) both; }
.a-hint > i { animation: hint-pulse 2.4s linear both; }
.timeline { display: grid; grid-template-columns: 320fr 500fr 160fr 400fr; gap: 3px; margin-top: 10px; }
.timeline div { padding: 10px 10px; border-radius: 8px; font-size: 12.5px; font-weight: 700; color: var(--ink-2); background: var(--surface-2); }
.timeline div small { display: block; font-weight: 600; color: var(--ink-3); }
.timeline .p1 { background: var(--primary-soft); color: var(--primary); } .timeline .p3 { background: var(--cell); }
@keyframes pop { 0% { transform: scale(1); } 35% { transform: scale(1.12); } 70% { transform: scale(.985); } 100% { transform: scale(1); } }
@keyframes fade { from { opacity: 0; } to { opacity: 1; } }
@keyframes flash { from { opacity: 1; } to { opacity: 0; } }
@keyframes cell-shake { 0%,100% { transform: translateX(0); } 8% { transform: translateX(6%); } 25% { transform: translateX(-5%); } 42% { transform: translateX(4%); } 58% { transform: translateX(-3%); } 75% { transform: translateX(2%); } 92% { transform: translateX(-1%); } }
@keyframes bridge-grow { from { transform: scaleY(0); } to { transform: scaleY(1); } }
@keyframes sweep { 0% { opacity: 0; } 50% { opacity: .55; } 100% { opacity: 0; } }
@keyframes enter { from { opacity: 0; transform: scale(.92); } to { opacity: 1; transform: scale(1); } }
@keyframes screen-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
@keyframes toast-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
@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,.16); } }
@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,.35); } }
@keyframes shake { 0%,100% { transform: translateX(0); } 25% { transform: translateX(-5px); } 50% { transform: translateX(5px); } 75% { transform: translateX(-3px); } }
@keyframes hint-pulse { 0% { box-shadow: 0 0 0 calc(var(--u) * 0.08) rgba(245,165,36,.55); } 15% { box-shadow: 0 0 0 calc(var(--u) * 0.08) rgba(245,165,36,1); } 30% { box-shadow: 0 0 0 calc(var(--u) * 0.08) rgba(245,165,36,.2); } 45% { box-shadow: 0 0 0 calc(var(--u) * 0.08) rgba(245,165,36,1); } 60% { box-shadow: 0 0 0 calc(var(--u) * 0.08) rgba(245,165,36,.2); } 75% { box-shadow: 0 0 0 calc(var(--u) * 0.08) rgba(245,165,36,1); } 100% { box-shadow: 0 0 0 calc(var(--u) * 0.08) rgba(245,165,36,0); } }
@media (max-width: 900px) { .doc { grid-template-columns: 1fr; } nav { position: static; height: auto; border-right: 0; border-bottom: 1px solid var(--line); } main { padding: 24px 20px 64px; } .g2, .g3, .g4 { grid-template-columns: 1fr; } .type-row { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<div class="doc">
<nav>
<h1>数字一笔 · UI 规范</h1>
<div class="ver">v1.5 · 2026-09-16</div>
<a href="#principles">1 设计原则</a>
<a href="#color">2 色彩</a>
<a href="#type">3 字体与字阶</a>
<a href="#space">4 间距 · 圆角 · 尺寸</a>
<a href="#board">5 盘面</a>
<a href="#components">6 组件</a>
<a href="#screens">7 页面</a>
<a href="#motion">8 动效</a>
<a href="#sound">9 声音与振动</a>
<a href="#sync">10 同步规则</a>
</nav>
<main>
<div class="sync">本页与 <code>docs/design.md</code> 同步维护。令牌真值在 <code>web/style.css :root</code> 与 <code>src/theme.ts</code>,由 <code>tests/theme.test.ts</code> 守门。改设计:先改 design.md → 改代码令牌 → 更新本页样张。</div>
<section id="principles">
<h2>1 · 设计原则</h2>
<p class="lead">逻辑 puzzle 的盘面只回答三个问题:我走到哪了、哪错了、过没过。</p>
<div class="grid g3">
<div class="card"><b>板要干净</b><p class="note">盘面无阴影、无纹理、无装饰。灰阶承担一切非路径信息。</p></div>
<div class="card"><b>一个主色</b><p class="note">蓝紫只表示「已走 / 当前 / 可继续」。红只表示冲突,琥珀只表示提示。不每关换色。</p></div>
<div class="card"><b>数字优先</b><p class="note">系统圆体、800 字重、等宽数字。其余文字都比数字轻。</p></div>
<div class="card"><b>动效四档</b><p class="note">只用 120 / 200 / 320 / 500 ms 与四条缓动。新动效先归档再写。</p></div>
<div class="card"><b>反馈贴手指</b><p class="note">落子、踩对、冲突、死路都发生在手指所在格子上。不弹窗、不撒纸片。</p></div>
<div class="card"><b>信息随手可见</b><p class="note">关号、包名、本包进度、剩余格数常驻顶栏。</p></div>
</div>
</section>
<section id="color">
<h2>2 · 色彩</h2>
<p class="lead">浅色单主题。DOM 用 CSS 变量,Canvas 用 <code>THEME</code>,同名同值。</p>
<div class="swatches" id="swatches"></div>
<h3>语义</h3>
<table>
<tr><th style="width:120px">颜色</th><th>只能用于</th><th>不能用于</th></tr>
<tr><td><span class="tag" style="background:var(--primary);color:#fff">主色</span></td><td>已走格、桥、起点 1、当前关、主按钮、进度</td><td>装饰、背景、标题文字</td></tr>
<tr><td><span class="tag" style="background:var(--danger);color:#fff">冲突</span></td><td>冲突格、「重置进度」</td><td>任何常驻元素、警示以外的强调</td></tr>
<tr><td><span class="tag" style="background:var(--hint);color:#fff">提示</span></td><td>提示描边、提示按钮脉冲</td><td>任何常驻元素</td></tr>
<tr><td><span class="tag" style="background:var(--primary-bright);color:#fff">主色·亮</span></td><td>过关后的全盘提亮(唯一允许的换色)</td><td>过关以外</td></tr>
</table>
<h3>对比度</h3>
<table>
<tr><th>组合</th><th>对比度</th><th>用途</th></tr>
<tr><td>白 on 主色</td><td>5.2 : 1</td><td>已走格数字、主按钮</td></tr>
<tr><td>墨 on 未走格</td><td>12.6 : 1</td><td>未走线索数字</td></tr>
<tr><td>白 on 冲突红</td><td>4.5 : 1</td><td>冲突格数字(角标另有墨底白字)</td></tr>
<tr><td>墨 2 on 白</td><td>5.1 : 1</td><td>副文案</td></tr>
<tr><td>墨 on 未走格</td><td>12.6 : 1</td><td>未走线索描边(与数字同色)</td></tr>
<tr><td>墨 3 on 白</td><td>2.5 : 1</td><td>仅非关键弱文案</td></tr>
</table>
</section>
<section id="type">
<h2>3 · 字体与字阶</h2>
<p class="lead">字体栈:<code>ui-rounded, "SF Pro Rounded", "PingFang SC", "Hiragino Sans GB", "Noto Sans SC", system-ui, -apple-system, "Segoe UI", sans-serif</code>。全局等宽数字。</p>
<div class="card">
<div class="type-row"><div class="lab">display · 34 / 800</div><div style="font-size:34px;font-weight:800;line-height:1.15">数字一笔 0123</div><div class="use">选关页标题</div></div>
<div class="type-row"><div class="lab">title · 22 / 800</div><div style="font-size:22px;font-weight:800;letter-spacing:.02em">1-7 · 2-10 · 3-6</div><div class="use">顶栏关号</div></div>
<div class="type-row"><div class="lab">heading · 17 / 700</div><div style="font-size:17px;font-weight:700">入门 · 进阶 · 奇与偶 · 继续 · 2-1</div><div class="use">包标题、主按钮</div></div>
<div class="type-row"><div class="lab">body · 15 / 500</div><div style="font-size:15px;font-weight:500;color:var(--ink-2)">从 1 开始,滑到旁边的格</div><div class="use">副标题、字幕</div></div>
<div class="type-row"><div class="lab">label · 14 / 700</div><div style="font-size:14px;font-weight:700;color:var(--ink-2)">6 · 完成 · 10 / 10 · 下一步在这里</div><div class="use">胶囊、包进度、toast</div></div>
<div class="type-row"><div class="lab">caption · 13 / 600</div><div style="font-size:13px;font-weight:600;color:var(--ink-2)">音效 · 振动 · 16 / 26</div><div class="use">chip、总进度数字</div></div>
<div class="type-row"><div class="lab">micro · 12 / 600</div><div style="font-size:12px;font-weight:600;color:var(--ink-3);letter-spacing:.04em">入门 · 第 7 关</div><div class="use">顶栏副标题</div></div>
</div>
<h3>盘面字阶(相对单位格 u)</h3>
<div class="card" style="display:flex;gap:28px;align-items:flex-end;flex-wrap:wrap">
<figure style="margin:0;text-align:center"><div class="bc start" style="--u:96px"><i>1</i></div><figcaption class="note">起点 0.60 u · 800</figcaption></figure>
<figure style="margin:0;text-align:center"><div class="bc visited" style="--u:96px"><i>7</i></div><figcaption class="note">单位数 0.52 u · 800</figcaption></figure>
<figure style="margin:0;text-align:center"><div class="bc visited two" style="--u:96px"><i>14</i></div><figcaption class="note">两位数 0.46 u · 800</figcaption></figure>
<figure style="margin:0;text-align:center"><div class="bc clue parity" style="--u:96px"><i>奇</i></div><figcaption class="note">奇偶 0.44 u · 700</figcaption></figure>
<figure style="margin:0;text-align:center"><div class="bc visited clue parity" style="--u:96px"><i>6<small>偶</small></i></div><figcaption class="note">小字 0.19 u · 700</figcaption></figure>
<figure style="margin:0;text-align:center"><div class="bc visited conflict" style="--u:96px"><i>6</i><b class="badge">8</b></div><figcaption class="note">角标 r=0.15 u · 字 1.25 r</figcaption></figure>
</div>
</section>
<section id="space">
<h2>4 · 间距 · 圆角 · 尺寸</h2>
<div class="grid g3">
<div class="card"><b>间距 · 4pt 网格</b><p class="note">4 / 8 / 12 / 16 / 20 / 24 / 28。页面左右 20,顶 12 + 安全区,底 16 + 安全区。</p></div>
<div class="card"><b>圆角</b><p class="note">sm 10 · md 14(关卡格、toast)· lg 18(主按钮、底栏按钮)· xl 24 · 胶囊 999 · 桌面框 28。盘面格 0.18 u。</p></div>
<div class="card"><b>尺寸</b><p class="note">点击区 ≥ 44。顶栏 52,胶囊 32,底栏按钮 56,主按钮 54,chip 34,关卡格 5 列间距 12。</p></div>
</div>
<div class="card" style="margin-top:14px;display:flex;gap:20px;align-items:center;flex-wrap:wrap">
<div style="width:10px;height:10px;border-radius:10px;background:var(--cell)"></div><span class="note">10</span>
<div style="width:14px;height:14px;border-radius:14px;background:var(--cell)"></div><span class="note">14</span>
<div style="width:18px;height:18px;border-radius:18px;background:var(--cell)"></div><span class="note">18</span>
<div style="width:24px;height:24px;border-radius:24px;background:var(--cell)"></div><span class="note">24</span>
<div style="width:60px;height:60px;border-radius:14px;background:var(--cell)"></div><span class="note">关卡格 14</span>
<div style="width:120px;height:56px;border-radius:18px;background:var(--surface-2)"></div><span class="note">底栏按钮 18</span>
<div style="width:64px;height:64px;border-radius:11.5px;background:var(--cell)"></div><span class="note">盘面格 0.18 u</span>
</div>
</section>
<section id="board">
<h2>5 · 盘面</h2>
<p class="lead">格圆角 0.18 u、缝 0.10 u、桥厚 0.46 u。整盘垂直居中后上提 3%。四周死区:触屏 8%,鼠标 14%。点任意已走格直接回退,可接着滑动。</p>
<h3>格的状态</h3>
<div class="legend card">
<figure><div class="bc"><i></i></div><figcaption>未走</figcaption></figure>
<figure><div class="bc clue"><i>8</i></div><figcaption>未走线索</figcaption></figure>
<figure><div class="bc clue parity"><i>偶</i></div><figcaption>未走奇偶</figcaption></figure>
<figure><div class="bc start"><i>1</i></div><figcaption>起点 1(未按下)</figcaption></figure>
<figure><div class="bc visited"><i>5</i></div><figcaption>已走</figcaption></figure>
<figure><div class="bc visited clue"><i>8</i></div><figcaption>已走线索</figcaption></figure>
<figure><div class="bc visited clue parity"><i>6<small>偶</small></i></div><figcaption>已走奇偶</figcaption></figure>
<figure><div class="bc visited conflict"><i>6</i><b class="badge">8</b></div><figcaption>冲突 + 角标</figcaption></figure>
<figure><div class="bc visited conflict"><i>5</i></div><figcaption>错位:红格 + 轻晃</figcaption></figure>
<figure><div class="bc clue hint"><i>5</i></div><figcaption>补数字:1~9 补 5</figcaption></figure>
<figure><div class="bc clue hint"><i>4</i></div><figcaption>提示目标</figcaption></figure>
<figure><div class="bc visited dim"><i>9</i></div><figcaption>退回压暗</figcaption></figure>
<figure><div class="bc visited bright"><i>12</i></div><figcaption>过关提亮</figcaption></figure>
</div>
<h3>样张:1-7 半途(含大格、紫色连线、红色错格)</h3>
<div class="card" style="display:flex;gap:32px;flex-wrap:wrap;align-items:flex-start">
<div class="board" id="board-sample"></div>
<div class="board" id="board-sample-2"></div>
<p class="note" style="max-width:280px">左:路径 1→2→3→4→5,6 踩到线索 8 冲突,只让冲突格变红,连接线始终紫色。右:2×2 大格只计一步,异形格由单位矩形 + 接缝同色拼成。桥保持路径色,与出错格的颜色分开。</p>
</div>
</section>
<section id="components">
<h2>6 · 组件</h2>
<h3>顶栏 · 本包进度条 · 字幕</h3>
<div class="card" style="max-width:440px">
<div class="topbar">
<button class="icon-btn" aria-label="返回"><svg viewBox="0 0 24 24" width="26" height="26"><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 class="level-title">1-7</div><div class="level-sub">入门 · 第 7 关</div></div>
<div class="remaining" id="pill-demo"><svg viewBox="0 0 24 24" width="16" height="16"><rect x="4" y="4" width="16" height="16" rx="4.5" fill="none" stroke="currentColor" stroke-width="2.4"/></svg><span>6</span></div>
</div>
<div class="level-progress"><i class="cleared"></i><i class="cleared"></i><i class="cleared"></i><i class="cleared"></i><i class="cleared"></i><i class="cleared"></i><i class="current"></i><i></i><i></i><i></i></div>
<div class="caption">走错了,沿原路滑回去</div>
<div style="display:flex;gap:10px;align-items:center;margin-top:10px;flex-wrap:wrap">
<div class="remaining"><svg viewBox="0 0 24 24" width="16" height="16"><rect x="4" y="4" width="16" height="16" rx="4.5" fill="none" stroke="currentColor" stroke-width="2.4"/></svg><span>12</span></div>
<div class="remaining done"><svg viewBox="0 0 24 24" width="16" height="16"><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>完成</span></div>
<button class="replay" onclick="replay('#pill-demo','bump')">回放 bump</button>
<span class="note">胶囊:默认 / 完成态</span>
</div>
</div>
<h3>底栏按钮 · Toast</h3>
<p class="note">三个等宽按钮,高 56px,15px 字号;补数字保留在本局盘面,指路沿用下一步 / 回退高亮。Toast 距页面底边 128px + 安全区,长句换行,避开手势说明。</p>
<div class="card" style="max-width:440px">
<p class="gesture-help">按住滑动连线 · 点已走格回退</p>
<div class="bottombar">
<button class="action"><svg viewBox="0 0 24 24" width="22" height="22"><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 class="action"><svg viewBox="0 0 24 24" width="22" height="22"><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 class="action"><svg viewBox="0 0 24 24" width="22" height="22"><path d="M5 19v-7a4 4 0 0 1 4-4h10m-4-4 4 4-4 4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg><span>指路</span></button>
</div>
<div class="bottombar">
<button class="action pulse"><span>重开(脉冲)</span></button>
<button class="action" disabled><span>禁用</span></button>
</div>
<div style="margin-top:14px;display:flex;gap:10px;align-items:center"><span class="toast" id="toast-demo">下一步在这里</span><button class="replay" onclick="replay('#toast-demo','anim')">回放</button></div>
</div>
<h3>选关:总进度 · 关卡格四态 · 主按钮 · chip</h3>
<div class="card" style="max-width:440px">
<div class="overall"><div class="overall-bar"><i></i></div><span class="overall-text">16 / 26</span></div>
<div class="pack"><div class="pack-title"><span>进阶</span><span class="pack-progress">2 / 10</span></div>
<div class="level-grid">
<button class="tile cleared"><span>1</span></button><button class="tile cleared"><span>2</span></button><button class="tile current"><span>3</span></button><button class="tile"><span>4</span></button><button class="tile locked" id="tile-locked" onclick="replay('#tile-locked','shake')"><span>5</span></button>
</div>
<p class="note" style="margin:8px 0 0">已过关 / 已过关 / 当前(呼吸)/ 未过关 / 锁定(点一下抖动)</p>
</div>
<div style="margin-top:18px"><button class="primary-btn">继续 · 2-3</button></div>
<div class="phone footer" style="display:flex;gap:8px;justify-content:center;padding-top:14px"><button class="chip-btn on">音效</button><button class="chip-btn">振动</button><button class="chip-btn danger">重置进度</button></div>
</div>
</section>
<section id="screens">
<h2>7 · 页面</h2>
<p class="lead">390 × 760 样机,桌面浏览器上以 480px 手机框居中,圆角 28 + 框阴影。</p>
<div class="phones">
<div class="phone">
<div style="padding:28px 4px 8px"><h1>数字一笔</h1><p class="sub">按数字顺序,一笔走遍每一格</p>
<div class="overall"><div class="overall-bar"><i></i></div><span class="overall-text">16 / 26</span></div></div>
<div class="list" id="phone-packs"></div>
<button class="primary-btn" style="margin-top:auto">继续 · 2-1</button>
<div class="footer"><button class="chip-btn on">音效</button><button class="chip-btn on">振动</button><button class="chip-btn danger">重置进度</button></div>
</div>
<div class="phone">
<div class="topbar">
<button class="icon-btn"><svg viewBox="0 0 24 24" width="26" height="26"><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 class="level-title">1-7</div><div class="level-sub">入门 · 第 7 关</div></div>
<div class="remaining"><svg viewBox="0 0 24 24" width="16" height="16"><rect x="4" y="4" width="16" height="16" rx="4.5" fill="none" stroke="currentColor" stroke-width="2.4"/></svg><span>6</span></div>
</div>
<div class="level-progress"><i class="cleared"></i><i class="cleared"></i><i class="cleared"></i><i class="cleared"></i><i class="cleared"></i><i class="cleared"></i><i class="current"></i><i></i><i></i><i></i></div>
<div class="caption"></div>
<div class="board-area"><div class="board" id="phone-board"></div></div>
<p class="gesture-help">按住滑动连线 · 点已走格回退</p>
<div class="bottombar">
<button class="action"><svg viewBox="0 0 24 24" width="22" height="22"><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 class="action"><svg viewBox="0 0 24 24" width="22" height="22"><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 class="action"><svg viewBox="0 0 24 24" width="22" height="22"><path d="M5 19v-7a4 4 0 0 1 4-4h10m-4-4 4 4-4 4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg><span>指路</span></button>
</div>
</div>
</div>
</section>
<section id="motion">
<h2>8 · 动效</h2>
<p class="lead">四档时长、四条缓动。Canvas 侧的弹簧与扫光在这里用 CSS 近似回放,参数以 <code>src/theme.ts MOTION</code> 为准。</p>
<table>
<tr><th>档</th><th>时长</th><th>用途</th><th>缓动</th></tr>
<tr><td>fast</td><td>120ms</td><td>按下反馈、步号淡入、桥生长</td><td>ease-out <code>cubic-bezier(.22,1,.36,1)</code></td></tr>
<tr><td>base</td><td>200ms</td><td>整屏切换、toast、胶囊 bump、进场单格</td><td>ease-out / spring <code>cubic-bezier(.34,1.56,.64,1)</code></td></tr>
<tr><td>slow</td><td>320ms</td><td>闪白衰减、过关扫光、抖动、进度条</td><td>linear(衰减)/ ease-out</td></tr>
<tr><td>hold</td><td>500ms</td><td>过关停留</td><td>–</td></tr>
<tr><td>循环</td><td>0.9s / 2.4s</td><td>提示按钮脉冲 / 当前关呼吸、提示描边</td><td>ease-in-out <code>cubic-bezier(.65,0,.35,1)</code></td></tr>
</table>
<h3>逐个回放</h3>
<div class="grid g3" id="demos">
<div class="demo"><header><b>开局进场</b><span>base · 每格 +14ms</span></header><div class="stage" id="d-enter"></div><p>缩放 0.92 → 1 + 淡入,按阅读顺序错落,延迟封顶 200ms。</p><button class="replay" onclick="replayStage('#d-enter','a-enter')">回放</button></div>
<div class="demo"><header><b>落子弹簧</b><span>≈200ms · k=260 c=18</span></header><div class="stage" id="d-pop"></div><p>当前格放大回 1,步号同时 120ms 淡入。</p><button class="replay" onclick="replayStage('#d-pop','a-pop a-fade')">回放</button></div>
<div class="demo"><header><b>桥生长</b><span>fast · ease-out</span></header><div class="stage" id="d-bridge"></div><p>只长厚度,跨缝方向第一帧就盖满,不露白缝。</p><button class="replay" onclick="replayStage('#d-bridge','a-bridge')">回放</button></div>
<div class="demo"><header><b>踩对线索闪白</b><span>slow · 线性衰减</span></header><div class="stage" id="d-flash"></div><p>描边闪白 1 → 0。这是「推对了」的主奖励。</p><button class="replay" onclick="replayStage('#d-flash','a-flash')">回放</button></div>
<div class="demo"><header><b>死路 / 非法起手抖动</b><span>280ms · 6 个来回</span></header><div class="stage" id="d-shake"></div><p>幅度 0.06 u 递减,不弹「失败」。</p><button class="replay" onclick="replayStage('#d-shake','a-shake')">回放</button></div>
<div class="demo"><header><b>过关扫光</b><span>slow · 每格 250ms</span></header><div class="stage" id="d-sweep"></div><p>全盘切亮色,按步序扫白光,320ms 内起完。</p><button class="replay" onclick="replayStage('#d-sweep','a-sweep')">回放</button></div>
<div class="demo"><header><b>提示描边</b><span>2.4s · ≈1.1Hz</span></header><div class="stage" id="d-hint"></div><p>琥珀外描边正弦脉冲,最后 0.4s 淡出。</p><button class="replay" onclick="replayStage('#d-hint','a-hint')">回放</button></div>
<div class="demo"><header><b>整屏切换</b><span>base · ease-out</span></header><div class="stage" id="d-screen"><div style="width:180px;height:80px;border-radius:14px;background:var(--bg);border:1px solid var(--line);display:flex;align-items:center;justify-content:center;font-weight:700">选关 → 对局</div></div><p>淡入 + 上移 8px。关与关之间不切屏,只有盘面进退场。</p><button class="replay" onclick="replayStage('#d-screen','a-screen', true)">回放</button></div>
<div class="demo"><header><b>冲突</b><span>280ms · 单格轻晃</span></header><div class="stage" id="d-conflict"><div class="bc visited conflict a-shake"><i>5</i></div></div><p>立刻红底 + 单格轻晃 + 闷音 + 40ms 振动。连接线始终紫色;步号错位不加叉号。撤销立即褪回并停止晃动。</p><button class="replay" onclick="replayStage('#d-conflict','a-shake')">回放</button></div>
</div>
<h3>过关时间线</h3>
<div class="card">
<div class="timeline">
<div class="p1">扫光<small>0 – 320ms · 和弦起、胶囊「完成」、按钮禁用</small></div>
<div>停留<small>320 – 820ms</small></div>
<div class="p3">淡出<small>820 – 980ms</small></div>
<div class="p1">下一关进场<small>980ms 起 · 顶栏就地更新,盘面错落进场 ≤ 400ms</small></div>
</div>
</div>
<h3>减弱动态</h3>
<p class="note">系统开启「减弱动态效果」时:CSS 动画与过渡全部关闭;Canvas 去掉进场错落、弹簧、扫光与淡出,只保留状态切换与提示描边。</p>
</section>
<section id="sound">
<h2>9 · 声音与振动</h2>
<table>
<tr><th>事件</th><th>音效</th><th>振动</th></tr>
<tr><td>落子</td><td>90ms 正弦短音,C5 起每步升半音,12 步循环</td><td>8ms(踩对线索 18ms)</td></tr>
<tr><td>冲突</td><td>160ms 方波 130Hz 闷音</td><td>40ms</td></tr>
<tr><td>非法起手</td><td>60ms 三角波 220Hz</td><td>15ms</td></tr>
<tr><td>死路</td><td>–</td><td>10 · 30 · 10</td></tr>
<tr><td>过关</td><td>C-E-G-C 三角波和弦,每音 220ms,间隔 70ms</td><td>20 · 40 · 30</td></tr>
</table>
</section>
<section id="sync">
<h2>10 · 同步规则</h2>
<div class="card">
<ol class="note" style="margin:0;padding-left:18px;line-height:1.9">
<li>先改 <code>docs/design.md</code> 的对应表格,并在「变更记录」加一行。</li>
<li>改 <code>modes/01-number-stroke/web/style.css</code> 的 <code>:root</code> 与 <code>src/theme.ts</code>。</li>
<li>更新本页顶部 <code>:root</code> 与相应样张。</li>
<li><code>npm run check</code>:<code>tests/theme.test.ts</code> 会比对 CSS 与 theme.ts 的颜色、动效时长与字体栈。</li>
<li><code>npm run build:standalone</code> 重新生成试玩版。</li>
</ol>
</div>
</section>
</main>
</div>
<script>
// ---- 色板 ----
const TOKENS = [
['背景', '--bg', '#FFFFFF', '页面与盘面背景 · THEME.bg'],
['桌面底', '--bg-app', '#F2F2F6', '手机框外的底色'],
['二级表面', '--surface-2', '#F3F3F7', '按钮、胶囊、chip、锁定格'],
['未走格', '--cell', '#E6E7EC', '盘面未走格、未过关格、进度条底 · THEME.cell'],
['分隔线', '--line', '#E1E2EA', '细线(预留)'],
['墨', '--ink', '#23243A', '标题、正文、未走线索数字、角标底 · THEME.clueInk'],
['墨 2', '--ink-2', '#6B6D80', '副文案、字幕、胶囊、图标'],
['墨 3', '--ink-3', '#A3A5B5', '弱文案、进度数字、锁图标'],
['主色', '--primary', '#6558F5', '已走格、桥、当前关、主按钮 · THEME.visited'],
['主色·亮', '--primary-bright', '#7D72FF', '过关提亮 · THEME.visitedBright'],
['主色·淡', '--primary-soft', '#ECEAFE', '当前关格底、开启态 chip、完成态胶囊'],
['主色上的字', '--on-primary', '#FFFFFF', '已走格数字、主按钮文字 · THEME.visitedInk'],
['冲突', '--danger', '#E5484D', '冲突格、重置进度 · THEME.conflict'],
['冲突·淡', '--danger-soft', '#FDECEC', '预留(高对比模式)'],
['提示', '--hint', '#F5A524', '提示描边、提示按钮脉冲 · THEME.hint'],
['提示·淡', '--hint-soft', '#FEF3DD', '预留'],
['线索描边', '--clue-ring', '#23243A', '未走线索 / 奇偶格描边(= 墨,与数字同色)· THEME.clueRing'],
['已走线索描边', '(canvas)', '#DFE1EC', '起点与已走线索外圈 · THEME.activeClueRing'],
['遮罩', '--overlay', 'rgba(35,36,58,.92)', 'toast 底'],
];
document.getElementById('swatches').innerHTML = TOKENS.map(([n, v, hex, use]) =>
`<div class="sw"><div class="chip" style="background:${hex}"></div><div class="meta"><b>${n}</b><code>${hex}</code> <code>${v}</code><small>${use}</small></div></div>`).join('');
// ---- 盘面样张 ----
function cell(cls, text, opts = {}) {
const badge = opts.badge ? `<b class="badge">${opts.badge}</b>` : '';
const sub = opts.sub ? `<small>${opts.sub}</small>` : '';
const style = (opts.col ? `grid-column:${opts.col};` : '') + (opts.row ? `grid-row:${opts.row};` : '') + (opts.i !== undefined ? `--i:${opts.i};` : '');
return `<div class="bc ${cls}" style="${style}"><i>${text}${sub}</i>${badge}</div>`;
}
// 桥:放在两格之间。unit 用 CSS 变量,位置按格坐标算
function bridge(dir, x, y, red = false) {
// dir h:竖向共享边(左右相邻),x 为边的列号,y 为格行号;dir v:横向共享边(上下相邻)
const u = 'var(--u)';
const pad = '14px';
if (dir === 'h') return `<div class="bridge h ${red ? 'red' : ''}" style="left:calc(${pad} + ${u} * ${x} - (${u} * 0.05 + 1px)); top:calc(${pad} + ${u} * ${y} + ${u} * 0.27)"></div>`;
return `<div class="bridge v ${red ? 'red' : ''}" style="top:calc(${pad} + ${u} * ${y} - (${u} * 0.05 + 1px)); left:calc(${pad} + ${u} * ${x} + ${u} * 0.27)"></div>`;
}
const b1 = document.getElementById('board-sample');
b1.style.gridTemplateColumns = 'repeat(4, var(--u))';
b1.innerHTML =
cell('start visited clue', '1') + cell('visited', '2') + cell('visited', '3') + cell('visited', '4') +
cell('clue', '4') + cell('', '') + cell('visited conflict', '6', { badge: '8' }) + cell('visited', '5') +
cell('', '') + cell('', '') + cell('', '') + cell('', '') +
bridge('h', 1, 0) + bridge('h', 2, 0) + bridge('h', 3, 0) + bridge('v', 3, 1) + bridge('h', 3, 1);
const b2 = document.getElementById('board-sample-2');
b2.style.gridTemplateColumns = 'repeat(4, var(--u))';
b2.innerHTML =
cell('start visited clue', '1') + cell('visited', '2') + cell('visited big clue', '3', { col: '3 / span 2', row: '1 / span 2' }) +
cell('', '') + cell('clue', '7') +
cell('', '') + cell('', '') + cell('clue two', '12') + cell('', '') +
bridge('h', 1, 0) + bridge('h', 2, 0);
const pb = document.getElementById('phone-board');
pb.style.gridTemplateColumns = 'repeat(4, var(--u))';
pb.innerHTML =
cell('start visited clue', '1') + cell('visited', '2') + cell('', '') + cell('', '') +
cell('visited clue', '4') + cell('visited', '3') + cell('clue', '8') + cell('', '') +
cell('visited', '5') + cell('visited', '6') + cell('', '') + cell('', '') +
bridge('h', 1, 0).replace('14px', '0px').replace('14px', '0px') + bridge('v', 1, 1).replace('14px', '0px').replace('14px', '0px') +
bridge('h', 1, 1).replace('14px', '0px').replace('14px', '0px') + bridge('v', 0, 2).replace('14px', '0px').replace('14px', '0px') +
bridge('h', 1, 2).replace('14px', '0px').replace('14px', '0px');
// ---- 选关样机 ----
function packHtml(title, n, cleared, current, locked) {
let tiles = '';
for (let i = 1; i <= n; i++) {
const cls = i <= cleared ? 'cleared' : i === current ? 'current' : locked && i > current ? 'locked' : '';
tiles += `<button class="tile ${cls}"><span>${i}</span></button>`;
}
return `<div class="pack ${cleared === n ? 'done' : ''}"><div class="pack-title"><span>${title}</span><span class="pack-progress">${cleared} / ${n}</span></div><div class="level-grid">${tiles}</div></div>`;
}
document.getElementById('phone-packs').innerHTML = packHtml('入门', 10, 10, 0, false) + packHtml('进阶', 10, 0, 1, true) + packHtml('奇与偶', 6, 6, 0, false);
// ---- 动效样张 ----
const stages = {
'd-enter': () => [0, 1, 2, 3].map((i) => cell(i === 0 ? 'start visited clue' : i === 2 ? 'clue' : '', i === 0 ? '1' : i === 2 ? '8' : '', { i })).join(''),
'd-pop': () => cell('visited', '4') + cell('visited', '<span>5</span>'),
'd-bridge': () => `<div style="position:relative;display:flex">${cell('visited', '4')}${cell('visited', '5')}<div class="bridge h" style="left:calc(64px - (64px * 0.05 + 1px));top:calc(64px * 0.27)"></div></div>`,
'd-flash': () => cell('visited clue', '8'),
'd-shake': () => cell('visited', '7'),
'd-sweep': () => [1, 2, 3, 4, 5].map((n, i) => cell('visited bright', String(n), { i })).join(''),
'd-hint': () => cell('clue', '4'),
};
for (const [id, html] of Object.entries(stages)) document.getElementById(id).innerHTML = html();
function replay(sel, cls) {
const el = document.querySelector(sel);
cls.split(' ').forEach((c) => el.classList.remove(c));
void el.offsetWidth;
cls.split(' ').forEach((c) => el.classList.add(c));
}
function replayStage(sel, cls, self = false) {
const stage = document.querySelector(sel);
const targets = self ? [stage.firstElementChild] : [...stage.querySelectorAll('.bc, .bridge')].concat(stage.querySelector('.bridge') ? [] : []);
const list = self ? targets : (cls.includes('a-bridge') ? [stage] : targets);
list.forEach((el) => { cls.split(' ').forEach((c) => el.classList.remove(c)); });
void stage.offsetWidth;
list.forEach((el) => { cls.split(' ').forEach((c) => el.classList.add(c)); });
}
// 首屏自动播一遍进场
setTimeout(() => replayStage('#d-enter', 'a-enter'), 300);
</script>
</body>
</html>
+329
View File
@@ -0,0 +1,329 @@
# UI 设计规范 · design.md
版本 1.5 · 2026-09-16 · 参考实现:玩法一「数字一笔」(`modes/01-number-stroke`)。设计师看图请打开同目录的 [`design.html`](design.html)(可离线双击,含色板、字阶、组件样张与可回放的动效)。
本文是产品级的视觉与动效规范。规则、关卡、交互语义仍以各玩法的 GDD 为准;本文只管「长什么样、怎么动」。
## 0. 怎么用这份文档
三处必须一致,改任何一处都要同步另外两处:
| 位置 | 内容 | 谁在用 |
|---|---|---|
| `docs/design.md` + `docs/design.html` | 规范与样张 | 设计师、接手人 |
| `modes/01-number-stroke/web/style.css` 的 `:root` | CSS 令牌 | DOM 壳(选关、顶栏、底栏、toast) |
| `modes/01-number-stroke/src/theme.ts` | `THEME` / `BOARD_TYPE` / `MOTION` | Canvas 盘面(Web 与 Cocos 共用) |
`tests/theme.test.ts` 会校验 `style.css` 与 `theme.ts` 的颜色、动效时长、字体栈一致,改了一边没改另一边 `npm run check` 会红。
**改设计的流程**:先改本文对应表格 → 改 `style.css` / `theme.ts` → 改 `design.html` 样张 → 在 §12 变更记录追加一行。
## 1. 设计原则
1. **板要干净。** 逻辑 puzzle 的盘面只回答三个问题:我走到哪了、哪错了、过没过。任何装饰都不能和这三件事抢注意力。
2. **一个主色。** 蓝紫 `#6558F5` 只表示「已走 / 当前 / 可继续」。红只表示冲突,琥珀只表示提示。不要每关换主题色。
3. **数字优先。** 盘面上的数字是内容本身。数字用系统圆体、800 字重、等宽数字;其余文字都比数字轻。
4. **动效四档。** 所有动效只用 120 / 200 / 320 / 500 ms 四档时长和四条缓动曲线。新动效先归档,再写代码。
5. **反馈紧贴手指。** 落子、踩对、冲突、死路的反馈都发生在手指所在的格子上,不弹窗、不撒纸片。
6. **信息随手可见。** 关号、包名、本包进度、剩余格数常驻顶栏,一眼看清「我在哪、还差多少」。
## 2. 色彩
浅色单主题。所有颜色取自下表,DOM 用 CSS 变量,Canvas 用 `THEME`。
### 2.1 令牌
| 令牌 | 值 | 用途 | CSS | theme.ts |
|---|---|---|---|---|
| 背景 | `#FFFFFF` | 页面与盘面背景 | `--bg` | `bg` |
| 桌面底 | `#F2F2F6` | 桌面浏览器上手机框外的底色 | `--bg-app` | – |
| 二级表面 | `#F3F3F7` | 按钮、胶囊、chip、锁定关卡格 | `--surface-2` | – |
| 未走格 | `#E6E7EC` | 盘面未走格、选关未过关格、进度条底 | `--cell` | `cell` |
| 分隔线 | `#E1E2EA` | 细线(预留) | `--line` | – |
| 墨 | `#23243A` | 标题、正文、未走线索数字、冲突角标底 | `--ink` | `clueInk` |
| 墨 2 | `#6B6D80` | 副文案、字幕、胶囊文字、图标 | `--ink-2` | – |
| 墨 3 | `#A3A5B5` | 弱文案、进度数字、锁图标 | `--ink-3` | – |
| 主色 | `#6558F5` | 已走格、桥、当前关、主按钮、进度 | `--primary` | `visited` |
| 主色·亮 | `#7D72FF` | 过关后全盘提亮 | `--primary-bright` | `visitedBright` |
| 主色·淡 | `#ECEAFE` | 当前关格底、开启态 chip、完成态胶囊 | `--primary-soft` | – |
| 主色上的字 | `#FFFFFF` | 已走格数字、主按钮文字 | `--on-primary` | `visitedInk` |
| 冲突 | `#E5484D` | 冲突格、重置进度 | `--danger` | `conflict` |
| 冲突·淡 | `#FDECEC` | 预留(高对比模式) | `--danger-soft` | – |
| 提示 | `#F5A524` | 提示描边、提示按钮脉冲 | `--hint` | `hint` |
| 提示·淡 | `#FEF3DD` | 预留 | `--hint-soft` | – |
| 线索描边 | `#23243A` | 未走线索 / 奇偶格的描边(= 墨,与线索数字同色) | `--clue-ring` | `clueRing` |
| 已走线索描边 | `#DFE1EC` | 起点与已走线索格外圈的灰白描边 | – | `activeClueRing` |
| 闪色 | `#FFFFFF` | 踩对线索闪白、过关扫光 | – | `flash` |
| 遮罩 | `rgba(35,36,58,.92)` | toast 底 | `--overlay` | – |
阴影:主按钮 `0 6px 18px rgba(101,88,245,.28)`,按下时 `0 3px 10px rgba(101,88,245,.22)`;桌面手机框 `0 20px 60px rgba(20,20,40,.14)`。盘面无阴影。
### 2.2 语义规则
- 紫 = 路径。已走格、桥、起点 `1`、当前关、主按钮、进度条都是同一个紫。
- 红只出现在冲突格。错误发生时该格轻晃 280ms,进出连线保持紫色;步号占用别处固定数字时不显示「×」。本格数字 / 奇偶要求不符时仍保留原要求角标。
- 琥珀只出现在提示描边和提示按钮脉冲,不用于任何常驻元素。
- 灰阶承担一切非路径信息:未走格 `cell`、文字三档墨。未走线索 / 奇偶格的描边与线索数字同用墨色,像印在盘上的一圈,不引入第二种彩色。
- 过关后全盘从 `primary` 切到 `primary-bright`,这是唯一允许的「换色」。
### 2.3 对比度
| 组合 | 对比度 | 用途 |
|---|---|---|
| `#FFFFFF` on `#6558F5` | 5.2 : 1 | 已走格数字、主按钮 |
| `#23243A` on `#E6E7EC` | 12.6 : 1 | 未走线索数字 |
| `#FFFFFF` on `#E5484D` | 4.5 : 1 | 冲突格数字(角标另有墨底白字) |
| `#6B6D80` on `#FFFFFF` | 5.1 : 1 | 副文案 |
| `#23243A` on `#E6E7EC` | 12.6 : 1 | 未走线索描边(与数字同色) |
| `#A3A5B5` on `#FFFFFF` | 2.5 : 1 | 仅用于非关键弱文案 |
## 3. 字体与字阶
### 3.1 字体栈
```
ui-rounded, "SF Pro Rounded", "PingFang SC", "Hiragino Sans GB", "Noto Sans SC", system-ui, -apple-system, "Segoe UI", sans-serif
```
`ui-rounded` 让 iOS / macOS 直接拿到系统圆体(数字圆润、与圆角格子同气质);Android 回落到 Noto Sans SC;Windows 回落到 Segoe UI。DOM 与 Canvas 用同一串(`--font` 与 `BOARD_FONT_FAMILY`)。
全局 `font-variant-numeric: tabular-nums`,所有数字等宽,计数变化时不跳动。
### 3.2 UI 字阶
| 名称 | 字号 / 字重 | 用途 | CSS |
|---|---|---|---|
| display | 34 / 800 | 选关页标题 | `--fs-display` |
| title | 22 / 800 | 顶栏关号 `1-7` | `--fs-title` |
| heading | 17 / 700 | 包标题、主按钮 | `--fs-heading` |
| body | 15 / 500 | 副标题、字幕 | `--fs-body` |
| label | 14 / 600–700 | 剩余格胶囊、包进度、toast | `--fs-label` |
| caption | 13 / 600 | chip、总进度数字 | `--fs-caption` |
| micro | 12 / 600 | 顶栏副标题「入门 · 第 7 关」(字距 0.04em) | `--fs-micro` |
选关关卡格数字 20 / 700;底栏按钮 15 / 700。
### 3.3 盘面字阶(相对单位格边长 u)
| 用途 | 比例 | 字重 | theme.ts |
|---|---|---|---|
| 起点 `1`(未按下的提示态与已走第 1 步) | 0.60 u | 800 | `BOARD_TYPE.start` |
| 单位数步号 / 线索 | 0.52 u | 800 | `single` |
| 两位数步号 / 线索 | 0.46 u | 800 | `double` |
| 未走奇偶格「奇 / 偶」 | 0.44 u | 700 | `parity` |
| 已走奇偶格下方小字「奇 / 偶」 | 0.19 u | 700 | `paritySub` |
| 冲突角标半径 / 角标字号 | 0.15 u / 半径 × 1.25 | 800 | `badge` / `badgeText` |
| 线索描边线宽 | 0.05 u(≥ 2px) | – | `ring` |
| 提示描边线宽 | 0.08 u(≥ 3px) | – | `hintRing` |
数字一律 800,汉字 700,文字基线下沉 4% 字号做视觉居中。大格 / 异形格的数字放在锚点单位(形心最近的单位格)中心。
## 4. 间距、圆角、尺寸
- **间距**:4pt 网格。`--sp-1..7` = 4 / 8 / 12 / 16 / 20 / 24 / 28。页面左右内边距 20,顶部 12 + 安全区,底部 16 + 安全区。
- **圆角**:`--r-sm` 10(预留)、`--r-md` 14(关卡格、toast)、`--r-lg` 18(主按钮、底栏按钮)、`--r-xl` 24(预留)、`--r-pill` 胶囊。桌面手机框 28。
- **盘面**:格圆角 0.18 u,格间缝 0.10 u,桥厚 0.46 u。
- **点击区**:所有可点元素 ≥ 44 × 44(`--tap`)。返回按钮 44,chip 34 高但左右各留 16 内边距。
- **桌面**:≥ 520px 宽时以 480px 手机框居中,四周 24 留白,圆角 28 + 框阴影。
## 5. 盘面(Canvas)
### 5.1 布局
- `fitBoard`:按短边缩放,四周留 10px,单位格封顶 96px(≤ 6 格的教学盘)或 110px。
- 整盘垂直居中后**上提容器高度的 3%**:视觉中心略高于几何中心,小盘不显得下坠。
- 命中测试:触屏每格四周 8% 死区,鼠标 14%。点按或滑到任意历史格可直接回退;触点进入历史格时优先处理回退。
### 5.2 格的状态
| 状态 | 底 | 描边 | 数字 | 备注 |
|---|---|---|---|---|
| 未走 | `cell` | 无 | 无 | – |
| 未走线索 | `cell` | `clue-ring`(墨)0.05 u | `ink` 0.52 / 0.46 u | 描边用「外层剪影 + 内层填充」画,矩形与异形同一画法 |
| 未走奇偶 | `cell` | `clue-ring`(墨)0.05 u | 「奇 / 偶」`ink` 0.44 u | – |
| 起点 `1`(未按下) | `primary` | `activeClueRing` | `on-primary` 0.60 u | 只是视觉提示,按下才计入路径 |
| 已走 | `primary` | 无 | 步号 `on-primary` | 与相邻已走格之间画桥 |
| 已走线索 / 起点 | `primary` | `activeClueRing` | 步号 | 描边保留,说明它曾是线索 |
| 已走奇偶 | `primary` | `activeClueRing` | 步号上移 0.09 u,下方 0.19 u 小字「奇 / 偶」 | – |
| 冲突 | `danger` | 线索有描边则 `activeClueRing` | 步号 `on-primary` | 单格轻晃 280ms,连接线仍紫色;步号错位无叉号。本格要求不符时保留数字 / 奇偶角标,半径 0.15 u |
| 补数字 | 同普通线索 | 先叠加琥珀外圈 2.4s,之后恢复普通线索描边 | 新数字使用普通数字字阶;奇偶格保留下方小字 | 本局重开仍保留数字 |
| 提示目标 | 不变 | 外圈 `hint` 描边 0.08 u,向外扩一线宽 | 不变 | 2.4s 脉冲后消失 |
| 退回压暗 | 覆 `bg` 45% | – | – | 提示「已偏离」时,分叉点之后的段压暗 |
| 过关 | `primary-bright` | – | – | 按步序扫白光 |
大格(2×2、3×3、1×2…)是一个圆角矩形;异形格由单位矩形 + 接缝矩形同色拼成一整块,凸角圆、凹角直。
### 5.3 桥
两个相邻已走格之间的连接:沿共享边居中,厚 0.46 u,跨缝方向盖满缝再多 1px。始终使用路径色;任一侧冲突也不变红、不随格子晃动。**生长动画只长厚度**,跨缝方向从第一帧起就盖满,不露白缝。
## 6. 组件
### 6.1 顶栏(对局)
```
[←] 1-7 [▢ 6]
入门 · 第 7 关
▬▬ ▬▬ ▬▬ ▬▬ ▬▬ ▬▬ ▬▬ ▬▬ ▬▬ ▬▬ ← 本包进度条
```
- 三列网格 `1fr auto 1fr`,中间永远居中。高 52。
- 返回:44 圆形点击区,墨 2 色 26px 箭头,按下时底 `surface-2`。
- 关号:title 22 / 800;副标题 micro 12 / 600 墨 3,内容 `{包名} · 第 {n} 关`。
- 剩余格胶囊:高 32,`surface-2` 底,墨 2 字 14 / 700,左侧 16px 圆角方框图标(墨 3)。数字变化时 `bump`(200ms spring 缩放 1 → 1.14 → 1)。过关后切到 `done`:底 `primary-soft`、字与图标 `primary`、图标换勾、文字「完成」。
- 本包进度条:每关一段,高 4,段间 3,圆角 2。灰 = 未到,紫 40% = 已过,紫 100% = 当前。
### 6.2 字幕
顶栏下方 36 高一行,body 15 / 500 墨 2 居中。只有教学关有内容,空时 200ms 淡出。
### 6.3 底栏
三个等宽按钮「重开 / 补数字 / 指路」(最大 170),高 56,圆角 18,`surface-2` 底,15 / 700 墨色,图标 22px 墨 2,图标与文字间距 4px。按下底色变 `cell` + 缩放 0.97。禁用 40% 透明。「指路」发现起手错时,重开按钮 `pulse`:0.9s 琥珀外圈呼吸。
底栏上方是常驻手势说明「按住滑动连线 · 点已走格回退」,caption 13px 墨 2,顶部间距 8px,居中。
### 6.4 Toast
距页面底边 128px + 底部安全区居中,避开手势说明。圆角 14,`overlay` 底白字 14 / 600,最大宽度为页面宽减 40px,长句自动换行;进出 200ms(透明 + 上移 8px),停留 1.8s。
### 6.5 选关页
- 标题 display 34 / 800,副标题 body 墨 2;下方**总进度**:6px 胶囊条(`cell` 底、`primary` 填充,320ms 过渡)+ 右侧 `16 / 26` caption 墨 3。
- 包标题 heading 17 / 700,右侧 `x / n` label 墨 3;整包完成时变 `primary`。
- 关卡格:5 列,间距 12,正方形,圆角 14,数字 20 / 700。
- 未过关:`cell` 底墨字。
- 已过关:`primary` 底白字,右上角 CSS 画的小勾。
- 当前:`primary-soft` 底 + 3px 内描边 `primary` + 紫字,2.4s 呼吸外圈。
- 锁定:`surface-2` 底,墨 3 锁图标,无数字;点击 320ms 抖动。
- 按下缩放 0.94。
- 列表可滚,顶部 12px、底部 20px 渐隐遮罩,不被按钮切断。
- 继续按钮:高 54,圆角 18,`primary` 底白字 17 / 700,紫色投影;文案 `继续 · 2-1`。
- 设置 chip:高 34 胶囊,`surface-2` 底墨 2 字 13 / 600;开启态 `primary-soft` 底紫字;「重置进度」红字。
## 7. 页面
### 7.1 对局页(自上而下)
| 区块 | 高度 |
|---|---|
| 安全区 + 12 | – |
| 顶栏 | 52 |
| 本包进度条 | 8 上边距 + 4 |
| 字幕 | 8 上边距 + 36 |
| 盘面 | 剩余全部(flex: 1) |
| 手势说明 | 8 上边距 + caption 行高 |
| 底栏 | 12 上边距 + 56 |
| 16 + 安全区 | – |
### 7.2 状态流
```
选关 ──点关卡 / 继续──▶ 对局(整屏淡入 200ms,盘面错落进场)
对局 ──过关──▶ 扫光 320ms → 停留 500ms → 盘面淡出 160ms → 下一关盘面进场(顶栏就地更新)
对局 ──最后一关过关──▶ 选关(toast「已通关全部 26 关」)
对局 ──返回──▶ 选关(整屏淡入)
```
## 8. 动效
### 8.1 时长与缓动
| 档 | 时长 | 用途 | CSS | theme.ts |
|---|---|---|---|---|
| fast | 120ms | 按下反馈、步号淡入、桥生长 | `--t-fast` | `MOTION.fast` |
| base | 200ms | 整屏切换、toast、胶囊 bump、进场单格 | `--t-base` | `MOTION.base` |
| slow | 320ms | 闪白衰减、过关扫光、抖动、进度条 | `--t-slow` | `MOTION.slow` |
| hold | 500ms | 过关停留 | `--t-hold` | `MOTION.hold` |
| 缓动 | 曲线 | 用途 |
|---|---|---|
| ease-out | `cubic-bezier(.22, 1, .36, 1)` | 进场、淡入、桥生长(Canvas 用 quart ease-out) |
| ease-in | `cubic-bezier(.4, 0, 1, 1)` | 退场 |
| ease-in-out | `cubic-bezier(.65, 0, .35, 1)` | 呼吸、脉冲 |
| spring | `cubic-bezier(.34, 1.56, .64, 1)` | bump;Canvas 弹簧 k=260、c=18、初速 2.4 |
### 8.2 事件 → 动效
| 事件 | 对象 | 动效 | 时长 | 音效 | 振动 |
|---|---|---|---|---|---|
| 开局 / 切关 | 每个格 | 缩放 0.92 → 1 + 淡入,按阅读顺序每格延迟 14ms,延迟封顶 200ms | base | – | – |
| 追加一步 | 当前格 | 弹簧放大回 1(初速 2.4) | ≈ 200 | step(音高随步号) | 8ms |
| 追加一步 | 步号 | 淡入 | fast | – | – |
| 追加一步 | 桥 | 厚度 0 → 1(ease-out),跨缝方向始终盖满 | fast | – | – |
| 踩对线索 | 该格描边 | 闪白 1 → 0 线性衰减 | slow | step | 18ms |
| 冲突 | 该格 | 立即红底 + 横向轻晃,幅度 0.06 u 递减;进出连线保持紫色 | 280ms | conflict(闷音) | 40ms |
| 撤销 | 该格 / 桥 | 立即褪回,无动画 | 0 | – | – |
| 多步回退 | 目标格 | 后缀立即撤回,目标格轻弹;toast 说明回到第几步 | ≈ 200 | – | 12ms |
| 补数字 | 目标格 | 数字成为固定线索,目标轻弹并显示琥珀外圈 | 2.4s | – | 18ms |
| 死路 | 当前格 | 横向抖动,6 个来回,幅度 0.06 u 递减 | 280ms | – | 10-30-10 |
| 非法起手 | 线索 `1` 格 | 同上抖动 | 280ms | reject | 15ms |
| 剩余格变化 | 顶栏胶囊 | bump 1 → 1.14 → 1(spring) | base | – | – |
| 提示 | 目标格 | 琥珀外描边,≈ 1.1Hz 正弦脉冲,最后 0.4s 淡出 | 2.4s | – | – |
| 提示·已偏离 | 分叉后的段 | 覆白 45% 随脉冲 | 2.4s | – | – |
| 提示·起手错 | 重开按钮 | 琥珀外圈脉冲 | 0.9s 循环 | – | – |
| 过关 | 全盘 | 底色切 `primary-bright`;按步序扫白光,每格 250ms 正弦,整盘 320ms 内起完 | slow | cleared(和弦) | 20-40-30 |
| 过关 | 胶囊 | 切 `done`(完成 + 勾) | base | – | – |
| 过关后 | 全盘 | 停留 500ms → 淡出 160ms → 下一关进场 | hold + 160 | – | – |
| 切屏 | 整屏 | 淡入 + 上移 8px | base | – | – |
| Toast | toast | 淡入 + 上移 8px,停 1.8s | base | – | – |
| 按钮按下 | 按钮 | 缩放 0.97(关卡格 0.94、主按钮 0.98) | fast | – | – |
| 锁定关卡被点 | 关卡格 | 横向抖动 ±5px | slow | – | – |
| 当前关 | 关卡格 | 外圈呼吸 0 → 6px 紫 16% | 2.4s 循环 | – | – |
### 8.3 过关时间线
```
0ms cleared 事件:全盘切亮色,和弦起,胶囊变「完成」,按钮禁用
0–320ms 按步序扫光(每格 250ms 正弦,起点先亮)
320–820 停留
820–980 整盘淡出(160ms)
980 下一关:顶栏 / 进度条就地更新,新盘面错落进场(≤ 400ms)
```
### 8.4 减弱动态
系统开启「减弱动态效果」时:CSS 全部动画与过渡关闭;Canvas 去掉进场错落、弹簧、扫光与淡出,只保留状态切换与提示描边(静态显示),错误格变红但不晃动。`BoardView` 通过 `reducedMotion` 选项接收。
### 8.5 性能约束
`BoardView.update()` 返回 `isAnimating`,宿主只在有动画时重绘;静止时 0 帧。所有动效都是无状态插值(`dt` 封顶 50ms),掉帧不会漏掉状态。
## 9. 声音与振动
| 事件 | 音效 | 振动 |
|---|---|---|
| 落子 | 90ms 正弦短音,C5 起每步升半音,12 步循环 | 8ms(踩对线索 18ms) |
| 冲突 | 160ms 方波 130Hz 闷音 | 40ms |
| 非法起手 | 60ms 三角波 220Hz | 15ms |
| 死路 | – | 10-30-10 |
| 过关 | C-E-G-C 三角波和弦,每音 220ms 间隔 70ms | 20-40-30 |
均可在选关页 chip 关闭。Cocos 侧换 4 段短音,接口同 `Sfx`。
## 10. 可访问性
- 关键文字对比度 ≥ 4.5 : 1(§2.3)。
- 冲突发生时单格晃动;本格要求不符时有角标。过关胶囊有勾和文字,已过关卡格有勾,锁定有锁图标。
- 所有可点元素 ≥ 44px;键盘焦点有 3px 紫描边。
- 剩余格胶囊 `aria-live="polite"`;装饰性 SVG `aria-hidden`。
- 遵守 `prefers-reduced-motion`(§8.4)。
## 11. 与其他玩法的关系
- **玩法三「六边一笔」**:结构与本规范一致(同一套壳、同一套四档动效),但 `theme.ts` 目前是靛蓝 `#4F5BD5` + 黄黑墙。待办:把 `--primary` 统一到 `#6558F5`(需先确认与墙黄 `#FFC826` 的对比),其余令牌直接复用本文。
- **玩法二「连连气球」**:独立的天蓝 / 气球体系(`#83B4EB` 系),不套用本文色板;动效时长与缓动可以对齐四档。
- **壳(`shared/`)**:首页选玩法、设置、通用按钮皮肤按本文 §4 / §6 的按钮、chip、toast 规格实现。
## 12. 变更记录
| 日期 | 变更 |
|---|---|
| 2026-09-16 | v1.5:去掉步号错位的 ×,错误格单独变红并晃动 280ms,进出连接线保持紫色;撤销 / 重开立即停止晃动。独立试玩版全部关卡直接可选。 |
| 2026-09-16 | v1.4:第 n 步走到预定数字 n 以外的位置立即标红,角标显示「×」;本格要求不符仍显示原要求。补数字的约束同步生效。 |
| 2026-09-16 | v1.3:触屏命中死区缩至 8%,任意历史格可直接回退;增加补数字提示,保留指路;底栏改为三个 15px 按钮,加常驻手势说明,toast 上移并支持换行。颜色、盘面字阶与动效令牌保持 v1.2。 |
| 2026-09-16 | v1.2:未走线索 / 奇偶格描边改为墨色 `#23243A`(与线索数字同色),线宽 0.05 u。试玩反馈:主色描边跳脱、不简洁。 |
| 2026-09-16 | v1.1:未走线索 / 奇偶格描边由灰 `#B4B8CC` 改为主色 `#6558F5`,线宽 0.05 u → 0.06 u(试玩反馈:灰描边不显眼)。已被 v1.2 替代。 |
| 2026-09-15 | v1.0:建立令牌体系(色彩 / 字阶 / 间距 / 圆角 / 动效四档),CSS 与 `theme.ts` 由测试守门;字体栈加 `ui-rounded`;线索描边加深到 `#B4B8CC`;顶栏加副标题「包名 · 第 n 关」、本包进度条、剩余格胶囊(bump / 完成态);选关页加总进度条、已过关勾、当前关呼吸、列表渐隐;盘面加错落进场与过关后淡出;桥生长改为只长厚度;支持减弱动态。 |
待办:高对比模式(冲突改红底 + 斜纹)、暗色主题、玩法三令牌统一、Cocos 侧 Label 池化后复核字阶。
+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);
}
}
+5
View File
@@ -0,0 +1,5 @@
node_modules/
web/dist/
release/
cocos/export/
.DS_Store
+57
View File
@@ -0,0 +1,57 @@
# 玩法二 · 连连气球
竖屏触摸连消合成游戏。连接顺号或同号气球,松手变大,周围和新掉落的气球自动接力。当前版本以连续合成、造大数、放飞为主循环,无关卡、步数或计时限制。
## 直接玩
```bash
cd modes/02-connect-1234
npm install
npm run dev
```
开发预览 **http://localhost:8002**;当前正式包 **http://localhost:8003**。手机同一 Wi-Fi 访问终端打印的局域网地址。可用 `npm run dev -- --port 8012` 改端口。
```bash
npm run check # 类型、规则、连续游玩抽样、物理、生产构建
npm run cocos:check # 导出核心和组件,检查本机 Creator 真实类型
```
`npm run build` 生成独立 `release/`,可以静态托管或离线打开 `release/index.html`。重新启动正式包预览:`python3 -m http.server 8003 --directory release`。无运行时第三方依赖、网络素材或服务器接口。
## 现在怎么玩
- 连接至少 3 颗相邻气球:`1→2→3`、`3→2→1` 或 `2→2→2`;两种规则不能混在一条里。
- 松手在末端合成最大数 +1。新气球能接上周围或刚掉下来的数字时,自动继续,最多 3 连消。
- 长链与连消攒满彩虹能量,接下来 5 次手势双倍得分,最多 4 连消;不限时间。
- 合成 10 自动放飞,额外 +10,000 分,腾出空间继续。
- 补球按连续数字成袋准备,试算落点后优先选择容易接力的位置;无路可连时自动微风补救。
- 提示、撤销、吹口气均免费。滑回上一颗撤回,取消触摸不会提交。键盘 `H` 提示、`Z` 撤销、`Esc` 关闭面板或取消手势。
- 本地保存最高分、最大气球、最高连消及单局放飞纪录;不保存中途盘面。
## 可移植结构
```text
src/flow.ts 畅玩状态、合成与掉落时间线、能量、完整撤销
src/flow-rules.ts 顺号/同号规则与合法链搜索
src/cascade.ts 自动接力和连消分数
src/supply.ts 成袋配号、落点选择、微风救场
src/flow-animation.ts 合成与物理下落重放
src/physics.ts 固定步长圆形物理
src/input.ts 可注入规则的手势控制器
src/view.ts 气球的跨平台绘制指令
src/record.ts v2 纪录(通过 StoragePort 读写)
web/ Canvas、触摸、声音和 HTML UI
cocos/ Creator 3.8 组件与导出包
```
核心是无 DOM / Node / Cocos 依赖的 TypeScript,`tsconfig.core.json` 持续检查隔离。两端共用规则、数字补给、手势、物理和动画,移植时重建宿主 UI 即可。与玩法一、三互不引用。
## 文档
- [当前玩法设计](docs/GDD.md)
- [Cocos 接入说明](docs/HANDOFF.md)
- [验证记录](docs/QA.md) · [连续游玩原始统计](docs/qa-v2/validation.json)
- [竖屏截图](docs/qa-v2/mobile.png)
旧关卡代码、题库和测试保留为历史实验,不进入当前 Web 入口及 Cocos 导出包。历史文档在 [archive-v1](docs/archive-v1/GDD.md),旧回放可运行 `npm run validate:legacy`。旧存档不会被新纪录覆盖。
@@ -0,0 +1,151 @@
/** Creator 3.8 适配组件;通过 npm run cocos:export 导出后放入 assets/modes/02-connect-1234。 */
import { _decorator, Component, EventTouch, Graphics, Label, Node, UITransform, Vec3, Color,
HorizontalTextAlignment, VerticalTextAlignment } from 'cc';
import { FlowGame, type FlowResult } from '../src/flow';
import { canFlowAppend } from '../src/flow-rules';
import { Gesture } from '../src/input';
import { WIDTH, HEIGHT } from '../src/config';
import { drawBoard, Draw } from '../src/view';
import { FlowAnimation } from '../src/flow-animation';
import type { Point } from '../src/types';
const { ccclass, property, requireComponent } = _decorator;
@ccclass('Connect1234Board')
@requireComponent(UITransform)
export class Connect1234Board extends Component {
@property({ tooltip: "开局种子;相同种子和操作得到相同盘面" }) seed = 1234;
private game!: FlowGame;
private gesture!: Gesture;
private graphics!: Graphics;
private surface!: Node;
private labels: Label[] = [];
private touchId: number | null = null;
private time = 0;
private hints: number[] = [];
private animation: FlowAnimation | null = null;
private seenReaction = 0;
onLoad(): void {
this.surface = new Node('BalloonDrawing'); this.surface.layer = this.node.layer; this.node.addChild(this.surface);
this.surface.addComponent(UITransform).setContentSize(WIDTH, HEIGHT);
this.graphics = this.surface.addComponent(Graphics);
this.startNew(this.seed);
}
onEnable(): void {
this.node.on(Node.EventType.TOUCH_START, this.onStart, this);
this.node.on(Node.EventType.TOUCH_MOVE, this.onMove, this);
this.node.on(Node.EventType.TOUCH_END, this.onEnd, this);
this.node.on(Node.EventType.TOUCH_CANCEL, this.cancel, this);
}
onDisable(): void { this.node.targetOff(this); this.cancel(); }
onDestroy(): void { this.surface?.destroy(); }
startNew(seed = Date.now() >>> 0): void {
this.seed = seed; this.game = new FlowGame(seed);
this.gesture = new Gesture(() => this.game.balls, canFlowAppend);
this.animation = null; this.touchId = null; this.hints = this.game.hint(); this.emitHUD();
}
retry(): void { this.startNew(this.seed); }
undo(): void { if (!this.animation && this.game.undo()) { this.cancel(); this.hints = []; this.emitHUD(); } }
showHint(): void { if (!this.animation) { this.cancel(); this.hints = this.game.hint(); } }
shuffle(): void { if (!this.animation) { this.cancel(); this.begin(this.game.shuffle()); } }
snapshot() { return this.game.snapshot(); }
private emitHUD(): void {
this.node.emit('balloon-hud', { snapshot: this.game.snapshot(), canUndo: this.game.canUndo,
busy: !!this.animation, fever: this.game.fever });
}
private begin(result: FlowResult): void {
this.hints = []; this.seenReaction = 0; this.animation = new FlowAnimation(result, this.time);
this.node.emit('balloon-merge', result); this.emitHUD();
}
private toPoint(e: EventTouch): Point {
const p = e.getUILocation();
const local = this.surface.getComponent(UITransform)!.convertToNodeSpaceAR(new Vec3(p.x, p.y, 0));
return { x: local.x + WIDTH / 2, y: HEIGHT / 2 - local.y };
}
private onStart(e: EventTouch): void {
if (this.touchId !== null || this.animation) return;
this.touchId = e.getID(); this.hints = []; this.gesture.down(this.toPoint(e));
}
private onMove(e: EventTouch): void { if (e.getID() === this.touchId) this.gesture.move(this.toPoint(e)); }
private onEnd(e: EventTouch): void {
if (e.getID() !== this.touchId) return;
this.gesture.move(this.toPoint(e)); const path = this.gesture.up(); this.touchId = null;
const merged = this.game.commit(path);
if (merged) this.begin(merged);
}
private cancel(e?: EventTouch): void {
if (e && e.getID() !== this.touchId) return;
this.touchId = null; this.gesture?.cancel();
}
update(dt: number): void {
this.time += dt * 1000;
const ui = this.node.getComponent(UITransform)!;
const scale = Math.min(ui.width / WIDTH, ui.height / HEIGHT);
this.surface.setScale(scale, scale, 1);
// node 可以使用任意 anchor,绘图始终居中于 UITransform 内容区域。
this.surface.setPosition((.5 - ui.anchorX) * ui.width, (.5 - ui.anchorY) * ui.height, 0);
let balls = this.game.balls;
if (this.animation) {
const sample = this.animation.sample(this.time);
if (sample) {
balls = sample;
const current = this.animation.reaction;
if (current && current.index > this.seenReaction) {
for (const stage of this.animation.result.stages.filter(s => s.index > this.seenReaction && s.index <= current.index))
this.node.emit('balloon-reaction', stage, this.animation.result.doubled);
this.seenReaction = current.index;
}
} else {
const result = this.animation.result; this.animation = null; this.emitHUD();
this.node.emit('balloon-settled', result, this.game.snapshot());
}
}
this.render(drawBoard({ balls, chain: this.animation ? this.animation.activePath : this.gesture.chain,
hint: this.animation ? [] : this.hints, time: this.time / 1000, fever: this.game.fever }));
}
private color(hex: string, alpha = 1): Color {
const value = parseInt(hex.slice(1), 16);
return new Color((value >> 16) & 255, (value >> 8) & 255, value & 255, Math.round(alpha * 255));
}
private render(commands: Draw[]): void {
const g = this.graphics; g.clear(); let labelIndex = 0;
for (const d of commands) {
if (d.type === 'text') {
let label = this.labels[labelIndex++];
if (!label) {
const node = new Node('BalloonLabel'); node.layer = this.node.layer; this.surface.addChild(node);
node.addComponent(UITransform).setContentSize(340, 60);
label = node.addComponent(Label); label.horizontalAlign = HorizontalTextAlignment.CENTER;
label.verticalAlign = VerticalTextAlignment.CENTER; label.fontFamily = 'Arial';
this.labels.push(label);
}
label.node.active = true; label.node.setPosition(d.x - WIDTH / 2, HEIGHT / 2 - d.y, 0);
label.string = d.text; label.fontSize = d.size; label.lineHeight = d.size * 1.3;
label.isBold = (d.weight || 600) >= 650; label.color = this.color(d.fill || '#40678e', d.alpha);
label.enableOutline = !!d.stroke; label.outlineWidth = d.width || 1;
label.outlineColor = this.color(d.stroke || '#ffffff', d.alpha);
continue;
}
g.fillColor = this.color(d.fill || '#ffffff', d.alpha);
g.strokeColor = this.color(d.stroke || '#ffffff', d.alpha); g.lineWidth = d.width || 1;
if (d.type === 'circle') g.circle(d.x - WIDTH / 2, HEIGHT / 2 - d.y, d.r);
else if (d.type === 'ellipse') g.ellipse(d.x - WIDTH / 2, HEIGHT / 2 - d.y, d.rx, d.ry);
else if (d.dash) {
// Graphics 没有 Canvas 的 setLineDash;逐段画虚线。
for (let i = 1; i < d.points.length; i++) {
const a = d.points[i - 1], b = d.points[i], length = Math.hypot(b.x - a.x, b.y - a.y);
for (let t = 0; t < length; t += d.dash[0] + d.dash[1]) {
const end = Math.min(length, t + d.dash[0]);
g.moveTo(a.x + (b.x - a.x) * t / length - WIDTH / 2, HEIGHT / 2 - a.y - (b.y - a.y) * t / length);
g.lineTo(a.x + (b.x - a.x) * end / length - WIDTH / 2, HEIGHT / 2 - a.y - (b.y - a.y) * end / length);
}
}
} else {
d.points.forEach((p, i) => i ? g.lineTo(p.x - WIDTH / 2, HEIGHT / 2 - p.y) : g.moveTo(p.x - WIDTH / 2, HEIGHT / 2 - p.y));
if (d.close) g.close();
}
if (d.fill) g.fill(); if (d.stroke) g.stroke();
}
for (let i = labelIndex; i < this.labels.length; i++) this.labels[i].node.active = false;
}
}
+54
View File
@@ -0,0 +1,54 @@
# 连连气球 · 连消畅玩版
2026-09-15 更新。用户反馈关卡打断连消节奏、球的视觉偏脏;本版把主循环改为持续合成与放飞,并重新设计气球和补给。v1 关卡设计保留在 [历史存档](archive-v1/GDD.md)。
## 体验目标
划出一串 → 气球聚到末端 → 新数膨胀 → 自动接力 → 新球落下再接 → 稳定后继续划。每次操作争取出现可理解的 2~3 连消,后期大数也有消化出口。没有过关弹窗、步数、倒计时、失败页。
## 规则
- 相邻距离仍为两球半径和的 1.35 倍;至少三颗。
- 顺号链必须全程严格 +1 或 −1;新增整条同号链。不能重复使用同一球,也不能混合顺号与同号。
- 合成结果为链中最大数 +1,ID 和位置继承链尾。
- 自动接力只围绕刚合成的球:优先寻找 `n−2 → n−1 → n`;否则寻找两颗同号连到它。每次升级后再检查。
- 首轮接力结束后补球,落稳再检查新球周围是否能接上;这一轮仍算同一次手势。
- 普通手势总计最多 3 次合成;彩虹期间最多 4 次。上限控制观看时长,也给玩家留下下一条手动长链。
- 基础分 = `100 × 新数字 × 链长`。第 k 次连消乘 k;彩虹再乘 2。
- 合成 10 时气球飞出,不占盘面,另加 10,000 分。放飞奖励固定,不再翻倍。
## 彩虹时刻
每次普通手势的能量增量是 `max(1, 手动链长−2) + 自动合成次数`。攒满 10 后,从下一次手势开始,连续 5 次双倍得分;用完重新积攒。没有限时催促,免费工具不消耗彩虹次数。撤销恢复能量和剩余次数。
## 掉落设计
| 环节 | 行为 | 目的 |
|---|---|---|
| 开局 | 24 颗,数字 1–4,固定可复现种子 | 直接上手,多条短链 |
| 下一袋 | 显示接下来 3 个数字,按序兑现 | 玩家能提前判断 |
| 配号 | 三颗连续数为一袋,随最高数逐步引入高号,自然掉落最高 6 | 补齐数字层次,保留造大数过程 |
| 落点 | 每次最多试 5 个物理落点,评价合法链数量、长链和最新气球的接力机会 | 让补球帮助下一连 |
| 容量 | 目标 24 颗,投影面积预算约 60%,半径缓慢增长 | 为新球保留空间 |
| 断号 | 自动微风;调整少量低号、重排,极端情况保留最高 6 颗并补低号 | 不出现失败页 |
| 大数 | 同号也能消化,10 号放飞 | 避免大数无限堵盘 |
正常掉落不改盘上气球的数字或 ID,不偷偷改已展示的预告;自动微风是额外救场,可能调整低号。掉落候选和救场均有迭代上限,不做无限随机重试。
## UI 与反馈
- 名称「连连气球 · POP & LINK」。浅天蓝与白色背景,粉彩气球,细描边、椭圆高光、绳结和短线;去掉纸色、杂点、叶子、厚重阴影。
- 顶部显示得分和最大气球;下一行显示彩虹能量与下一袋,中央尽量留给触摸盘面。
- 底栏是撤一步、找一串、吹口气、新一局。重开是玩家主动打开记录面板后选择。
- 拖动期间显示数字链、预计升级和最低得分。落下后的新连消是额外惊喜。
- 合成收拢、轻弹膨胀、递升短音、得分浮字和 2/3/4 连消字样;放飞 10 有独立提示。
- 合成约 155ms;补球约 215ms;手动微风约 390ms。动画完成后接收下一次手势,稳定盘面不受显示帧率影响。
- 支持系统减少动态效果设置;关闭动画也能立即看到最终分数与能量。
## 技术与存档
纯 TypeScript 核心 + 小型圆形物理 + 可序列化状态和时间线。Web 使用 Canvas 与 PointerEvent;Cocos 使用相同手势/动画/绘制数据,组件以 Graphics + Label 呈现,可后续换 Sprite 池。
新键 `mode:02-connect-1234:balloons-v2` 保存纪录与音效偏好,不覆盖旧 v1 关卡成绩。完整撤销保留最近 40 次操作,包括随机数状态、预告、盘面、能量与统计;纪录只增不减。
连消率通过固定种子随机策略和提示策略抽样验证,详见 [QA](QA.md)。它用于排查玩法断档,不代表真实玩家留存或主观爽感;后续可以基于实机试玩调整落点评分、能量阈值与节奏。
+78
View File
@@ -0,0 +1,78 @@
# 连连气球 · Cocos Creator 接入
当前交付是可玩的 Web 畅连版,以及同源 TypeScript 核心和 Creator 球槽组件。组件已使用本机 **Creator 3.8.8 的真实 cc.d.ts** 检查;Cocos 场景、手机原生包尚未运行验收。
## 导出与球槽
```bash
npm run cocos:check
npm run cocos:export -- /你的工程/assets/modes/02-connect-1234
```
默认生成 `cocos/export/02-connect-1234/`,每次重新生成,包含当前核心和 `adapters/Connect1234Board.ts`。自定义导出只写文件,不删除已有工程文件;从 v1 升级时建议导入新目录。JSON 常量导出为 TypeScript 数据模块,减少对宿主打包方式的依赖。
1. 创建本玩法 2D 场景,Canvas 建议 720 × 1280。
2. 创建 `Board`,添加 `UITransform`,尺寸例如 640 × 740;挂 `Connect1234Board`。
3. 设置 `seed`,默认 1234。没有关卡编号。
4. 球槽添加矩形 Mask 裁掉顶部进入的气球;背景、顶栏、能量条和工具按钮在宿主 UI 搭建。
逻辑坐标 **360 × 416,左上原点,Y 向下**。组件用 `UITransform.convertToNodeSpaceAR` 转换触摸,等比缩放并兼容任意锚点。只接受一根手指,触摸取消不提交;弹窗或切后台时禁用 Board 组件以取消当前手势。
## 方法与事件
| 方法 | 行为 |
|---|---|
| `startNew(seed?)` | 新开畅玩局,省略种子时使用当前时间 |
| `retry()` | 用相同种子重新开始 |
| `undo()` | 撤销上一操作,含补球、彩虹和微风 |
| `showHint()` | 显示可以实际滑出的提示链 |
| `shuffle()` | 免费微风重排并播放掉落 |
| `snapshot()` | 完整、可序列化状态副本 |
在宿主组件 `start` 中订阅并主动读取初始 `snapshot()`,避免错过 Board 的 `onLoad` 事件:
```ts
board.on('balloon-hud', ({ snapshot, canUndo, busy, fever }) => {
// 分数、最大数字、能量、剩余双倍次数、queue 前 3 项;busy 时禁用工具。
});
board.on('balloon-reaction', (stage, doubled) => {
// 每段合成的浮字/短音:stage.points、stage.index、stage.created。
});
board.on('balloon-settled', (result, snapshot) => {
// 更新本地纪录;result.feverStarted 提示下一次开始双倍。
});
```
`balloon-merge` 在整次操作开始发出完整 `FlowResult`,含按序的 `beats`。不发过关或失败事件,不自动弹结算页。
## 规则、显示与存档
- `FlowGame.commit(ids)` 同步计算最终稳定状态,返回 `merge → fall → merge…` 时间线。`FlowAnimation` 按毫秒重放,无 DOM 或帧率依赖。
- `FlowResult.stages` 是所有合成段;`beats` 是真实播放顺序;最终盘面读 `result.supply.balls` 或 `game.balls`。`result.balls` 是最后一次合成后、最终补球前的盘面。
- 数字预告先兑现再补后续袋;掉落物理与自动接力不由宿主帧率决定。
- `view.ts` 的椭圆、路径、文字指令映射到 Graphics + Label。Label 节点池复用,支持数字描边;发布时可改成气球 Sprite + Label 池。
- `record.ts` 与 `StoragePort` 适配 `sys.localStorage`,键为 `mode:02-connect-1234:balloons-v2`。记录最高分、最大数、连消、放飞和声音设置,不保存中途局面。
```ts
import { sys } from 'cc';
import { loadRecord, recordRound, saveRecord } from './src/record';
const storage = {
read: (key: string) => sys.localStorage.getItem(key),
write: (key: string, value: string) => sys.localStorage.setItem(key, value),
};
const record = loadRecord(storage);
// 每次 balloon-settled:recordRound(record, snapshot.stats); saveRecord(storage, record);
```
## 性能与后续验收
最多 24 球;稳定时仅绘制,提交时试算少量落点。所有补给随机搜索有上限。测量脚本输出本机提交耗时,见 [QA](QA.md);真机需重新测量,必要时将供给候选计算移入宿主分帧任务,同时保持同一随机序列。
不要直接换成帧率驱动的 Box2D 落点再复用当前数据假定;更换物理后需重新验证邻接、补给和手势路线。
```bash
npm run check
COCOS_TYPES=/路径/cc.d.ts npm run cocos:check
```
移植阶段剩余:Creator 实际场景、Mask 与安全区、音频、减少动态效果配置、前后台生命周期、Android/iOS 与目标小游戏平台触摸及性能验收。
+65
View File
@@ -0,0 +1,65 @@
# 连连气球 · v2 验证记录
日期:2026-09-15。当前版本为无关卡连消畅玩版。历史关卡验收见 [v1 存档](archive-v1/QA.md)。
## 自动检查
- TypeScript Web 和纯核心检查通过;核心检查不加载 DOM / Node 类型。
- **37 项测试通过**:14 项覆盖当前顺号/同号、连消分数、补球接力、彩虹五次、放飞、预告、撤销与随机确定性、微风救场、动画跳帧、存档和拥挤物理回归;23 项保留 v1 及共用代码回归。
- **3,200 次操作、3,232 个稳定盘面**通过:唯一 ID、合法数字、边界、圆形重叠和至少一条可连路线。
- 最大数值穿透 **1.174 逻辑像素**,阈值为 2px;气球外观比碰撞圆略窄。针对种子 6559 的拥挤盘面保留回归数据,补球稳定阶段增加了约束迭代。
- 物理先判断平方距离,仅对接触球开方,降低补球试算成本。
- 生产包构建成功,JS **29,024 字节**(未 gzip),没有开发观察接口。
### 连续游玩抽样
每局连续操作 100 次;不调用免费工具跳过难盘。无路时由核心自动微风处理,次数列在表内。
| 策略 | 局数 / 操作数 | 触发连消 | 每局放飞 | 自动微风 | 本机提交耗时中位数 / P95 |
|---|---:|---:|---:|---:|---:|
| 从合法链随机抽取 | 24 / 2,400 | 812,33.8% | 3–5 颗 | 161 | 8.5 / 16.7 ms |
| 沿提示路线 | 8 / 800 | 499,62.4% | 5–8 颗 | 46 | 8.5 / 15.3 ms |
32 局都玩满 100 次,全部至少合成过 10。提示策略每次还通过 `Gesture` 实际路线采样,验证链条确实能按球心滑出。
这是固定种子算法抽样,不是玩家行为统计,也不是无死局的数学证明。随机策略对搜索出的合法链抽样;提示策略更偏向长链和即时连消。耗时来自当前开发机,手机端需另测。
完整种子、分数、放飞和性能数据见 [validation.json](qa-v2/validation.json)。复现:`npm run validate`。
## 浏览器与正式包
使用 ego-browser Chromium,390 × 844 移动视口,CDP `Input.dispatchTouchEvent` 输入:
- 实际连续完成 **24 次触摸合成**,达到 **317,500 分**,最高数字 **10**,放飞 **1 颗**;触发彩虹双倍及多连消,过程未弹出关卡/失败面板。
- 验证不足三颗、触摸取消、划回上一颗均不提交。
- 合成后撤销并重走,完整盘面、队列、随机数、分数与能量相同。
- 微风不扣分或能量,撤销恢复原局面。
- 页面错误和未处理 Promise 拒绝为 **0**。
- 帮助、纪录、重开和 Escape 关闭面板可用。
- 系统减少动态效果时,手势可正常提交,立即显示最终状态;横幅不依赖动画才能可见。
- **正式包 http://localhost:8003** 使用真实触摸完成首轮合成,得到 **10,000 分**;刷新后纪录保留,开发观察接口不存在。验收后恢复验收前的 v2 纪录,旧 v1 存档保持原样。
结果:[browser.json](qa-v2/browser.json) · [production.json](qa-v2/production.json)。浏览器长局检查在物理平方距离优化前完成;优化后重新完成全部 3,200 次核心抽样、减少动态效果交互及正式包触摸检查。
## 布局
| 视口 | 结果 |
|---|---|
| 320 × 568 | 无横向溢出,页面高 650,可滚动到工具栏 |
| 360 × 740 | 完整布局 |
| 390 × 844 | 完整竖屏,主触摸验收尺寸 |
| 430 × 932 | 完整布局 |
| 844 × 390 | 保持竖屏面板,可纵向滚动 |
| 1280 × 900 | 居中面板与桌面气球插画 |
上述尺寸下主要按钮均至少 44 × 44 CSS 像素。数值见 [layout.json](qa-v2/layout.json)。
截图:[当前开局](qa-v2/mobile.png) · [3 连消](qa-v2/first-merge.png) · [彩虹](qa-v2/rainbow.png) · [放飞后](qa-v2/flight.png) · [帮助](qa-v2/help.png) · [桌面](qa-v2/desktop.png)。
浏览器验收脚本为 `tools/browser-flow*.mjs`。其中空间 ID 4 是本次已有 QA 空间;重新验收时应先创建自己的一个 QA 空间并替换 ID,再通过 `ego-browser nodejs < 脚本` 执行,不能接管已交还用户的空间。正式包脚本只恢复它自己产生的测试纪录。
## Cocos
`npm run cocos:check` 通过:导出当前核心与 `Connect1234Board`,使用本机 **Creator 3.8.8 的真实 cc.d.ts** 检查触摸、坐标转换、Graphics、Label 描边、事件和状态接口。导出目录不含旧关卡模块或题库。
尚未验收:Creator 场景运行、实体手机触摸与性能、Android/iOS 和小游戏平台原生包。接入步骤及宿主 UI 工作见 [HANDOFF](HANDOFF.md)。
@@ -0,0 +1,67 @@
> 历史存档:关卡版 v1,已由连消畅玩版替换。当前规则见 [GDD](../GDD.md)。
# 连号花园 · 玩法二实现规格
2026-09-15:按本轮需求,从最初的无尽分数设计演化为 **10 关挑战 + 自由生长**。本文件是 v1 规则。
## 核心规则
- 圆球物理堆积;相邻圆心距 ≤ 半径之和 × 1.35。
- 单指按住球,连接至少 3 个严格连续数字;升序、降序均合法,不能换向或重复用球。
- 滑回上一颗撤回一步;快速滑动分段采样;触摸取消、窗口失焦不会提交合成。
- 松手合成 `max + 1`,结果落在链尾;计分 `100 × 新数字 × 链长`。
- 球半径 `min(82, 22 + 6 × (n−1))`,保留变大的空间压力;高数封顶保证自由模式仍可触摸。
- 每次补到本关目标球数,面积最多占槽的 59%;物理稳定后才接收下一次操作。下落期间顶部开放,稳定收尾加入顶部约束,保证所有球可见且可触摸。
- 无计时,非法手势不扣步。挑战每次有效合成消耗一步,所有目标达成即过关(最后一步先检查胜利)。
- 没有合法链时允许撤销或使用本关唯一一次打乱;步数用尽可撤销或重试。
## 原创演化
- **固定种子与补球队列**:同一关重试能复盘,撤销连随机状态一并恢复。
- **10 关目标制**:三连、倒序、四连、双目标、造大数逐步教学;有限步数,不限思考时间。
- **容错**:免费提示、连续撤销、每关一次免费打乱。打乱保留球的数字、不耗步;无可行重排不扣次数。
- **提示**:开局及沿标准解操作时显示经验证的下一步;偏离后寻找当前合法链,不保证整局最优。
- **星级**:不打乱且在标准解步数以内为 3 星;标准步数 +2 以内为 2 星,其余通关为 1 星。提示、撤销不扣星。
- **自由生长**:保留无尽分数循环,用掉落权重抽样,无关卡目标和步数上限。
## 10 关
完整配置、种子、补球队列、通关轨迹见 [campaign.json](../levels/campaign.json)。
| 关 | 名称 | 主要训练 | 目标 | 步数 |
|---|---|---|---|---|
| 1 | 初芽 | 三连松手 | 合成 4 | 3 |
| 2 | 回转 | 降序与落点 | 倒序合成一次 | 4 |
| 3 | 伸展 | 长链 | 一次连 4 颗 | 5 |
| 4 | 双生 | 材料预留 | 合成两颗 5 | 6 |
| 5 | 丰收 | 长链得分 | 6,500 分 | 7 |
| 6 | 抽叶 | 两个目标 | 合成两颗 6,四连 | 8 |
| 7 | 繁枝 | 大球与落点 | 合成 7 | 9 |
| 8 | 花径 | 弯曲长链 | 五连,14,000 分 | 10 |
| 9 | 并蒂 | 连续筹备 | 合成两颗 7 | 12 |
| 10 | 满园 | 综合挑战 | 合成 8,22,000 分 | 14 |
标准解由有界搜索找到并回放验算,不宣称唯一解或最优解。自动验证证明可通关,实际玩家难度仍可继续调参。
## UI 与触摸
- 对外名称「连号花园」,奶油纸色、鼠尾草绿、柔和彩球;6 以上长叶,8 以上小花。
- 竖屏从上到下:品牌/声音/帮助 → 关卡入口 → 目标与步数 → 球槽 → 当前连线预览 → 撤销/提示/打乱/重来。
- 数字在球心,颜色不参与判定。按钮触摸区至少 44px。
- 适配安全区、320px 窄屏和横屏;球槽维持逻辑宽高比,短屏允许页面滚动,槽内禁止浏览器拖动。
- 连线显示将生成的新数和分数;合成收拢、下落、得分浮字、过关结果;支持关闭音效与减少动态效果。
- 选关底部面板,完成后解锁下一关;末关展示整套成绩并可转自由生长。
## 技术边界
`src/` 仅使用 ES2020 标准库;第二份 tsconfig 排除 DOM 和 Node 类型,持续证明无平台依赖。
- `game / rules / physics / random`:确定性状态机、规则、固定步长圆形物理。
- `input`:平台无关手势状态机。
- `goals / progress / levels`:目标、存档数据与关卡。
- `view`:输出基础绘制指令,由 Canvas 或 Cocos Graphics/Label 适配。
- `solver`:离线设计搜索、运行时轻量提示。
- `web/`:浏览器输入、Canvas、音效、HTML 界面与本地存储。
- `cocos/`:Creator 3.x 场景接入组件示例,迁移说明见 HANDOFF。
所有代码与玩法一、三隔离;不引入 React、DOM 规则、第三方物理引擎、服务端或远程素材。
@@ -0,0 +1,114 @@
> 历史存档:关卡版 v1,已由连消畅玩版替换。当前规则见 [GDD](../GDD.md)。
# 连号花园 · Cocos Creator 接手说明
## 当前交付边界
Web 版已完成十关、自由模式、动画、音效、提示、撤销、打乱、结算与本地进度。Cocos 交付的是 **可导出的同一套核心代码 + 球槽组件**;已经用本机 Creator 3.8.8 的真实 `cc.d.ts` 通过类型检查,尚未在 Creator 场景内完成运行验收。
后续移植需要在 Cocos 场景搭建顶栏、目标、按钮、选关和结算面板,绑定本文列出的事件。HTML/CSS 不作为游戏逻辑带入引擎。
## 1. 导出
在 `modes/02-connect-1234` 执行:
```bash
npm run cocos:export
```
输出 `cocos/export/02-connect-1234/`。将整个目录复制到 Creator 项目的 `assets/modes/02-connect-1234/`。也可显式指定输出路径:
```bash
npm run cocos:export -- /你的工程/assets/modes/02-connect-1234
```
导出时仅将 JSON 导入替换成生成的等价 TypeScript 数据模块,避免依赖 Creator 对 JSON module 的打包行为。规则、物理、手势、绘制指令、动画、目标、存档结构保持同一份源码。
不要手动修改导出目录;改 `src/`、`levels/` 或 `cocos/Connect1234Board.ts`,重新导出即可。
## 2. 创建球槽
1. 新建本玩法的 2D 场景,Canvas 设计分辨率建议 **720 × 1280**。
2. 在 Canvas 下创建 `Board` 节点,加 `UITransform`,尺寸可用 **640 × 740**。
3. 将导出包的 `adapters/Connect1234Board.ts` 挂到 `Board`。组件自动创建 Graphics 与复用的 Label 节点。
4. 在 Inspector 设置 `levelNumber` 为 1–10;运行后即可对球槽进行触摸连号。
5. 为 Board 的绘图区域配置矩形 Mask,裁掉从顶部掉入的球。背景颜色、圆角容器由场景美术完成。
核心坐标为 **360 × 416,左上角原点,Y 向下**。适配组件自动等比缩放并用 `UITransform.convertToNodeSpaceAR` 转换触摸坐标,不需要改变球的半径、物理或邻接算法。
## 3. 绑定 UI
组件公开:
| 方法 | 行为 |
|---|---|
| `loadLevel(number)` | 加载第 1–10 关 |
| `retry()` | 按固定种子重开当前关 |
| `undo()` | 撤回一次合成或打乱 |
| `showHint()` | 显示提示连线 |
| `shuffle()` | 每关一次重排,数字保留、不扣步 |
| `snapshot()` | 获取完整状态副本 |
在 Board 节点监听:
```ts
board.on('garden-hud', ({ level, snapshot, goals, canUndo }) => {
// 刷新标题、分数、步数、目标、工具按钮。
});
board.on('garden-merge', (result) => {
// 播放音效、振动、得分浮字。球槽自身已播放合成和下落动画。
});
board.on('garden-result', (status, snapshot) => {
// won: 成功;lost: 步数用完;stuck: 无合法链。
// 核心不会自动弹面板或跳关,由壳/UI 决定。
});
```
玩法切场景时由组件生命周期解绑触摸;只接受一根活动手指,TOUCH_CANCEL 会取消手势。宿主弹模态面板或进后台时应禁用 Board 组件,触发取消。
组件示例接入的是挑战模式。自由模式的逻辑已经在 `new Game(level, true)` 内实现,按 Web 的入口方式补一个场景按钮即可;其随机开局应确保至少有一条合法链。
## 4. 存档和音效
`src/progress.ts` 仅接受 `StoragePort` 接口。Cocos 实现:
```ts
import { sys } from 'cc';
const storage = {
read: (key: string) => sys.localStorage.getItem(key),
write: (key: string, value: string) => sys.localStorage.setItem(key, value),
};
```
存档键 `mode:02-connect-1234:v1`,保存各关最高分/星级及最高数字。不存中途盘面。音效使用 Cocos `AudioSource`,订阅 `garden-merge`;Web 的音效是本地合成的短音,不需要下载音频素材。
## 5. 物理与性能
- 继续使用核心固定步长圆形分离物理,可保证 Web 与 Cocos 的固定关卡解一致。
- `Game.commit` 先算稳定状态;`MergeAnimation` 负责重放收拢和物理过程,宿主只传入毫秒时间。
- 核心不会在每个显示帧重算关卡。最多 18 个球,稳定盘面冻住,操作时不会追着移动的球连线。
- Graphics 适配器是接入示例;发布时可将彩球换为 Sprite + Label 预制体池,保留相同球 ID 和坐标。无需变更规则。
- 如果未来换 Box2D,请保留逻辑判定盘面与显示物理解耦,或重新验证全部关卡。直接替换积分器可能改变邻接和落点。
## 6. 检查
```bash
npm run check
npm run cocos:check
```
`cocos:check` 自动寻找 macOS `/Applications/Cocos/Creator/` 已安装版本,也可提供声明文件路径:
```bash
COCOS_TYPES=/路径/cc.d.ts npm run cocos:check
```
Creator 内还需验收:实际场景字体、触摸坐标、Mask、UI 安全区、音频、Android/iOS 原生包,以及微信等目标平台的生命周期。
## 参考接口
- [Cocos 3.8 节点触摸事件](https://docs.cocos.com/creator/3.8/manual/en/engine/event/event-node.html)
- [UITransform 坐标转换](https://docs.cocos.com/creator/3.8/api/en/class/UITransform)
- [Graphics 图形接口](https://docs.cocos.com/creator/3.8/api/en/class/GraphicsComponent)
实现及类型检查以本机 Creator 3.8.8 的声明文件为准。
@@ -0,0 +1,60 @@
> 历史存档:关卡版 v1,已由连消畅玩版替换。当前规则见 [GDD](../GDD.md)。
# 验证记录
日期:2026-09-15。当前实现的验证范围如下;未将浏览器触摸模拟称为实体手机测试。
## 自动检查
`npm run check` 全部通过:
- 两套 TypeScript 检查通过,其中 `src/` 不含 DOM / Node 类型。
- **23 项测试**通过:规则、升降序、拒绝非法链、落点、最后一步成功、确定性重试、撤销、快速划线、取消、打乱、死局、失败、存档容错、十关触摸路线、帧率无关动画。
- **10 / 10 关**使用固定种子逐步回放通过;验证同时经过 `Gesture`,避免“规则上合法、实际划线无法选中”的假解。
- **1,225 个稳定盘面**通过边界与圆形重叠检查,含 **1,046 次随机合成**;最大数值穿透 **0.772 逻辑像素**(验收阈值 2px)。随机自由局自然结束;这批样本中最长连续局为 18 次合成。
- 所有挑战关的开局与补球数字都在 1–4,最终通过正常合成得到 8。
- 生产构建成功,JS 约 **33 KB**(未 gzip),不包含开发观察端口。
- 独立 `release/` 正式包在端口 8003 实际完成第一关触摸合成(1,200 分,余 2 步),确认无开发观察端口,并已清理测试成绩、恢复第一关开局。见 [production.json](../qa/production.json)。
详细关卡逐步数字、球 ID、分数、步数与压力检查统计见 [validation.json](../qa/validation.json)。搜索给出的是一条可行路线,非唯一解、非最优解证明。
## 浏览器触摸验收
Ego Chromium,390 × 844 移动视口,使用 CDP `Input.dispatchTouchEvent` 产生浏览器输入:
- 从 **第 1 关实际连续游玩到第 10 关**,没有直接修改分数、盘面或过关状态。
- 验证两颗松手不扣步、TOUCH_CANCEL 不提交、滑回上一颗撤回。
- 确认十关依次解锁、最终结果为 10 / 10、存档记录 30 / 30 星。
- 页面错误和未处理 Promise 拒绝记录为 **0**。
- 后续刷新验证成绩保留;实际点按钮验证撤销和重走完全一致、打乱不扣步且保留数字、撤销恢复打乱库存。
- 自由模式实际完成合成且步数保持无限;帮助面板可用 Escape 关闭。
- 抽取动画为跨平台模块后,再次通过浏览器验证合成、撤销、重走和打乱。
结果见 [browser.json](../qa/browser.json)、[layout.json](../qa/layout.json)。`tools/browser-*.mjs` 是本次验收脚本,使用 `GARDEN_SPACE` 指定当前已有的 Ego QA 空间、`GARDEN_QA_DIR` 指定输出目录;应使用独立测试存档,先跑通关脚本再跑布局脚本。
## 尺寸检查
| 视口 | 结果 |
|---|---|
| 320 × 568 | 无横向溢出;页面高 650px,可纵向滚动触及底栏 |
| 360 × 740 | 完整布局,主要按钮 ≥ 44px |
| 390 × 844 | 完整布局,触摸通关主测试尺寸 |
| 430 × 932 | 完整布局,主要按钮 ≥ 44px |
| 844 × 390 | 横屏仍可操作,可纵向滚动 |
| 1280 × 900 | 居中竖屏游戏面板与桌面背景 |
截图:[开局](../qa/mobile-initial.png) · [后段关卡](../qa/mobile-level8.png) · [帮助](../qa/mobile-help.png) · [选关](../qa/mobile-level-select.png) · [全通关](../qa/mobile-all-clear.png) · [桌面](../qa/desktop.png)。
## Cocos 迁移检查
`npm run cocos:check` 通过:
- 生成独立的 Creator 导入目录。
- 使用本机 **Cocos Creator 3.8.8 的真实 `cc.d.ts`** 检查核心、数据与适配组件,非手写模拟类型。
- 球槽坐标转换、Graphics、Label、触摸 API 均通过类型检查。
**仍需后续移植阶段完成**:Creator 场景实际运行、Cocos UI 面板搭建、原生 Android/iOS 与目标小游戏平台验收。当前不宣称这些工作已完成。
## 难度判断的范围
难度梯度由教学目标、解的步数、容错余量、长链和多轮合成要求制定,已验证全部关卡可通关。尚未做多人试玩统计,平均通关耗时、提示使用率、玩家卡关分布需要后续试玩采样。
@@ -0,0 +1,205 @@
{
"viewport": [
390,
844
],
"input": "CDP touch events",
"invalidCancelBacktrack": true,
"undoDeterministic": true,
"breezeUndo": true,
"moves": [
{
"turn": 1,
"score": 10000,
"combo": 3,
"peak": 6,
"flown": 0,
"feverLeft": 0
},
{
"turn": 2,
"score": 19000,
"combo": 3,
"peak": 6,
"flown": 0,
"feverLeft": 5
},
{
"turn": 3,
"score": 31000,
"combo": 2,
"peak": 7,
"flown": 0,
"feverLeft": 4
},
{
"turn": 4,
"score": 43000,
"combo": 2,
"peak": 7,
"flown": 0,
"feverLeft": 3
},
{
"turn": 5,
"score": 62200,
"combo": 3,
"peak": 7,
"flown": 0,
"feverLeft": 2
},
{
"turn": 6,
"score": 77800,
"combo": 2,
"peak": 9,
"flown": 0,
"feverLeft": 1
},
{
"turn": 7,
"score": 84200,
"combo": 2,
"peak": 9,
"flown": 0,
"feverLeft": 0
},
{
"turn": 8,
"score": 93800,
"combo": 3,
"peak": 9,
"flown": 0,
"feverLeft": 0
},
{
"turn": 9,
"score": 100700,
"combo": 2,
"peak": 9,
"flown": 0,
"feverLeft": 0
},
{
"turn": 10,
"score": 108300,
"combo": 2,
"peak": 9,
"flown": 0,
"feverLeft": 0
},
{
"turn": 11,
"score": 120200,
"combo": 3,
"peak": 9,
"flown": 0,
"feverLeft": 5
},
{
"turn": 12,
"score": 142200,
"combo": 1,
"peak": 10,
"flown": 1,
"feverLeft": 4
},
{
"turn": 13,
"score": 184200,
"combo": 4,
"peak": 10,
"flown": 1,
"feverLeft": 3
},
{
"turn": 14,
"score": 198000,
"combo": 2,
"peak": 10,
"flown": 1,
"feverLeft": 2
},
{
"turn": 15,
"score": 217200,
"combo": 3,
"peak": 10,
"flown": 1,
"feverLeft": 1
},
{
"turn": 16,
"score": 221200,
"combo": 1,
"peak": 10,
"flown": 1,
"feverLeft": 0
},
{
"turn": 17,
"score": 236200,
"combo": 3,
"peak": 10,
"flown": 1,
"feverLeft": 0
},
{
"turn": 18,
"score": 242200,
"combo": 3,
"peak": 10,
"flown": 1,
"feverLeft": 0
},
{
"turn": 19,
"score": 249100,
"combo": 2,
"peak": 10,
"flown": 1,
"feverLeft": 0
},
{
"turn": 20,
"score": 253300,
"combo": 2,
"peak": 10,
"flown": 1,
"feverLeft": 5
},
{
"turn": 21,
"score": 267100,
"combo": 2,
"peak": 10,
"flown": 1,
"feverLeft": 4
},
{
"turn": 22,
"score": 275500,
"combo": 2,
"peak": 10,
"flown": 1,
"feverLeft": 3
},
{
"turn": 23,
"score": 305500,
"combo": 3,
"peak": 10,
"flown": 1,
"feverLeft": 2
},
{
"turn": 24,
"score": 317500,
"combo": 2,
"peak": 10,
"flown": 1,
"feverLeft": 1
}
],
"errors": []
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

@@ -0,0 +1,312 @@
{
"layouts": [
{
"width": 320,
"height": 568,
"scrollWidth": 320,
"scrollHeight": 650,
"buttons": [
{
"id": "sound",
"w": 44,
"h": 44,
"bottom": 50
},
{
"id": "help",
"w": 44,
"h": 44,
"bottom": 50
},
{
"id": "records",
"w": 60,
"h": 61,
"bottom": 119.921875
},
{
"id": "undo",
"w": 68.75,
"h": 59.5,
"bottom": 620
},
{
"id": "hint",
"w": 68.75,
"h": 59.5,
"bottom": 620
},
{
"id": "shuffle",
"w": 68.75,
"h": 59.5,
"bottom": 620
},
{
"id": "restart",
"w": 68.75,
"h": 59.5,
"bottom": 620
}
],
"stageHeight": 326.65625
},
{
"width": 360,
"height": 740,
"scrollWidth": 360,
"scrollHeight": 740,
"buttons": [
{
"id": "sound",
"w": 44,
"h": 44,
"bottom": 50
},
{
"id": "help",
"w": 44,
"h": 44,
"bottom": 50
},
{
"id": "records",
"w": 60,
"h": 61,
"bottom": 119.921875
},
{
"id": "undo",
"w": 73.5,
"h": 59.5,
"bottom": 710
},
{
"id": "hint",
"w": 73.5,
"h": 59.5,
"bottom": 710
},
{
"id": "shuffle",
"w": 73.5,
"h": 59.5,
"bottom": 710
},
{
"id": "restart",
"w": 73.5,
"h": 59.5,
"bottom": 710
}
],
"stageHeight": 416.65625
},
{
"width": 390,
"height": 844,
"scrollWidth": 390,
"scrollHeight": 844,
"buttons": [
{
"id": "sound",
"w": 44,
"h": 44,
"bottom": 57
},
{
"id": "help",
"w": 44,
"h": 44,
"bottom": 57
},
{
"id": "records",
"w": 60,
"h": 75,
"bottom": 147.9453125
},
{
"id": "undo",
"w": 81,
"h": 61,
"bottom": 810
},
{
"id": "hint",
"w": 81,
"h": 61,
"bottom": 810
},
{
"id": "shuffle",
"w": 81,
"h": 61,
"bottom": 810
},
{
"id": "restart",
"w": 81,
"h": 61,
"bottom": 810
}
],
"stageHeight": 470.1015625
},
{
"width": 430,
"height": 932,
"scrollWidth": 430,
"scrollHeight": 932,
"buttons": [
{
"id": "sound",
"w": 44,
"h": 44,
"bottom": 57
},
{
"id": "help",
"w": 44,
"h": 44,
"bottom": 57
},
{
"id": "records",
"w": 60,
"h": 75,
"bottom": 147.9453125
},
{
"id": "undo",
"w": 91,
"h": 61,
"bottom": 898
},
{
"id": "hint",
"w": 91,
"h": 61,
"bottom": 898
},
{
"id": "shuffle",
"w": 91,
"h": 61,
"bottom": 898
},
{
"id": "restart",
"w": 91,
"h": 61,
"bottom": 898
}
],
"stageHeight": 558.1015625
},
{
"width": 844,
"height": 390,
"scrollWidth": 844,
"scrollHeight": 650,
"buttons": [
{
"id": "sound",
"w": 44,
"h": 44,
"bottom": 51
},
{
"id": "help",
"w": 44,
"h": 44,
"bottom": 51
},
{
"id": "records",
"w": 60,
"h": 61,
"bottom": 120.921875
},
{
"id": "undo",
"w": 88,
"h": 59.5,
"bottom": 619
},
{
"id": "hint",
"w": 88,
"h": 59.5,
"bottom": 619
},
{
"id": "shuffle",
"w": 88,
"h": 59.5,
"bottom": 619
},
{
"id": "restart",
"w": 88,
"h": 59.5,
"bottom": 619
}
],
"stageHeight": 324.65625
},
{
"width": 1280,
"height": 900,
"scrollWidth": 1280,
"scrollHeight": 900,
"buttons": [
{
"id": "sound",
"w": 44,
"h": 44,
"bottom": 83
},
{
"id": "help",
"w": 44,
"h": 44,
"bottom": 83
},
{
"id": "records",
"w": 60,
"h": 75,
"bottom": 176.5
},
{
"id": "undo",
"w": 88,
"h": 61,
"bottom": 844
},
{
"id": "hint",
"w": 88,
"h": 61,
"bottom": 844
},
{
"id": "shuffle",
"w": 88,
"h": 61,
"bottom": 844
},
{
"id": "restart",
"w": 88,
"h": 61,
"bottom": 844
}
],
"stageHeight": 470
}
],
"reducedMotion": true,
"keyboardModal": true
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

@@ -0,0 +1 @@
{"balls":[{"id":73,"n":8,"r":46.400000000000006,"x":155.1468869644993,"y":361.6,"vx":0,"vy":0},{"id":34,"n":4,"r":33.6,"x":232.1383484022295,"y":374.4,"vx":0,"vy":0},{"id":106,"n":8,"r":46.400000000000006,"x":306.6,"y":346.91101173225013,"vx":0,"vy":0},{"id":119,"n":1,"r":24,"x":31,"y":384,"vx":0,"vy":0},{"id":79,"n":7,"r":43.2,"x":242.29979747458898,"y":285.3798913004701,"vx":0,"vy":0},{"id":127,"n":2,"r":27.2,"x":87.42066087373445,"y":338.81330205261503,"vx":0,"vy":0},{"id":134,"n":2,"r":27.2,"x":325.8,"y":275.0430952965191,"vx":0,"vy":0},{"id":94,"n":2,"r":27.2,"x":34.2,"y":338.04324185934405,"vx":0,"vy":0},{"id":141,"n":5,"r":36.8,"x":316.2,"y":211.18521655052808,"vx":0,"vy":0},{"id":148,"n":8,"r":46.400000000000006,"x":59.06976562543874,"y":270.57975200986914,"vx":0,"vy":0},{"id":152,"n":4,"r":33.6,"x":274.2729088066474,"y":153.89093156184353,"vx":0,"vy":0},{"id":151,"n":9,"r":49.6,"x":154.30680989880818,"y":254.69261229076352,"vx":0,"vy":0},{"id":157,"n":5,"r":36.8,"x":316.2,"y":96.84270530601155,"vx":0,"vy":0},{"id":158,"n":6,"r":40,"x":47,"y":185.95289916855464,"vx":0,"vy":0},{"id":155,"n":4,"r":33.6,"x":203.52445014195436,"y":139.33193488508178,"vx":0,"vy":0},{"id":160,"n":5,"r":36.8,"x":43.8,"y":110.39302971436348,"vx":0,"vy":0},{"id":161,"n":6,"r":40,"x":125.67701639860698,"y":169.1546351531075,"vx":0,"vy":0},{"id":149,"n":5,"r":36.8,"x":226.66064639633422,"y":206.41680839495766,"vx":0,"vy":0},{"id":162,"n":4,"r":33.6,"x":40.6,"y":41.6,"vx":0,"vy":0},{"id":163,"n":1,"r":24,"x":105.05076572430656,"y":107.92440190412071,"vx":0,"vy":0},{"id":164,"n":2,"r":27.2,"x":156.26870064135628,"y":100.18081469709189,"vx":0,"vy":0}],"stats":{"score":265500,"turns":37,"reactions":66,"maxNumber":10,"bestCombo":3,"lastCombo":1,"popped":208,"flown":1},"rng":1760922726,"nextId":165,"queue":[1,2,3],"feverCharge":0,"feverLeft":3,"breezes":3}
Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

@@ -0,0 +1,10 @@
{
"turns": 1,
"score": "10,000",
"errors": [],
"debugExposed": false,
"persistence": true,
"originalRecordRestored": true,
"legacyRecordPreserved": true,
"url": "http://localhost:8003/"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

@@ -0,0 +1,300 @@
{
"generatedAt": "2026-09-15T11:34:17.717Z",
"note": "固定种子算法抽样;随机策略从合法链均匀抽取,提示策略通过真实手势控制器。连消率不是玩家数据。",
"states": 3232,
"maxOverlap": 1.173909676008897,
"strategies": [
{
"strategy": "random",
"sessions": 24,
"gestures": 2400,
"comboGestures": 812,
"comboRate": 0.3383333333333333,
"reactions": 3572,
"flown": 94,
"breezes": 161,
"minPaths": 2,
"commitMs": {
"median": 8.465915999997378,
"p95": 16.694249999998647,
"max": 27.764500000000226
},
"runs": [
{
"seed": 937,
"score": 606600,
"peak": 10,
"flown": 3,
"comboGestures": 26,
"breezes": 5
},
{
"seed": 1874,
"score": 524300,
"peak": 10,
"flown": 3,
"comboGestures": 31,
"breezes": 7
},
{
"seed": 2811,
"score": 584800,
"peak": 10,
"flown": 4,
"comboGestures": 32,
"breezes": 9
},
{
"seed": 3748,
"score": 510100,
"peak": 10,
"flown": 3,
"comboGestures": 30,
"breezes": 5
},
{
"seed": 4685,
"score": 535500,
"peak": 10,
"flown": 4,
"comboGestures": 32,
"breezes": 5
},
{
"seed": 5622,
"score": 687600,
"peak": 10,
"flown": 4,
"comboGestures": 35,
"breezes": 8
},
{
"seed": 6559,
"score": 548700,
"peak": 10,
"flown": 5,
"comboGestures": 28,
"breezes": 9
},
{
"seed": 7496,
"score": 765100,
"peak": 10,
"flown": 4,
"comboGestures": 49,
"breezes": 8
},
{
"seed": 8433,
"score": 605900,
"peak": 10,
"flown": 3,
"comboGestures": 40,
"breezes": 5
},
{
"seed": 9370,
"score": 626500,
"peak": 10,
"flown": 5,
"comboGestures": 34,
"breezes": 2
},
{
"seed": 10307,
"score": 597300,
"peak": 10,
"flown": 3,
"comboGestures": 34,
"breezes": 7
},
{
"seed": 11244,
"score": 606400,
"peak": 10,
"flown": 4,
"comboGestures": 35,
"breezes": 11
},
{
"seed": 12181,
"score": 716600,
"peak": 10,
"flown": 5,
"comboGestures": 41,
"breezes": 5
},
{
"seed": 13118,
"score": 685200,
"peak": 10,
"flown": 4,
"comboGestures": 46,
"breezes": 9
},
{
"seed": 14055,
"score": 634000,
"peak": 10,
"flown": 4,
"comboGestures": 35,
"breezes": 3
},
{
"seed": 14992,
"score": 624700,
"peak": 10,
"flown": 4,
"comboGestures": 37,
"breezes": 7
},
{
"seed": 15929,
"score": 644100,
"peak": 10,
"flown": 5,
"comboGestures": 32,
"breezes": 6
},
{
"seed": 16866,
"score": 571300,
"peak": 10,
"flown": 4,
"comboGestures": 32,
"breezes": 4
},
{
"seed": 17803,
"score": 601300,
"peak": 10,
"flown": 5,
"comboGestures": 36,
"breezes": 9
},
{
"seed": 18740,
"score": 518400,
"peak": 10,
"flown": 4,
"comboGestures": 33,
"breezes": 8
},
{
"seed": 19677,
"score": 532500,
"peak": 10,
"flown": 3,
"comboGestures": 26,
"breezes": 4
},
{
"seed": 20614,
"score": 540900,
"peak": 10,
"flown": 4,
"comboGestures": 29,
"breezes": 4
},
{
"seed": 21551,
"score": 569800,
"peak": 10,
"flown": 3,
"comboGestures": 28,
"breezes": 10
},
{
"seed": 22488,
"score": 550700,
"peak": 10,
"flown": 4,
"comboGestures": 31,
"breezes": 11
}
]
},
{
"strategy": "guided",
"sessions": 8,
"gestures": 800,
"comboGestures": 499,
"comboRate": 0.62375,
"reactions": 1570,
"flown": 49,
"breezes": 46,
"minPaths": 2,
"commitMs": {
"median": 8.500040999999328,
"p95": 15.332957999999053,
"max": 30.179541999998037
},
"runs": [
{
"seed": 937,
"score": 968000,
"peak": 10,
"flown": 6,
"comboGestures": 62,
"breezes": 6
},
{
"seed": 1874,
"score": 837000,
"peak": 10,
"flown": 5,
"comboGestures": 54,
"breezes": 6
},
{
"seed": 2811,
"score": 899000,
"peak": 10,
"flown": 6,
"comboGestures": 56,
"breezes": 7
},
{
"seed": 3748,
"score": 1238900,
"peak": 10,
"flown": 7,
"comboGestures": 71,
"breezes": 3
},
{
"seed": 4685,
"score": 1138000,
"peak": 10,
"flown": 7,
"comboGestures": 63,
"breezes": 4
},
{
"seed": 5622,
"score": 952800,
"peak": 10,
"flown": 5,
"comboGestures": 62,
"breezes": 9
},
{
"seed": 6559,
"score": 909300,
"peak": 10,
"flown": 5,
"comboGestures": 61,
"breezes": 6
},
{
"seed": 7496,
"score": 1297900,
"peak": 10,
"flown": 8,
"comboGestures": 70,
"breezes": 5
}
]
}
]
}
@@ -0,0 +1,60 @@
{
"checks": [
"两颗松手不扣步",
"触摸取消不提交",
"回滑撤回"
],
"levels": [
{
"level": 1,
"moves": 1,
"score": 1200
},
{
"level": 2,
"moves": 1,
"score": 1200
},
{
"level": 3,
"moves": 1,
"score": 2000
},
{
"level": 4,
"moves": 2,
"score": 3500
},
{
"level": 5,
"moves": 4,
"score": 7700
},
{
"level": 6,
"moves": 4,
"score": 8800
},
{
"level": 7,
"moves": 4,
"score": 8700
},
{
"level": 8,
"moves": 6,
"score": 14200
},
{
"level": 9,
"moves": 8,
"score": 20500
},
{
"level": 10,
"moves": 10,
"score": 23400
}
],
"errors": []
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 299 KiB

+241
View File
@@ -0,0 +1,241 @@
{
"checks": [
"刷新保留十关成绩",
"撤销精确恢复盘面及补球",
"重走结果确定一致",
"打乱保留数字和步数",
"撤销恢复打乱库存",
"自由模式无步数限制",
"帮助可用 Escape 关闭"
],
"layouts": [
{
"width": 320,
"height": 568,
"overflow": false,
"bodyHeight": 650,
"buttons": [
{
"id": "sound",
"w": 44,
"h": 44
},
{
"id": "help",
"w": 44,
"h": 44
},
{
"id": "undo",
"w": 69.5,
"h": 56
},
{
"id": "hint",
"w": 69.5,
"h": 56
},
{
"id": "shuffle",
"w": 69.5,
"h": 56
},
{
"id": "restart",
"w": 69.5,
"h": 56
}
]
},
{
"width": 360,
"height": 740,
"overflow": false,
"bodyHeight": 740,
"buttons": [
{
"id": "sound",
"w": 44,
"h": 44
},
{
"id": "help",
"w": 44,
"h": 44
},
{
"id": "undo",
"w": 74.25,
"h": 56
},
{
"id": "hint",
"w": 74.25,
"h": 56
},
{
"id": "shuffle",
"w": 74.25,
"h": 56
},
{
"id": "restart",
"w": 74.25,
"h": 56
}
]
},
{
"width": 390,
"height": 844,
"overflow": false,
"bodyHeight": 844,
"buttons": [
{
"id": "sound",
"w": 44,
"h": 44
},
{
"id": "help",
"w": 44,
"h": 44
},
{
"id": "undo",
"w": 81.75,
"h": 62
},
{
"id": "hint",
"w": 81.75,
"h": 62
},
{
"id": "shuffle",
"w": 81.75,
"h": 62
},
{
"id": "restart",
"w": 81.75,
"h": 62
}
]
},
{
"width": 430,
"height": 932,
"overflow": false,
"bodyHeight": 932,
"buttons": [
{
"id": "sound",
"w": 44,
"h": 44
},
{
"id": "help",
"w": 44,
"h": 44
},
{
"id": "undo",
"w": 91.75,
"h": 62
},
{
"id": "hint",
"w": 91.75,
"h": 62
},
{
"id": "shuffle",
"w": 91.75,
"h": 62
},
{
"id": "restart",
"w": 91.75,
"h": 62
}
]
},
{
"width": 844,
"height": 390,
"overflow": false,
"bodyHeight": 690,
"buttons": [
{
"id": "sound",
"w": 44,
"h": 44
},
{
"id": "help",
"w": 44,
"h": 44
},
{
"id": "undo",
"w": 88.75,
"h": 56
},
{
"id": "hint",
"w": 88.75,
"h": 56
},
{
"id": "shuffle",
"w": 88.75,
"h": 56
},
{
"id": "restart",
"w": 88.75,
"h": 56
}
]
},
{
"width": 1280,
"height": 900,
"overflow": false,
"bodyHeight": 900,
"buttons": [
{
"id": "sound",
"w": 44,
"h": 44
},
{
"id": "help",
"w": 44,
"h": 44
},
{
"id": "undo",
"w": 88.75,
"h": 62
},
{
"id": "hint",
"w": 88.75,
"h": 62
},
{
"id": "shuffle",
"w": 88.75,
"h": 62
},
{
"id": "restart",
"w": 88.75,
"h": 62
}
]
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

@@ -0,0 +1,10 @@
{
"url": "http://localhost:8003",
"debugPortAbsent": true,
"touch": {
"status": "won",
"score": "1,200 分",
"moves": "2"
},
"resetToCleanLevel1": true
}
@@ -0,0 +1,883 @@
{
"generatedAt": "2026-09-15T09:43:46.893Z",
"levels": [
{
"id": 1,
"title": "初芽",
"seed": 1234,
"budget": 3,
"solutionMoves": 1,
"spareMoves": 2,
"score": 1200,
"openings": 18,
"goals": [
{
"text": "合成 1 颗「4」",
"current": 1,
"target": 1,
"complete": true
}
],
"moves": [
{
"ids": [
3,
11,
10
],
"numbers": [
3,
2,
1
],
"created": 4,
"score": 1200
}
]
},
{
"id": 2,
"title": "回转",
"seed": 2212,
"budget": 4,
"solutionMoves": 1,
"spareMoves": 3,
"score": 1200,
"openings": 14,
"goals": [
{
"text": "完成 1 次倒序合成",
"current": 1,
"target": 1,
"complete": true
}
],
"moves": [
{
"ids": [
3,
8,
1
],
"numbers": [
3,
2,
1
],
"created": 4,
"score": 1200
}
]
},
{
"id": 3,
"title": "伸展",
"seed": 3421,
"budget": 5,
"solutionMoves": 1,
"spareMoves": 4,
"score": 2000,
"openings": 28,
"goals": [
{
"text": "一次连起 4 颗",
"current": 4,
"target": 4,
"complete": true
}
],
"moves": [
{
"ids": [
1,
2,
7,
8
],
"numbers": [
1,
2,
3,
4
],
"created": 5,
"score": 2000
}
]
},
{
"id": 4,
"title": "双生",
"seed": 4517,
"budget": 6,
"solutionMoves": 2,
"spareMoves": 4,
"score": 3500,
"openings": 38,
"goals": [
{
"text": "合成 2 颗「5」",
"current": 2,
"target": 2,
"complete": true
}
],
"moves": [
{
"ids": [
4,
9,
5,
8
],
"numbers": [
4,
3,
2,
1
],
"created": 5,
"score": 2000
},
{
"ids": [
2,
3,
7
],
"numbers": [
2,
3,
4
],
"created": 5,
"score": 3500
}
]
},
{
"id": 5,
"title": "丰收",
"seed": 5603,
"budget": 7,
"solutionMoves": 4,
"spareMoves": 3,
"score": 7700,
"openings": 4,
"goals": [
{
"text": "获得 6,500 分",
"current": 7700,
"target": 6500,
"complete": true
}
],
"moves": [
{
"ids": [
1,
2,
10
],
"numbers": [
1,
2,
3
],
"created": 4,
"score": 1200
},
{
"ids": [
11,
7,
18,
15
],
"numbers": [
4,
3,
2,
1
],
"created": 5,
"score": 3200
},
{
"ids": [
12,
14,
19,
20,
15
],
"numbers": [
1,
2,
3,
4,
5
],
"created": 6,
"score": 6200
},
{
"ids": [
8,
13,
9
],
"numbers": [
4,
3,
2
],
"created": 5,
"score": 7700
}
]
},
{
"id": 6,
"title": "抽叶",
"seed": 6723,
"budget": 8,
"solutionMoves": 4,
"spareMoves": 4,
"score": 8800,
"openings": 40,
"goals": [
{
"text": "合成 2 颗「6」",
"current": 2,
"target": 2,
"complete": true
},
{
"text": "一次连起 4 颗",
"current": 4,
"target": 4,
"complete": true
}
],
"moves": [
{
"ids": [
1,
9,
6,
4
],
"numbers": [
1,
2,
3,
4
],
"created": 5,
"score": 2000
},
{
"ids": [
2,
3,
7,
4
],
"numbers": [
2,
3,
4,
5
],
"created": 6,
"score": 4400
},
{
"ids": [
15,
17,
22,
20
],
"numbers": [
4,
3,
2,
1
],
"created": 5,
"score": 6400
},
{
"ids": [
18,
21,
11,
20
],
"numbers": [
2,
3,
4,
5
],
"created": 6,
"score": 8800
}
]
},
{
"id": 7,
"title": "繁枝",
"seed": 7841,
"budget": 9,
"solutionMoves": 4,
"spareMoves": 5,
"score": 8700,
"openings": 12,
"goals": [
{
"text": "合成 1 颗「7」",
"current": 1,
"target": 1,
"complete": true
}
],
"moves": [
{
"ids": [
4,
3,
2
],
"numbers": [
4,
3,
2
],
"created": 5,
"score": 1500
},
{
"ids": [
17,
9,
11,
2
],
"numbers": [
2,
3,
4,
5
],
"created": 6,
"score": 3900
},
{
"ids": [
7,
15,
19,
8
],
"numbers": [
4,
3,
2,
1
],
"created": 5,
"score": 5900
},
{
"ids": [
12,
21,
8,
2
],
"numbers": [
3,
4,
5,
6
],
"created": 7,
"score": 8700
}
]
},
{
"id": 8,
"title": "花径",
"seed": 8917,
"budget": 10,
"solutionMoves": 6,
"spareMoves": 4,
"score": 14200,
"openings": 18,
"goals": [
{
"text": "一次连起 5 颗",
"current": 5,
"target": 5,
"complete": true
},
{
"text": "获得 14,000 分",
"current": 14200,
"target": 14000,
"complete": true
}
],
"moves": [
{
"ids": [
4,
3,
5,
12
],
"numbers": [
4,
3,
2,
1
],
"created": 5,
"score": 2000
},
{
"ids": [
16,
17,
13,
11,
12
],
"numbers": [
1,
2,
3,
4,
5
],
"created": 6,
"score": 5000
},
{
"ids": [
15,
10,
23,
25
],
"numbers": [
4,
3,
2,
1
],
"created": 5,
"score": 7000
},
{
"ids": [
20,
21,
7,
25
],
"numbers": [
2,
3,
4,
5
],
"created": 6,
"score": 9400
},
{
"ids": [
19,
9,
18,
22
],
"numbers": [
1,
2,
3,
4
],
"created": 5,
"score": 11400
},
{
"ids": [
12,
22,
32,
31
],
"numbers": [
6,
5,
4,
3
],
"created": 7,
"score": 14200
}
]
},
{
"id": 9,
"title": "并蒂",
"seed": 9123,
"budget": 12,
"solutionMoves": 8,
"spareMoves": 4,
"score": 20500,
"openings": 16,
"goals": [
{
"text": "合成 2 颗「7」",
"current": 2,
"target": 2,
"complete": true
}
],
"moves": [
{
"ids": [
1,
2,
3
],
"numbers": [
2,
3,
4
],
"created": 5,
"score": 1500
},
{
"ids": [
3,
10,
6,
8,
17
],
"numbers": [
5,
4,
3,
2,
1
],
"created": 6,
"score": 4500
},
{
"ids": [
4,
12,
13,
7
],
"numbers": [
1,
2,
3,
4
],
"created": 5,
"score": 6500
},
{
"ids": [
22,
15,
9,
14,
7,
17
],
"numbers": [
1,
2,
3,
4,
5,
6
],
"created": 7,
"score": 10700
},
{
"ids": [
21,
16,
20,
27
],
"numbers": [
4,
3,
2,
1
],
"created": 5,
"score": 12700
},
{
"ids": [
11,
25,
19,
24,
27
],
"numbers": [
1,
2,
3,
4,
5
],
"created": 6,
"score": 15700
},
{
"ids": [
33,
29,
23,
28
],
"numbers": [
1,
2,
3,
4
],
"created": 5,
"score": 17700
},
{
"ids": [
30,
39,
28,
27
],
"numbers": [
3,
4,
5,
6
],
"created": 7,
"score": 20500
}
]
},
{
"id": 10,
"title": "满园",
"seed": 10429,
"budget": 14,
"solutionMoves": 10,
"spareMoves": 4,
"score": 23400,
"openings": 16,
"goals": [
{
"text": "合成 1 颗「8」",
"current": 1,
"target": 1,
"complete": true
},
{
"text": "获得 22,000 分",
"current": 23400,
"target": 22000,
"complete": true
}
],
"moves": [
{
"ids": [
1,
6,
3
],
"numbers": [
2,
3,
4
],
"created": 5,
"score": 1500
},
{
"ids": [
3,
7,
9,
5,
4
],
"numbers": [
5,
4,
3,
2,
1
],
"created": 6,
"score": 4500
},
{
"ids": [
10,
20,
19,
23
],
"numbers": [
4,
3,
2,
1
],
"created": 5,
"score": 6500
},
{
"ids": [
8,
13,
18,
23,
4
],
"numbers": [
2,
3,
4,
5,
6
],
"created": 7,
"score": 10000
},
{
"ids": [
14,
30,
31,
29
],
"numbers": [
4,
3,
2,
1
],
"created": 5,
"score": 12000
},
{
"ids": [
22,
16,
25,
29
],
"numbers": [
2,
3,
4,
5
],
"created": 6,
"score": 14400
},
{
"ids": [
27,
24,
33
],
"numbers": [
2,
3,
4
],
"created": 5,
"score": 15900
},
{
"ids": [
4,
29,
33,
37,
32
],
"numbers": [
7,
6,
5,
4,
3
],
"created": 8,
"score": 19900
},
{
"ids": [
28,
2,
15,
35
],
"numbers": [
4,
3,
2,
1
],
"created": 5,
"score": 21900
},
{
"ids": [
39,
38,
40
],
"numbers": [
2,
3,
4
],
"created": 5,
"score": 23400
}
]
}
],
"physics": {
"checkedStates": 1225,
"randomMerges": 1046,
"longestRun": 18,
"maxOverlap": 0.7719946571965153
}
}
+744
View File
@@ -0,0 +1,744 @@
{
"version": 1,
"title": "从一颗种子开始",
"levels": [
{
"id": 1,
"title": "初芽",
"subtitle": "每一次相连,都是新的开始",
"lesson": "按住 1,滑过 2、3,再松手",
"difficulty": 1,
"seed": 1234,
"moves": 3,
"par": 1,
"count": 16,
"initial": [
1,
2,
3,
1,
2,
3,
1,
2,
3,
1,
2,
3
],
"refill": [
1,
2,
3,
2,
1,
3
],
"goals": [
{
"kind": "number",
"value": 4
}
],
"solution": [
[
3,
11,
10
]
]
},
{
"id": 2,
"title": "回转",
"subtitle": "换个方向,也能遇见生长",
"lesson": "试试 3 → 2 → 1,最后一颗是新球的落点",
"difficulty": 1,
"seed": 2212,
"moves": 4,
"par": 1,
"count": 14,
"initial": [
1,
2,
3,
4,
2,
1,
3,
2,
4,
1,
3,
2,
1,
3
],
"refill": [
2,
1,
3,
2,
4,
1
],
"goals": [
{
"kind": "reverse",
"value": 1
}
],
"solution": [
[
3,
8,
1
]
]
},
{
"id": 3,
"title": "伸展",
"subtitle": "把小小的连线,再拉长一点",
"lesson": "1 → 2 → 3 → 4,一次连起四颗",
"difficulty": 1,
"seed": 3421,
"moves": 5,
"par": 1,
"count": 15,
"initial": [
1,
2,
3,
4,
1,
2,
3,
4,
1,
2,
3,
4,
2,
3,
1
],
"refill": [
1,
2,
3,
4,
2,
1,
3
],
"goals": [
{
"kind": "chain",
"value": 4
}
],
"solution": [
[
1,
2,
7,
8
]
]
},
{
"id": 4,
"title": "双生",
"subtitle": "让两颗金色的心,接连发芽",
"lesson": "2、3、4 合成 5;记得为下一条链留位置",
"difficulty": 2,
"seed": 4517,
"moves": 6,
"par": 2,
"count": 16,
"initial": [
1,
2,
3,
4,
2,
3,
4,
1,
3,
2,
4,
1,
2,
3,
2,
1
],
"refill": [
2,
3,
1,
4,
2,
3,
1,
2
],
"goals": [
{
"kind": "number",
"value": 5,
"count": 2
}
],
"solution": [
[
4,
9,
5,
8
],
[
2,
3,
7
]
]
},
{
"id": 5,
"title": "丰收",
"subtitle": "每一颗,都有自己的分量",
"lesson": "分数 = 新数字 × 连线颗数 × 100",
"difficulty": 2,
"seed": 5603,
"moves": 7,
"par": 4,
"count": 16,
"initial": [
1,
2,
3,
4,
1,
2,
3,
4,
2,
3,
4,
1,
3,
2,
1,
4
],
"refill": [
1,
2,
3,
4,
2,
3,
1,
2,
4
],
"goals": [
{
"kind": "score",
"value": 6500
}
],
"solution": [
[
1,
2,
10
],
[
11,
7,
18,
15
],
[
12,
14,
19,
20,
15
],
[
8,
13,
9
]
]
},
{
"id": 6,
"title": "抽叶",
"subtitle": "长长的连线,长出新的叶子",
"lesson": "先造 5,再把 3、4、5 连成 6",
"difficulty": 3,
"seed": 6723,
"moves": 8,
"par": 4,
"count": 16,
"initial": [
1,
2,
3,
4,
2,
3,
4,
1,
2,
3,
4,
2,
3,
1,
4,
2
],
"refill": [
3,
2,
4,
1,
3,
2,
4,
1
],
"goals": [
{
"kind": "number",
"value": 6,
"count": 2
},
{
"kind": "chain",
"value": 4
}
],
"solution": [
[
1,
9,
6,
4
],
[
2,
3,
7,
4
],
[
15,
17,
22,
20
],
[
18,
21,
11,
20
]
]
},
{
"id": 7,
"title": "繁枝",
"subtitle": "给更大的生长,留一点空间",
"lesson": "把新球留在 4、5 附近,筹备下一次合成",
"difficulty": 3,
"seed": 7841,
"moves": 9,
"par": 4,
"count": 18,
"initial": [
1,
2,
3,
4,
2,
3,
4,
1,
3,
2,
4,
3,
1,
2,
3,
4,
2,
3
],
"refill": [
2,
3,
4,
1,
2,
3,
4,
2,
1,
3
],
"goals": [
{
"kind": "number",
"value": 7
}
],
"solution": [
[
4,
3,
2
],
[
17,
9,
11,
2
],
[
7,
15,
19,
8
],
[
12,
21,
8,
2
]
]
},
{
"id": 8,
"title": "花径",
"subtitle": "绕一个弯,连起整片风景",
"lesson": "不必走直线;试着连起五颗连续数字",
"difficulty": 4,
"seed": 8917,
"moves": 10,
"par": 6,
"count": 18,
"initial": [
1,
2,
3,
4,
2,
3,
4,
1,
2,
3,
4,
1,
3,
2,
4,
1,
2,
3
],
"refill": [
1,
2,
3,
4,
2,
3,
1,
4,
2,
3
],
"goals": [
{
"kind": "chain",
"value": 5
},
{
"kind": "score",
"value": 14000
}
],
"solution": [
[
4,
3,
5,
12
],
[
16,
17,
13,
11,
12
],
[
15,
10,
23,
25
],
[
20,
21,
7,
25
],
[
19,
9,
18,
22
],
[
12,
22,
32,
31
]
]
},
{
"id": 9,
"title": "并蒂",
"subtitle": "一次盛开之后,再来一次",
"lesson": "规划两次造 7 的材料;大球会挤走小球",
"difficulty": 4,
"seed": 9123,
"moves": 12,
"par": 8,
"count": 18,
"initial": [
2,
3,
4,
1,
2,
3,
4,
2,
3,
4,
1,
2,
3,
4,
2,
3,
1,
4
],
"refill": [
3,
2,
4,
1,
3,
4,
2,
3,
1,
4,
2
],
"goals": [
{
"kind": "number",
"value": 7,
"count": 2
}
],
"solution": [
[
1,
2,
3
],
[
3,
10,
6,
8,
17
],
[
4,
12,
13,
7
],
[
22,
15,
9,
14,
7,
17
],
[
21,
16,
20,
27
],
[
11,
25,
19,
24,
27
],
[
33,
29,
23,
28
],
[
30,
39,
28,
27
]
]
},
{
"id": 10,
"title": "满园",
"subtitle": "从 1 出发,把花园连成 8",
"lesson": "综合运用长链和落点,迎接第一颗 8",
"difficulty": 5,
"seed": 10429,
"moves": 14,
"par": 10,
"count": 18,
"initial": [
2,
3,
4,
1,
2,
3,
4,
2,
3,
4,
1,
2,
3,
4,
2,
3,
1,
4
],
"refill": [
2,
3,
4,
2,
1,
3,
4,
3,
2,
4,
1,
3
],
"goals": [
{
"kind": "number",
"value": 8
},
{
"kind": "score",
"value": 22000
}
],
"solution": [
[
1,
6,
3
],
[
3,
7,
9,
5,
4
],
[
10,
20,
19,
23
],
[
8,
13,
18,
23,
4
],
[
14,
30,
31,
29
],
[
22,
16,
25,
29
],
[
27,
24,
33
],
[
4,
29,
33,
37,
32
],
[
28,
2,
15,
35
],
[
39,
38,
40
]
]
}
]
}
+12
View File
@@ -0,0 +1,12 @@
{
"mode": "02-connect-1234",
"notes": "自由生长模式的掉落权重,以及通用半径、邻接参数。挑战关使用 campaign.json 的固定初始数列和补球队列。自由模式开局至少一条合法链,否则重掷。",
"startCount": 16,
"minCount": 12,
"maxCount": 16,
"weights": { "1": 40, "2": 30, "3": 20, "4": 10 },
"weightsWhenBigPresent": { "1": 45, "2": 30, "3": 20, "4": 5 },
"bigThreshold": 7,
"connectSlack": 1.35,
"radius": { "base": 22, "perLevel": 6 }
}
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
{
"name": "connect-1234",
"version": "2.0.0",
"private": true,
"type": "module",
"description": "连连气球 · 连消合成畅玩版,纯 TypeScript 核心,可迁移 Cocos Creator",
"scripts": {
"dev": "node scripts/build.mjs --serve",
"build": "node scripts/build.mjs",
"typecheck": "tsc -p tsconfig.json && tsc -p tsconfig.core.json",
"test": "node --import tsx --test tests/*.test.ts",
"validate": "node --import tsx tools/validate-flow.ts",
"check": "npm run typecheck && npm run test && npm run validate && npm run build",
"cocos:export": "node scripts/export-cocos.mjs",
"cocos:check": "npm run cocos:export && node scripts/check-cocos.mjs",
"validate:legacy": "node --import tsx tools/validate.ts"
},
"devDependencies": {
"@types/node": "^22.7.0",
"esbuild": "^0.24.0",
"tsx": "^4.19.0",
"typescript": "^5.6.0"
}
}
+27
View File
@@ -0,0 +1,27 @@
import * as esbuild from 'esbuild';
import { networkInterfaces } from 'node:os';
import { mkdir, copyFile } from 'node:fs/promises';
const args = process.argv.slice(2);
const serve = args.includes('--serve');
const index = args.indexOf('--port');
const port = index >= 0 ? Number(args[index + 1]) : Number(process.env.PORT || 8002);
const ctx = await esbuild.context({
entryPoints: ['web/main.ts'], bundle: true, format: 'iife', target: 'es2020',
outfile: serve ? 'web/dist/app.js' : 'release/dist/app.js', minify: !serve, sourcemap: serve, logLevel: 'info',
define: { __DEV__: JSON.stringify(serve) },
});
if (serve) {
await ctx.watch();
const result = await ctx.serve({ host: '0.0.0.0', port, servedir: 'web' });
console.log(`\n连连气球 http://localhost:${result.port}`);
for (const item of Object.values(networkInterfaces()).flat()) {
if (item?.family === 'IPv4' && !item.internal) console.log(`手机同一 Wi-Fi: http://${item.address}:${result.port}`);
}
} else {
await mkdir('release', { recursive: true });
for (const file of ['index.html', 'style.css']) await copyFile(`web/${file}`, `release/${file}`);
await ctx.rebuild();
await ctx.dispose();
console.log('离线 / 静态发布目录:release/');
}
@@ -0,0 +1,22 @@
import { access, readdir, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { spawnSync } from 'node:child_process';
let declarations = process.argv[2] || process.env.COCOS_TYPES;
if (!declarations) {
const root = '/Applications/Cocos/Creator';
try {
for (const version of (await readdir(root)).sort((a, b) => b.localeCompare(a, undefined, { numeric: true }))) {
const candidate = `${root}/${version}/CocosCreator.app/Contents/Resources/resources/3d/engine/bin/.declarations/cc.d.ts`;
try { await access(candidate); declarations = candidate; break; } catch {}
}
} catch {}
}
if (!declarations) throw new Error('请用 COCOS_TYPES 或命令参数提供 Creator 的 cc.d.ts 路径。');
const target = resolve('cocos/export/02-connect-1234');
const config = resolve('cocos/export/tsconfig.check.json');
await writeFile(config, JSON.stringify({ compilerOptions: { target: 'ES2020', module: 'ESNext', moduleResolution: 'Bundler',
lib: ['ES2020', 'DOM'], strict: true, experimentalDecorators: true, useDefineForClassFields: false,
noEmit: true, skipLibCheck: true, types: [], baseUrl: '.', paths: { cc: [resolve(declarations)] } }, include: [target] }, null, 2));
console.log(`检查真实 Creator 类型:${declarations}`);
const result = spawnSync(process.execPath, ['node_modules/typescript/bin/tsc', '-p', config], { stdio: 'inherit' });
process.exit(result.status ?? 1);
@@ -0,0 +1,20 @@
import { mkdir, rm, readFile, writeFile, copyFile } from 'node:fs/promises';
import { resolve, join } from 'node:path';
const target = resolve(process.argv[2] || 'cocos/export/02-connect-1234');
// 默认目录是可再生构建产物,清掉 v1 遗留;自定义导出目录不删除用户文件。
if (!process.argv[2]) await rm(target, {recursive:true, force:true});
for (const dir of ['src', 'levels', 'adapters']) await mkdir(join(target, dir), { recursive: true });
const active = ['flow.ts', 'flow-rules.ts', 'cascade.ts', 'supply.ts', 'flow-animation.ts', 'record.ts', 'input.ts', 'rules.ts', 'types.ts', 'config.ts', 'physics.ts', 'random.ts', 'view.ts', 'storage.ts'];
for (const name of active) {
if (!name.endsWith('.ts')) continue;
let source = await readFile(join('src', name), 'utf8');
// Creator 不必依赖 bundler 的 JSON module 支持,导出时生成等价 TypeScript 数据模块。
source = source.replace("'../levels/spawn.json'", "'../levels/spawn-data'").replace("'../levels/campaign.json'", "'../levels/campaign-data'");
await writeFile(join(target, 'src', name), source);
}
for (const name of ['spawn']) {
const json = await readFile(`levels/${name}.json`, 'utf8');
await writeFile(join(target, 'levels', `${name}-data.ts`), `// 由 export-cocos.mjs 生成;在原始 levels/${name}.json 编辑。\nexport default ${json.trim()};\n`);
}
await copyFile('cocos/Connect1234Board.ts', join(target, 'adapters/Connect1234Board.ts'));
console.log(`Cocos 迁移包已导出:${target}`);
+32
View File
@@ -0,0 +1,32 @@
import { step } from './physics';
import type { Ball, MergeResult } from './types';
/** Web 和 Cocos 共用动画采样;时间由宿主传入,单位 ms。 */
export class MergeAnimation {
private frame = 0;
private balls: Ball[];
constructor(public readonly result: MergeResult, private readonly start: number) {
this.balls = result.after.map(b => ({ ...b }));
}
sample(now: number): Ball[] | null {
const elapsed = Math.max(0, now - this.start);
if (elapsed >= 760) return null;
if (elapsed < 220) {
const t = 1 - Math.pow(1 - elapsed / 220, 3), chain = this.result.path;
const others = this.result.before.filter(b => !chain.some(c => c.id === b.id));
return [...others, ...chain.map((b, i) => {
const position = i + (chain.length - 1 - i) * t, j = Math.floor(position), part = position - j;
const a = chain[j], z = chain[Math.min(j + 1, chain.length - 1)];
return { ...b, x: a.x + (z.x - a.x) * part, y: a.y + (z.y - a.y) * part,
r: b.r * (1 - (i === chain.length - 1 ? 0 : t * .65)) };
})];
}
const target = Math.min(232, Math.floor((elapsed - 220) / 540 * 232));
while (this.frame < target) {
step(this.balls, this.frame < 200 ? 1 / 60 : 0, this.frame >= 200);
if (this.frame === 199) for (const b of this.balls) { b.vx = 0; b.vy = 0; }
this.frame++;
}
return this.balls;
}
}
+46
View File
@@ -0,0 +1,46 @@
import { adjacent, points, resultNumber } from './rules';
import { legalFlow } from './flow-rules';
import type { Ball } from './types';
export const balloonRadius = (n: number): number => 24 + Math.min(9, n - 1) * 3.2;
export const copyBalls = (balls: Ball[]): Ball[] => balls.map(b => ({ ...b }));
export interface Reaction { before: Ball[]; path: Ball[]; created: Ball; points: number; index: number }
export interface Cascade { stages: Reaction[]; balls: Ball[]; score: number; popped: number; flight: Ball | null }
/** 自动接上 n−2 → n−1 → 新球;新球一直留在玩家选择的链尾。 */
export function continuation(balls: Ball[], head: Ball): number[] | null {
const seconds = balls.filter(b => b.n === head.n - 1 && adjacent(b, head))
.sort((a, b) => Math.hypot(a.x - head.x, a.y - head.y) - Math.hypot(b.x - head.x, b.y - head.y));
for (const second of seconds) {
const first = balls.find(b => b.n === head.n - 2 && adjacent(b, second));
if (first) return [first.id, second.id, head.id];
}
const same = balls.filter(b => b.id !== head.id && b.n === head.n);
for (const second of same.filter(b => adjacent(b, head))) {
const first = same.find(b => b.id !== second.id && adjacent(b, second));
if (first) return [first.id, second.id, head.id];
}
return null;
}
export function cascade(source: Ball[], ids: number[], fever = false, offset = 0): Cascade | null {
let balls = copyBalls(source), next: number[] | null = ids;
const initial = ids.map(id => balls.find(b => b.id === id));
if (initial.some(b => !b) || !legalFlow(initial as Ball[])) return null;
const stages: Reaction[] = [];
let score = 0, popped = 0, flight: Ball | null = null;
while (next && stages.length + offset < (fever ? 4 : 3)) {
const path = next.map(id => balls.find(b => b.id === id)!);
const tail = path[path.length - 1], n = resultNumber(path), index = stages.length + offset + 1;
const created = { ...tail, n, r: balloonRadius(n), vx: 0, vy: 0 };
const gain = points(path) * index * (fever ? 2 : 1) + (n >= 10 ? 10000 : 0);
stages.push({ before: copyBalls(balls), path: copyBalls(path), created: { ...created }, points: gain, index });
score += gain; popped += path.length;
const removed = new Set(next);
balls = balls.filter(b => !removed.has(b.id));
if (n >= 10) { flight = created; break; }
balls.push(created);
next = continuation(balls, created);
}
return { stages, balls, score, popped, flight };
}
+18
View File
@@ -0,0 +1,18 @@
import spawn from '../levels/spawn.json';
export const SPAWN = spawn;
export const WIDTH = 360;
export const HEIGHT = 416;
export const SLACK = spawn.connectSlack;
export const radius = (n: number): number => Math.min(82, spawn.radius.base + spawn.radius.perLevel * (n - 1));
export const PALETTE = [
{ fill: '#74cfae', ink: '#2d8e71', edge: '#5abc9a' },
{ fill: '#ff8a9b', ink: '#c94e72', edge: '#ec768e' },
{ fill: '#79bafa', ink: '#3e88d0', edge: '#62a7eb' },
{ fill: '#f9d16c', ink: '#c69738', edge: '#ecc059' },
{ fill: '#b29aef', ink: '#8062bb', edge: '#a184e0' },
{ fill: '#ffac79', ink: '#d67f45', edge: '#f29a67' },
{ fill: '#77d3e1', ink: '#3ba2ba', edge: '#62c3d5' },
{ fill: '#ec94cd', ink: '#ba60a0', edge: '#dc7ebe' },
{ fill: '#94a6fa', ink: '#667bce', edge: '#8095ed' },
{ fill: '#ffbb62', ink: '#d8943c', edge: '#f5a94d' },
];
@@ -0,0 +1,50 @@
import { copyBalls, type Reaction } from './cascade';
import { HEIGHT } from './config';
import type { FlowResult } from './flow';
import { step } from './physics';
import type { Ball } from './types';
/** 合成→下落→接力→再下落的有序时间线。Web / Cocos 共用。 */
export class FlowAnimation {
private frame = 0;
private beat = -1;
private falling: Ball[] = [];
reaction: Reaction | null = null;
activePath: number[] = [];
constructor(readonly result: FlowResult, private start: number) {}
private durationAt(index: number): number { return this.result.beats[index].kind === 'merge' ? 155 : this.result.kind === 'breeze' ? 390 : 215; }
get duration(): number { return this.result.beats.reduce((sum, _, i) => sum + this.durationAt(i), 0); }
sample(now: number): Ball[] | null {
let elapsed = Math.max(0, now - this.start), index = 0;
while (index < this.result.beats.length && elapsed >= this.durationAt(index)) elapsed -= this.durationAt(index++);
if (index >= this.result.beats.length) { this.activePath = []; this.reaction = null; return null; }
const event = this.result.beats[index];
if (index !== this.beat) {
this.beat = index; this.frame = 0;
if (event.kind === 'fall') this.falling = copyBalls(event.supply.falling);
}
const t = elapsed / this.durationAt(index);
if (event.kind === 'merge') {
const stage = event.stage, chain = stage.path, eased = 1 - (1 - t) ** 3;
this.reaction = stage; this.activePath = chain.map(b => b.id);
const others = stage.before.filter(b => !this.activePath.includes(b.id));
const ghosts = chain.slice(0, -1).map((b, i) => {
const p = i + (chain.length - 1 - i) * eased, a = Math.floor(p), fraction = p - a;
const first = chain[a], last = chain[Math.min(a + 1, chain.length - 1)];
return { ...b, x: first.x + (last.x - first.x) * fraction, y: first.y + (last.y - first.y) * fraction, r: b.r * (1 - eased * .96) };
});
const fresh = t > .48 ? { ...stage.created, r: stage.created.r * (1 + Math.sin((t - .48) / .52 * Math.PI) * .12) } : chain[chain.length - 1];
return [...others, ...ghosts, fresh];
}
this.reaction = null; this.activePath = [];
const target = Math.floor(t * (event.supply.frames + event.supply.relaxFrames));
while (this.frame < target) {
const base = event.supply.frames;
step(this.falling, this.frame < base ? 1 / 60 : 0, this.frame >= base);
if (this.frame === base - 1) for (const b of this.falling) { b.vx = 0; b.vy = 0; }
this.frame++;
}
if (event.flight) return [...this.falling, { ...event.flight, y: event.flight.y - t * t * HEIGHT * 1.8, x: event.flight.x + Math.sin(t * 5) * 12 }];
return this.falling;
}
}
+27
View File
@@ -0,0 +1,27 @@
import type { Ball } from './types';
import { adjacent, canAppend } from './rules';
/** 畅连版允许连续数列,或整条同号。两种不能在一条手势中混用。 */
export function canFlowAppend(chain: Ball[], ball: Ball): boolean {
if (!chain.length) return true;
if (chain.some(b => b.id === ball.id)) return false;
const last = chain[chain.length - 1];
if (last.n === ball.n && chain.every(b => b.n === ball.n)) return adjacent(last, ball);
return canAppend(chain, ball);
}
export function legalFlow(chain: Ball[]): boolean {
return chain.length >= 3 && chain.every((b, i) => !i || canFlowAppend(chain.slice(0, i), b));
}
export function findFlowChains(balls: Ball[], limit = 240): number[][] {
const result: number[][] = [], perHead = Math.max(4, Math.floor(limit / Math.max(1, balls.length)));
for (const head of balls) {
let count = 0;
function walk(chain: Ball[]) {
if (count >= perHead || result.length >= limit) return;
if (chain.length >= 3) { result.push(chain.map(b => b.id)); count++; }
for (const b of balls) if (canFlowAppend(chain, b)) walk([...chain, b]);
}
walk([head]);
}
return result;
}
+106
View File
@@ -0,0 +1,106 @@
import { cascade, copyBalls, continuation, type Cascade, type Reaction } from './cascade';
import { findFlowChains, canFlowAppend } from './flow-rules';
import { Random } from './random';
import { fillQueue, supply, type Supply } from './supply';
import { Gesture } from './input';
import type { Ball } from './types';
export const FEVER_TARGET = 10;
export interface FlowStats { score: number; turns: number; reactions: number; maxNumber: number; bestCombo: number; lastCombo: number; popped: number; flown: number }
export interface FlowSnapshot { balls: Ball[]; stats: FlowStats; rng: number; nextId: number; queue: number[]; feverCharge: number; feverLeft: number; breezes: number }
export type FlowBeat = { kind: 'merge'; stage: Reaction } | { kind: 'fall'; supply: Supply; flight: Ball | null };
export interface FlowResult extends Cascade { supply: Supply; beats: FlowBeat[]; feverStarted: boolean; doubled: boolean; kind: 'merge' | 'breeze' }
/** 当前主玩法:无关卡、无倒计时、自动接力合成。全部状态可序列化。 */
export class FlowGame {
balls: Ball[] = [];
stats: FlowStats = { score: 0, turns: 0, reactions: 0, maxNumber: 4, bestCombo: 0, lastCombo: 0, popped: 0, flown: 0 };
queue: number[] = [];
feverCharge = 0;
feverLeft = 0;
breezes = 0;
private random: Random;
private nextId = 1;
private undos: FlowSnapshot[] = [];
constructor(public readonly seed = 1234) {
this.random = new Random(seed);
this.queue = [1, 2, 3, 2, 3, 4, 1, 2, 3, 1, 2, 3, 2, 3, 4, 1, 2, 3, 1, 2, 3, 2, 3, 4];
const opening = supply([], this.queue, this.nextId, this.random);
this.balls = opening.balls; this.nextId = opening.nextId;
fillQueue(this.queue, 3, this.balls, this.stats.maxNumber, this.random);
}
get canUndo(): boolean { return this.undos.length > 0; }
get fever(): boolean { return this.feverLeft > 0; }
snapshot(): FlowSnapshot {
return { balls: copyBalls(this.balls), stats: { ...this.stats }, rng: this.random.state, nextId: this.nextId,
queue: [...this.queue], feverCharge: this.feverCharge, feverLeft: this.feverLeft, breezes: this.breezes };
}
restore(s: FlowSnapshot): void {
this.balls = copyBalls(s.balls); this.stats = { ...s.stats }; this.random.state = s.rng;
this.nextId = s.nextId; this.queue = [...s.queue]; this.feverCharge = s.feverCharge; this.feverLeft = s.feverLeft; this.breezes = s.breezes;
}
preview(ids: number[]): Cascade | null { return cascade(this.balls, ids, this.fever); }
commit(ids: number[]): FlowResult | null {
const result = this.preview(ids); if (!result) return null;
this.remember();
const doubled = this.fever;
const beats: FlowBeat[] = result.stages.map(stage => ({ kind: 'merge', stage }));
let dropped!: Supply;
for (let pass = 0; pass < 4; pass++) {
const head = result.stages[result.stages.length - 1].created;
const peak = Math.max(this.stats.maxNumber, head.n);
fillQueue(this.queue, 24, result.balls, peak, this.random);
dropped = supply(result.balls, this.queue, this.nextId, this.random, head.x, false, result.flight ? undefined : head.id);
this.nextId = dropped.nextId;
if (dropped.breeze) this.breezes++;
beats.push({ kind: 'fall', supply: dropped, flight: result.flight });
const settledHead = dropped.balls.find(b => b.id === head.id);
if (result.flight || result.stages.length >= (doubled ? 4 : 3) || !settledHead) break;
const next = continuation(dropped.balls, settledHead);
if (!next) break;
const extra = cascade(dropped.balls, next, doubled, result.stages.length)!;
result.stages.push(...extra.stages); result.score += extra.score; result.popped += extra.popped;
result.balls = extra.balls; result.flight = extra.flight;
beats.push(...extra.stages.map(stage => ({ kind: 'merge' as const, stage })));
}
this.stats.turns++; this.stats.reactions += result.stages.length; this.stats.score += result.score;
this.stats.lastCombo = result.stages.length; this.stats.bestCombo = Math.max(this.stats.bestCombo, result.stages.length);
this.stats.maxNumber = Math.max(this.stats.maxNumber, ...result.stages.map(s => s.created.n));
this.stats.popped += result.popped; if (result.flight) this.stats.flown++;
let feverStarted = false;
if (doubled) this.feverLeft--;
else {
this.feverCharge += Math.max(1, ids.length - 2) + result.stages.length - 1;
if (this.feverCharge >= FEVER_TARGET) { this.feverCharge = 0; this.feverLeft = 5; feverStarted = true; }
}
// 只保留玩家看过的下一袋;后续补给用更新后的盘面重新配号。
this.queue = this.queue.slice(0, 3);
fillQueue(this.queue, 3, dropped.balls, this.stats.maxNumber, this.random);
this.balls = dropped.balls; this.nextId = dropped.nextId;
return { ...result, supply: dropped, beats, feverStarted, doubled, kind: 'merge' };
}
undo(): boolean { const s = this.undos.pop(); if (!s) return false; this.restore(s); return true; }
shuffle(): FlowResult {
this.remember();
const dropped = supply(this.balls, [], this.nextId, this.random, 180, true);
this.balls = dropped.balls; this.nextId = dropped.nextId; this.breezes++;
return { stages: [], balls: this.balls, score: 0, popped: 0, flight: null, supply: dropped,
beats: [{ kind: 'fall', supply: dropped, flight: null }], feverStarted: false, doubled: false, kind: 'breeze' };
}
hint(): number[] {
const paths = findFlowChains(this.balls, 220);
let best: number[] = [], bestScore = -1;
const gesture = new Gesture(() => this.balls, canFlowAppend);
for (const ids of paths) {
// 提示只采用可直接沿球心滑出的路线,避免中途误经过别的球。
gesture.down(this.balls.find(b => b.id === ids[0])!);
for (const id of ids.slice(1)) gesture.move(this.balls.find(b => b.id === id)!);
if (gesture.chain.join(',') !== ids.join(',')) continue;
const result = this.preview(ids)!;
const value = result.stages.length * 300 + ids.length * 45 + result.score / 100 + (result.flight ? 1000 : 0);
if (value > bestScore) { bestScore = value; best = ids; }
}
return best;
}
private remember(): void { this.undos.push(this.snapshot()); if (this.undos.length > 40) this.undos.shift(); }
}
+132
View File
@@ -0,0 +1,132 @@
import { HEIGHT, WIDTH, radius, SPAWN } from './config';
import { complete } from './goals';
import { settle } from './physics';
import { Random } from './random';
import { findChains, legal, points, resultNumber } from './rules';
import type { Ball, Level, MergeResult, Snapshot, Stats, Status } from './types';
const copyBalls = (balls: Ball[]): Ball[] => balls.map(b => ({ ...b }));
const copyStats = (s: Stats): Stats => ({ ...s, made: { ...s.made } });
export class Game {
balls: Ball[] = [];
stats: Stats = { score: 0, merges: 0, maxNumber: 0, longest: 0, reverse: 0, made: {} };
movesLeft: number;
status: Status = 'playing';
shuffles = 1;
history: number[][] = [];
private nextId = 1;
private refillIndex = 0;
private random: Random;
private undos: Snapshot[] = [];
constructor(public readonly level: Level, public readonly endless = false, initialize = true) {
this.movesLeft = endless ? -1 : level.moves;
this.random = new Random(level.seed);
if (initialize) {
for (let i = 0; i < level.count; i++) {
const n = level.initial[i % level.initial.length];
const r = radius(n);
this.balls.push({ id: this.nextId++, n, r, x: r + 10 + this.random.next() * (WIDTH - 2 * r - 20),
y: -r - i * 42, vx: 0, vy: 0 });
}
settle(this.balls, 300);
this.stats.maxNumber = Math.max(...this.balls.map(b => b.n));
this.assess();
}
}
get canUndo(): boolean { return this.undos.length > 0; }
snapshot(): Snapshot {
return { balls: copyBalls(this.balls), stats: copyStats(this.stats), movesLeft: this.movesLeft,
nextId: this.nextId, refillIndex: this.refillIndex, rng: this.random.state,
shuffles: this.shuffles, status: this.status, history: this.history.map(p => [...p]) };
}
restore(s: Snapshot): void {
this.balls = copyBalls(s.balls); this.stats = copyStats(s.stats); this.movesLeft = s.movesLeft;
this.nextId = s.nextId; this.refillIndex = s.refillIndex; this.random.state = s.rng;
this.shuffles = s.shuffles; this.status = s.status; this.history = s.history.map(p => [...p]);
}
clone(): Game { const g = new Game(this.level, this.endless, false); g.restore(this.snapshot()); return g; }
commit(ids: number[], remember = true): MergeResult | null {
if (this.status !== 'playing') return null;
const chain = ids.map(id => this.balls.find(b => b.id === id));
if (chain.some(b => !b) || !legal(chain as Ball[])) return null;
const path = copyBalls(chain as Ball[]), before = copyBalls(this.balls);
if (remember) this.undos.push(this.snapshot());
const tail = path[path.length - 1], n = resultNumber(path), gained = points(path);
const created = { ...tail, n, r: radius(n), vx: 0, vy: 0 };
this.balls = this.balls.filter(b => !ids.includes(b.id));
this.balls.push(created);
this.stats.score += gained; this.stats.merges++;
this.stats.maxNumber = Math.max(this.stats.maxNumber, n);
this.stats.longest = Math.max(this.stats.longest, path.length);
if (path[0].n > path[1].n) this.stats.reverse++;
this.stats.made[n] = (this.stats.made[n] || 0) + 1;
if (!this.endless) this.movesLeft--;
this.history.push([...ids]);
const spawned = this.refill();
const falling = copyBalls(this.balls);
settle(this.balls);
this.assess();
return { before, after: falling, path, created: { ...falling.find(b => b.id === created.id)! }, points: gained, spawned };
}
undo(): boolean {
const previous = this.undos.pop();
if (!previous) return false;
this.restore(previous);
return true;
}
shuffle(): boolean {
if (this.shuffles <= 0 || this.status === 'won' || this.status === 'lost') return false;
const previous = this.snapshot();
for (let trial = 0; trial < 20; trial++) {
for (let i = 0; i < this.balls.length; i++) {
const b = this.balls[i];
b.x = b.r + 10 + this.random.next() * (WIDTH - 2 * b.r - 20);
b.y = -b.r - i * 36; b.vx = 0; b.vy = 0;
}
settle(this.balls, 300);
if (findChains(this.balls, 1).length && this.balls.every(b => b.y >= b.r)) {
this.undos.push(previous); this.shuffles--; this.assess(); return true;
}
}
this.restore(previous); return false;
}
private refill(): Ball[] {
const spawned: Ball[] = [];
let area = this.balls.reduce((sum, b) => sum + Math.PI * b.r * b.r, 0);
const missing = this.level.count - this.balls.length;
for (let i = 0; i < missing; i++) {
let n: number;
if (this.endless) {
const weights = this.balls.some(b => b.n >= SPAWN.bigThreshold) ? SPAWN.weightsWhenBigPresent : SPAWN.weights;
let draw = this.random.next() * Object.values(weights).reduce((sum, w) => sum + w, 0);
n = 1;
for (const [value, weight] of Object.entries(weights)) {
draw -= weight;
if (draw < 0) { n = Number(value); break; }
}
} else n = this.level.refill[this.refillIndex % this.level.refill.length];
const r = radius(n);
if (area + Math.PI * r * r > WIDTH * HEIGHT * 0.59) break;
this.refillIndex++;
area += Math.PI * r * r;
const ball: Ball = { id: this.nextId++, n, r,
x: r + 10 + this.random.next() * (WIDTH - 2 * r - 20), y: -r - i * 64, vx: 0, vy: 0 };
this.balls.push(ball); spawned.push({ ...ball });
}
return spawned;
}
private assess(): void {
if (!this.endless && complete(this.level.goals, this.stats)) this.status = 'won';
else if (!this.endless && this.movesLeft <= 0) this.status = 'lost';
else if (!findChains(this.balls, 1).length) this.status = 'stuck';
else this.status = 'playing';
}
}
+13
View File
@@ -0,0 +1,13 @@
import type { Goal, GoalView, Stats } from './types';
export function goalView(goal: Goal, stats: Stats): GoalView {
let current: number, target: number, text: string;
switch (goal.kind) {
case 'score': current = stats.score; target = goal.value; text = `获得 ${target.toLocaleString('en-US')} 分`; break;
case 'number': current = stats.made[goal.value] || 0; target = goal.count || 1; text = `合成 ${target} 颗「${goal.value}」`; break;
case 'chain': current = stats.longest; target = goal.value; text = `一次连起 ${target} 颗`; break;
case 'merges': current = stats.merges; target = goal.value; text = `完成 ${target} 次合成`; break;
case 'reverse': current = stats.reverse; target = goal.value; text = `完成 ${target} 次倒序合成`; break;
}
return { text, current, target, complete: current >= target };
}
export function complete(goals: Goal[], stats: Stats): boolean { return goals.every(g => goalView(g, stats).complete); }

Some files were not shown because too many files have changed in this diff Show More