commit 8026e4628d4daff066cad0ea519feed1fb16f35d Author: cabinw Date: Wed Sep 16 14:30:59 2026 +0800 初始提交:三玩法 Web 原型与设计文档 - 玩法一 数字一笔:规则库、求解器、26 关、竖屏触摸 Web 原型、测试 - 玩法二 连连气球:连消畅玩版核心、Web 原型、Cocos 球槽组件与导出脚本 - 玩法三 六边一笔:规则库、求解器、10 关 + 转墙实验包、Web 原型、测试 - 产品文档、UI 设计规范、各玩法 GDD / HANDOFF - 文档移除全部外部参考作引用;删除玩法二 v0 旧稿 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..646ac51 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.DS_Store +node_modules/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..5bc37cb --- /dev/null +++ b/README.md @@ -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)。 diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md new file mode 100644 index 0000000..6526f89 --- /dev/null +++ b/docs/PRODUCT.md @@ -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//`。 + +壳看到的入口: + +```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/` 里做可玩原型,互不阻塞 | +| 壳 | 至少两个玩法原型可玩之后,再做选玩法首页 | + +不要等三个玩法都做完再写代码。任意一个玩法都可以单独进 M1。 diff --git a/docs/design.html b/docs/design.html new file mode 100644 index 0000000..760de90 --- /dev/null +++ b/docs/design.html @@ -0,0 +1,543 @@ + + + + + +数字一笔 · UI 设计规范 v1.5 + + + +
+ +
+
本页与 docs/design.md 同步维护。令牌真值在 web/style.css :root 与 src/theme.ts,由 tests/theme.test.ts 守门。改设计:先改 design.md → 改代码令牌 → 更新本页样张。
+ +
+

1 · 设计原则

+

逻辑 puzzle 的盘面只回答三个问题:我走到哪了、哪错了、过没过。

+
+
板要干净

盘面无阴影、无纹理、无装饰。灰阶承担一切非路径信息。

+
一个主色

蓝紫只表示「已走 / 当前 / 可继续」。红只表示冲突,琥珀只表示提示。不每关换色。

+
数字优先

系统圆体、800 字重、等宽数字。其余文字都比数字轻。

+
动效四档

只用 120 / 200 / 320 / 500 ms 与四条缓动。新动效先归档再写。

+
反馈贴手指

落子、踩对、冲突、死路都发生在手指所在格子上。不弹窗、不撒纸片。

+
信息随手可见

关号、包名、本包进度、剩余格数常驻顶栏。

+
+
+ +
+

2 · 色彩

+

浅色单主题。DOM 用 CSS 变量,Canvas 用 THEME,同名同值。

+
+

语义

+ + + + + + +
颜色只能用于不能用于
主色已走格、桥、起点 1、当前关、主按钮、进度装饰、背景、标题文字
冲突冲突格、「重置进度」任何常驻元素、警示以外的强调
提示提示描边、提示按钮脉冲任何常驻元素
主色·亮过关后的全盘提亮(唯一允许的换色)过关以外
+

对比度

+ + + + + + + + +
组合对比度用途
白 on 主色5.2 : 1已走格数字、主按钮
墨 on 未走格12.6 : 1未走线索数字
白 on 冲突红4.5 : 1冲突格数字(角标另有墨底白字)
墨 2 on 白5.1 : 1副文案
墨 on 未走格12.6 : 1未走线索描边(与数字同色)
墨 3 on 白2.5 : 1仅非关键弱文案
+
+ +
+

3 · 字体与字阶

+

字体栈:ui-rounded, "SF Pro Rounded", "PingFang SC", "Hiragino Sans GB", "Noto Sans SC", system-ui, -apple-system, "Segoe UI", sans-serif。全局等宽数字。

+
+
display · 34 / 800
数字一笔 0123
选关页标题
+
title · 22 / 800
1-7 · 2-10 · 3-6
顶栏关号
+
heading · 17 / 700
入门 · 进阶 · 奇与偶 · 继续 · 2-1
包标题、主按钮
+
body · 15 / 500
从 1 开始,滑到旁边的格
副标题、字幕
+
label · 14 / 700
6 · 完成 · 10 / 10 · 下一步在这里
胶囊、包进度、toast
+
caption · 13 / 600
音效 · 振动 · 16 / 26
chip、总进度数字
+
micro · 12 / 600
入门 · 第 7 关
顶栏副标题
+
+

盘面字阶(相对单位格 u)

+
+
1
起点 0.60 u · 800
+
7
单位数 0.52 u · 800
+
14
两位数 0.46 u · 800
+
奇
奇偶 0.44 u · 700
+
6偶
小字 0.19 u · 700
+
68
角标 r=0.15 u · 字 1.25 r
+
+
+ +
+

4 · 间距 · 圆角 · 尺寸

+
+
间距 · 4pt 网格

4 / 8 / 12 / 16 / 20 / 24 / 28。页面左右 20,顶 12 + 安全区,底 16 + 安全区。

+
圆角

sm 10 · md 14(关卡格、toast)· lg 18(主按钮、底栏按钮)· xl 24 · 胶囊 999 · 桌面框 28。盘面格 0.18 u。

+
尺寸

点击区 ≥ 44。顶栏 52,胶囊 32,底栏按钮 56,主按钮 54,chip 34,关卡格 5 列间距 12。

+
+
+
10 +
14 +
18 +
24 +
关卡格 14 +
底栏按钮 18 +
盘面格 0.18 u +
+
+ +
+

5 · 盘面

+

格圆角 0.18 u、缝 0.10 u、桥厚 0.46 u。整盘垂直居中后上提 3%。四周死区:触屏 8%,鼠标 14%。点任意已走格直接回退,可接着滑动。

+

格的状态

+
+
未走
+
8
未走线索
+
偶
未走奇偶
+
1
起点 1(未按下)
+
5
已走
+
8
已走线索
+
6偶
已走奇偶
+
68
冲突 + 角标
+
5
错位:红格 + 轻晃
+
5
补数字:1~9 补 5
+
4
提示目标
+
9
退回压暗
+
12
过关提亮
+
+

样张:1-7 半途(含大格、紫色连线、红色错格)

+
+
+
+

左:路径 1→2→3→4→5,6 踩到线索 8 冲突,只让冲突格变红,连接线始终紫色。右:2×2 大格只计一步,异形格由单位矩形 + 接缝同色拼成。桥保持路径色,与出错格的颜色分开。

+
+
+ +
+

6 · 组件

+

顶栏 · 本包进度条 · 字幕

+
+
+ +
1-7
入门 · 第 7 关
+
6
+
+
+
走错了,沿原路滑回去
+
+
12
+
完成
+ + 胶囊:默认 / 完成态 +
+
+

底栏按钮 · Toast

+

三个等宽按钮,高 56px,15px 字号;补数字保留在本局盘面,指路沿用下一步 / 回退高亮。Toast 距页面底边 128px + 安全区,长句换行,避开手势说明。

+
+

按住滑动连线 · 点已走格回退

+
+ + + +
+
+ + +
+
下一步在这里
+
+

选关:总进度 · 关卡格四态 · 主按钮 · chip

+
+
16 / 26
+
进阶2 / 10
+
+ +
+

已过关 / 已过关 / 当前(呼吸)/ 未过关 / 锁定(点一下抖动)

+
+
+ +
+
+ +
+

7 · 页面

+

390 × 760 样机,桌面浏览器上以 480px 手机框居中,圆角 28 + 框阴影。

+
+
+

数字一笔

按数字顺序,一笔走遍每一格

+
16 / 26
+
+ + +
+
+
+ +
1-7
入门 · 第 7 关
+
6
+
+
+
+
+

按住滑动连线 · 点已走格回退

+
+ + + +
+
+
+
+ +
+

8 · 动效

+

四档时长、四条缓动。Canvas 侧的弹簧与扫光在这里用 CSS 近似回放,参数以 src/theme.ts MOTION 为准。

+ + + + + + + +
档时长用途缓动
fast120ms按下反馈、步号淡入、桥生长ease-out cubic-bezier(.22,1,.36,1)
base200ms整屏切换、toast、胶囊 bump、进场单格ease-out / spring cubic-bezier(.34,1.56,.64,1)
slow320ms闪白衰减、过关扫光、抖动、进度条linear(衰减)/ ease-out
hold500ms过关停留–
循环0.9s / 2.4s提示按钮脉冲 / 当前关呼吸、提示描边ease-in-out cubic-bezier(.65,0,.35,1)
+

逐个回放

+
+
开局进场base · 每格 +14ms

缩放 0.92 → 1 + 淡入,按阅读顺序错落,延迟封顶 200ms。

+
落子弹簧≈200ms · k=260 c=18

当前格放大回 1,步号同时 120ms 淡入。

+
桥生长fast · ease-out

只长厚度,跨缝方向第一帧就盖满,不露白缝。

+
踩对线索闪白slow · 线性衰减

描边闪白 1 → 0。这是「推对了」的主奖励。

+
死路 / 非法起手抖动280ms · 6 个来回

幅度 0.06 u 递减,不弹「失败」。

+
过关扫光slow · 每格 250ms

全盘切亮色,按步序扫白光,320ms 内起完。

+
提示描边2.4s · ≈1.1Hz

琥珀外描边正弦脉冲,最后 0.4s 淡出。

+
整屏切换base · ease-out
选关 → 对局

淡入 + 上移 8px。关与关之间不切屏,只有盘面进退场。

+
冲突280ms · 单格轻晃
5

立刻红底 + 单格轻晃 + 闷音 + 40ms 振动。连接线始终紫色;步号错位不加叉号。撤销立即褪回并停止晃动。

+
+

过关时间线

+
+
+
扫光0 – 320ms · 和弦起、胶囊「完成」、按钮禁用
+
停留320 – 820ms
+
淡出820 – 980ms
+
下一关进场980ms 起 · 顶栏就地更新,盘面错落进场 ≤ 400ms
+
+
+

减弱动态

+

系统开启「减弱动态效果」时:CSS 动画与过渡全部关闭;Canvas 去掉进场错落、弹簧、扫光与淡出,只保留状态切换与提示描边。

+
+ +
+

9 · 声音与振动

+ + + + + + + +
事件音效振动
落子90ms 正弦短音,C5 起每步升半音,12 步循环8ms(踩对线索 18ms)
冲突160ms 方波 130Hz 闷音40ms
非法起手60ms 三角波 220Hz15ms
死路–10 · 30 · 10
过关C-E-G-C 三角波和弦,每音 220ms,间隔 70ms20 · 40 · 30
+
+ +
+

10 · 同步规则

+
+
    +
  1. 先改 docs/design.md 的对应表格,并在「变更记录」加一行。
  2. +
  3. 改 modes/01-number-stroke/web/style.css 的 :root 与 src/theme.ts。
  4. +
  5. 更新本页顶部 :root 与相应样张。
  6. +
  7. npm run check:tests/theme.test.ts 会比对 CSS 与 theme.ts 的颜色、动效时长与字体栈。
  8. +
  9. npm run build:standalone 重新生成试玩版。
  10. +
+
+
+
+
+ + + diff --git a/docs/design.md b/docs/design.md new file mode 100644 index 0000000..9cd6b28 --- /dev/null +++ b/docs/design.md @@ -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 池化后复核字阶。 diff --git a/modes/01-number-stroke/.gitignore b/modes/01-number-stroke/.gitignore new file mode 100644 index 0000000..4611037 --- /dev/null +++ b/modes/01-number-stroke/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +web/dist/ diff --git a/modes/01-number-stroke/README.md b/modes/01-number-stroke/README.md new file mode 100644 index 0000000..1414221 --- /dev/null +++ b/modes/01-number-stroke/README.md @@ -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/`)。 diff --git a/modes/01-number-stroke/docs/GDD.md b/modes/01-number-stroke/docs/GDD.md new file mode 100644 index 0000000..19d3cda --- /dev/null +++ b/modes/01-number-stroke/docs/GDD.md @@ -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 才建。 diff --git a/modes/01-number-stroke/docs/HANDOFF.md b/modes/01-number-stroke/docs/HANDOFF.md new file mode 100644 index 0000000..94851a4 --- /dev/null +++ b/modes/01-number-stroke/docs/HANDOFF.md @@ -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 解;-- 打印盘面与解 + 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 ... # 临时关卡文件也行 +``` diff --git a/modes/01-number-stroke/levels/pack-1-tutorial.json b/modes/01-number-stroke/levels/pack-1-tutorial.json new file mode 100644 index 0000000..c71b021 --- /dev/null +++ b/modes/01-number-stroke/levels/pack-1-tutorial.json @@ -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 } + ] + } + ] +} diff --git a/modes/01-number-stroke/levels/pack-2-advanced.json b/modes/01-number-stroke/levels/pack-2-advanced.json new file mode 100644 index 0000000..6d0b13e --- /dev/null +++ b/modes/01-number-stroke/levels/pack-2-advanced.json @@ -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 } + ] + } + ] +} diff --git a/modes/01-number-stroke/levels/pack-3-parity.json b/modes/01-number-stroke/levels/pack-3-parity.json new file mode 100644 index 0000000..7743682 --- /dev/null +++ b/modes/01-number-stroke/levels/pack-3-parity.json @@ -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 + } + ] + } + ] +} diff --git a/modes/01-number-stroke/package-lock.json b/modes/01-number-stroke/package-lock.json new file mode 100644 index 0000000..39e768b --- /dev/null +++ b/modes/01-number-stroke/package-lock.json @@ -0,0 +1,1033 @@ +{ + "name": "number-stroke", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "number-stroke", + "version": "0.1.0", + "devDependencies": { + "@types/node": "^22.7.0", + "esbuild": "^0.24.0", + "tsx": "^4.19.0", + "typescript": "^5.6.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz", + "integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz", + "integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz", + "integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz", + "integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz", + "integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz", + "integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz", + "integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz", + "integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz", + "integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz", + "integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz", + "integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz", + "integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz", + "integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz", + "integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz", + "integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz", + "integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz", + "integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz", + "integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz", + "integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz", + "integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz", + "integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz", + "integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz", + "integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz", + "integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz", + "integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "22.20.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.2.tgz", + "integrity": "sha512-xlvWf4Vs9n1PEVYwP1n4vvG07M6y8WgvJ2t0vbrWTmijsIHp1cS+uJ2kMIRdY3nHZK0nCYKrPeD171+SzF4/zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/esbuild": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz", + "integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.24.2", + "@esbuild/android-arm": "0.24.2", + "@esbuild/android-arm64": "0.24.2", + "@esbuild/android-x64": "0.24.2", + "@esbuild/darwin-arm64": "0.24.2", + "@esbuild/darwin-x64": "0.24.2", + "@esbuild/freebsd-arm64": "0.24.2", + "@esbuild/freebsd-x64": "0.24.2", + "@esbuild/linux-arm": "0.24.2", + "@esbuild/linux-arm64": "0.24.2", + "@esbuild/linux-ia32": "0.24.2", + "@esbuild/linux-loong64": "0.24.2", + "@esbuild/linux-mips64el": "0.24.2", + "@esbuild/linux-ppc64": "0.24.2", + "@esbuild/linux-riscv64": "0.24.2", + "@esbuild/linux-s390x": "0.24.2", + "@esbuild/linux-x64": "0.24.2", + "@esbuild/netbsd-arm64": "0.24.2", + "@esbuild/netbsd-x64": "0.24.2", + "@esbuild/openbsd-arm64": "0.24.2", + "@esbuild/openbsd-x64": "0.24.2", + "@esbuild/sunos-x64": "0.24.2", + "@esbuild/win32-arm64": "0.24.2", + "@esbuild/win32-ia32": "0.24.2", + "@esbuild/win32-x64": "0.24.2" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/tsx": { + "version": "4.23.13", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.13.tgz", + "integrity": "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/modes/01-number-stroke/package.json b/modes/01-number-stroke/package.json new file mode 100644 index 0000000..0b72520 --- /dev/null +++ b/modes/01-number-stroke/package.json @@ -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" + } +} diff --git a/modes/01-number-stroke/scripts/build.mjs b/modes/01-number-stroke/scripts/build.mjs new file mode 100644 index 0000000..3b42048 --- /dev/null +++ b/modes/01-number-stroke/scripts/build.mjs @@ -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`); +} diff --git a/modes/01-number-stroke/scripts/standalone.mjs b/modes/01-number-stroke/scripts/standalone.mjs new file mode 100644 index 0000000..8255ba5 --- /dev/null +++ b/modes/01-number-stroke/scripts/standalone.mjs @@ -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, '', ``); +html = inlineOnce(html, '', ''); +html = inlineOnce(html, '按数字顺序,一笔走遍每一格', '全部关卡开放,自由选关试玩'); +html = inlineOnce(html, '', ``); +html = html.replace('数字一笔', '数字一笔 · 试玩版'); +await mkdir(dirname(out), { recursive: true }); +await writeFile(out, html, 'utf8'); +console.log(`独立 HTML:${out}`); +console.log(`大小:${(Buffer.byteLength(html) / 1024).toFixed(1)} KB;脚本、样式及全部关卡已内嵌,所有关卡可直接选择。`); diff --git a/modes/01-number-stroke/src/board.ts b/modes/01-number-stroke/src/board.ts new file mode 100644 index 0000000..f7d56dc --- /dev/null +++ b/modes/01-number-stroke/src/board.ts @@ -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; + /** 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 = [ + [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 = buildShapeCatalog(); + +type Offset = readonly [number, number]; + +function normalizeShape(pts: ReadonlyArray): 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 { + 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(); + 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; + readonly n: number; + /** 单位网格尺寸(已把最小 x/y 平移到 0) */ + readonly cols: number; + readonly rows: number; + /** adj[i] = 与 i 相邻的格下标 */ + readonly adj: ReadonlyArray>; + /** 线索步号 → 格下标 */ + readonly clueCellOfStep: ReadonlyMap; + /** 线索 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(); + 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(); + 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 = []; + 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(); + 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(); + 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([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; +} diff --git a/modes/01-number-stroke/src/clue-hint.ts b/modes/01-number-stroke/src/clue-hint.ts new file mode 100644 index 0000000..e2b7fa2 --- /dev/null +++ b/modes/01-number-stroke/src/clue-hint.ts @@ -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; +} diff --git a/modes/01-number-stroke/src/clue.ts b/modes/01-number-stroke/src/clue.ts new file mode 100644 index 0000000..4931c21 --- /dev/null +++ b/modes/01-number-stroke/src/clue.ts @@ -0,0 +1,20 @@ +// 数字与奇偶线索的共同语义;规则、求解器、视图与设计工具共用。 +import type { Cell } from './board'; + +type Clue = Pick; + +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' ? '偶' : ''; +} diff --git a/modes/01-number-stroke/src/game.ts b/modes/01-number-stroke/src/game.ts new file mode 100644 index 0000000..1037643 --- /dev/null +++ b/modes/01-number-stroke/src/game.ts @@ -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(); + + 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 { + const c = this.board.cells[cell]; + return { clue: this.extraClues.get(cell) ?? c.clue, parity: c.parity }; + } + + get revealedClues(): ReadonlyMap { + 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' }]; + } +} diff --git a/modes/01-number-stroke/src/input.ts b/modes/01-number-stroke/src/input.ts new file mode 100644 index 0000000..234bb7c --- /dev/null +++ b/modes/01-number-stroke/src/input.ts @@ -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); + } +} diff --git a/modes/01-number-stroke/src/layout.ts b/modes/01-number-stroke/src/layout.ts new file mode 100644 index 0000000..78da704 --- /dev/null +++ b/modes/01-number-stroke/src/layout.ts @@ -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 }; +} diff --git a/modes/01-number-stroke/src/levels.ts b/modes/01-number-stroke/src/levels.ts new file mode 100644 index 0000000..84d79fd --- /dev/null +++ b/modes/01-number-stroke/src/levels.ts @@ -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; + 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, + })); +} diff --git a/modes/01-number-stroke/src/painter.ts b/modes/01-number-stroke/src/painter.ts new file mode 100644 index 0000000..e8434ca --- /dev/null +++ b/modes/01-number-stroke/src/painter.ts @@ -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; +} diff --git a/modes/01-number-stroke/src/progress.ts b/modes/01-number-stroke/src/progress.ts new file mode 100644 index 0000000..ecc71d9 --- /dev/null +++ b/modes/01-number-stroke/src/progress.ts @@ -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(); + 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): 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; + 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(); + 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); + } +} diff --git a/modes/01-number-stroke/src/solver.ts b/modes/01-number-stroke/src/solver.ts new file mode 100644 index 0000000..943301c --- /dev/null +++ b/modes/01-number-stroke/src/solver.ts @@ -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 }; +} diff --git a/modes/01-number-stroke/src/theme.ts b/modes/01-number-stroke/src/theme.ts new file mode 100644 index 0000000..5d48c03 --- /dev/null +++ b/modes/01-number-stroke/src/theme.ts @@ -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; diff --git a/modes/01-number-stroke/src/types.ts b/modes/01-number-stroke/src/types.ts new file mode 100644 index 0000000..2fc1191 --- /dev/null +++ b/modes/01-number-stroke/src/types.ts @@ -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; + /** 线索:路径走到本格时步号必须等于它 */ + 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[]; +} diff --git a/modes/01-number-stroke/src/view.ts b/modes/01-number-stroke/src/view.ts new file mode 100644 index 0000000..2f20095 --- /dev/null +++ b/modes/01-number-stroke/src/view.ts @@ -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(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); + } + } + } + } +} diff --git a/modes/01-number-stroke/tests/board.test.ts b/modes/01-number-stroke/tests/board.test.ts new file mode 100644 index 0000000..4daff93 --- /dev/null +++ b/modes/01-number-stroke/tests/board.test.ts @@ -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> = [ + [[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 中心'); +}); diff --git a/modes/01-number-stroke/tests/clue-hint.test.ts b/modes/01-number-stroke/tests/clue-hint.test.ts new file mode 100644 index 0000000..e5fdcf2 --- /dev/null +++ b/modes/01-number-stroke/tests/clue-hint.test.ts @@ -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); + } + } +}); diff --git a/modes/01-number-stroke/tests/game.test.ts b/modes/01-number-stroke/tests/game.test.ts new file mode 100644 index 0000000..760de04 --- /dev/null +++ b/modes/01-number-stroke/tests/game.test.ts @@ -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); +}); diff --git a/modes/01-number-stroke/tests/input.test.ts b/modes/01-number-stroke/tests/input.test.ts new file mode 100644 index 0000000..def6145 --- /dev/null +++ b/modes/01-number-stroke/tests/input.test.ts @@ -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]); +}); diff --git a/modes/01-number-stroke/tests/layout.test.ts b/modes/01-number-stroke/tests/layout.test.ts new file mode 100644 index 0000000..8f1618f --- /dev/null +++ b/modes/01-number-stroke/tests/layout.test.ts @@ -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 }); +}); diff --git a/modes/01-number-stroke/tests/parity.test.ts b/modes/01-number-stroke/tests/parity.test.ts new file mode 100644 index 0000000..f2959c4 --- /dev/null +++ b/modes/01-number-stroke/tests/parity.test.ts @@ -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('偶')); + } +}); diff --git a/modes/01-number-stroke/tests/progress.test.ts b/modes/01-number-stroke/tests/progress.test.ts new file mode 100644 index 0000000..75d2da3 --- /dev/null +++ b/modes/01-number-stroke/tests/progress.test.ts @@ -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)); +}); diff --git a/modes/01-number-stroke/tests/reserved-step.test.ts b/modes/01-number-stroke/tests/reserved-step.test.ts new file mode 100644 index 0000000..e487a13 --- /dev/null +++ b/modes/01-number-stroke/tests/reserved-step.test.ts @@ -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); + } +}); diff --git a/modes/01-number-stroke/tests/solver.test.ts b/modes/01-number-stroke/tests/solver.test.ts new file mode 100644 index 0000000..962fdf7 --- /dev/null +++ b/modes/01-number-stroke/tests/solver.test.ts @@ -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' }); +}); diff --git a/modes/01-number-stroke/tests/theme.test.ts b/modes/01-number-stroke/tests/theme.test.ts new file mode 100644 index 0000000..f327588 --- /dev/null +++ b/modes/01-number-stroke/tests/theme.test.ts @@ -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 { + const m = /:root\s*\{([\s\S]*?)\n\}/.exec(source); + assert.ok(m, 'style.css 缺少 :root 块'); + const out = new Map(); + 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); +}); diff --git a/modes/01-number-stroke/tools/ascii.ts b/modes/01-number-stroke/tools/ascii.ts new file mode 100644 index 0000000..a893722 --- /dev/null +++ b/modes/01-number-stroke/tools/ascii.ts @@ -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(); + 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(' → '); +} diff --git a/modes/01-number-stroke/tools/design.ts b/modes/01-number-stroke/tools/design.ts new file mode 100644 index 0000000..c6a34de --- /dev/null +++ b/modes/01-number-stroke/tools/design.ts @@ -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, 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} 个方案`); + } +} diff --git a/modes/01-number-stroke/tools/generate.ts b/modes/01-number-stroke/tools/generate.ts new file mode 100644 index 0000000..98ef228 --- /dev/null +++ b/modes/01-number-stroke/tools/generate.ts @@ -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): 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(); + 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(); +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(); +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(); + 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,只打印不写文件)'); +} diff --git a/modes/01-number-stroke/tools/validate.ts b/modes/01-number-stroke/tools/validate.ts new file mode 100644 index 0000000..cbdc271 --- /dev/null +++ b/modes/01-number-stroke/tools/validate.ts @@ -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); diff --git a/modes/01-number-stroke/tsconfig.json b/modes/01-number-stroke/tsconfig.json new file mode 100644 index 0000000..6978032 --- /dev/null +++ b/modes/01-number-stroke/tsconfig.json @@ -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"] +} diff --git a/modes/01-number-stroke/tsconfig.src.json b/modes/01-number-stroke/tsconfig.src.json new file mode 100644 index 0000000..69a64f1 --- /dev/null +++ b/modes/01-number-stroke/tsconfig.src.json @@ -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"] +} diff --git a/modes/01-number-stroke/web/audio.ts b/modes/01-number-stroke/web/audio.ts new file mode 100644 index 0000000..fbf86a7 --- /dev/null +++ b/modes/01-number-stroke/web/audio.ts @@ -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); + } +} diff --git a/modes/01-number-stroke/web/canvas-painter.ts b/modes/01-number-stroke/web/canvas-painter.ts new file mode 100644 index 0000000..c0696b5 --- /dev/null +++ b/modes/01-number-stroke/web/canvas-painter.ts @@ -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; + } +} diff --git a/modes/01-number-stroke/web/index.html b/modes/01-number-stroke/web/index.html new file mode 100644 index 0000000..3577eaf --- /dev/null +++ b/modes/01-number-stroke/web/index.html @@ -0,0 +1,78 @@ + + + + + + + + + + 数字一笔 + + + +
+ +
+
+

数字一笔

+

按数字顺序,一笔走遍每一格

+
+
+ 0 / 0 +
+
+
+ +
+ + + +
+
+ + + +
+
+ + + diff --git a/modes/01-number-stroke/web/main.ts b/modes/01-number-stroke/web/main.ts new file mode 100644 index 0000000..847758b --- /dev/null +++ b/modes/01-number-stroke/web/main.ts @@ -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 $ = (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 = $('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 = `${clearedInPack} / ${levels.length}`; + 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 = `${lv.index}`; + 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(); diff --git a/modes/01-number-stroke/web/style.css b/modes/01-number-stroke/web/style.css new file mode 100644 index 0000000..b65d619 --- /dev/null +++ b/modes/01-number-stroke/web/style.css @@ -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); + } +} diff --git a/modes/02-connect-1234/.gitignore b/modes/02-connect-1234/.gitignore new file mode 100644 index 0000000..410ec16 --- /dev/null +++ b/modes/02-connect-1234/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +web/dist/ +release/ +cocos/export/ +.DS_Store diff --git a/modes/02-connect-1234/README.md b/modes/02-connect-1234/README.md new file mode 100644 index 0000000..0e1ae75 --- /dev/null +++ b/modes/02-connect-1234/README.md @@ -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`。旧存档不会被新纪录覆盖。 diff --git a/modes/02-connect-1234/cocos/Connect1234Board.ts b/modes/02-connect-1234/cocos/Connect1234Board.ts new file mode 100644 index 0000000..7099adf --- /dev/null +++ b/modes/02-connect-1234/cocos/Connect1234Board.ts @@ -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; + } +} diff --git a/modes/02-connect-1234/docs/GDD.md b/modes/02-connect-1234/docs/GDD.md new file mode 100644 index 0000000..94af49b --- /dev/null +++ b/modes/02-connect-1234/docs/GDD.md @@ -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)。它用于排查玩法断档,不代表真实玩家留存或主观爽感;后续可以基于实机试玩调整落点评分、能量阈值与节奏。 diff --git a/modes/02-connect-1234/docs/HANDOFF.md b/modes/02-connect-1234/docs/HANDOFF.md new file mode 100644 index 0000000..c0f4c2d --- /dev/null +++ b/modes/02-connect-1234/docs/HANDOFF.md @@ -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 与目标小游戏平台触摸及性能验收。 diff --git a/modes/02-connect-1234/docs/QA.md b/modes/02-connect-1234/docs/QA.md new file mode 100644 index 0000000..613d7c6 --- /dev/null +++ b/modes/02-connect-1234/docs/QA.md @@ -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)。 diff --git a/modes/02-connect-1234/docs/archive-v1/GDD.md b/modes/02-connect-1234/docs/archive-v1/GDD.md new file mode 100644 index 0000000..501a853 --- /dev/null +++ b/modes/02-connect-1234/docs/archive-v1/GDD.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 规则、第三方物理引擎、服务端或远程素材。 diff --git a/modes/02-connect-1234/docs/archive-v1/HANDOFF.md b/modes/02-connect-1234/docs/archive-v1/HANDOFF.md new file mode 100644 index 0000000..cb1c88c --- /dev/null +++ b/modes/02-connect-1234/docs/archive-v1/HANDOFF.md @@ -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 的声明文件为准。 diff --git a/modes/02-connect-1234/docs/archive-v1/QA.md b/modes/02-connect-1234/docs/archive-v1/QA.md new file mode 100644 index 0000000..fe385f4 --- /dev/null +++ b/modes/02-connect-1234/docs/archive-v1/QA.md @@ -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 与目标小游戏平台验收。当前不宣称这些工作已完成。 + +## 难度判断的范围 + +难度梯度由教学目标、解的步数、容错余量、长链和多轮合成要求制定,已验证全部关卡可通关。尚未做多人试玩统计,平均通关耗时、提示使用率、玩家卡关分布需要后续试玩采样。 diff --git a/modes/02-connect-1234/docs/qa-v2/browser.json b/modes/02-connect-1234/docs/qa-v2/browser.json new file mode 100644 index 0000000..6ebdcc1 --- /dev/null +++ b/modes/02-connect-1234/docs/qa-v2/browser.json @@ -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": [] +} diff --git a/modes/02-connect-1234/docs/qa-v2/desktop.png b/modes/02-connect-1234/docs/qa-v2/desktop.png new file mode 100644 index 0000000..ddab67f Binary files /dev/null and b/modes/02-connect-1234/docs/qa-v2/desktop.png differ diff --git a/modes/02-connect-1234/docs/qa-v2/first-merge.png b/modes/02-connect-1234/docs/qa-v2/first-merge.png new file mode 100644 index 0000000..7a6f053 Binary files /dev/null and b/modes/02-connect-1234/docs/qa-v2/first-merge.png differ diff --git a/modes/02-connect-1234/docs/qa-v2/flight.png b/modes/02-connect-1234/docs/qa-v2/flight.png new file mode 100644 index 0000000..1d78aca Binary files /dev/null and b/modes/02-connect-1234/docs/qa-v2/flight.png differ diff --git a/modes/02-connect-1234/docs/qa-v2/help.png b/modes/02-connect-1234/docs/qa-v2/help.png new file mode 100644 index 0000000..a03bb56 Binary files /dev/null and b/modes/02-connect-1234/docs/qa-v2/help.png differ diff --git a/modes/02-connect-1234/docs/qa-v2/layout.json b/modes/02-connect-1234/docs/qa-v2/layout.json new file mode 100644 index 0000000..0821289 --- /dev/null +++ b/modes/02-connect-1234/docs/qa-v2/layout.json @@ -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 +} diff --git a/modes/02-connect-1234/docs/qa-v2/mobile.png b/modes/02-connect-1234/docs/qa-v2/mobile.png new file mode 100644 index 0000000..41e7f2a Binary files /dev/null and b/modes/02-connect-1234/docs/qa-v2/mobile.png differ diff --git a/modes/02-connect-1234/docs/qa-v2/overlap-regression.json b/modes/02-connect-1234/docs/qa-v2/overlap-regression.json new file mode 100644 index 0000000..3c138a0 --- /dev/null +++ b/modes/02-connect-1234/docs/qa-v2/overlap-regression.json @@ -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} \ No newline at end of file diff --git a/modes/02-connect-1234/docs/qa-v2/production-merge.png b/modes/02-connect-1234/docs/qa-v2/production-merge.png new file mode 100644 index 0000000..6c2a5a2 Binary files /dev/null and b/modes/02-connect-1234/docs/qa-v2/production-merge.png differ diff --git a/modes/02-connect-1234/docs/qa-v2/production.json b/modes/02-connect-1234/docs/qa-v2/production.json new file mode 100644 index 0000000..80ac82b --- /dev/null +++ b/modes/02-connect-1234/docs/qa-v2/production.json @@ -0,0 +1,10 @@ +{ + "turns": 1, + "score": "10,000", + "errors": [], + "debugExposed": false, + "persistence": true, + "originalRecordRestored": true, + "legacyRecordPreserved": true, + "url": "http://localhost:8003/" +} diff --git a/modes/02-connect-1234/docs/qa-v2/rainbow.png b/modes/02-connect-1234/docs/qa-v2/rainbow.png new file mode 100644 index 0000000..83052cd Binary files /dev/null and b/modes/02-connect-1234/docs/qa-v2/rainbow.png differ diff --git a/modes/02-connect-1234/docs/qa-v2/records.png b/modes/02-connect-1234/docs/qa-v2/records.png new file mode 100644 index 0000000..cc6a15a Binary files /dev/null and b/modes/02-connect-1234/docs/qa-v2/records.png differ diff --git a/modes/02-connect-1234/docs/qa-v2/small-phone.png b/modes/02-connect-1234/docs/qa-v2/small-phone.png new file mode 100644 index 0000000..1b0e285 Binary files /dev/null and b/modes/02-connect-1234/docs/qa-v2/small-phone.png differ diff --git a/modes/02-connect-1234/docs/qa-v2/validation.json b/modes/02-connect-1234/docs/qa-v2/validation.json new file mode 100644 index 0000000..4254bac --- /dev/null +++ b/modes/02-connect-1234/docs/qa-v2/validation.json @@ -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 + } + ] + } + ] +} diff --git a/modes/02-connect-1234/docs/qa/browser.json b/modes/02-connect-1234/docs/qa/browser.json new file mode 100644 index 0000000..6eb3f3e --- /dev/null +++ b/modes/02-connect-1234/docs/qa/browser.json @@ -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": [] +} \ No newline at end of file diff --git a/modes/02-connect-1234/docs/qa/desktop.png b/modes/02-connect-1234/docs/qa/desktop.png new file mode 100644 index 0000000..9f6891d Binary files /dev/null and b/modes/02-connect-1234/docs/qa/desktop.png differ diff --git a/modes/02-connect-1234/docs/qa/layout.json b/modes/02-connect-1234/docs/qa/layout.json new file mode 100644 index 0000000..5fa8d60 --- /dev/null +++ b/modes/02-connect-1234/docs/qa/layout.json @@ -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 + } + ] + } + ] +} \ No newline at end of file diff --git a/modes/02-connect-1234/docs/qa/mobile-320.png b/modes/02-connect-1234/docs/qa/mobile-320.png new file mode 100644 index 0000000..68cfc44 Binary files /dev/null and b/modes/02-connect-1234/docs/qa/mobile-320.png differ diff --git a/modes/02-connect-1234/docs/qa/mobile-all-clear.png b/modes/02-connect-1234/docs/qa/mobile-all-clear.png new file mode 100644 index 0000000..6fd7802 Binary files /dev/null and b/modes/02-connect-1234/docs/qa/mobile-all-clear.png differ diff --git a/modes/02-connect-1234/docs/qa/mobile-help.png b/modes/02-connect-1234/docs/qa/mobile-help.png new file mode 100644 index 0000000..04518f6 Binary files /dev/null and b/modes/02-connect-1234/docs/qa/mobile-help.png differ diff --git a/modes/02-connect-1234/docs/qa/mobile-initial.png b/modes/02-connect-1234/docs/qa/mobile-initial.png new file mode 100644 index 0000000..f6b5e8e Binary files /dev/null and b/modes/02-connect-1234/docs/qa/mobile-initial.png differ diff --git a/modes/02-connect-1234/docs/qa/mobile-level-select.png b/modes/02-connect-1234/docs/qa/mobile-level-select.png new file mode 100644 index 0000000..b6d376a Binary files /dev/null and b/modes/02-connect-1234/docs/qa/mobile-level-select.png differ diff --git a/modes/02-connect-1234/docs/qa/mobile-level8.png b/modes/02-connect-1234/docs/qa/mobile-level8.png new file mode 100644 index 0000000..8015bfe Binary files /dev/null and b/modes/02-connect-1234/docs/qa/mobile-level8.png differ diff --git a/modes/02-connect-1234/docs/qa/mobile-result.png b/modes/02-connect-1234/docs/qa/mobile-result.png new file mode 100644 index 0000000..550db8d Binary files /dev/null and b/modes/02-connect-1234/docs/qa/mobile-result.png differ diff --git a/modes/02-connect-1234/docs/qa/production.json b/modes/02-connect-1234/docs/qa/production.json new file mode 100644 index 0000000..3d6180e --- /dev/null +++ b/modes/02-connect-1234/docs/qa/production.json @@ -0,0 +1,10 @@ +{ + "url": "http://localhost:8003", + "debugPortAbsent": true, + "touch": { + "status": "won", + "score": "1,200 分", + "moves": "2" + }, + "resetToCleanLevel1": true +} \ No newline at end of file diff --git a/modes/02-connect-1234/docs/qa/validation.json b/modes/02-connect-1234/docs/qa/validation.json new file mode 100644 index 0000000..2b3e9fa --- /dev/null +++ b/modes/02-connect-1234/docs/qa/validation.json @@ -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 + } +} diff --git a/modes/02-connect-1234/levels/campaign.json b/modes/02-connect-1234/levels/campaign.json new file mode 100644 index 0000000..9c44152 --- /dev/null +++ b/modes/02-connect-1234/levels/campaign.json @@ -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 + ] + ] + } + ] +} diff --git a/modes/02-connect-1234/levels/spawn.json b/modes/02-connect-1234/levels/spawn.json new file mode 100644 index 0000000..d729b6d --- /dev/null +++ b/modes/02-connect-1234/levels/spawn.json @@ -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 } +} diff --git a/modes/02-connect-1234/package-lock.json b/modes/02-connect-1234/package-lock.json new file mode 100644 index 0000000..c7b2e06 --- /dev/null +++ b/modes/02-connect-1234/package-lock.json @@ -0,0 +1,1033 @@ +{ + "name": "connect-1234", + "version": "2.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "connect-1234", + "version": "2.0.0", + "devDependencies": { + "@types/node": "^22.7.0", + "esbuild": "^0.24.0", + "tsx": "^4.19.0", + "typescript": "^5.6.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz", + "integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz", + "integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz", + "integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz", + "integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz", + "integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz", + "integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz", + "integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz", + "integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz", + "integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz", + "integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz", + "integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz", + "integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz", + "integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz", + "integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz", + "integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz", + "integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz", + "integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz", + "integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz", + "integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz", + "integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz", + "integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz", + "integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz", + "integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz", + "integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz", + "integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "22.20.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.2.tgz", + "integrity": "sha512-xlvWf4Vs9n1PEVYwP1n4vvG07M6y8WgvJ2t0vbrWTmijsIHp1cS+uJ2kMIRdY3nHZK0nCYKrPeD171+SzF4/zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/esbuild": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz", + "integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.24.2", + "@esbuild/android-arm": "0.24.2", + "@esbuild/android-arm64": "0.24.2", + "@esbuild/android-x64": "0.24.2", + "@esbuild/darwin-arm64": "0.24.2", + "@esbuild/darwin-x64": "0.24.2", + "@esbuild/freebsd-arm64": "0.24.2", + "@esbuild/freebsd-x64": "0.24.2", + "@esbuild/linux-arm": "0.24.2", + "@esbuild/linux-arm64": "0.24.2", + "@esbuild/linux-ia32": "0.24.2", + "@esbuild/linux-loong64": "0.24.2", + "@esbuild/linux-mips64el": "0.24.2", + "@esbuild/linux-ppc64": "0.24.2", + "@esbuild/linux-riscv64": "0.24.2", + "@esbuild/linux-s390x": "0.24.2", + "@esbuild/linux-x64": "0.24.2", + "@esbuild/netbsd-arm64": "0.24.2", + "@esbuild/netbsd-x64": "0.24.2", + "@esbuild/openbsd-arm64": "0.24.2", + "@esbuild/openbsd-x64": "0.24.2", + "@esbuild/sunos-x64": "0.24.2", + "@esbuild/win32-arm64": "0.24.2", + "@esbuild/win32-ia32": "0.24.2", + "@esbuild/win32-x64": "0.24.2" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/tsx": { + "version": "4.23.13", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.13.tgz", + "integrity": "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/modes/02-connect-1234/package.json b/modes/02-connect-1234/package.json new file mode 100644 index 0000000..5448ad8 --- /dev/null +++ b/modes/02-connect-1234/package.json @@ -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" + } +} diff --git a/modes/02-connect-1234/scripts/build.mjs b/modes/02-connect-1234/scripts/build.mjs new file mode 100644 index 0000000..c4ecd65 --- /dev/null +++ b/modes/02-connect-1234/scripts/build.mjs @@ -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/'); +} diff --git a/modes/02-connect-1234/scripts/check-cocos.mjs b/modes/02-connect-1234/scripts/check-cocos.mjs new file mode 100644 index 0000000..642dfbc --- /dev/null +++ b/modes/02-connect-1234/scripts/check-cocos.mjs @@ -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); diff --git a/modes/02-connect-1234/scripts/export-cocos.mjs b/modes/02-connect-1234/scripts/export-cocos.mjs new file mode 100644 index 0000000..3f13455 --- /dev/null +++ b/modes/02-connect-1234/scripts/export-cocos.mjs @@ -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}`); diff --git a/modes/02-connect-1234/src/animation.ts b/modes/02-connect-1234/src/animation.ts new file mode 100644 index 0000000..8c80f7e --- /dev/null +++ b/modes/02-connect-1234/src/animation.ts @@ -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; + } +} diff --git a/modes/02-connect-1234/src/cascade.ts b/modes/02-connect-1234/src/cascade.ts new file mode 100644 index 0000000..94a28f5 --- /dev/null +++ b/modes/02-connect-1234/src/cascade.ts @@ -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 }; +} diff --git a/modes/02-connect-1234/src/config.ts b/modes/02-connect-1234/src/config.ts new file mode 100644 index 0000000..00cceed --- /dev/null +++ b/modes/02-connect-1234/src/config.ts @@ -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' }, +]; diff --git a/modes/02-connect-1234/src/flow-animation.ts b/modes/02-connect-1234/src/flow-animation.ts new file mode 100644 index 0000000..c8e7105 --- /dev/null +++ b/modes/02-connect-1234/src/flow-animation.ts @@ -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; + } +} diff --git a/modes/02-connect-1234/src/flow-rules.ts b/modes/02-connect-1234/src/flow-rules.ts new file mode 100644 index 0000000..204037b --- /dev/null +++ b/modes/02-connect-1234/src/flow-rules.ts @@ -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; +} diff --git a/modes/02-connect-1234/src/flow.ts b/modes/02-connect-1234/src/flow.ts new file mode 100644 index 0000000..b2e0460 --- /dev/null +++ b/modes/02-connect-1234/src/flow.ts @@ -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(); } +} diff --git a/modes/02-connect-1234/src/game.ts b/modes/02-connect-1234/src/game.ts new file mode 100644 index 0000000..76150a1 --- /dev/null +++ b/modes/02-connect-1234/src/game.ts @@ -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'; + } +} diff --git a/modes/02-connect-1234/src/goals.ts b/modes/02-connect-1234/src/goals.ts new file mode 100644 index 0000000..6d7d3ba --- /dev/null +++ b/modes/02-connect-1234/src/goals.ts @@ -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); } diff --git a/modes/02-connect-1234/src/input.ts b/modes/02-connect-1234/src/input.ts new file mode 100644 index 0000000..bab2e5d --- /dev/null +++ b/modes/02-connect-1234/src/input.ts @@ -0,0 +1,35 @@ +import type { Ball, Point } from './types'; +import { canAppend } from './rules'; + +/** 同一控制器接 PointerEvent 或 Cocos EventTouch;取消不等于松手。 */ +export class Gesture { + chain: number[] = []; + active = false; + private previous: Point | null = null; + constructor(private readonly balls: () => Ball[], private readonly append = canAppend) {} + down(p: Point): void { + this.cancel(); this.previous = p; + const b = this.hit(p); + if (b) { this.active = true; this.chain = [b.id]; } + } + move(p: Point): void { + if (!this.active) return; + const from = this.previous || p; + // 分段采样避免快速扫过小球时跳号;一次事件最多 120 次。 + const steps = Math.min(120, Math.max(1, Math.ceil(Math.hypot(p.x - from.x, p.y - from.y) / 6))); + for (let i = 1; i <= steps; i++) { + const b = this.hit({ x: from.x + (p.x - from.x) * i / steps, y: from.y + (p.y - from.y) * i / steps }); + if (!b || b.id === this.chain[this.chain.length - 1]) continue; + if (this.chain.length > 1 && b.id === this.chain[this.chain.length - 2]) { this.chain.pop(); continue; } + const balls = this.balls(); + if (this.append(this.chain.map(id => balls.find(b => b.id === id)!), b)) this.chain.push(b.id); + } + this.previous = p; + } + up(): number[] { const path = [...this.chain]; this.cancel(); return path; } + cancel(): void { this.chain = []; this.active = false; this.previous = null; } + private hit(p: Point): Ball | undefined { + return this.balls().filter(b => Math.hypot(b.x - p.x, b.y - p.y) <= b.r + 4) + .sort((a, b) => Math.hypot(a.x - p.x, a.y - p.y) / a.r - Math.hypot(b.x - p.x, b.y - p.y) / b.r)[0]; + } +} diff --git a/modes/02-connect-1234/src/levels.ts b/modes/02-connect-1234/src/levels.ts new file mode 100644 index 0000000..a967d37 --- /dev/null +++ b/modes/02-connect-1234/src/levels.ts @@ -0,0 +1,3 @@ +import campaign from '../levels/campaign.json'; +import type { Level } from './types'; +export const LEVELS = campaign.levels as Level[]; diff --git a/modes/02-connect-1234/src/physics.ts b/modes/02-connect-1234/src/physics.ts new file mode 100644 index 0000000..27653da --- /dev/null +++ b/modes/02-connect-1234/src/physics.ts @@ -0,0 +1,51 @@ +import { HEIGHT, WIDTH } from './config'; +import type { Ball } from './types'; + +/** 固定步长简化圆形刚体。游戏只在稳定盘面接收操作,帧率不参与规则。 */ +export function step(balls: Ball[], dt = 1 / 60, containTop = false): void { + for (const b of balls) { + b.vy = Math.min(450, b.vy + 1050 * dt); + b.vx *= 0.975; + b.x += b.vx * dt; + b.y += b.vy * dt; + } + for (let pass = 0; pass < 10; pass++) { + for (let i = 0; i < balls.length; i++) { + const a = balls[i]; + for (let j = i + 1; j < balls.length; j++) { + const b = balls[j]; + let dx = b.x - a.x, dy = b.y - a.y; + const min = a.r + b.r + 0.6; + // 绝大多数圆不接触,先比较平方距离,避免每个候选都算开方。 + const distanceSquared = dx * dx + dy * dy; + if (distanceSquared >= min * min) continue; + let d = Math.sqrt(distanceSquared); + if (d < 0.001) { dx = a.id < b.id ? 0.01 : -0.01; dy = -0.01; d = Math.hypot(dx, dy); } + const nx = dx / d, ny = dy / d; + const wa = b.r * b.r / (a.r * a.r + b.r * b.r), wb = 1 - wa; + const overlap = min - d; + a.x -= nx * overlap * wa; a.y -= ny * overlap * wa; + b.x += nx * overlap * wb; b.y += ny * overlap * wb; + const rel = (b.vx - a.vx) * nx + (b.vy - a.vy) * ny; + if (rel < 0) { + a.vx += nx * rel * wa; a.vy += ny * rel * wa; + b.vx -= nx * rel * wb; b.vy -= ny * rel * wb; + } + } + } + for (const b of balls) { + if (containTop && b.y < b.r + 8) b.y = b.r + 8; + if (b.x < b.r + 7) { b.x = b.r + 7; b.vx = Math.abs(b.vx) * 0.12; } + if (b.x > WIDTH - b.r - 7) { b.x = WIDTH - b.r - 7; b.vx = -Math.abs(b.vx) * 0.12; } + if (b.y > HEIGHT - b.r - 8) { b.y = HEIGHT - b.r - 8; b.vy = -Math.abs(b.vy) * 0.08; b.vx *= 0.8; } + } + } +} + +export function settle(balls: Ball[], frames = 200, relaxFrames = 32): void { + for (let i = 0; i < frames; i++) step(balls); + for (const b of balls) { b.vx = 0; b.vy = 0; } + // 大球压在多球接触点时,带重力迭代会留下少量穿透。 + // 停表后继续约束投影,让可交互的稳定快照没有挤压重叠。 + for (let i = 0; i < relaxFrames; i++) step(balls, 0, true); +} diff --git a/modes/02-connect-1234/src/progress.ts b/modes/02-connect-1234/src/progress.ts new file mode 100644 index 0000000..f99e09a --- /dev/null +++ b/modes/02-connect-1234/src/progress.ts @@ -0,0 +1,33 @@ +export const SAVE_KEY = 'mode:02-connect-1234:v1'; +export interface RecordEntry { stars: number; score: number } +export interface Progress { version: 1; levels: Record; bestScore: number; bestNumber: number; sound: boolean } +import type { StoragePort } from './storage'; +export type { StoragePort } from './storage'; +export function fresh(): Progress { return { version: 1, levels: {}, bestScore: 0, bestNumber: 0, sound: true }; } +export function loadProgress(storage: StoragePort): Progress { + try { + const p = JSON.parse(storage.read(SAVE_KEY) || 'null'); + const next = fresh(); + if (!p || p.version !== 1) return next; + if (typeof p.sound === 'boolean') next.sound = p.sound; + for (let i = 1; i <= 10; i++) { + const v = p.levels?.[i]; + if (v && Number.isInteger(v.stars) && v.stars >= 1 && v.stars <= 3 && Number.isFinite(v.score) && v.score >= 0) + next.levels[i] = { stars: v.stars, score: v.score }; + } + if (Number.isFinite(p.bestScore) && p.bestScore >= 0) next.bestScore = p.bestScore; + if (Number.isInteger(p.bestNumber) && p.bestNumber >= 0) next.bestNumber = p.bestNumber; + return next; + } catch { return fresh(); } +} +export function saveProgress(storage: StoragePort, progress: Progress): boolean { + try { storage.write(SAVE_KEY, JSON.stringify(progress)); return true; } catch { return false; } +} +export function unlocked(p: Progress): number { + let level = 1; + while (level < 10 && p.levels[level]) level++; + return level; +} +export function starsFor(merges: number, par: number, usedShuffle: boolean): number { + return merges <= par && !usedShuffle ? 3 : merges <= par + 2 ? 2 : 1; +} diff --git a/modes/02-connect-1234/src/random.ts b/modes/02-connect-1234/src/random.ts new file mode 100644 index 0000000..672acc5 --- /dev/null +++ b/modes/02-connect-1234/src/random.ts @@ -0,0 +1,15 @@ +/** 可序列化的 PRNG。重试、撤销、Web / Cocos 使用同一序列。 */ +export class Random { + constructor(public state: number) { this.state >>>= 0; } + next(): number { + this.state = (Math.imul(1664525, this.state) + 1013904223) >>> 0; + return this.state / 4294967296; + } + shuffle(items: T[]): T[] { + for (let i = items.length - 1; i > 0; i--) { + const j = Math.floor(this.next() * (i + 1)); + [items[i], items[j]] = [items[j], items[i]]; + } + return items; + } +} diff --git a/modes/02-connect-1234/src/record.ts b/modes/02-connect-1234/src/record.ts new file mode 100644 index 0000000..65def52 --- /dev/null +++ b/modes/02-connect-1234/src/record.ts @@ -0,0 +1,25 @@ +import type { StoragePort } from './storage'; +import type { FlowStats } from './flow'; +export const FLOW_SAVE_KEY = 'mode:02-connect-1234:balloons-v2'; +export interface FlowRecord { version: 2; bestScore: number; bestNumber: number; bestCombo: number; bestFlown: number; sound: boolean } +export function freshRecord(): FlowRecord { return { version: 2, bestScore: 0, bestNumber: 0, bestCombo: 0, bestFlown: 0, sound: true }; } +export function loadRecord(storage: StoragePort): FlowRecord { + const result = freshRecord(); + try { + const saved = JSON.parse(storage.read(FLOW_SAVE_KEY) || 'null'); + if (saved?.version !== 2) return result; + for (const key of ['bestScore', 'bestNumber', 'bestCombo', 'bestFlown'] as const) + if (Number.isSafeInteger(saved[key]) && saved[key] >= 0) result[key] = saved[key]; + if (typeof saved.sound === 'boolean') result.sound = saved.sound; + } catch { /* 损坏或不可用存储不影响游玩 */ } + return result; +} +export function recordRound(record: FlowRecord, stats: FlowStats): void { + record.bestScore = Math.max(record.bestScore, stats.score); + record.bestNumber = Math.max(record.bestNumber, stats.maxNumber); + record.bestCombo = Math.max(record.bestCombo, stats.bestCombo); + record.bestFlown = Math.max(record.bestFlown, stats.flown); +} +export function saveRecord(storage: StoragePort, record: FlowRecord): boolean { + try { storage.write(FLOW_SAVE_KEY, JSON.stringify(record)); return true; } catch { return false; } +} diff --git a/modes/02-connect-1234/src/rules.ts b/modes/02-connect-1234/src/rules.ts new file mode 100644 index 0000000..1c19dcc --- /dev/null +++ b/modes/02-connect-1234/src/rules.ts @@ -0,0 +1,30 @@ +import { SLACK } from './config'; +import type { Ball } from './types'; +export function adjacent(a: Ball, b: Ball): boolean { + return a.id !== b.id && Math.hypot(a.x - b.x, a.y - b.y) <= (a.r + b.r) * SLACK; +} +export function canAppend(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]; + const diff = ball.n - last.n; + return adjacent(last, ball) && Math.abs(diff) === 1 && + (chain.length === 1 || diff === chain[1].n - chain[0].n); +} +export function legal(chain: Ball[]): boolean { + return chain.length >= 3 && chain.every((b, i) => i === 0 || canAppend(chain.slice(0, i), b)); +} +export function resultNumber(chain: Ball[]): number { return Math.max(...chain.map(b => b.n)) + 1; } +export function points(chain: Ball[]): number { return 100 * resultNumber(chain) * chain.length; } + +/** 单调连续数列不会成环;枚举正反两向,保留不同落点。 */ +export function findChains(balls: Ball[], limit = 300): number[][] { + const result: number[][] = []; + function walk(chain: Ball[]) { + if (result.length >= limit) return; + if (chain.length >= 3) result.push(chain.map(b => b.id)); + for (const b of balls) if (canAppend(chain, b)) walk([...chain, b]); + } + for (const b of balls) walk([b]); + return result; +} diff --git a/modes/02-connect-1234/src/solver.ts b/modes/02-connect-1234/src/solver.ts new file mode 100644 index 0000000..69fe5cc --- /dev/null +++ b/modes/02-connect-1234/src/solver.ts @@ -0,0 +1,59 @@ +import { Game } from './game'; +import { goalView } from './goals'; +import { findChains, resultNumber } from './rules'; + +export interface Solution { path: number[][]; visited: number } +export function rank(game: Game): number { + if (game.status === 'won') return 100000 + game.movesLeft * 100; + if (game.status === 'lost' || game.status === 'stuck') return -10000; + let value = 0; + for (const goal of game.level.goals) { + const view = goalView(goal, game.stats); + value += Math.min(1, view.current / view.target) * 1000; + if (goal.kind === 'number' && !view.complete) { + value += game.balls.reduce((sum, b) => sum + (b.n < goal.value ? Math.pow(b.n / goal.value, 3) * 25 : 0), 0); + } + } + return value + game.stats.score / 1000 + game.stats.maxNumber * 4; +} + +/** 有界 beam search 只用于离线难度校验,不阻塞触摸帧。 */ +export function solve(initial: Game, width = 24, maxDepth = initial.movesLeft): Solution | null { + let frontier = [{ game: initial.clone(), path: [] as number[][] }], visited = 0; + for (let depth = 0; depth < maxDepth; depth++) { + const next: typeof frontier = []; + for (const item of frontier) { + const chains = findChains(item.game.balls, 100); + for (const chain of chains) { + const game = item.game.clone(); game.commit(chain, false); visited++; + const path = [...item.path, chain]; + if (game.status === 'won') return { path, visited }; + if (game.status === 'playing') next.push({ game, path }); + } + } + next.sort((a, b) => rank(b.game) - rank(a.game)); + frontier = next.slice(0, width); + if (!frontier.length) return null; + } + return null; +} + +export function hint(game: Game): number[] { + const proof = game.level.solution; + if (proof && !game.endless && game.shuffles === 1 && + game.history.every((p, i) => JSON.stringify(p) === JSON.stringify(proof[i]))) { + const next = proof[game.history.length]; + if (next && findChains(game.balls).some(p => JSON.stringify(p) === JSON.stringify(next))) return [...next]; + } + const paths = findChains(game.balls); + paths.sort((a, b) => { + const score = (p: number[]) => { + const balls = p.map(id => game.balls.find(b => b.id === id)!); + const n = resultNumber(balls); + return p.length * 8 + n * 5 + game.level.goals.reduce((v, g) => v + + (g.kind === 'number' && g.value === n && !goalView(g, game.stats).complete ? 500 : 0), 0); + }; + return score(b) - score(a); + }); + return paths[0] || []; +} diff --git a/modes/02-connect-1234/src/storage.ts b/modes/02-connect-1234/src/storage.ts new file mode 100644 index 0000000..d9ed69f --- /dev/null +++ b/modes/02-connect-1234/src/storage.ts @@ -0,0 +1,2 @@ +/** 宿主存储接口,Web / Cocos 各自注入实现。 */ +export interface StoragePort { read(key: string): string | null; write(key: string, value: string): void } diff --git a/modes/02-connect-1234/src/supply.ts b/modes/02-connect-1234/src/supply.ts new file mode 100644 index 0000000..b715816 --- /dev/null +++ b/modes/02-connect-1234/src/supply.ts @@ -0,0 +1,91 @@ +import { HEIGHT, WIDTH } from './config'; +import { balloonRadius, copyBalls, continuation } from './cascade'; +import { settle } from './physics'; +import { Random } from './random'; +import { findFlowChains } from './flow-rules'; +import type { Ball } from './types'; + +export const SUPPLY_COUNT = 24; +export interface Supply { balls: Ball[]; falling: Ball[]; spawned: Ball[]; nextId: number; breeze: boolean; choices: number; frames: number; relaxFrames: number } + +/** 三颗一袋的连续数列。大数成长后才解锁高号补给,最高自然掉 6。 */ +export function fillQueue(queue: number[], count: number, balls: Ball[], peak: number, random: Random): void { + const ceiling = Math.min(6, Math.max(3, peak - 1)); + while (queue.length < count) { + let base = 1; + if (ceiling > 3 && random.next() < .55) { + const big = [...balls].sort((a, b) => b.n - a.n)[0]?.n || ceiling; + base = Math.max(1, Math.min(ceiling - 2, big - 3)); + } + queue.push(base, base + 1, base + 2); + } +} + +export function quality(balls: Ball[]): { score: number; choices: number } { + const chains = findFlowChains(balls, 150), unique = new Set(chains.map(p => [...p].sort((a, b) => a - b).join(','))); + const long = chains.filter(p => p.length >= 4).length; + const relays = balls.filter(b => b.n >= 4 && continuation(balls, b)).length; + return { choices: unique.size, score: unique.size * 3 + Math.min(12, long) * 2 + relays * 5 }; +} + +function dropPlan(existing: Ball[], values: number[], nextId: number, random: Random, anchor: number): Ball[] { + const balls = copyBalls(existing); + for (let i = 0; i < values.length; i++) { + if (i % 3 === 0) anchor = random.next() < .6 ? anchor : 70 + random.next() * 220; + const n = values[i], r = balloonRadius(n); + const x = Math.max(r + 8, Math.min(WIDTH - r - 8, anchor + (i % 3 - 1) * 53 + (random.next() - .5) * 16)); + balls.push({ id: nextId + i, n, r, x, y: -r - i * 40, vx: 0, vy: 0 }); + } + return balls; +} + +/** 预告中的数字保持不变,只试落点。正常补给不改现有气球。 */ +export function supply(existing: Ball[], queue: number[], nextId: number, random: Random, anchor = WIDTH / 2, forceBreeze = false, targetId?: number): Supply { + let area = existing.reduce((v, b) => v + Math.PI * b.r ** 2, 0); + const values: number[] = []; + while (existing.length + values.length < SUPPLY_COUNT && queue.length) { + const n = queue[0], size = Math.PI * balloonRadius(n) ** 2; + if (area + size > WIDTH * HEIGHT * .6) break; + values.push(queue.shift()!); area += size; + } + let best: Supply | null = null, bestScore = -Infinity; + for (let trial = 0; trial < 5; trial++) { + const falling = forceBreeze ? dropPlan([], [...existing.map(b => b.n), ...values], nextId, random, anchor) + : dropPlan(existing, values, nextId, random, anchor); + // 微风重排也保留原 ID,便于撤销、观察和宿主节点池复用。 + if (forceBreeze) falling.slice(0, existing.length).forEach((b, i) => { b.id = existing[i].id; }); + if (forceBreeze) falling.slice(existing.length).forEach((b, i) => { b.id = nextId + i; }); + const frames = forceBreeze || !existing.length ? 300 : 200; + const balls = copyBalls(falling); settle(balls, frames, 96); + const q = quality(balls); + const head = balls.find(b => b.id === targetId); + const relay = head && continuation(balls, head); + if (relay) q.score += 70; + if (q.score > bestScore) { + bestScore = q.score; + best = { balls, falling, spawned: falling.filter(b => b.id >= nextId), nextId: nextId + values.length, + breeze: forceBreeze, choices: q.choices, frames, relaxFrames: 96 }; + } + if (q.choices >= 4 && (relay || targetId === undefined) && q.score >= 35) break; + } + if (best!.choices > 0) return best!; + + // 极少数数字断档:自动微风保留大球,换入一袋低号,绝不弹失败页。 + // 前面已经兑现的掉落预告与这次额外救场分开记录。 + const candidates = best!.balls; + const lows = [...candidates].filter(b => b.n <= 4).sort((a, b) => a.y - b.y); + for (let i = 0; i < Math.min(6, lows.length); i++) { lows[i].n = i % 3 + 1; lows[i].r = balloonRadius(lows[i].n); } + for (let trial = 0; trial < 40; trial++) { + const falling = dropPlan([], candidates.map(b => b.n), 1, random, WIDTH / 2); + falling.forEach((b, i) => { b.id = candidates[i].id; }); + const balls = copyBalls(falling); settle(balls, 300, 96); + const q = quality(balls); + if (q.choices) return { ...best!, balls, falling, breeze: true, choices: q.choices, frames: 300 }; + } + // 有界兜底:保留最高的六颗,其余换成连续低号;无需网络或循环重试。 + const kept = [...candidates].sort((a, b) => b.n - a.n).slice(0, 6); + const reset = dropPlan([], Array.from({ length: 18 }, (_, i) => i % 3 + 1), best!.nextId, random, WIDTH / 2); + const falling = [...reset, ...copyBalls(kept).map((b, i) => ({ ...b, x: 70 + i % 3 * 95, y: -1000 - i * 60 }))]; + const balls = copyBalls(falling); settle(balls, 420, 96); + return { balls, falling, spawned: reset, nextId: best!.nextId + reset.length, breeze: true, choices: quality(balls).choices, frames: 420, relaxFrames: 96 }; +} diff --git a/modes/02-connect-1234/src/types.ts b/modes/02-connect-1234/src/types.ts new file mode 100644 index 0000000..de53eea --- /dev/null +++ b/modes/02-connect-1234/src/types.ts @@ -0,0 +1,17 @@ +/** 平台无关;所有坐标以左上为原点,逻辑像素。 */ +export interface Point { x: number; y: number } +export interface Ball extends Point { id: number; n: number; r: number; vx: number; vy: number } +export interface Goal { kind: 'score' | 'number' | 'chain' | 'merges' | 'reverse'; value: number; count?: number } +export interface Level { + id: number; title: string; subtitle: string; lesson: string; difficulty: number; + seed: number; moves: number; par: number; count: number; initial: number[]; + refill: number[]; goals: Goal[]; solution?: number[][]; +} +export interface Stats { score: number; merges: number; maxNumber: number; longest: number; reverse: number; made: Record } +export type Status = 'playing' | 'won' | 'lost' | 'stuck'; +export interface Snapshot { + balls: Ball[]; stats: Stats; movesLeft: number; nextId: number; + refillIndex: number; rng: number; shuffles: number; status: Status; history: number[][]; +} +export interface MergeResult { before: Ball[]; after: Ball[]; path: Ball[]; created: Ball; points: number; spawned: Ball[] } +export interface GoalView { text: string; current: number; target: number; complete: boolean } diff --git a/modes/02-connect-1234/src/view.ts b/modes/02-connect-1234/src/view.ts new file mode 100644 index 0000000..9f0891f --- /dev/null +++ b/modes/02-connect-1234/src/view.ts @@ -0,0 +1,60 @@ +import { PALETTE } from './config'; +import type { Ball, Point } from './types'; +interface Style { fill?: string; stroke?: string; width?: number; alpha?: number } +export type Draw = + | ({ type: 'circle'; x: number; y: number; r: number } & Style) + | ({ type: 'ellipse'; x: number; y: number; rx: number; ry: number } & Style) + | ({ type: 'path'; points: Point[]; close?: boolean; dash?: number[] } & Style) + | ({ type: 'text'; x: number; y: number; text: string; size: number; weight?: number; align?: 'left' | 'center' | 'right' } & Style); +export interface BoardView { balls: Ball[]; chain: number[]; hint: number[]; time: number; reducedMotion?: boolean; fever?: boolean } + +/** 椭圆、透明高光、绳结组成气球;相同指令可映射到 Canvas / Cocos。 */ +export function drawBoard(view: BoardView): Draw[] { + const out: Draw[] = []; + const ellipse = (x: number, y: number, rx: number, ry: number, fill: string, extra: Style = {}) => + out.push({ type: 'ellipse', x, y, rx, ry, fill, ...extra }); + const path = view.chain.length ? view.chain : view.hint; + const selected = view.chain.length > 0; + const linked = path.map(id => view.balls.find(b => b.id === id)).filter((b): b is Ball => !!b); + const linkColor = view.fever ? '#ab85e5' : '#6eabec'; + if (linked.length > 1) { + out.push({ type: 'path', points: linked, stroke: linkColor, width: 18, alpha: .13 }); + out.push({ type: 'path', points: linked, stroke: linkColor, width: selected ? 7 : 3.5, alpha: selected ? 1 : .85, dash: selected ? undefined : [3, 7] }); + } + for (const b of [...view.balls].sort((a, z) => Number(path.includes(a.id)) - Number(path.includes(z.id)))) { + if (b.r < 3) continue; + const p = PALETTE[(b.n - 1) % PALETTE.length], index = path.indexOf(b.id); + const active = selected && index >= 0; + const bob = view.reducedMotion || active ? 0 : Math.sin(view.time * 1.8 + b.id * .9) * .6; + const x = b.x, y = b.y + bob, r = b.r * (active ? 1.045 : 1); + const sway = view.reducedMotion ? 0 : Math.sin(view.time * 1.4 + b.id) * 2; + out.push({ type: 'path', points: [{ x, y: y + r * .89 }, { x: x - 1 + sway, y: y + r + 7 }, + { x: x + 2 + sway, y: y + r + 13 }], stroke: '#acbfd4', width: 1, alpha: .6 }); + if (index >= 0) ellipse(x, y, r + 4, r + 5, '#ffffff', { stroke: linkColor, width: 2.2 }); + // 一个干净的底色、浅描边和两道柔光,不叠泥色或颗粒纹理。 + ellipse(x, y, r * .90, r * .99, p.fill, { stroke: p.edge, width: 1.1 }); + ellipse(x - r * .30, y - r * .58, r * .22, r * .09, '#ffffff', { alpha: .78 }); + ellipse(x + r * .10, y + r * .58, r * .52, r * .13, '#ffffff', { alpha: .14 }); + out.push({ type: 'path', points: [{ x, y: y + r * .89 }, { x: x - 3.4, y: y + r * 1.05 }, + { x: x + 3.4, y: y + r * 1.05 }], close: true, fill: p.edge }); + if (r >= 10) out.push({ type: 'text', x, y: y + 1, text: String(b.n), size: Math.min(47, r * .93), + fill: '#ffffff', stroke: p.ink, width: 1.5, weight: 750 }); + if (index >= 0 && !selected) { + ellipse(x + r * .66, y - r * .67, 8, 8, '#ffffff', { stroke: '#7faee1', width: 1 }); + out.push({ type: 'text', x: x + r * .66, y: y - r * .67 + .5, text: String(index + 1), size: 9, fill: '#5089c7', weight: 700 }); + } + if (b.n >= 10) { + for (const sign of [-1, 1]) { + const sx = x + sign * r * .78, sy = y - r * .68; + out.push({ type: 'path', points: [{ x: sx, y: sy - 6 }, { x: sx + 2, y: sy - 2 }, { x: sx + 6, y: sy }, + { x: sx + 2, y: sy + 2 }, { x: sx, y: sy + 6 }, { x: sx - 2, y: sy + 2 }, { x: sx - 6, y: sy }, { x: sx - 2, y: sy - 2 }], close: true, fill: '#ffffff' }); + } + } + } + if (!selected && linked.length >= 2 && !view.reducedMotion) { + const progress = view.time * .75 % (linked.length - 1), i = Math.floor(progress), t = progress - i; + const a = linked[i], b = linked[i + 1], x = a.x + (b.x - a.x) * t, y = a.y + (b.y - a.y) * t; + ellipse(x, y, 10, 10, '#ffffff', { alpha: .5 }); ellipse(x, y, 4, 4, '#ffffff'); + } + return out; +} diff --git a/modes/02-connect-1234/tests/flow.test.ts b/modes/02-connect-1234/tests/flow.test.ts new file mode 100644 index 0000000..d904d3b --- /dev/null +++ b/modes/02-connect-1234/tests/flow.test.ts @@ -0,0 +1,139 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { FlowGame, FEVER_TARGET } from '../src/flow'; +import { FlowAnimation } from '../src/flow-animation'; +import { balloonRadius, cascade } from '../src/cascade'; +import { canFlowAppend, legalFlow } from '../src/flow-rules'; +import { Gesture } from '../src/input'; +import { supply } from '../src/supply'; +import { Random } from '../src/random'; +import { FLOW_SAVE_KEY, freshRecord, loadRecord, recordRound, saveRecord } from '../src/record'; +import { readFileSync } from 'node:fs'; +import { settle } from '../src/physics'; +import type { Ball } from '../src/types'; +const ball = (n: number, id: number, x: number, y = 160): Ball => ({ n, id, x, y, r: balloonRadius(n), vx: 0, vy: 0 }); +// 最右端合成的 4,依次接上左侧的 2/3、3/4 和 4/5。 +const relay = () => [ball(1, 1, 100, 280), ball(2, 2, 150, 235), ball(3, 3, 190, 190), + ball(2, 4, 85, 125), ball(3, 5, 135, 155), ball(3, 6, 90, 210), ball(4, 7, 140, 205), + ball(4, 8, 92, 180), ball(5, 9, 140, 130)]; + +test('畅连:正反顺号和同号都可连,不能混号、重复、跳号或只有两颗', () => { + const ascending = [ball(1, 1, 50), ball(2, 2, 100), ball(3, 3, 150)]; + const same = [ball(2, 1, 50), ball(2, 2, 100), ball(2, 3, 150)]; + assert(legalFlow(ascending)); assert(legalFlow([...ascending].reverse())); assert(legalFlow(same)); + assert(!legalFlow(same.slice(0, 2))); assert(!legalFlow([same[0], same[1], ascending[2]])); + assert(!legalFlow([ascending[0], ascending[1], same[2]])); + assert(!canFlowAppend(ascending, ascending[0])); assert(!canFlowAppend([ascending[0]], ascending[2])); + assert(!canFlowAppend([ascending[0], ascending[1]], ball(1, 9, 140))); + assert(!legalFlow([ball(2, 1, 40), ball(2, 2, 200), ball(2, 3, 300)])); +}); +test('同号手势支持快滑、回退、取消;非法松手不会提交', () => { + const balls = [ball(2, 1, 45), ball(2, 2, 95), ball(2, 3, 145)]; + const gesture = new Gesture(() => balls, canFlowAppend); + gesture.down(balls[0]); gesture.move(balls[2]); assert.deepEqual(gesture.chain, [1, 2, 3]); + gesture.move(balls[1]); assert.deepEqual(gesture.chain, [1, 2]); + gesture.cancel(); assert.deepEqual(gesture.up(), []); +}); +test('自动接力在链尾升级,按 1/2/3 倍计分,普通手势最多三次', () => { + const source = relay(), saved = structuredClone(source), r = cascade(source, [1, 2, 3])!; + assert.equal(r.stages.length, 3); assert.deepEqual(r.stages.map(s => s.created.n), [4, 5, 6]); + assert.deepEqual(r.stages.map(s => s.points), [1200, 3000, 5400]); assert.equal(r.score, 9600); + assert(r.stages.every(s => s.created.id === 3 && s.created.x === 190 && s.created.y === 190)); + for (const s of r.stages) assert(legalFlow(s.path)); + assert.deepEqual(source, saved); +}); +test('彩虹允许第四连,每次基础合成双倍;10 号放飞只加一次奖励', () => { + const r = cascade(relay(), [1, 2, 3], true)!; + assert.equal(r.stages.length, 4); assert.deepEqual(r.stages.map(s => s.points), [2400, 6000, 10800, 16800]); + const ten = cascade([ball(7, 1, 70), ball(8, 2, 160), ball(9, 3, 250)], [1, 2, 3], true)!; + assert.equal(ten.score, 16000); assert.equal(ten.flight?.n, 10); assert.equal(ten.balls.length, 0); + assert.equal(ten.stages.length, 1); +}); +test('非法路径与预览不消耗随机数、预告、能量或撤销记录', () => { + const game = new FlowGame(), before = game.snapshot(); + assert(game.preview(game.hint())); assert.equal(game.commit([1, 2]), null); + assert.equal(game.commit([9991, 9992, 9993]), null); + assert.deepEqual(game.snapshot(), before); assert.equal(game.canUndo, false); +}); +test('补球后的自动接力进入同一条时间线,撤销重走逐项一致', () => { + const game = new FlowGame(1), before = game.snapshot(), ids = [1, 2, 3]; + const first = game.commit(ids)!; assert.equal(first.stages.length, 3); + const kinds = first.beats.map(b => b.kind).join(','); assert(kinds.includes('fall,merge')); + assert.equal(game.stats.turns, 1); assert.equal(game.stats.reactions, 3); + const after = game.snapshot(); assert(game.undo()); assert.deepEqual(game.snapshot(), before); + const second = game.commit(ids); assert.deepEqual(second, first); assert.deepEqual(game.snapshot(), after); +}); +test('下一袋预告按序兑现,新落点保留现有气球 ID 和数字', () => { + const game = new FlowGame(1234), announced = game.queue.slice(0, 3), result = game.commit(game.hint())!; + const drops = result.beats.filter(b => b.kind === 'fall'); + assert(drops.length); assert.deepEqual(drops[0].supply.spawned.slice(0, 3).map(b => b.n), announced); + for (let i = 1; i < result.beats.length; i++) { + const beat = result.beats[i]; if (beat.kind !== 'fall' || beat.supply.breeze) continue; + const previous = result.beats[i - 1]; if (previous.kind !== 'merge') continue; + const consumed = new Set(previous.stage.path.map(b => b.id)); + for (const b of previous.stage.before.filter(b => !consumed.has(b.id))) + assert(beat.supply.balls.some(a => a.id === b.id && a.n === b.n)); + } +}); +test('彩虹从下一次手势起持续五次,免费工具不消耗,结束后重新积攒', () => { + const game = new FlowGame(45), prepared = game.snapshot(); prepared.feverCharge = FEVER_TARGET - 1; game.restore(prepared); + const trigger = game.commit(game.hint())!; assert(trigger.feverStarted); assert(!trigger.doubled); assert.equal(game.feverLeft, 5); + for (let remaining = 4; remaining >= 0; remaining--) { + const before = game.snapshot(); game.shuffle(); assert.equal(game.feverLeft, before.feverLeft); assert(game.undo()); + const r = game.commit(game.hint())!; assert(r.doubled); assert.equal(game.feverLeft, remaining); assert.equal(game.feverCharge, 0); + } + assert(!game.commit(game.hint())!.doubled); assert(game.feverCharge > 0); +}); +test('10 号放飞腾出空间、累计记录,撤销恢复放飞前全部状态', () => { + const game = new FlowGame(), s = game.snapshot(); + s.balls = [ball(7, 1, 70), ball(8, 2, 160), ball(9, 3, 250)]; s.nextId = 4; game.restore(s); + const r = game.commit([1, 2, 3])!; assert.equal(game.stats.flown, 1); assert.equal(game.stats.maxNumber, 10); + assert.equal(game.stats.score, 13000); assert(!game.balls.some(b => b.n >= 10)); assert(game.hint().length >= 3); + assert(r.beats.some(b => b.kind === 'fall' && b.flight?.n === 10)); + assert(game.undo()); assert.deepEqual(game.snapshot(), s); +}); +test('吹口气免费、可连续使用并撤销;正常重排保留全部数字和 ID', () => { + const game = new FlowGame(65), before = game.snapshot(); + for (let i = 0; i < 3; i++) { + const r = game.shuffle(); assert.equal(r.kind, 'breeze'); assert(game.hint().length >= 3); + assert.deepEqual(game.balls.map(b => [b.id, b.n]).sort(), before.balls.map(b => [b.id, b.n]).sort()); + assert.deepEqual(game.stats, before.stats); assert.deepEqual(game.queue, before.queue); + } + for (let i = 0; i < 3; i++) assert(game.undo()); assert.deepEqual(game.snapshot(), before); +}); +test('极端断号通过有界微风恢复连线,并保留大气球', () => { + const big = [ball(5, 1, 70), ball(7, 2, 180), ball(9, 3, 285)]; + const r = supply(big, [], 4, new Random(88)); + assert(r.breeze); assert(r.choices > 0); assert(r.balls.some(b => b.id === 3 && b.n === 9)); + assert(r.balls.length <= 24); assert.equal(new Set(r.balls.map(b => b.id)).size, r.balls.length); +}); +test('合成和多次掉落的动画跳帧与逐帧相同,且不改变最终规则状态', () => { + const game = new FlowGame(), result = game.commit(game.hint())!, saved = game.snapshot(); + const step = new FlowAnimation(result, 0), jump = new FlowAnimation(result, 0); + for (let t = 0; t < step.duration; t += 97) { + const a = step.sample(t); assert.deepEqual(a, jump.sample(t)); + const direct = new FlowAnimation(result, 0); assert.deepEqual(a, direct.sample(t)); + } + assert.equal(step.sample(step.duration), null); assert.deepEqual(game.snapshot(), saved); +}); +test('v2 纪录独立、单调累计;损坏、非法及不可用存储可恢复', () => { + const map = new Map([['mode:02-connect-1234:v1', '{"version":1,"bestScore":99}']]); + const storage = { read: (k: string) => map.get(k) || null, write: (k: string, v: string) => {map.set(k, v);} }; + assert.deepEqual(loadRecord(storage), freshRecord()); + const record = freshRecord(), game = new FlowGame(); game.commit(game.hint()); recordRound(record, game.stats); + assert(saveRecord(storage, record)); assert.deepEqual(loadRecord(storage), record); assert.equal(map.get('mode:02-connect-1234:v1'), '{"version":1,"bestScore":99}'); + game.undo(); recordRound(record, game.stats); assert(record.bestScore > 0); + map.set(FLOW_SAVE_KEY, '{broken'); assert.deepEqual(loadRecord(storage), freshRecord()); + map.set(FLOW_SAVE_KEY, JSON.stringify({version:2,bestScore:-1,bestNumber:1.5,bestCombo:Infinity,sound:false})); + assert.deepEqual(loadRecord(storage), {...freshRecord(),sound:false}); + const blocked = {read(): string|null {throw Error('blocked');}, write(){throw Error('quota');}}; + assert.deepEqual(loadRecord(blocked), freshRecord()); assert(!saveRecord(blocked, record)); +}); + + +test('后期多球接触点充分松弛,修复种子 6559 的拥挤重叠', () => { + const fixture = JSON.parse(readFileSync(new URL('../docs/qa-v2/overlap-regression.json', import.meta.url), 'utf8')); + const balls: Ball[] = fixture.balls; settle(balls, 0, 96); + for (const a of balls) for (const b of balls) if (a.id < b.id) + assert(a.r + b.r - Math.hypot(a.x - b.x, a.y - b.y) < 1); +}); diff --git a/modes/02-connect-1234/tests/legacy.test.ts b/modes/02-connect-1234/tests/legacy.test.ts new file mode 100644 index 0000000..060e5e4 --- /dev/null +++ b/modes/02-connect-1234/tests/legacy.test.ts @@ -0,0 +1,117 @@ +// v1 关卡版回归:保留历史实验,当前 Web / Cocos 主入口使用 FlowGame。 +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { Game } from '../src/game'; +import { Gesture } from '../src/input'; +import { LEVELS } from '../src/levels'; +import { WIDTH, HEIGHT, radius } from '../src/config'; +import { adjacent, canAppend, findChains, legal, resultNumber } from '../src/rules'; +import { fresh, loadProgress, saveProgress, starsFor, unlocked } from '../src/progress'; +import { settle } from '../src/physics'; +import { hint } from '../src/solver'; +import type { Ball } from '../src/types'; +const ball = (n: number, id = n, x = n * 45, y = 120): Ball => ({ id, n, x, y, r: radius(n), vx: 0, vy: 0 }); + +test('正反连续链得到同一个最大数 +1;不能同号、跳号、换向或重复', () => { + const chain = [ball(1), ball(2), ball(3), ball(4)]; + assert(legal(chain)); assert(legal([...chain].reverse())); + assert.equal(resultNumber(chain), 5); assert.equal(resultNumber([...chain].reverse()), 5); + assert(!legal(chain.slice(0, 2))); assert(!legal([chain[0], chain[1], chain[3]])); + assert(!canAppend(chain.slice(0, 3), ball(2, 9, 150))); + assert(!legal([ball(2, 1, 100), ball(2, 2, 140), ball(2, 3, 180)])); + assert(!canAppend([chain[0], chain[1]], chain[0])); +}); +test('邻接只看圆心距离,边界是半径和的 1.35 倍', () => { + const a = ball(1, 1, 0), b = ball(2, 2, (22 + 28) * 1.35); + assert(adjacent(a, b)); b.x += .001; assert(!adjacent(a, b)); +}); +test('合成位置取链尾,最后一步目标完成优先于步数失败', () => { + const g = new Game({ ...LEVELS[0], moves: 1 }); + const ids = hint(g), tail = g.balls.find(b => b.id === ids[ids.length - 1])!; + const x = tail.x, y = tail.y, r = tail.r; + const result = g.commit(ids)!; + assert.equal(result.created.id, tail.id); assert.equal(result.created.x, x); assert.equal(result.created.y, y); + assert(result.created.r > r); assert.equal(g.stats.score, 1200); assert.equal(g.movesLeft, 0); assert.equal(g.status, 'won'); +}); +test('非法和过时 ID 不改变盘面、步数和随机数', () => { + const g = new Game(LEVELS[2]), before = g.snapshot(); + assert.equal(g.commit([1, 2]), null); assert.equal(g.commit([991, 992, 993]), null); + assert.deepEqual(g.snapshot(), before); assert.equal(g.canUndo, false); +}); +test('同一种子、撤销重走后,补球、坐标、分数都完全一致', () => { + const g = new Game(LEVELS[4]), ids = hint(g), initial = g.snapshot(); + assert.deepEqual(initial, new Game(LEVELS[4]).snapshot()); + assert(g.commit(ids)); const after = g.snapshot(); + assert(g.undo()); assert.deepEqual(g.snapshot(), initial); + assert(g.commit(ids)); assert.deepEqual(g.snapshot(), after); +}); +test('快滑采样中间球;回到上一颗撤回,取消不提交', () => { + const balls = [ball(1, 1, 40), ball(2, 2, 90), ball(3, 3, 145)]; + const input = new Gesture(() => balls); + input.down(balls[0]); input.move(balls[2]); assert.deepEqual(input.chain, [1, 2, 3]); + input.move(balls[1]); assert.deepEqual(input.chain, [1, 2]); + input.move(balls[2]); assert.deepEqual(input.up(), [1, 2, 3]); + assert.equal(input.active, false); assert.deepEqual(input.up(), []); + input.down(balls[0]); input.move(balls[2]); input.cancel(); assert.deepEqual(input.up(), []); +}); +test('打乱保留数字、不扣步且只用一次;撤销恢复打乱之前', () => { + const g = new Game(LEVELS[4]), before = g.snapshot(), numbers = g.balls.map(b => b.n).sort(); + assert(g.shuffle()); assert.equal(g.shuffles, 0); assert.equal(g.movesLeft, before.movesLeft); + assert.deepEqual(g.balls.map(b => b.n).sort(), numbers); assert(!g.shuffle()); + assert(g.undo()); assert.deepEqual(g.snapshot(), before); +}); +test('无解数字不会空扣打乱次数', () => { + const g = new Game(LEVELS[0]); g.balls.forEach(b => { b.n = 1; b.r = 22; }); g.status = 'stuck'; + const before = g.snapshot(); assert(!g.shuffle()); assert.deepEqual(g.snapshot(), before); +}); +test('目标没完成而步数耗尽,进入失败且允许撤销', () => { + const g = new Game({ ...LEVELS[0], moves: 1, goals: [{ kind: 'number', value: 8 }] }); + assert(g.commit(findChains(g.balls)[0])); assert.equal(g.status, 'lost'); + assert(g.undo()); assert.equal(g.status, 'playing'); assert.equal(g.movesLeft, 1); +}); +test('自由模式不检查关卡目标、不扣步数', () => { + const g = new Game(LEVELS[0], true); assert(g.commit(findChains(g.balls)[0])); + assert.notEqual(g.status, 'won'); assert.equal(g.movesLeft, -1); +}); +test('物理将重叠同心球分开并限制左右与底部边界', () => { + const balls = Array.from({ length: 18 }, (_, i) => ball(i % 4 + 1, i + 1, 180, 100)); settle(balls, 400); + for (const a of balls) { + assert(Number.isFinite(a.x) && Number.isFinite(a.y)); + assert(a.x >= a.r && a.x <= WIDTH - a.r); assert(a.y <= HEIGHT - a.r); + for (const b of balls) if (a.id !== b.id) assert(Math.hypot(a.x - b.x, a.y - b.y) >= a.r + b.r - 1.5); + } +}); +test('损坏/不可用存档不阻塞游戏,非法成绩被过滤', () => { + assert.deepEqual(loadProgress({ read: () => '{broken', write() {} }), fresh()); + assert.deepEqual(loadProgress({ read() { throw Error('blocked'); }, write() {} }), fresh()); + assert.equal(saveProgress({ read: () => null, write() { throw Error('quota'); } }, fresh()), false); + const p = loadProgress({ read: () => JSON.stringify({ version: 1, levels: { 1: { stars: 3, score: 1200 }, 2: { stars: 99, score: -1 } }, sound: false }), write() {} }); + assert.equal(unlocked(p), 2); assert.equal(p.sound, false); assert.equal(p.levels[2], undefined); + assert.equal(starsFor(3, 3, false), 3); assert.equal(starsFor(3, 3, true), 2); assert.equal(starsFor(6, 3, false), 1); +}); +for (const level of LEVELS) test(`第 ${level.id} 关:标准解通过真实手势控制器逐步回放`, () => { + const g = new Game(level), input = new Gesture(() => g.balls); + assert(level.solution?.length); assert.equal(g.status, 'playing'); + for (const path of level.solution!) { + assert.deepEqual(hint(g), path); + const targets = path.map(id => g.balls.find(b => b.id === id)!); + input.down(targets[0]); for (const target of targets.slice(1)) input.move(target); + assert.deepEqual(input.chain, path, `第 ${g.stats.merges + 1} 步不能直接划到`); + assert(g.commit(input.up())); + for (const b of g.balls) { + assert(b.y >= b.r - 2, `球 ${b.id} 超出顶部`); + assert(b.x >= b.r && b.x <= WIDTH - b.r && b.y <= HEIGHT - b.r); + } + } + assert.equal(g.status, 'won'); assert(g.stats.merges <= level.moves); +}); + +test('动画不改变规则状态,跳帧与逐帧采样得到相同物理画面', async () => { + const { MergeAnimation } = await import('../src/animation'); + const g = new Game(LEVELS[7]), result = g.commit(hint(g))!, saved = g.snapshot(); + const a = new MergeAnimation(result, 0), b = new MergeAnimation(result, 0); + for (let t = 0; t < 700; t += 16) a.sample(t); + assert.deepEqual(a.sample(700), b.sample(700)); + assert.deepEqual(g.snapshot(), saved); + assert.equal(a.sample(760), null); +}); diff --git a/modes/02-connect-1234/tools/browser-flow-layout.mjs b/modes/02-connect-1234/tools/browser-flow-layout.mjs new file mode 100644 index 0000000..f44bf6b --- /dev/null +++ b/modes/02-connect-1234/tools/browser-flow-layout.mjs @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict'; +import {writeFile} from 'node:fs/promises'; +// 本次任务已有的空间;新的验收任务请改成已创建的空间 ID。 +const task=await taskSpace(4),page=task.page('p1'); +const dir='/Users/wukaibing/Grok/number-swipe/modes/02-connect-1234/docs/qa-v2'; +await page.keyboard.press('Escape'); +assert(await page.evaluate(()=>document.querySelector('#modal').hidden)); +await page.click('#records'); await page.screenshot({path:dir+'/records.png'}); +await page.keyboard.press('Escape'); +const layouts=[]; +for(const [width,height] of [[320,568],[360,740],[390,844],[430,932],[844,390],[1280,900]]){ + await page.cdp('Emulation.setDeviceMetricsOverride',{width,height,deviceScaleFactor:2,mobile:width<600}); + await page.waitForFunction(w=>innerWidth===w,width,{timeout:3000}); + const layout=await page.evaluate(()=>({width:innerWidth,height:innerHeight,scrollWidth:document.documentElement.scrollWidth,scrollHeight:document.documentElement.scrollHeight, + buttons:[...document.querySelectorAll('#app button')].map(b=>{const r=b.getBoundingClientRect();return {id:b.id,w:r.width,h:r.height,bottom:r.bottom}}),stageHeight:document.querySelector('#stage').getBoundingClientRect().height})); + assert.equal(layout.scrollWidth,width); assert(layout.buttons.every(b=>b.w>=44&&b.h>=44)); layouts.push(layout); + if(width===320)await page.screenshot({path:dir+'/small-phone.png'}); + if(width===1280)await page.screenshot({path:dir+'/desktop.png'}); +} +await page.cdp('Emulation.setDeviceMetricsOverride',{width:390,height:844,deviceScaleFactor:2,mobile:true}); +await page.cdp('Emulation.setEmulatedMedia',{features:[{name:'prefers-reduced-motion',value:'reduce'}]}); +await page.click('#restart'); await page.click('button[data-action="new"]'); +assert.equal(await page.evaluate(()=>window.__balloons.snapshot().stats.turns),0); +const points=await page.evaluate(()=>{const api=window.__balloons,s=api.snapshot(),t=api.transform(),r=document.querySelector('#board').getBoundingClientRect();return api.hint().map(id=>{const b=s.balls.find(b=>b.id===id);return{x:r.left+t.x+b.x*t.scale,y:r.top+t.y+b.y*t.scale,id:0}})}); +for(let i=0;iwindow.__balloons.snapshot().stats.turns),1);assert(!await page.evaluate(()=>window.__balloons.animating())); +// 减少动态效果下的横幅仍可显示(不依赖 CSS keyframes)。 +const bannerStyle=await page.evaluate(()=>{const el=document.querySelector('#combo-banner');el.classList.add('show');const style=getComputedStyle(el);return{opacity:style.opacity,animation:style.animationName};}); +assert.equal(bannerStyle.opacity,'1');assert.equal(bannerStyle.animation,'none'); +await page.cdp('Emulation.setEmulatedMedia',{features:[]}); +await writeFile(dir+'/layout.json',JSON.stringify({layouts,reducedMotion:true,keyboardModal:true},null,2)+'\n'); +console.log(JSON.stringify({layouts:layouts.map(l=>({width:l.width,height:l.height,scrollHeight:l.scrollHeight,stageHeight:l.stageHeight})),reducedMotion:true})); diff --git a/modes/02-connect-1234/tools/browser-flow-production.mjs b/modes/02-connect-1234/tools/browser-flow-production.mjs new file mode 100644 index 0000000..41c4699 --- /dev/null +++ b/modes/02-connect-1234/tools/browser-flow-production.mjs @@ -0,0 +1,34 @@ +import assert from 'node:assert/strict'; +import {writeFile} from 'node:fs/promises'; +const task=await taskSpace(4),page=task.page('p1'); +const dir='/Users/wukaibing/Grok/number-swipe/modes/02-connect-1234/docs/qa-v2'; +await page.goto('http://localhost:8002'); +const opening=await page.evaluate(()=>({state:window.__balloons.snapshot(),hint:window.__balloons.hint()})); +await page.goto('http://localhost:8003'); +await page.waitForFunction(()=>document.querySelector('#app').dataset.mode==='flow',undefined,{timeout:5000}); +const key='mode:02-connect-1234:balloons-v2'; +const backup=await page.evaluate(k=>({v2:localStorage.getItem(k),v1:localStorage.getItem('mode:02-connect-1234:v1')}),key); +assert.equal(await page.evaluate(()=>typeof window.__balloons),'undefined'); +await page.evaluate(()=>{window.qaErrors=[];addEventListener('error',e=>window.qaErrors.push(e.message));addEventListener('unhandledrejection',e=>window.qaErrors.push(String(e.reason)));}); +const coords=await page.evaluate(data=>{const r=document.querySelector('#board').getBoundingClientRect(),s=Math.min(r.width/360,r.height/416);return data.hint.map(id=>{const b=data.state.balls.find(b=>b.id===id);return{x:r.left+(r.width-360*s)/2+b.x*s,y:r.top+(r.height-416*s)/2+b.y*s,id:0};});},opening); +for(let i=0;idocument.querySelector('#app').dataset.animating==='false',undefined,{timeout:7000}); +const result=await page.evaluate(()=>({turns:Number(document.querySelector('#app').dataset.turns),score:document.querySelector('#score').textContent,errors:window.qaErrors,debugExposed:typeof window.__balloons!=='undefined'})); +assert.equal(result.turns,1);assert(Number(result.score.replaceAll(',',''))>0);assert.deepEqual(result.errors,[]); +await page.screenshot({path:dir+'/production-merge.png'}); +await page.reload();assert.equal(await page.evaluate(()=>document.querySelector('#best-score').textContent),result.score); +await page.click('#help'); +await page.waitForFunction(()=>document.querySelector('#sheet').getAnimations().every(a=>a.playState==='finished'),undefined,{timeout:3000}); +await page.screenshot({path:dir+'/help.png'});await page.click('button[aria-label="关闭面板"]'); +// 仅恢复本次正式包验收前的 v2 纪录;保留其它存档。 +await page.evaluate(({key,v2})=>{if(v2===null)localStorage.removeItem(key);else localStorage.setItem(key,v2);},{key,v2:backup.v2}); +assert.equal(await page.evaluate(()=>localStorage.getItem('mode:02-connect-1234:v1')),backup.v1); +await page.reload(); +await page.screenshot({path:dir+'/mobile.png'}); +await page.cdp('Emulation.setDeviceMetricsOverride',{width:1280,height:900,deviceScaleFactor:2,mobile:false}); +await page.waitForFunction(()=>innerWidth===1280,undefined,{timeout:3000});await page.screenshot({path:dir+'/desktop.png'}); +await page.cdp('Emulation.setDeviceMetricsOverride',{width:390,height:844,deviceScaleFactor:2,mobile:true}); +await page.waitForFunction(()=>innerWidth===390,undefined,{timeout:3000}); +await writeFile(dir+'/production.json',JSON.stringify({...result,persistence:true,originalRecordRestored:true,legacyRecordPreserved:true,url:await page.url()},null,2)+'\n'); +console.log({...result,persistence:true,originalRecordRestored:true,url:await page.url()}); diff --git a/modes/02-connect-1234/tools/browser-flow.mjs b/modes/02-connect-1234/tools/browser-flow.mjs new file mode 100644 index 0000000..2cf00f6 --- /dev/null +++ b/modes/02-connect-1234/tools/browser-flow.mjs @@ -0,0 +1,45 @@ +// 本次任务已有空间为 4;新验收任务需改成已创建的空间 ID。 +// 只读调试快照作为触摸定位依据,不改游戏状态。 +import assert from 'node:assert/strict'; +import { writeFile } from 'node:fs/promises'; +const task = await taskSpace(4); +const page = task.page('p1'); +const dir = process.env.BALLOON_QA_DIR || '/Users/wukaibing/Grok/number-swipe/modes/02-connect-1234/docs/qa-v2'; +await page.reload(); +await page.evaluate(() => {window.qaErrors=[];addEventListener('error',e=>window.qaErrors.push(e.message));addEventListener('unhandledrejection',e=>window.qaErrors.push(String(e.reason)));}); +const snapshot = () => page.evaluate(() => window.__balloons.snapshot()); +const targets = () => page.evaluate(() => {const api=window.__balloons, s=api.snapshot(), t=api.transform(), r=document.querySelector('#board').getBoundingClientRect();return api.hint().map(id=>{const b=s.balls.find(b=>b.id===id);return {x:r.left+t.x+b.x*t.scale,y:r.top+t.y+b.y*t.scale,id:0,radiusX:5,radiusY:5,force:1};});}); +const touch = (type, points=[]) => page.cdp('Input.dispatchTouchEvent',{type,touchPoints:points}); +const swipe = async(points,cancel=false) => { + for(let i=0;i!window.__balloons.animating(),undefined,{timeout:7000}); + const after=await snapshot(); assert.equal(after.stats.turns,before.stats.turns+1); assert(after.stats.score>before.stats.score); + assert(await page.evaluate(()=>document.querySelector('#modal').hidden)); + moves.push({turn:after.stats.turns,score:after.stats.score,combo:after.stats.lastCombo,peak:after.stats.maxNumber,flown:after.stats.flown,feverLeft:after.feverLeft}); + if(i===0){ + await page.screenshot({path:dir+'/first-merge.png'}); + await page.click('#undo'); assert.deepEqual(await snapshot(),before); + await swipe(await targets()); await page.waitForFunction(()=>!window.__balloons.animating(),undefined,{timeout:7000}); + assert.deepEqual(await snapshot(),after); + } + if(after.feverLeft===5 && !moves.slice(0,-1).some(m=>m.feverLeft===5)) await page.screenshot({path:dir+'/rainbow.png'}); + if(after.stats.flown===1 && !moves.slice(0,-1).some(m=>m.flown>0)) await page.screenshot({path:dir+'/flight.png'}); +} +const beforeBreeze=await snapshot(); await page.click('#shuffle'); +await page.waitForFunction(()=>!window.__balloons.animating(),undefined,{timeout:7000}); +assert.deepEqual((await snapshot()).stats,beforeBreeze.stats); +await page.click('#undo'); assert.deepEqual(await snapshot(),beforeBreeze); +const errors=await page.evaluate(()=>window.qaErrors); assert.deepEqual(errors,[]); +assert(moves.some(m=>m.combo>=3)); assert(moves.some(m=>m.flown>0)); assert(moves.some(m=>m.feverLeft>0)); +await writeFile(dir+'/browser.json',JSON.stringify({viewport:[390,844],input:'CDP touch events',invalidCancelBacktrack:true,undoDeterministic:true,breezeUndo:true,moves,errors},null,2)+'\n'); +console.log(JSON.stringify({gestures:moves.length,final:moves.at(-1),errors})); +console.log(await page.snapshot()); diff --git a/modes/02-connect-1234/tools/browser-v1-layout.mjs b/modes/02-connect-1234/tools/browser-v1-layout.mjs new file mode 100644 index 0000000..4194656 --- /dev/null +++ b/modes/02-connect-1234/tools/browser-v1-layout.mjs @@ -0,0 +1,54 @@ +const space = Number(process.env.GARDEN_SPACE); +if (!space || !process.env.GARDEN_QA_DIR) throw new Error('请设置 GARDEN_SPACE 与 GARDEN_QA_DIR,复用当前 QA 空间。'); +const task = await taskSpace(space), page = task.page('p1'); +const fs = await import('node:fs/promises'); +const root = process.env.GARDEN_QA_DIR; +await page.reload(); +await page.waitForFunction(() => !!window.__garden); +const saved = await page.evaluate(() => JSON.parse(localStorage.getItem('mode:02-connect-1234:v1'))); +if (Object.keys(saved.levels).length !== 10) throw new Error('completion not persisted'); +let before = await page.evaluate(() => window.__garden.snapshot()); +const touchHint = async () => { + const data = await page.evaluate(() => { + const g=window.__garden, s=g.snapshot(), t=g.transform(), r=document.getElementById('board').getBoundingClientRect(); + return { merges:s.stats.merges, points:g.hint().map(id=>{const b=s.balls.find(b=>b.id===id);return {x:r.left+t.x+b.x*t.scale,y:r.top+t.y+b.y*t.scale};})}; + }); + for (let i=0;iwindow.__garden.snapshot().stats.merges===n+1&&!window.__garden.animating(),data.merges,{timeout:5000}); +}; +await touchHint(); +const after = await page.evaluate(() => window.__garden.snapshot()); +await page.click('#undo'); +if (JSON.stringify(await page.evaluate(()=>window.__garden.snapshot()))!==JSON.stringify(before)) throw new Error('undo not exact'); +await touchHint(); +if (JSON.stringify(await page.evaluate(()=>window.__garden.snapshot()))!==JSON.stringify(after)) throw new Error('redo not deterministic'); +await page.click('#undo'); +await page.click('#shuffle'); +const shuffled = await page.evaluate(()=>window.__garden.snapshot()); +if (shuffled.shuffles!==0 || shuffled.movesLeft!==before.movesLeft || JSON.stringify(shuffled.balls.map(b=>b.n).sort())!==JSON.stringify(before.balls.map(b=>b.n).sort())) throw new Error('shuffle mismatch'); +await page.click('#undo'); +if (JSON.stringify(await page.evaluate(()=>window.__garden.snapshot()))!==JSON.stringify(before)) throw new Error('shuffle undo mismatch'); +await page.click('#levels'); await page.click('[data-action="endless"]'); +await touchHint(); +if ((await page.evaluate(()=>window.__garden.snapshot())).movesLeft!==-1) throw new Error('endless consumed move'); +await page.click('#levels'); await page.click('[data-level="8"]'); +const layouts=[]; +for (const [width,height,mobile] of [[320,568,true],[360,740,true],[390,844,true],[430,932,true],[844,390,true],[1280,900,false]]) { + await page.cdp('Emulation.setDeviceMetricsOverride',{width,height,deviceScaleFactor:2,mobile}); + await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))); + const layout=await page.evaluate(()=>({width:innerWidth,height:innerHeight,overflow:document.documentElement.scrollWidth>innerWidth+1,bodyHeight:document.documentElement.scrollHeight,buttons:[...document.querySelectorAll('.tools button,.header-actions button')].map(b=>({id:b.id,w:b.getBoundingClientRect().width,h:b.getBoundingClientRect().height}))})); + if(layout.overflow)throw new Error(`horizontal overflow at ${width}`); + if(layout.buttons.some(b=>b.w<44||b.h<44))throw new Error(`small touch target at ${width}`); + layouts.push(layout); + if(width===320||width===1280)await page.screenshot({path:`${root}/${width===320?'mobile-320':'desktop'}.png`}); +} +await page.cdp('Emulation.setDeviceMetricsOverride',{width:390,height:844,deviceScaleFactor:2,mobile:true}); +await page.click('#help'); +await page.waitForFunction(()=>!document.getElementById('sheet').getAnimations().some(a=>a.playState==='running')); +await page.screenshot({path:`${root}/mobile-help.png`}); +await page.keyboard.press('Escape'); +if(!(await page.evaluate(()=>document.getElementById('modal').hidden)))throw new Error('escape not closed'); +await fs.writeFile(`${root}/layout.json`,JSON.stringify({checks:['刷新保留十关成绩','撤销精确恢复盘面及补球','重走结果确定一致','打乱保留数字和步数','撤销恢复打乱库存','自由模式无步数限制','帮助可用 Escape 关闭'],layouts},null,2)); +console.log(JSON.stringify(layouts)); +console.log(await page.snapshot()); diff --git a/modes/02-connect-1234/tools/browser-v1-smoke.mjs b/modes/02-connect-1234/tools/browser-v1-smoke.mjs new file mode 100644 index 0000000..2cd2d5a --- /dev/null +++ b/modes/02-connect-1234/tools/browser-v1-smoke.mjs @@ -0,0 +1,59 @@ +// 通过 ego-browser nodejs < tools/browser-smoke.mjs 运行;仅访问本地测试页面。 +// 空间编号由首次 browser QA 建立,同一任务一直复用该空间。 +const space = Number(process.env.GARDEN_SPACE); +if (!space || !process.env.GARDEN_QA_DIR) throw new Error('请设置 GARDEN_SPACE 与 GARDEN_QA_DIR,复用当前 QA 空间。'); +const task = await taskSpace(space), page = task.page('p1'); +await page.reload(); +await page.waitForFunction(() => !!window.__garden); +const fs = await import('node:fs/promises'); +const root = process.env.GARDEN_QA_DIR; +const report = { checks: [], levels: [] }; +await page.evaluate(() => { window.__qaErrors = []; window.addEventListener('error', e => window.__qaErrors.push(e.message)); window.addEventListener('unhandledrejection', e => window.__qaErrors.push(String(e.reason))); }); +const info = () => page.evaluate(() => { + const g = window.__garden, s = g.snapshot(), t = g.transform(), r = document.getElementById('board').getBoundingClientRect(); + return { snapshot: s, level: g.level(), hint: g.hint(), points: g.hint().map(id => { const b = s.balls.find(b => b.id === id); return { x: r.left + t.x + b.x * t.scale, y: r.top + t.y + b.y * t.scale }; }) }; +}); +const send = async (type, points) => page.cdp('Input.dispatchTouchEvent', { type, touchPoints: points.map((p, id) => ({...p, id, radiusX: 5, radiusY: 5, force: 1})) }); +let initial = await info(); +if (initial.level.id !== 1) throw new Error('QA expects an untouched campaign'); +// 两颗松手、系统取消、回滑撤回均不得扣步。 +await send('touchStart', [initial.points[0]]); await send('touchMove', [initial.points[1]]); await send('touchEnd', []); +if ((await info()).snapshot.stats.merges !== 0) throw new Error('short gesture committed'); +report.checks.push('两颗松手不扣步'); +await send('touchStart', [initial.points[0]]); await send('touchMove', [initial.points[1]]); await send('touchCancel', []); +if ((await info()).snapshot.stats.merges !== 0) throw new Error('cancel committed'); +report.checks.push('触摸取消不提交'); +await send('touchStart', [initial.points[0]]); await send('touchMove', [initial.points[1]]); await send('touchMove', [initial.points[2]]); await send('touchMove', [initial.points[1]]); await send('touchEnd', []); +if ((await info()).snapshot.stats.merges !== 0) throw new Error('backtrack failed'); +report.checks.push('回滑撤回'); +await page.click('#sound'); +await page.click('#hint'); +await page.screenshot({path: `${root}/mobile-initial.png`}); +for (let expected = 1; expected <= 10; expected++) { + let data = await info(); + if (data.level.id !== expected) throw new Error(`Expected L${expected}, got ${data.level.id}`); + for (let turn = 0; turn < data.level.moves; turn++) { + data = await info(); + if (data.snapshot.status === 'won') break; + if (data.snapshot.status !== 'playing') throw new Error(`L${expected} prematurely ${data.snapshot.status}`); + const previous = data.snapshot.stats.merges; + await send('touchStart', [data.points[0]]); + for (const p of data.points.slice(1)) await send('touchMove', [p]); + await send('touchEnd', []); + await page.waitForFunction(previous => window.__garden.snapshot().stats.merges > previous && !window.__garden.animating(), previous, { timeout: 5000 }); + } + data = await info(); + if (data.snapshot.status !== 'won') throw new Error(`L${expected} not won`); + report.levels.push({ level: expected, moves: data.snapshot.stats.merges, score: data.snapshot.stats.score }); + console.log(`触摸通关 L${expected} ${data.snapshot.stats.merges} 步 ${data.snapshot.stats.score} 分`); + if (expected === 7) await page.screenshot({path: `${root}/mobile-result.png`}); + if (expected < 10) { + await page.click('[data-action="next"]'); + if (expected === 7) await page.screenshot({path: `${root}/mobile-level8.png`}); + } +} +await page.screenshot({path: `${root}/mobile-all-clear.png`}); +report.errors = await page.evaluate(() => window.__qaErrors); +if (report.errors.length) throw new Error(JSON.stringify(report.errors)); +await fs.writeFile(`${root}/browser.json`, JSON.stringify(report, null, 2)); +console.log(await page.snapshot()); diff --git a/modes/02-connect-1234/tools/design.ts b/modes/02-connect-1234/tools/design.ts new file mode 100644 index 0000000..afc24c1 --- /dev/null +++ b/modes/02-connect-1234/tools/design.ts @@ -0,0 +1,25 @@ +import { readFileSync, writeFileSync } from 'node:fs'; +import { Game } from '../src/game'; +import { solve } from '../src/solver'; +import type { Level } from '../src/types'; + +const file = new URL('../levels/campaign.json', import.meta.url); +const pack = JSON.parse(readFileSync(file, 'utf8')) as { levels: Level[] }; +for (const level of pack.levels) { + if (level.solution?.length) continue; + let found = false; + for (let trial = 0; trial < 24; trial++) { + const start = Date.now(); + const game = new Game(level); + const solution = solve(game, level.id < 7 ? 12 : 18); + console.log(`L${level.id} seed=${level.seed} ${solution ? `WIN ${solution.path.length} moves / ${solution.visited} states` : 'no proof'} ${Date.now() - start}ms`); + if (solution) { + level.solution = solution.path; + level.par = solution.path.length; + writeFileSync(file, JSON.stringify(pack, null, 2) + '\n'); + found = true; break; + } + level.seed += 137; + } + if (!found) throw new Error(`Unable to find L${level.id}`); +} diff --git a/modes/02-connect-1234/tools/validate-flow.ts b/modes/02-connect-1234/tools/validate-flow.ts new file mode 100644 index 0000000..b73a640 --- /dev/null +++ b/modes/02-connect-1234/tools/validate-flow.ts @@ -0,0 +1,53 @@ +import assert from 'node:assert/strict'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { FlowGame } from '../src/flow'; +import { findFlowChains, canFlowAppend } from '../src/flow-rules'; +import { Gesture } from '../src/input'; +import { HEIGHT, WIDTH } from '../src/config'; +import { Random } from '../src/random'; +let states = 0, maxOverlap = 0; +function check(game: FlowGame): void { + states++; + assert(game.balls.length <= 24); assert.equal(new Set(game.balls.map(b => b.id)).size, game.balls.length); + for (const a of game.balls) { + assert(Number.isFinite(a.x) && Number.isFinite(a.y)); assert(a.n >= 1 && a.n < 10); + assert(a.x >= a.r && a.x <= WIDTH - a.r && a.y >= a.r - 2 && a.y <= HEIGHT - a.r, `outside: ${JSON.stringify(a)}`); + for (const b of game.balls) if (a.id < b.id) { + const overlap = a.r + b.r - Math.hypot(a.x - b.x, a.y - b.y); + maxOverlap = Math.max(maxOverlap, overlap); assert(overlap < 2, `overlap ${overlap}`); + } + } + assert(findFlowChains(game.balls).length > 0, 'dead board'); +} +const report = []; +for (const strategy of ['random', 'guided'] as const) { + const sessions = strategy === 'random' ? 24 : 8; + let combos = 0, reactions = 0, flown = 0, breezes = 0, minChoices = Infinity; + const times: number[] = [], runs = []; + for (let seed = 1; seed <= sessions; seed++) { + const game = new FlowGame(seed * 937), rng = new Random(seed), input = new Gesture(() => game.balls, canFlowAppend); + check(game); let comboGestures = 0; + for (let turn = 0; turn < 100; turn++) { + const paths = findFlowChains(game.balls); minChoices = Math.min(minChoices, paths.length); + const ids = strategy === 'guided' ? game.hint() : paths[Math.floor(rng.next() * paths.length)]; + assert(ids.length >= 3, 'hint missing'); + if (strategy === 'guided') { + const targets = ids.map(id => game.balls.find(b => b.id === id)!); + input.down(targets[0]); targets.slice(1).forEach(b => input.move(b)); assert.deepEqual(input.up(), ids, 'unplayable hint'); + } + const started = performance.now(), result = game.commit(ids); times.push(performance.now() - started); + assert(result); if (result.stages.length > 1) {combos++;comboGestures++;} check(game); + assert(result.stages.length <= (result.doubled ? 4 : 3)); + } + flown += game.stats.flown; reactions += game.stats.reactions; breezes += game.breezes; + runs.push({seed:seed * 937,score:game.stats.score,peak:game.stats.maxNumber,flown:game.stats.flown,comboGestures,breezes:game.breezes}); + } + times.sort((a,b)=>a-b); + const metrics = {strategy,sessions,gestures:sessions*100,comboGestures:combos,comboRate:combos/(sessions*100),reactions,flown,breezes,minPaths:minChoices, + commitMs:{median:times[Math.floor(times.length*.5)],p95:times[Math.floor(times.length*.95)],max:times[times.length-1]}}; + report.push({...metrics,runs}); console.log(JSON.stringify(metrics)); +} +const result = {generatedAt:new Date().toISOString(),note:'固定种子算法抽样;随机策略从合法链均匀抽取,提示策略通过真实手势控制器。连消率不是玩家数据。',states,maxOverlap,strategies:report}; +mkdirSync(new URL('../docs/qa-v2/',import.meta.url),{recursive:true}); +writeFileSync(new URL('../docs/qa-v2/validation.json',import.meta.url),JSON.stringify(result,null,2)+'\n'); +console.log(`检查 ${states} 个稳定盘面;最大重叠 ${maxOverlap.toFixed(3)}px。`); diff --git a/modes/02-connect-1234/tools/validate.ts b/modes/02-connect-1234/tools/validate.ts new file mode 100644 index 0000000..b118f67 --- /dev/null +++ b/modes/02-connect-1234/tools/validate.ts @@ -0,0 +1,57 @@ +import assert from 'node:assert/strict'; +import { writeFileSync } from 'node:fs'; +import { Game } from '../src/game'; +import { LEVELS } from '../src/levels'; +import { goalView } from '../src/goals'; +import { findChains } from '../src/rules'; +import { Gesture } from '../src/input'; +import { HEIGHT, WIDTH } from '../src/config'; +import { Random } from '../src/random'; + +assert.equal(LEVELS.length, 10); +const report: unknown[] = []; +let states = 0, maxOverlap = 0; +function check(g: Game): void { + states++; + for (const a of g.balls) { + assert(Number.isFinite(a.x) && Number.isFinite(a.y)); + assert(a.x >= a.r && a.x <= WIDTH - a.r && a.y <= HEIGHT - a.r && a.y >= a.r - 2, `ball outside: ${JSON.stringify(a)}`); + for (const b of g.balls) if (a.id < b.id) { + const overlap = a.r + b.r - Math.hypot(a.x - b.x, a.y - b.y); + maxOverlap = Math.max(maxOverlap, overlap); + assert(overlap < 2, `overlap ${overlap}`); + } + } +} +for (const l of LEVELS) { + assert(l.initial.every(n => n >= 1 && n <= 4)); + assert(l.refill.length > 0 && l.refill.every(n => n >= 1 && n <= 4)); + const g = new Game(l), input = new Gesture(() => g.balls); check(g); + const openings = findChains(g.balls).length; + const moves: unknown[] = []; + for (const ids of l.solution || []) { + const balls = ids.map(id => g.balls.find(b => b.id === id)!); + input.down(balls[0]); balls.slice(1).forEach(b => input.move(b)); + assert.deepEqual(input.chain, ids, `L${l.id} touch route`); + const result = g.commit(input.up()); assert(result); check(g); + moves.push({ ids, numbers: balls.map(b => b.n), created: result.created.n, score: g.stats.score }); + } + assert.equal(g.status, 'won', `L${l.id}`); + assert.equal(l.par, g.stats.merges); + report.push({ id: l.id, title: l.title, seed: l.seed, budget: l.moves, solutionMoves: g.stats.merges, + spareMoves: g.movesLeft, score: g.stats.score, openings, goals: l.goals.map(goal => goalView(goal, g.stats)), moves }); + console.log(`L${String(l.id).padStart(2,'0')} ${l.title}: ${g.stats.merges}/${l.moves} 步,${g.stats.score} 分,最高 ${g.stats.maxNumber}`); +} +let randomMerges = 0, longestRun = 0; +for (let seed = 1; seed <= 128; seed++) { + const r = new Random(seed), g = new Game({ ...LEVELS[7], seed: seed * 937 }, true); check(g); + for (let turn = 0; turn < 30 && g.status === 'playing'; turn++) { + const paths = findChains(g.balls); const path = paths[Math.floor(r.next() * paths.length)]; + assert(g.commit(path)); check(g); randomMerges++; longestRun = Math.max(longestRun, turn + 1); + if (g.snapshot().status === 'stuck' && g.shuffles) g.shuffle(); + } +} +assert(randomMerges >= 500, `随机合成覆盖不足: ${randomMerges}`); +const result = { generatedAt: new Date().toISOString(), levels: report, physics: { checkedStates: states, randomMerges, longestRun, maxOverlap } }; +writeFileSync(new URL('../docs/qa/validation.json', import.meta.url), JSON.stringify(result, null, 2) + '\n'); +console.log(`物理检查:${states} 个稳定盘面,${randomMerges} 次随机合成,最长连续 ${longestRun} 次,最大重叠 ${maxOverlap.toFixed(3)}px`); diff --git a/modes/02-connect-1234/tsconfig.core.json b/modes/02-connect-1234/tsconfig.core.json new file mode 100644 index 0000000..9d34c07 --- /dev/null +++ b/modes/02-connect-1234/tsconfig.core.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "target": "ES2020", "module": "ESNext", "moduleResolution": "Bundler", + "lib": ["ES2020"], "strict": true, "resolveJsonModule": true, + "esModuleInterop": true, "isolatedModules": true, "noEmit": true, "types": [], + "skipLibCheck": true + }, + "include": ["src"] +} diff --git a/modes/02-connect-1234/tsconfig.json b/modes/02-connect-1234/tsconfig.json new file mode 100644 index 0000000..a995437 --- /dev/null +++ b/modes/02-connect-1234/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "target": "ES2020", "module": "ESNext", "moduleResolution": "Bundler", + "lib": ["ES2020", "DOM", "DOM.Iterable"], "strict": true, + "resolveJsonModule": true, "esModuleInterop": true, "isolatedModules": true, + "skipLibCheck": true, "noEmit": true, "types": ["node"] + }, + "include": ["src", "web", "tests", "tools"] +} diff --git a/modes/02-connect-1234/web/audio.ts b/modes/02-connect-1234/web/audio.ts new file mode 100644 index 0000000..9bb3437 --- /dev/null +++ b/modes/02-connect-1234/web/audio.ts @@ -0,0 +1,23 @@ +export class Sound { + enabled = true; + private context?: AudioContext; + unlock(): void { + if (!this.enabled) return; + try { this.context ||= new AudioContext(); if (this.context.state === 'suspended') void this.context.resume().catch(() => {}); } catch { /* 无音频设备仍能玩 */ } + } + note(n: number, merge = false): void { + if (!this.enabled || !this.context) return; + try { + const c = this.context, time = c.currentTime, gain = c.createGain(), oscillator = c.createOscillator(); + oscillator.type = 'sine'; oscillator.frequency.value = 220 * Math.pow(2, (n + (merge ? 5 : 0)) / 12); + gain.gain.setValueAtTime(0, time); gain.gain.linearRampToValueAtTime(0.09, time + 0.015); + gain.gain.exponentialRampToValueAtTime(0.001, time + (merge ? 0.32 : 0.13)); + oscillator.connect(gain); gain.connect(c.destination); oscillator.start(); oscillator.stop(time + 0.36); + } catch { /* 音效不是游戏运行前提 */ } + } + pop(n: number, combo: number, fever: boolean): void { + this.note(n + combo * 2 + (fever ? 3 : 0), true); + if (combo > 1) window.setTimeout(() => this.note(n + combo * 2 + 5, true), 45); + } + +} diff --git a/modes/02-connect-1234/web/canvas-painter.ts b/modes/02-connect-1234/web/canvas-painter.ts new file mode 100644 index 0000000..d940776 --- /dev/null +++ b/modes/02-connect-1234/web/canvas-painter.ts @@ -0,0 +1,22 @@ +import type { Draw } from '../src/view'; +export function paint(ctx: CanvasRenderingContext2D, commands: Draw[]): void { + for (const d of commands) { + ctx.save(); ctx.globalAlpha = d.alpha ?? 1; + ctx.fillStyle = d.fill || 'transparent'; ctx.strokeStyle = d.stroke || 'transparent'; + ctx.lineWidth = d.width || 1; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; + ctx.beginPath(); + if (d.type === 'circle') ctx.arc(d.x, d.y, Math.max(0, d.r), 0, Math.PI * 2); + else if (d.type === 'ellipse') ctx.ellipse(d.x, d.y, d.rx, d.ry, 0, 0, Math.PI * 2); + else if (d.type === 'path') { + if (d.dash) ctx.setLineDash(d.dash); + d.points.forEach((p, i) => i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y)); + if (d.close) ctx.closePath(); + } else { + ctx.font = `${d.weight || 600} ${d.size}px "Avenir Next", "Nunito", "PingFang SC", sans-serif`; + ctx.textAlign = d.align || 'center'; ctx.textBaseline = 'middle'; + if (d.stroke) ctx.strokeText(d.text, d.x, d.y); + ctx.fillText(d.text, d.x, d.y); ctx.restore(); continue; + } + if (d.fill) ctx.fill(); if (d.stroke) ctx.stroke(); ctx.restore(); + } +} diff --git a/modes/02-connect-1234/web/index.html b/modes/02-connect-1234/web/index.html new file mode 100644 index 0000000..383b1e6 --- /dev/null +++ b/modes/02-connect-1234/web/index.html @@ -0,0 +1,61 @@ + + + + + + + + 连连气球 · POP & LINK + + + + + + +
+
+

连连气球

POP & LINK
+
+
+
+
本局得分0最佳 0
+ +
+
+
彩虹能量0 / 10
+
接下来
+
+
+ +
1 · 2 · 3 或 2 · 2 · 2

顺号连,同号也能连

+
+
+ 合成 10,放飞一颗快乐 +
+
连起 3 颗,看看能接出几连消
+ +
不计时,不限步,开心一直连。已放飞 0 颗
+
+ + + + + diff --git a/modes/02-connect-1234/web/main.ts b/modes/02-connect-1234/web/main.ts new file mode 100644 index 0000000..c4cf026 --- /dev/null +++ b/modes/02-connect-1234/web/main.ts @@ -0,0 +1,215 @@ +import { WIDTH, HEIGHT, PALETTE } from '../src/config'; +import { FlowGame, FEVER_TARGET, type FlowResult } from '../src/flow'; +import { FlowAnimation } from '../src/flow-animation'; +import { canFlowAppend } from '../src/flow-rules'; +import { Gesture } from '../src/input'; +import { drawBoard } from '../src/view'; +import { loadRecord, saveRecord, recordRound } from '../src/record'; +import type { Reaction } from '../src/cascade'; +import type { Point } from '../src/types'; +import { paint } from './canvas-painter'; +import { Sound } from './audio'; +declare const __DEV__: boolean; +const $ = (id: string): T => document.getElementById(id) as T; +const icon = (name: string) => ``; +const format = (n: number) => Math.round(n).toLocaleString('en-US'); +const canvas = $('board'), ctx = canvas.getContext('2d')!; +const modal = $('modal'), sheet = $('sheet'); +const storage = { read: (key: string) => localStorage.getItem(key), write: (key: string, value: string) => localStorage.setItem(key, value) }; +const record = loadRecord(storage), sound = new Sound(); sound.enabled = record.sound; +const reduced = matchMedia('(prefers-reduced-motion: reduce)'); +let game = new FlowGame(1234), gesture = new Gesture(() => game.balls, canFlowAppend); +let hints = game.hint(), hintUntil = Infinity; +let transform = { scale: 1, x: 0, y: 0, dpr: 1 }; +let animation: FlowAnimation | null = null, seenReaction = 0; +let pointer: number | null = null, previousFocus: HTMLElement | null = null; +let toastTimer = 0, bannerTimer = 0, previewKey = '', saveAvailable = true; +let displayScore = 0, scoreTarget = 0, lastFrame = 0; + +function save(): void { recordRound(record, game.stats); saveAvailable = saveRecord(storage, record); } +function resize(): void { + const r = canvas.getBoundingClientRect(), dpr = Math.min(3, devicePixelRatio || 1), scale = Math.min(r.width / WIDTH, r.height / HEIGHT); + canvas.width = Math.round(r.width * dpr); canvas.height = Math.round(r.height * dpr); + transform = { scale, x: (r.width - WIDTH * scale) / 2, y: (r.height - HEIGHT * scale) / 2, dpr }; +} +function coordinates(e: PointerEvent): Point { + const r = canvas.getBoundingClientRect(); + return { x: (e.clientX - r.left - transform.x) / transform.scale, y: (e.clientY - r.top - transform.y) / transform.scale }; +} +function cancel(): void { + gesture.cancel(); const id = pointer; pointer = null; + if (id !== null && canvas.hasPointerCapture(id)) canvas.releasePointerCapture(id); + updatePreview(); +} +function toast(message: string): void { + clearTimeout(toastTimer); $('toast').textContent = message; $('toast').classList.add('show'); + toastTimer = window.setTimeout(() => $('toast').classList.remove('show'), 2100); +} +function banner(title: string, subtitle: string, rainbow = false): void { + clearTimeout(bannerTimer); const el = $('combo-banner'); + el.className = `combo-banner${rainbow ? ' rainbow' : ''}`; el.innerHTML = `${title}${subtitle}`; + void el.offsetWidth; el.classList.add('show'); + bannerTimer = window.setTimeout(() => el.classList.remove('show'), 1100); +} +function updateSound(): void { + $('sound').innerHTML = icon(sound.enabled ? 'sound' : 'muted'); + $('sound').setAttribute('aria-label', sound.enabled ? '关闭音效' : '开启音效'); $('sound').setAttribute('aria-pressed', String(sound.enabled)); +} +function updateHUD(): void { + $('app').dataset.mode = 'flow'; $('app').dataset.turns = String(game.stats.turns); $('app').dataset.animating = String(!!animation); + $('app').classList.toggle('fever', game.fever); + $('peak').textContent = String(game.stats.maxNumber); + const p = PALETTE[(game.stats.maxNumber - 1) % PALETTE.length]; $('peak').style.backgroundColor = p.fill; + $('best-score').textContent = format(record.bestScore); + $('fever-label').textContent = game.fever ? '彩虹时刻 · 双倍得分' : '彩虹能量'; + $('fever-count').textContent = game.fever ? `剩 ${game.feverLeft} 次` : `${game.feverCharge} / ${FEVER_TARGET}`; + $('fever-fill').style.width = `${game.fever ? game.feverLeft / 5 * 100 : game.feverCharge / FEVER_TARGET * 100}%`; + $('fever-meter').setAttribute('aria-valuenow', String(game.fever ? game.feverLeft * 2 : game.feverCharge)); + $('fever-meter').setAttribute('aria-valuetext', game.fever ? `双倍得分,剩 ${game.feverLeft} 次合成` : `${game.feverCharge} / 10`); + $('queue').innerHTML = game.queue.slice(0, 3).map(n => `${n}`).join(''); + $('queue').setAttribute('aria-label', `接下来掉落 ${game.queue.slice(0, 3).join('、')}`); + $('undo').disabled = !game.canUndo || !!animation; + $('hint').disabled = !!animation; $('shuffle').disabled = !!animation; + $('welcome').classList.toggle('hidden', game.stats.turns > 0 || !!animation); + $('welcome').setAttribute('aria-hidden', String(game.stats.turns > 0 || !!animation)); + $('flight-count').textContent = `已放飞 ${game.stats.flown} 颗`; + $('footer-text').textContent = saveAvailable ? '不计时,不限步,开心一直连。' : '浏览器未允许保存,成绩暂存本次'; + $('stage-caption').textContent = game.fever ? '彩虹时刻 · 最多 4 连消' : '合成 10,放飞一颗快乐'; + updatePreview(); +} +function updatePreview(): void { + const ids = gesture.chain, key = `${ids.join(',')}/${game.stats.turns}/${!!animation}`; + if (key === previewKey) return; previewKey = key; + const el = $('chain-preview'); + if (!ids.length) { + el.innerHTML = animation ? '啵!还有下一连…' : game.stats.turns < 2 ? '连起 3 颗,看看能接出几连消' : '顺号或同号都能连 · 新球还能自动接力'; + return; + } + const balls = ids.map(id => game.balls.find(b => b.id === id)!); + const result = game.preview(ids), visible = balls.length > 5 ? [...balls.slice(0, 2), null, ...balls.slice(-2)] : balls; + el.innerHTML = visible.map(b => b ? `${b.n}` : '…').join('·') + + (result ? `→${result.stages[result.stages.length - 1].created.n}${result.stages.length > 1 ? `${result.stages.length} 连消` : ''}+${format(result.score)} 起` : `再连 ${Math.max(0, 3 - ids.length)} 颗`); +} +function feedback(stage: Reaction): void { + const x = Math.max(65, Math.min($('stage').clientWidth - 65, transform.x + stage.created.x * transform.scale)), y = transform.y + stage.created.y * transform.scale; + const el = document.createElement('span'); el.className = 'score-pop'; el.textContent = `+${format(stage.points)}`; + el.style.left = `${x}px`; el.style.top = `${y - 16}px`; $('stage').append(el); setTimeout(() => el.remove(), 750); + sound.pop(stage.created.n, stage.index, animation?.result.doubled || false); + if (stage.index >= 2) banner(`${stage.index} 连消!`, stage.index >= 3 ? '一口气,停不下来' : '接上了,再长大一点'); + if (stage.created.n >= 10) banner('放飞啦!', '10 号气球 · 额外 +10,000', true); + if (!reduced.matches) for (let i = 0; i < 10; i++) { + const spark = document.createElement('i'); spark.className = 'spark'; spark.style.left = `${x}px`; spark.style.top = `${y}px`; + const angle = i / 10 * Math.PI * 2; + spark.style.setProperty('--dx', `${Math.cos(angle) * (30 + stage.index * 7)}px`); + spark.style.setProperty('--dy', `${Math.sin(angle) * (30 + stage.index * 7)}px`); + spark.style.setProperty('--spark-color', PALETTE[i % PALETTE.length].fill); + $('stage').append(spark); setTimeout(() => spark.remove(), 700); + } +} +function begin(result: FlowResult): void { + hints = []; seenReaction = 0; animation = new FlowAnimation(result, performance.now()); + scoreTarget = game.stats.score - result.score; + if (result.kind === 'breeze') toast('呼——换个位置,继续连。'); + updateHUD(); + if (reduced.matches) finish(); +} +function finish(): void { + const result = animation?.result; animation = null; + scoreTarget = game.stats.score; displayScore = game.stats.score; + save(); updateHUD(); + if (result?.feverStarted) banner('彩虹时刻!', '接下来 5 次合成 · 双倍得分', true); + else if (result?.supply.breeze && result.kind !== 'breeze') toast('微风送来新气球,接着连。'); +} +function commit(ids: number[]): void { + const result = game.commit(ids); + if (result) begin(result); + else { if (ids.length) toast('至少连 3 颗顺号或同号气球。'); updatePreview(); } +} +function frame(now: number): void { + const dt = Math.min(60, now - lastFrame || 16); lastFrame = now; + let balls = game.balls; + if (animation) { + const sample = animation.sample(now); + if (sample) { + balls = sample; + const reaction = animation.reaction; + if (reaction && reaction.index > seenReaction) { + for (const event of animation.result.stages.filter(s => s.index > seenReaction && s.index <= reaction.index)) { scoreTarget += event.points; feedback(event); } + seenReaction = reaction.index; + } + } else finish(); + } + displayScore += (scoreTarget - displayScore) * (1 - Math.exp(-dt / 95)); + if (Math.abs(displayScore - scoreTarget) < 1) displayScore = scoreTarget; + const score = format(displayScore); if ($('score').textContent !== score) $('score').textContent = score; + if (now > hintUntil) hints = []; + const t = transform; + ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.setTransform(t.dpr * t.scale, 0, 0, t.dpr * t.scale, t.dpr * t.x, t.dpr * t.y); + paint(ctx, drawBoard({ balls, chain: animation ? animation.activePath : gesture.chain, hint: animation ? [] : hints, time: now / 1000, fever: game.fever, reducedMotion: reduced.matches })); + requestAnimationFrame(frame); +} +function undo(): void { + if (animation) return; cancel(); + if (game.undo()) { hints = []; displayScore = scoreTarget = game.stats.score; previewKey = ''; updateHUD(); toast('已撤回,补球和彩虹能量一起还原。'); } +} +function newRound(): void { + cancel(); animation = null; clearTimeout(bannerTimer); clearTimeout(toastTimer); + $('stage').querySelectorAll('.spark,.score-pop').forEach(el => el.remove()); $('combo-banner').classList.remove('show'); $('toast').classList.remove('show'); + game = new FlowGame(Date.now() >>> 0); gesture = new Gesture(() => game.balls, canFlowAppend); + hints = game.hint(); hintUntil = performance.now() + 4500; displayScore = scoreTarget = 0; previewKey = ''; + closeSheet(); updateHUD(); +} +function openSheet(html: string): void { + cancel(); if (animation) finish(); + if (modal.hidden) previousFocus = document.activeElement as HTMLElement; + sheet.innerHTML = html; modal.hidden = false; $('app').inert = true; sheet.focus(); +} +function closeSheet(): void { modal.hidden = true; $('app').inert = false; previousFocus?.focus(); previousFocus = null; } +const closeButton = ``; +function showHelp(): void { + openSheet(`
A LITTLE POP, A LOT OF JOY

一划,就连起来

${closeButton}
${[1, 2, 3, 4].map((n, i) => `${i ? `${i === 3 ? '→' : '·'}` : ''}${n}`).join('')}
1

顺号连,同号也能连。
连接至少 3 颗相邻气球:1→2→3、3→2→1,或 2→2→2。顺号和同号不能混在一条里。

2

松手变大,自动接着合成。
新数字是最大数 +1,留在连线末端。周围或新掉落的气球能接上,就自动连消,最多 3 连!

3

攒满彩虹,双倍快乐。
长链和连消积攒能量,满 10 格后,接下来 5 次合成双倍得分,最多 4 连消。没有倒计时。

4

合成 10,让它飞向天空。
10 号气球自动放飞,奖励 10,000 分,腾出空间继续玩。没有关卡和步数限制,提示、撤销、吹口气都免费。

`,); +} +function showRecords(restart = false): void { + save(); + openSheet(`
YOUR BALLOON SKY

${restart ? '这一局,飞得不错' : '你的放飞记录'}

${closeButton}
${format(game.stats.score)}本局得分 · 最佳 ${format(record.bestScore)}
${game.stats.bestCombo}本局最高连消
${game.stats.reactions}已完成合成
${game.stats.flown}已放飞气球
${[4, 6, 8, 10].map(n => `
${n}${record.bestNumber >= n ? '已点亮' : '等你来连'}
`).join('')}

最高 ${record.bestCombo} 连消 · 单局最多放飞 ${record.bestFlown} 颗
想继续就继续,想重来就换一片天空。

${saveAvailable ? '' : '

浏览器未允许保存,记录暂存本次会话。

'}`); +} +canvas.addEventListener('pointerdown', e => { + if (e.button !== 0 || !e.isPrimary || pointer !== null || animation || !modal.hidden) return; + e.preventDefault(); sound.unlock(); pointer = e.pointerId; canvas.setPointerCapture(pointer); hints = []; + gesture.down(coordinates(e)); updatePreview(); + if (gesture.chain.length) sound.note(game.balls.find(b => b.id === gesture.chain[0])!.n); +}); +canvas.addEventListener('pointermove', e => { + if (e.pointerId !== pointer || animation) return; + const old = gesture.chain.join(','); gesture.move(coordinates(e)); + if (old !== gesture.chain.join(',')) { const n = game.balls.find(b => b.id === gesture.chain[gesture.chain.length - 1])?.n; if (n) sound.note(n); updatePreview(); } +}); +canvas.addEventListener('pointerup', e => { + if (e.pointerId !== pointer) return; gesture.move(coordinates(e)); const ids = gesture.up(); pointer = null; + if (canvas.hasPointerCapture(e.pointerId)) canvas.releasePointerCapture(e.pointerId); commit(ids); +}); +canvas.addEventListener('pointercancel', e => { if (e.pointerId === pointer) cancel(); }); +canvas.addEventListener('lostpointercapture', e => { if (e.pointerId === pointer) cancel(); }); +window.addEventListener('blur', cancel); window.addEventListener('resize', () => { cancel(); resize(); }); +document.addEventListener('visibilitychange', () => { if (document.hidden) cancel(); }); +new ResizeObserver(resize).observe($('stage')); +$('sound').addEventListener('click', () => { sound.enabled = !sound.enabled; record.sound = sound.enabled; sound.unlock(); updateSound(); save(); }); +$('help').addEventListener('click', showHelp); $('records').addEventListener('click', () => showRecords()); +$('undo').addEventListener('click', undo); $('restart').addEventListener('click', () => showRecords(true)); +$('shuffle').addEventListener('click', () => { if (!animation) { cancel(); begin(game.shuffle()); } }); +$('hint').addEventListener('click', () => { if (animation) return; cancel(); hints = game.hint(); hintUntil = performance.now() + 7000; if (hints.length) toast('沿白色标记连起来,松手看它自动接力。'); else toast('吹口气,换个位置试试。'); }); +modal.addEventListener('click', e => { if (e.target === modal) closeSheet(); }); +sheet.addEventListener('click', e => { const action = (e.target as HTMLElement).closest('button')?.dataset.action; if (action === 'close') closeSheet(); if (action === 'new') newRound(); }); +document.addEventListener('keydown', e => { + if (!modal.hidden) { + if (e.key === 'Escape') { e.preventDefault(); closeSheet(); } + if (e.key === 'Tab') { const buttons = [...sheet.querySelectorAll('button:not(:disabled)')], first = buttons[0], last = buttons[buttons.length - 1]; + if (e.shiftKey && (document.activeElement === first || document.activeElement === sheet)) { e.preventDefault(); last?.focus(); } + else if (!e.shiftKey && (document.activeElement === last || document.activeElement === sheet)) { e.preventDefault(); first?.focus(); } } + } else if (!e.ctrlKey && !e.metaKey && !e.altKey) { + if (e.key.toLowerCase() === 'z') undo(); if (e.key.toLowerCase() === 'h') $('hint').click(); if (e.key === 'Escape') cancel(); + } +}); +updateSound(); updateHUD(); resize(); requestAnimationFrame(frame); +if (__DEV__) Object.assign(window, { __balloons: { snapshot: () => game.snapshot(), hint: () => game.hint(), transform: () => ({ ...transform }), animating: () => !!animation } }); diff --git a/modes/02-connect-1234/web/style.css b/modes/02-connect-1234/web/style.css new file mode 100644 index 0000000..0d71ff9 --- /dev/null +++ b/modes/02-connect-1234/web/style.css @@ -0,0 +1,6 @@ +:root{font-family:"Avenir Next","PingFang SC","Microsoft YaHei",sans-serif;color:#40678e;background:#eaf5ff;font-synthesis:none;-webkit-tap-highlight-color:transparent;font-weight:500}*{box-sizing:border-box}body{margin:0;min-height:100svh;display:flex;align-items:center;justify-content:center;background:radial-gradient(ellipse at 15% 20%,#ffffff 0,transparent 48%),radial-gradient(ellipse at 84% 90%,#eae8ff 0,transparent 45%),#eaf5ff}button{font:inherit;color:inherit;cursor:pointer;border:0;-webkit-user-select:none;user-select:none}button:focus-visible{outline:3px solid #659de6;outline-offset:3px}button:disabled{opacity:.36;cursor:default}button:not(:disabled):active{transform:scale(.97)}svg{width:22px;height:22px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round;flex-shrink:0}.symbols{position:absolute;width:0;height:0;overflow:hidden}.app{width:432px;height:min(890px,calc(100svh - 32px));min-height:740px;padding:22px 24px 16px;display:flex;flex-direction:column;gap:10px;background:#f9fcff;border:1px solid #fff;border-radius:30px;box-shadow:0 24px 80px #5f8fb518;position:relative;overflow:hidden}.brand-bar{height:44px;display:flex;align-items:center;justify-content:space-between;flex-shrink:0}.brand{display:flex;gap:8px;align-items:center}.brand-mark{height:40px;width:34px;color:#77b7f5;display:grid;place-items:center}.brand-mark svg{width:27px;height:31px;stroke-width:1.8}.brand h1{margin:0;font-size:20px;letter-spacing:2px;font-weight:750;color:#46739f}.brand div>span{display:block;font-size:8px;letter-spacing:2.2px;color:#a0b6cb;margin-top:2px;font-weight:650}.header-actions{display:flex}.icon-button{width:44px;height:44px;display:grid;place-items:center;background:transparent;color:#94adc4;border-radius:50%}.icon-button:hover{background:#eaf4ff}.scoreboard{display:flex;align-items:center;justify-content:space-between;min-height:88px;padding:4px 4px 0;flex-shrink:0}.score-main{display:grid;grid-template-columns:auto 1fr;align-items:baseline;column-gap:10px}.eyebrow{font-size:10px;color:#7c94ac;letter-spacing:1px}.score-main .eyebrow{grid-column:1/-1}.score-main>strong{font-size:48px;font-weight:700;color:#477cac;line-height:1.15;letter-spacing:-2px;font-variant-numeric:tabular-nums}.best-line{font-size:9px;color:#a2b3c7;grid-column:1/-1;margin-top:1px}.best-line b{font-weight:500;font-variant-numeric:tabular-nums}.collection{background:transparent;padding:0;min-width:60px;min-height:75px;display:flex;flex-direction:column;gap:8px;align-items:center;color:#9aaec0;font-size:9px}.best-balloon{position:relative;display:grid;place-items:center;width:42px;height:46px;background:#f9d16c;border-radius:48% 48% 47% 47%;color:#fff;font-size:25px;font-weight:750;text-shadow:0 1px #d7a847}.best-balloon:before{content:"";width:12px;height:5px;position:absolute;background:#ffffffad;left:8px;top:7px;border-radius:50%;transform:rotate(-18deg)}.best-balloon:after{content:"";position:absolute;bottom:-4px;left:17px;border-left:4px solid transparent;border-right:4px solid transparent;border-bottom:6px solid #ecc059}.flow-bar{display:flex;align-items:center;gap:23px;background:#fff;border:1px solid #e7eff8;border-radius:15px;padding:12px 14px;flex-shrink:0;height:61px}.fever-block{flex:1;min-width:0}.fever-label{display:flex;justify-content:space-between;font-size:10px;color:#6d89a4;margin-bottom:7px}.fever-label b{font-weight:500;font-size:9px;white-space:nowrap;margin-left:6px}.fever-track{height:6px;border-radius:5px;background:#edf2f9;overflow:hidden}.fever-track>i{display:block;height:100%;width:0;border-radius:5px;background:linear-gradient(90deg,#83bdfa,#b79fee,#f2a0c2,#f5ce74);transition:width .35s}.upcoming{display:flex;align-items:center;gap:9px;border-left:1px solid #eaf0f7;padding-left:13px}.upcoming>span{font-size:9px;color:#a1b2c5}.upcoming>div{display:flex;gap:4px}.next-ball{height:23px;width:21px;display:inline-grid;place-items:center;position:relative;border-radius:48%;color:#fff;font-weight:700;font-size:12px;box-shadow:inset 0 1px 0 #ffffff60}.next-ball:after{position:absolute;content:"";width:4px;height:3px;border-radius:1px;background:inherit;bottom:-2px;left:8px}.stage{flex:1;min-height:300px;border:1.5px solid #dfecf9;border-radius:25px;background:linear-gradient(180deg,#f3f9ff 0,#fafdff 45%,#f0f7ff 100%);position:relative;overflow:hidden;isolation:isolate;transition:border-color .3s,background .3s}.stage:before,.stage:after{content:"";position:absolute;z-index:-1;background:#ffffffb5;height:29px;width:99px;border-radius:60%;left:-27px;top:90px;box-shadow:22px -13px 0 -1px #ffffffb5}.stage:after{left:auto;right:-28px;top:36px;width:80px;opacity:.7}canvas{display:block;width:100%;height:100%;touch-action:none;-webkit-user-select:none;user-select:none}.welcome{position:absolute;top:12%;left:50%;transform:translateX(-50%);text-align:center;white-space:nowrap;pointer-events:none;transition:opacity .3s}.welcome>span{font-size:18px;color:#86aed3;letter-spacing:2px;font-weight:650}.welcome i{font-size:10px;color:#afc2d3;font-weight:400;letter-spacing:0;padding:0 5px;font-style:normal}.welcome p{font-size:10px;color:#a3b7cb;letter-spacing:1px;margin:8px 0}.welcome.hidden{opacity:0}.stage-caption{position:absolute;top:14px;left:0;right:0;text-align:center;font-size:9px;color:#87a3bd;letter-spacing:1px;pointer-events:none}.chain-preview{height:31px;display:flex;justify-content:center;align-items:center;gap:5px;flex-shrink:0;font-size:11px;color:#6e8cab;white-space:nowrap;min-width:0}.chain-preview .mini-ball{display:inline-grid;place-items:center;border-radius:48%;color:#fff;min-width:22px;height:24px;font-size:13px;font-weight:700}.chain-preview .gain{font-size:10px;color:#aaa0cf;margin-left:5px}.chain-preview .predict{padding:4px 7px;border-radius:7px;background:#efe9fd;color:#9876c7;font-size:10px}.tools{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;flex-shrink:0}.tools button{min-height:61px;border-radius:15px;border:1px solid #e4edf7;background:#f0f6fc;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;padding:8px 1px;color:#6487a9}.tools button>span{font-size:11px}.tools svg{width:20px;height:20px}.tools .hint-button{background:#82b6ee;border-color:#82b6ee;color:#fff;box-shadow:0 3px 0 #6094cc19}.game-footer{display:flex;align-items:center;justify-content:space-between;font-size:8px;letter-spacing:.2px;color:#afbfce;min-height:13px;flex-shrink:0}.toast{position:absolute;bottom:28px;left:50%;transform:translateX(-50%) translateY(8px);border-radius:12px;padding:10px 14px;max-width:94%;width:max-content;background:#ffffffed;border:1px solid #dcebf9;color:#749abc;font-size:11px;line-height:1.6;text-align:center;pointer-events:none;opacity:0;transition:opacity .2s,transform .2s;z-index:4;box-shadow:0 4px 18px #7facd112}.toast.show{opacity:1;transform:translateX(-50%) translateY(0)}.combo-banner{position:absolute;top:17%;left:0;right:0;text-align:center;pointer-events:none;z-index:5;opacity:0}.combo-banner.show{opacity:1;animation:combo-pop .85s both}.combo-banner strong{font-size:31px;font-weight:800;letter-spacing:2px;color:#a083d5;text-shadow:0 2px 0 white;display:block}.combo-banner span{display:block;font-size:10px;color:#a7a7cd;letter-spacing:2px;margin-top:3px}.combo-banner.rainbow strong{background:linear-gradient(90deg,#6ca9e9,#b17fdf,#ef98b5,#e9b357);background-clip:text;-webkit-background-clip:text;color:transparent;text-shadow:none}.score-pop{pointer-events:none;position:absolute;color:#669bd1;font-size:24px;font-weight:750;text-shadow:0 2px white;z-index:6;animation:score-up .7s both}.spark{pointer-events:none;position:absolute;width:5px;height:9px;background:var(--spark-color);border-radius:3px;z-index:5;animation:spark .65s ease-out both}.app.fever .stage{border-color:#cfbef0;background:linear-gradient(150deg,#f7f0ff,#fafdff 45%,#fff3f4)}.app.fever .fever-label{color:#a08ac7}.app.fever .fever-track>i{background:linear-gradient(90deg,#79baff,#ba99f0,#f895bf,#f4ce70)}.desktop-note{position:absolute;right:calc(50% + 285px);top:23%;width:275px;color:#90b1cf;padding-top:140px}.desktop-note p{font-size:10px;letter-spacing:2.2px;color:#a2b9cf;font-weight:600}.desktop-note h2{font-size:26px;line-height:1.8;color:#7e9fbc;font-weight:500;letter-spacing:2px;margin:22px 0 18px}.desktop-note>span{font-size:10px;letter-spacing:1px;color:#adc3d5}.floating-balloon{position:absolute;top:8px;width:68px;height:76px;display:grid;place-items:center;border-radius:48%;font-size:34px;font-weight:700;color:#fff;background:#a3cdf5;transform:rotate(-12deg)}.floating-balloon:before{content:"";position:absolute;top:13px;left:12px;width:23px;height:8px;background:#ffffff88;border-radius:50%;transform:rotate(-18deg)}.floating-balloon:after{content:"";height:65px;width:16px;border-left:1px solid #a5b9cd;position:absolute;top:79px;left:33px;border-radius:40%}.b-two{left:78px;top:-14px;background:#f5a7b8;transform:rotate(10deg)}.b-three{left:149px;top:21px;background:#acddcc;transform:rotate(-3deg)}.modal-backdrop{position:fixed;inset:0;background:#6187ac30;z-index:20;display:flex;align-items:center;justify-content:center;padding:20px;backdrop-filter:blur(5px)}[hidden]{display:none!important}.sheet{width:405px;max-height:90svh;overflow-y:auto;border-radius:27px;padding:26px;background:#fbfdff;box-shadow:0 20px 80px #3d70902a;animation:sheet-in .2s ease-out;outline:none}.sheet-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px}.sheet h2{font-size:23px;font-weight:650;margin:0;letter-spacing:1px;color:#5b86ad}.sheet p{font-size:12px;line-height:1.9;color:#657f99}.sheet .eyebrow{font-size:9px;margin-bottom:6px;display:block}.help-chain{display:flex;justify-content:center;align-items:center;gap:9px;background:#eef6ff;border-radius:17px;padding:18px 8px;margin:18px 0}.help-ball{display:grid;place-items:center;width:39px;height:42px;border-radius:48%;font-size:22px;font-weight:700;color:#fff}.help-rule{display:flex;gap:11px;align-items:flex-start;margin:16px 0}.help-rule>span{flex-shrink:0;background:#eaf3fe;color:#7fa8d2;width:25px;height:25px;border-radius:50%;display:grid;place-items:center;font-size:11px}.help-rule p{margin:0;font-size:12px}.help-rule b{color:#507494;font-weight:650}.primary,.secondary{display:flex;align-items:center;justify-content:center;gap:9px;border-radius:14px;min-height:48px;padding:12px 16px;width:100%;font-size:13px;font-weight:600;letter-spacing:.5px}.primary{color:#fff;background:#83b4eb;box-shadow:0 3px 0 #5b90c81a}.secondary{background:#edf5fd;border:1px solid #e2edf8;color:#8ba9c6;margin-top:10px}.record-hero{text-align:center;margin:22px 0}.record-hero>strong{font-size:43px;font-weight:650;color:#5988b4;letter-spacing:-1px;display:block}.record-hero>span{font-size:10px;color:#a3b6c7}.record-grid{display:grid;grid-template-columns:repeat(3,1fr);background:#eff6fd;border-radius:16px;margin:18px 0;padding:17px 3px;text-align:center}.record-grid strong{display:block;font-size:25px;color:#769bbc;font-weight:600}.record-grid span{font-size:9px;color:#a6bacb}.sky-collection{display:flex;justify-content:space-around;gap:12px;padding:20px 8px;margin-bottom:15px}.sky-item{text-align:center;font-size:9px;color:#a9b7cc}.sky-item .help-ball{margin-bottom:8px;opacity:.25}.sky-item.earned .help-ball{opacity:1}.sheet .small-note{text-align:center;font-size:10px;line-height:1.8;margin:15px 0}.close{width:44px;height:44px}.save-note{font-size:9px!important;color:#c19c78!important}@keyframes combo-pop{0%{opacity:0;transform:translateY(8px) scale(.86)}18%{opacity:1;transform:translateY(0) scale(1.04)}65%{opacity:1;transform:translateY(0) scale(1)}100%{opacity:0;transform:translateY(-10px)}}@keyframes score-up{0%{opacity:0;transform:translate(-50%,4px) scale(.8)}20%{opacity:1;transform:translate(-50%,-4px) scale(1.05)}100%{opacity:0;transform:translate(-50%,-57px) scale(.9)}}@keyframes spark{0%{opacity:1;transform:translate(-50%,-50%) scale(1)}100%{opacity:0;transform:translate(calc(-50% + var(--dx)),calc(-50% + var(--dy))) rotate(130deg) scale(.25)}}@keyframes sheet-in{from{opacity:0;transform:translateY(18px)}to{opacity:1;transform:translateY(0)}} +@media(max-width:1090px){.desktop-note{display:none}} +@media(max-width:520px){body{align-items:flex-start;background:#f9fcff}.app{width:100%;height:100svh;min-height:740px;border-radius:0;border:0;box-shadow:none;padding:calc(13px + env(safe-area-inset-top)) 18px calc(12px + env(safe-area-inset-bottom));gap:9px}.modal-backdrop{align-items:flex-end;padding:0}.sheet{width:100%;max-width:480px;max-height:92svh;border-radius:27px 27px 0 0;padding:24px 24px calc(24px + env(safe-area-inset-bottom))}.scoreboard{min-height:80px}.score-main>strong{font-size:46px}.flow-bar{gap:12px;padding:11px 12px}} +@media(max-height:750px){.app{min-height:650px;gap:7px;padding-top:10px;padding-bottom:10px}.brand-bar{height:36px}.scoreboard{min-height:68px;padding-top:0}.score-main>strong{font-size:39px}.collection{min-height:61px}.best-balloon{width:34px;height:38px;font-size:22px}.best-balloon:after{left:13px}.flow-bar{height:53px}.tools button{min-height:55px}.chain-preview{height:27px}.game-footer{font-size:7.5px}.stage{min-height:300px}} +@media(max-width:350px){.app{padding-left:12px;padding-right:12px}.flow-bar{gap:9px;padding-left:10px;padding-right:10px}.upcoming{gap:6px;padding-left:9px}.upcoming>span{font-size:8px}.next-ball{width:19px;height:21px}.tools{gap:7px}.chain-preview{font-size:10px}.chain-preview .gain{font-size:9px}.sheet{padding-left:19px;padding-right:19px}} +@media(prefers-reduced-motion:reduce){*,*:before,*:after{animation:none!important;transition:none!important}} diff --git a/modes/03-hex-walls/.gitignore b/modes/03-hex-walls/.gitignore new file mode 100644 index 0000000..4611037 --- /dev/null +++ b/modes/03-hex-walls/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +web/dist/ diff --git a/modes/03-hex-walls/README.md b/modes/03-hex-walls/README.md new file mode 100644 index 0000000..1f4de52 --- /dev/null +++ b/modes/03-hex-walls/README.md @@ -0,0 +1,113 @@ +# 玩法三 · 六边一笔 + +六边形上一笔画走遍;墙贴在边上,挡住那一条邻接;线索数字必须踩在对应步号。 + +本目录自包含:规格、关卡、规则代码、求解器、生成器和 Web 原型。不 import 玩法一的矩形模型,也不把墙做成玩法一的后期关。壳的约定见 [`docs/PRODUCT.md`](../../docs/PRODUCT.md)。 + +几何锁六边形,墙是边不是格。关卡、资源、文案全部原创,不使用第三方题库或素材。 + +## 状态 + +M1/M2 已达:竖屏触摸 Web 原型可玩,10 关全部经求解器断言「唯一解 + 墙参与唯一性 + 无冗余墙」,存档、提示、音效、振动齐全。另有 **转墙** 实验包 9 关(GDD 附录 A)。下一步 M4 迁 Cocos。 + +## 目录 + +| 路径 | 用途 | +|---|---| +| [`docs/GDD.md`](docs/GDD.md) | 本玩法规格(权威) | +| [`docs/HANDOFF.md`](docs/HANDOFF.md) | 交接文档:实现了什么、怎么实现、与 GDD 的偏差、待办与 Cocos 迁移顺序 | +| [`levels/pack-1-tutorial.json`](levels/pack-1-tutorial.json) | 第一包 10 关 | +| [`levels/pack-2-spinner.json`](levels/pack-2-spinner.json) | 第二包(实验):转墙 9 关 | +| [`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 HexBoard:轴坐标六邻接、墙、转墙(按步号判定)、几何邻接 / 可走邻接、连通性 +game.ts 对局状态机:Command → GameEvent(start/append/undo/release/resume/reset;含 wallHit) +input.ts 手势语义:按下/移动/松手 + “当前压在哪个格” → Command +solver.ts 枚举满足线索的哈密顿路径、唯一解、墙有效性、提示(下一步 / 退回分叉) +geom.ts 纯几何:圆角多边形、凸裁剪、斜纹带 +layout.ts 六边形布局:轴坐标 ↔ 像素、命中测试(带死区)、共用边、桥、墙带 +view.ts HexBoardView:格/桥/数字/墙/冲突/提示/过关动效,只调用 Painter +painter.ts 绘制原语接口(多边形、线、圆、居中文字) +theme.ts 配色 +progress.ts 存档(KeyValueStore 抽象,桶 mode:03-hex-walls) +levels.ts 关卡包解析 +``` + +## 运行 + +```bash +cd modes/03-hex-walls +npm install +npm run dev # 起本地服务(默认 8100)+ 监听改动,终端会打印手机可访问的局域网地址 +npm run build # 打包到 web/dist/app.js,之后可直接双击 web/index.html +npm run check # 类型检查(含 src/ 无 DOM 校验)+ 单元测试 + 全关卡校验 +``` + +`?dev=1` 解锁全部关卡并暴露 `window.__hexWalls` 调试句柄(自动化手势测试用)。 + +关卡工具: + +```bash +npm run validate # 每关:连通、恰好 1 解、第 3 关起去墙后不唯一、无冗余墙、转墙不可换静态墙 +npm run validate -- 1-7 # 只看某关,并打印解 +npm run design -- 1-7 --minimize # 当前线索里哪些可删 +npm run design -- 1-7 --walls # 哪些墙可删 +npm run design -- 1-7 --suggest 3 --path a,b,c,... # 给作者解找最少线索 +npm run generate -- --shape hex2 --holes 2 --walls 3 --clues 3 --seed 7 --count 5 # 出候选盘 +npm run generate -- --shape rect4x2 --walls 0 --spinners 1 --clues 2 --seed 212 # 带转墙的候选 +``` + +## 第一包 · 10 关 + +每关只加一个旋钮(GDD §7)。「去墙解」= 拿掉全部墙后满足线索的解数,> 1 才说明墙在干活。 + +| 关 | N | 教什么 | 墙 | 线索 | 去墙解 | +|---|---|---|---|---|---| +| 1-1 | 3 | 滑动,邻格自动出步号 | 0 | 1 | – | +| 1-2 | 5 | 必须走完所有格 | 0 | 1, 5 | – | +| 1-3 | 4 | 第一堵墙:看起来相邻,滑不过去 | 1 | 1, 4 | 2 | +| 1-4 | 7 | 墙贴在 1 旁边,第一步只剩一个方向 | 1 | 1, 7 | 2 | +| 1-5 | 8 | 中段线索:3 在 1 旁边却要绕一步 | 1 | 1, 3, 8 | 2 | +| 1-6 | 11 | 空洞 + 两堵墙 | 2 | 1, 9 | 7 | +| 1-7 | 12 | 先想再画:旁边那格是陷阱 | 2 | 1, 9 | 8 | +| 1-8 | 12 | 综合:3 墙 3 线索 | 3 | 1, 6, 12 | 13 | +| 1-9 | 15 | 走廊:墙把一侧切成单向通道 | 3 | 1, 11, 15 | 16 | +| 1-10 | 19 | 小考:满花盘 4 墙 | 4 | 1, 10, 14 | 26 | + +## 第二包 · 转墙(实验) + +绕顶点转的墙,每走一格顺时针转 120°,轴心那端有圆点。规则与铁律见 GDD 附录 A。 + +| 关 | N | 教什么 | 静态墙 | 转墙 | 线索 | 去墙解 | +|---|---|---|---|---|---|---| +| 2-1 | 7 | 起手后墙正好挡住直觉的第一步,绕回来时它已转开 | 0 | 1 | 1, 7 | 2 | +| 2-2 | 8 | 用线索 4 算拍子:绕两格到 4,再往左时墙刚好开 | 0 | 1 | 1, 4 | 6 | +| 2-3 | 12 | 静态墙 + 转墙综合 | 1 | 1 | 1, 12 | 9 | +| 2-4 | 7 | 两个转墙,轴心相邻 | 0 | 2 | 1, 2 | 8 | +| 2-5 | 10 | 三角盘,两个转墙分居两端 | 0 | 2 | 1, 6 | 8 | +| 2-6 | 12 | 三个转墙围着中心格 | 0 | 3 | 1, 11 | 20 | +| 2-7 | 15 | 三个转墙挤在右侧 + 一堵静态墙 | 1 | 3 | 1, 7 | 49 | +| 2-8 | 17 | 三个转墙分布三处 | 0 | 3 | 1, 10 | 52 | +| 2-9 | 19 | 满花盘,四个转墙 | 0 | 4 | 1, 8, 17 | 41 | + +## 交互(已实现) + +- 有线索 `1` 只能从它起手,点别处 `1` 抖一下。 +- 按住滑到相邻、无墙、未走格 → 追加(弹一下、桥长出来、步号淡入、音高随步号上升)。 +- 滑向有墙的邻格 → 墙闪白 + 红描边 + 轻推、短振动、闷响;不追加,指针逻辑上仍在末端。撞转墙同样反馈。 +- 每追加 / 撤销一格,转墙做 120° 旋转动画。 +- 滑回上一格 → 撤销。不相邻 / 已走且非上一格 → 忽略。 +- 松手保留;从末端继续;点前一格撤销;点末端旁的空格直接追加。 +- 线索踩错 → 红底 + 角标写应有步号,不中断手势。走完且全对 → 提亮、扫光、自动下一关。 +- 提示:起点 / 下一步 / 退回分叉(压暗要退的段)/ 起手错(重开按钮脉冲)。 + +## 移植到 Cocos Creator + +见 [`docs/HANDOFF.md`](docs/HANDOFF.md) §4.2 D。要点:`src/` 整目录拷进 `assets/modes/03-hex-walls/scripts/` 不改一行;写 `GraphicsPainter`(`Graphics.moveTo/lineTo/close/fill/stroke` + `Label` 池);触摸事件转节点坐标后喂 `hitTest` → `PointerInput`;`sys.localStorage` 实现 `KeyValueStore`。 diff --git a/modes/03-hex-walls/docs/GDD.md b/modes/03-hex-walls/docs/GDD.md new file mode 100644 index 0000000..10c3a15 --- /dev/null +++ b/modes/03-hex-walls/docs/GDD.md @@ -0,0 +1,386 @@ +# 玩法三 · 六边一笔 + +本文只描述**玩法三**。玩法一、玩法二有自己的目录和 GDD,见仓库 [`docs/PRODUCT.md`](../../../docs/PRODUCT.md)。 + +在六边形拼板上画一条哈密顿路径。格子之间的边上可以有墙,墙挡住那一条邻接。预先写好的数字,必须刚好在那一步走到。 + +本玩法是**独立玩法**:六边形 + 边墙,不用矩形大格。墙的价值是把“第一步往哪走”收成推理——看起来相邻的格子,有墙就走不过去。关卡、资源、文案全部原创。 + +--- + +## 1. 一句话 + +在六边形盘上走遍每一格;黄黑条纹的墙贴在两条格子的共用边上,那条路走不通;线索数字必须踩在对应步号上。 + +## 2. 产品定位 + +| 项 | 决定 | +|---|---| +| 类型 | 单机、离线、无体力、无计时的逻辑 puzzle | +| 平台 | 手机竖屏。本玩法 V1 先在 `modes/03-hex-walls/web/` 可玩 | +| 单局 | 20–120 秒想,5–12 秒画完 | +| 会话 | 通勤 3 关,睡前 10 关 | +| 对象 | 已经能接受一笔画的人;墙是给“矩形走腻了”的第二层 | +| 付费 | 竖直切面免费无广告 | + +和玩法一同属“顺序一笔画”,但几何不同: + +| | 玩法一 | 玩法三 | +|---|---|---| +| 格子 | 矩形,可 2×2 只计一步 | 正六边形,一格一步 | +| 邻接 | 四向共边 | 六向共边 | +| 障碍 | 空洞 | 空洞 + **边墙** | +| 代码 | `modes/01-number-stroke` | `modes/03-hex-walls`,禁止互相 import | + +不要把墙补进玩法一的后期关。墙是本玩法的主旋钮。 + +## 3. 核心循环 + +1. 看盘:线索在哪,墙封了哪几条边,哪一格看起来近其实过不去。 +2. 推:从线索往外挤,尤其是被墙封死的第一步。 +3. 画:滑到相邻且无墙的未走格,自动填步号。 +4. 错了沿原路退,或重开。 +5. 全填满且线索全部对上 → 过关。 + +没有分数。爽感来自“墙把表面的近路掐掉,只剩一条”。 + +--- + +## 4. 规则(权威) + +### 4.1 盘面 + +- 格子是点顶(pointy-top)正六边形,用轴坐标 `(q, r)`。 +- 每个格是图上一个节点,路径里计 1 步。没有大格。 +- 单位格要么属于恰好一个六边形,要么是空洞。 +- 盘面可不规则。 + +轴坐标邻接(未考虑墙): + +``` +(q+1, r), (q-1, r), (q, r+1), (q, r-1), (q+1, r-1), (q-1, r+1) +``` + +### 4.2 墙 + +墙是**无向边**,贴在两个相邻格的共用边上,画成黄黑斜纹警示条。 + +- 有墙的两格视为**不相邻**。其它邻接不受影响。 +- 墙不占格,不能走在墙上。 +- 同一对格最多一堵墙。 +- 墙必须画在真实共用边上。不能“对角墙”。 + +这是本玩法相对玩法一多出来的唯一新原语。 + +### 4.3 路径 + +目标:在「去墙之后的格图」上找一条哈密顿路径。 + +- 每个格恰好一次。 +- 只能走到相邻、无墙、尚未走过的格。 +- 第 k 个格显示数字 k(从 1 起)。 +- 不能分叉、不能跳、不能穿墙。 + +### 4.4 线索格 + +部分格预先写了正整数 `c`,带描边。 + +- 走到该格时步号必须等于 `c`。 +- 可以没有 `1`(起点未知)。教学前 8 关必须有 `1`。 +- 线索互不相同,`1 ≤ c ≤ N`。 +- 可以没有 `N`。 + +### 4.5 对错 + +允许暂时画错: + +- 踩到线索但步号 ≠ `c`:该格冲突态(红底),显示当前步号。 +- 冲突不打断手势。 +- **过关**:所有格走过,且所有线索步号正确。 +- 无失败弹窗。穿墙或对角滑:忽略。 + +### 4.6 内容铁律 + +发布前求解器必须断言: + +1. 去墙后的图连通。 +2. 恰好 1 条哈密顿路径满足全部线索。 +3. 线索尽量少。教学关可以多给。 + +若去掉全部墙后解变得不唯一,说明墙在干活,这关才算合格的“墙关”。第 3 关起,每关至少 1 堵墙,且去掉墙会破坏唯一性。 + +--- + +## 5. 交互 + +单指。和玩法一同一套手感,只是命中的是六边形、且要检查墙。 + +| 手势 | 行为 | +|---|---| +| 点合法起点 | 该格成为第 1 步 | +| 滑向相邻、无墙、未走格 | 追加 | +| 滑向有墙的邻居 | 忽略(可在墙上闪一下) | +| 滑回上一格 | 撤销 | +| 滑到不相邻 / 已走且非上一格 | 忽略 | +| 松手 | 保留路径,可从末端继续 | + +合法起点:有线索 `1` 则只能从它起手;否则任意格。 + +按钮:重开、提示、菜单(本玩法选关 / 回壳)。顶栏:`{pack}-{index}`、剩余格、菜单。 + +输入状态机与玩法一相同:Empty → Drawing → Idle → Cleared。实现时不要 import 玩法一,允许各写一份。 + +--- + +## 6. 反馈与手感 + +| 事件 | 反馈 | +|---|---| +| 追加一步 | 六边形 80ms 弹,共用边拉一条短线 | +| 撞墙 | 墙闪白 100ms,短振动,不追加 | +| 线索步号正确 | 描边闪白 | +| 冲突 | 红底 | +| 过关 | 路径色提高,短线顺次扫过 | + +未走:中灰。已走:蓝紫。墙:黄黑斜纹,对比足够,不靠颜色当唯一信息(色弱可把墙改成粗黑 + 缺口)。背景白。 + +--- + +## 7. 关卡语言 + +一次只加一个旋钮。 + +| 旋钮 | 作用 | 何时引入 | +|---|---|---| +| 格数 N | 6–8 教学,9–14 常规,15–20 后期 | 全程 | +| 墙的条数 | 1 堵教规则,2–4 堵做推理 | 第 3 关 | +| 墙贴在线索旁 | 第一步往往只剩一个方向 | 第 4 关 | +| 空洞 | 和墙叠加,近路更假 | 第 6 关 | +| 中段线索 | 不只靠起点 | 第 5 关 | +| 无线索 1 | 起点未知 | 第 13 关之后 | + +不做:矩形大格、斜穿、传送、限时。大格是玩法一的旋钮,不要混进来。 + +### 7.1 教学 8 关(必须手做) + +1-1 到 1-4 的坐标在 `levels/pack-1-tutorial.json`。 + +| 关 | 教什么 | +|---|---| +| 1-1 | 六边形、滑动、自动出数字。无墙。3 格 | +| 1-2 | 必须走完。5 格,标 1 和 5 | +| 1-3 | 第一堵墙:看起来相邻,滑不过去,必须绕 | +| 1-4 | 墙贴在 1 旁边,第一步不是“随便挑邻居” | +| 1-5 | 中段线索 | +| 1-6 | 两堵墙 + 一个空洞 | +| 1-7 | 先想再画,错了要退 | +| 1-8 | 综合:约 12 格、3 堵墙、3 个线索 | + +1-3 是关键:过不了“墙是边不是格”,后面全废。 + +### 7.2 结构 + +一包 8 关。V1:教学 8 + 3 包 × 8 = **32 关**。线性解锁。 + +### 7.3 规则样例 + +规则样例直接用 `levels/pack-1-tutorial.json`:1-3 演示“墙是边不是格”(两格共边却被墙封住,看起来相邻,滑不过去,必须绕),1-4 演示“墙贴在线索旁,第一步只剩一个方向”。全部关卡由求解器断言唯一解且墙参与唯一性(§4.6)。 + +--- + +## 8. 生成器与求解器 + +教学关手做。第 9 关起可生成,人要过一眼。 + +求解器: + +- 输入:六边形列表 + 墙边列表 + 线索。 +- 邻接 = 轴坐标六邻接减去墙。 +- 回溯哈密顿路径,线索处剪枝。 +- `N ≤ 20`。发布条件 `paths.length === 1`。 +- 额外检查:删掉所有墙之后 `paths.length !== 1`(墙必须改变解集合)。 + +生成: + +1. 铺一块连通的六边形聚落,可挖洞。 +2. 随机一条哈密顿路径当作者解。 +3. 在**不是作者解所用的边**上插 1~3 堵墙。 +4. 加 2~3 个线索直到唯一。 +5. 跑“去墙不唯一”检查。失败则换墙。 + +关卡文件: + +```json +{ + "id": "1-3", + "pack": 1, + "index": 3, + "orientation": "pointy", + "cells": [ + { "id": "a", "q": 0, "r": 0, "clue": 1 }, + { "id": "b", "q": 1, "r": 0 }, + { "id": "c", "q": 0, "r": 1 } + ], + "walls": [ + ["a", "b"] + ] +} +``` + +`walls` 是格 id 对。求解器不写进 JSON。 + +--- + +## 9. 界面与流程 + +``` +(壳:选玩法)→ 本玩法选关 → 对局 → 过关 → 下一关 + ↘ 菜单:关卡列表 / 回壳 +``` + +对局层:顶栏关号与剩余格,中间六边形盘(按短边缩放),底栏重开 / 提示。过关短动画后自动下一关。 + +1-1 一行:“从一个数字滑到旁边的格子”。1-3 一行:“条纹是墙,绕开它”。之后无文案。 + +--- + +## 10. 提示、存档 + +提示: + +- 无路径:指出合法起点。 +- 路径是唯一解前缀:指出下一步。 +- 已偏离:高亮分叉点,不继续给错下一步。 +- 竖直切面无限次。 + +存档键前缀 `mode:03-hex-walls`。`clearedId[]`、`currentId`。不存半途路径。 + +--- + +## 11. 设计边界 + +V1 只做主规则(§4)。明确不做: + +- 矩形大格(玩法一的旋钮) +- 矩形 + 墙、斜穿、传送、限时 +- 计分 + +几何锁定为六边形,墙是边不是格。关卡、资源、文案全部原创,不使用第三方题库或素材。 + +--- + +## 12. 技术 + +全部落在 `modes/03-hex-walls/`。 + +禁止: + +- import 玩法一的 Cell / 四向邻接 / 矩形碰撞 +- 把墙做成“第三种格子” + +| 项 | 决定 | +|---|---| +| 竖直切面 | `web/` 单页 | +| 坐标 | 轴坐标 pointy-top | +| 求解器 | `tools/solver.ts`,只服务本玩法 | +| 关卡 | `levels/*.json` | + +模块:`model`(Hex、Wall、Path)、`rules`、`input`、`solver`、`view`。 + +--- + +## 13. 竖直切面 + +1. 1-1、1-2 无墙可过。 +2. 1-3 直滑进墙无效,绕开后能过。 +3. 1-4 有线索 1 时点别的格起手无效;墙让第一步只剩一个方向。 +4. 冲突、撤销、重开与玩法一同样稳。 +5. 求解器对 VS 关断言唯一解,且去墙后不唯一(1-3 起)。 +6. 提示在偏离时拉回分叉。 + +--- + +## 14. 里程碑 + +| 阶段 | 交付 | +|---|---| +| M0 | 本 GDD 锁定 | +| M1 | 规则 + 求解器 + 1-1~1-4 可玩 | +| M2 | 1-1~1-8 + 存档 | +| M3 | 再 24 关 + 选关 | +| M4 | 迁 Cocos 本玩法场景 | + +--- + +## 15. 关键决定 + +1. **独立玩法,不是玩法一的 DLC。** 六边形模型和边墙都住在本目录。 +2. **墙是边,不是格。** 穿墙 = 那条邻接不存在。 +3. **解唯一,且墙必须参与唯一性。** +4. **允许画错,随时撤回。** +5. **无大格。** 大格留给玩法一。 +6. **先 Web,再 Cocos。** + +--- + +## 16. 验收 + +- 没玩过的人在 1-3 结束前理解墙。 +- 1-4 开始出现停顿,不是闭眼滑。 +- 任意关关闭提示仍可人解。 +- 撞墙 10 次不误走、不穿模。 + +--- + +## 17. 已关闭的问题 + +- 要不要合并进玩法一?不要。 +- 要不要矩形 + 墙?不要。本玩法锁六边。 +- 要不要计分?不要。 + +## 18. 留给实现的缺口 + +- 对外名称是否就用「六边一笔 / Hex Stroke」。 +- 墙的最终画法(斜纹宽度、是否要缺口给色弱)。 +- M3 关是手做还是生成后人工改,看 M2。 + +--- + +## 19. 附录 A · 转墙(实验机制,2026-09-15 加入) + +### A.1 设计铁律:手指就是笔尖 + +路径的末端永远在指尖下面;手指走过哪些格,路径就是哪些格。任何让末端离开指尖的机制(滑行、传送、跳格、自动延伸)一律不做。新机制只能改三样东西:哪一步合法、数字怎么数、过关条件是什么。 + +### A.2 规则 + +- 六边形网格上每个顶点交汇三条边,互成 120°。转墙固定在一个顶点上,任意时刻占其中一条边,画法与静态墙相同,只在轴心那一端加一个圆点。 +- 每走一格(含起手那一格),所有转墙同拍顺时针转 120°。已走 t 格时,墙在第 `(phase + t) mod 3` 条边上。 +- 判定能否从末端走到邻格,用的是**出发前**的位置;走完再转。撤销一步则倒转回去。状态完全由步号推出,不需要额外存档。 +- 转到盘外或空洞的边上时,墙暂时不挡任何东西。 +- 转墙不得与静态墙重叠;周围至少要有两条盘上的边。 + +### A.3 玩家体验 + +玩家不仅要估计路线,还要估计到达某条边时墙在哪。线索数字给出步号,步号 mod 3 就是墙的位置,所以线索和转墙天然互相配合:为了让某一步“刚好开着”,路线要多绕或少绕一格。 + +### A.4 内容铁律(在 §4.6 之上追加) + +每个转墙必须同时满足: + +1. 去掉它,解不再唯一。 +2. 把它换成钉在任意一条候选边上的静态墙,解都不再唯一(保证“会转”参与推理,而不是随便一堵墙就能替代)。 + +同一顶点只能放一个转墙;两个转墙可以共用一条候选边。实验包里最多放到 4 个转墙(19 格),正式包按试玩结果定上限。 + +### A.5 关卡文件 + +```json +"spinners": [{ "cell": "c", "corner": 0, "phase": 1 }] +``` + +`corner` 是该格第几个角(从 30° 起顺时针 0..5),`phase` 是空盘时墙在第几条候选边。候选边顺时针编号:0 = 背离该格的那条边,1 = 该格与顺时针下一邻格,2 = 该格与上一邻格。 + +### A.6 实验包 + +`levels/pack-2-spinner.json`,9 关:2-1 教规则(起手后墙正好挡住直觉的第一步),2-2 用中段线索算拍子,2-3 静态墙 + 转墙综合;2-4 起转墙数逐步加到 4,格数 7→19,让“满盘都在动”的感觉逐关加强。 diff --git a/modes/03-hex-walls/docs/HANDOFF.md b/modes/03-hex-walls/docs/HANDOFF.md new file mode 100644 index 0000000..2bee22e --- /dev/null +++ b/modes/03-hex-walls/docs/HANDOFF.md @@ -0,0 +1,247 @@ +# 玩法三 · 六边一笔 — 交接文档 + +> 面向接手的人。读完本文 + 跑一遍 `npm run check`,应该能不问人就继续往下做。 +> 规格权威是 [`GDD.md`](GDD.md),目录/运行方式速查在 [`../README.md`](../README.md),本文讲“做了什么、怎么做的、为什么、还差什么”。 +> +> 交接日期:2026-09-15。状态:M1/M2 达成(Web 原型可玩、10 关唯一解且墙参与推理、存档/提示/音效/振动齐全),另有转墙实验包 9 关(§1.4),未进 Cocos。 + +--- + +## 0. 一分钟上手 + +```bash +cd modes/03-hex-walls +npm install # 若 esbuild 的 postinstall 被 npm 拦住:npm approve-scripts esbuild && npm rebuild esbuild +npm run dev # 起服务(默认 8100,--port N 或 PORT=N 改)+ 监听改动 +npm run check # 类型检查(含 src/ 无 DOM 校验)+ 30 个单元测试 + 10 关校验 +``` + +浏览器打开 `http://localhost:8100/?dev=1`:`?dev=1` 解锁全部关卡并暴露 `window.__hexWalls`(`debugStart(id)` / `debugCellCenter(id)` / `debugState()`)。 + +--- + +## 1. 实现了什么 + +### 1.1 交付清单(对照 GDD) + +| GDD 条目 | 状态 | 备注 | +|---|---|---| +| §4.1 点顶六边形、轴坐标、六邻接 | 完成 | `src/board.ts`,`HEX_DIRS` | +| §4.2 墙 = 无向边、去掉那条邻接、必须在真实共用边上、同一对格最多一堵 | 完成 | `HexBoard` 构造时校验;`adj`(可走)与 `geoAdj`(几何)分开 | +| §4.3–4.5 路径、线索、冲突态、过关 | 完成 | `src/game.ts`,有单测 | +| §4.6 内容铁律:连通、恰好 1 解、第 3 关起墙参与唯一性 | 完成 | `npm run validate` + `tests/solver.test.ts` 双重断言;额外断言无冗余墙 | +| §5 手势(含滑向有墙邻居 → 忽略并反馈) | 完成 | `src/input.ts`;`Game` 发 `wallHit` 事件 | +| §6 反馈:追加弹、共用边拉桥、撞墙闪白 + 振动、线索闪白、冲突红、过关提亮扫光 | 完成 | `src/view.ts`;音效 `web/audio.ts` | +| §7.1 教学关 | 完成 10 关(用户要求 10 关) | 见 §1.3 | +| §8 求解器(回溯 + 连通/线索距离剪枝,N ≤ 20) | 完成 | 19 格 1.5ms | +| §8 生成器五步 | 完成 | `tools/generate.ts`;出候选,人过一眼再进包 | +| §9 界面流程 | 完成 | 选关 → 对局 → 过关自动下一关 → 最后一关回选关 | +| §10 提示三态 + 起手错;存档 `mode:03-hex-walls` | 完成 | 存档带 `v: 1` | +| §12 模块 model/rules/input/solver/view,纯 TS | 完成 | `tsconfig.src.json` 用 `lib: ["ES2020"]` 强制 `src/` 不碰 DOM | +| §13 竖直切面 6 条 | 全部在浏览器触摸仿真下验证过 | 见 §2.7 | + +### 1.2 关卡:10 关 + +坐标 `(q, r)` 点顶六边形,格 id 按阅读顺序编号(先 r 再 q)。下面每关左边是盘面(`[n]` 线索),右边是唯一解步号,`墙:` 列出被封的格对。全部由 `npm run validate` 断言。 + +**1-1** 滑动(字幕:从 1 滑到旁边的格子) +``` +[1] b c 1 2 3 +``` + +**1-2** 必须走完(字幕:走完每一格,数字要对上) +``` +[1] 1 + b 2 + c d [5] 3 4 5 +``` + +**1-3** 第一堵墙。a、b 共边但被封,必须经 c 绕到 b(字幕:条纹是墙,绕开它)。去墙后 2 解。 +``` +[1] b 1 3 + c [4] 2 4 墙: a|b +``` + +**1-4** 墙贴在 1 旁边(字幕:先看墙和数字,再决定第一步)。1 在左下 f,f|g 被封;中心 d=7 只能最后进,所以第一步只能往上 c,绕外圈一周落中心。 +``` + a b 3 4 + c [7] e 2 7 5 + [1] g 1 6 墙: f|g +``` + +**1-5** 中段线索。3 就在 1 右边却不能直接过去,必须先下到 e 再折回 b;墙 b|c 堵住“3 之后直走”,逼出之字形。3 个线索全部必要。 +``` +[1] [3] c [8] 1 3 5 8 + e f g h 2 4 6 7 墙: b|c +``` + +**1-6** 空洞 + 两堵墙。1 下方是洞只能往右;顶行到 c 被 b|e 逼下去,e、f、回 d、g,右侧下到 k,底行从右往左收到 9。去墙后 7 解。 +``` +[1] b c d 1 2 3 6 + e f g 4 5 7 + h i [9] k 11 10 9 8 墙: e|j b|e +``` + +**1-7** 先想再画。1 在底行 k,旁边 h 被 h|i、h|j 围住,直觉先进 h 会把 j 孤立;正解先右绕整圈,e=9 → h → g → j。无线索路径 320 条。 +``` + a b c 7 6 5 + d [9] f 8 9 4 + g h i 11 10 3 + j [1] l 12 1 2 墙: h|i h|j +``` + +**1-8** 综合。i=1 起手、a=12 收尾、l=6 决定先走底行;e 被 b|e、e|f 夹住只剩上下两个口。去墙后 13 解。 +``` +[12] b c d 12 11 9 8 + e f g h 2 10 5 7 +[1] j k [6] 1 3 4 6 墙: f|k b|e e|f +``` + +**1-9** 走廊。1 在 g 五向可起手;a=15 定终点、l=11 定绕向;c|g、i|n、e|i 把右侧切成单向通道。去墙后 16 解。 +``` +[15] b c d e 15 14 3 4 5 + f [1] h i j 13 1 2 7 6 + k [11] m n o 12 11 10 9 8 墙: c|g i|n e|i +``` + +**1-10** 小考。19 格满花盘。i=1 在内圈左,k=14 / p=10 在右侧;先上绕到顶行、顺右侧下、穿底回内圈、左侧收尾到 h。去墙后 26 解。 +``` + a b c 4 5 6 + d e f g 3 2 7 8 + h [1] j [14] l 19 1 15 14 9 + m n o [10] 18 16 13 10 + q r s 17 12 11 墙: f|k d|i k|l b|f +``` + +难度旋钮引入顺序:格数 → 墙(1-3) → 墙贴起点(1-4) → 中段线索(1-5) → 空洞(1-6) → 陷阱格(1-7) → 3 墙 3 线索(1-8) → 走廊(1-9) → 满花盘(1-10)。GDD 的“无线索 1”按 §7 留到第 13 关之后,本包全部有 `1`。 + +关卡 JSON 里 `teach` 是设计备注(含剧透)**永远不要渲染**;只有 `caption` 显示在顶部。 + +### 1.4 转墙实验包(`levels/pack-2-spinner.json`) + +规则见 GDD 附录 A。实现要点: + +- `SpinnerDef {cell, corner, phase}` → `HexBoard.spinners[]`,构造时把顶点周围三条边算成 `edges[3]`(不在盘上的为 null)。角 k 夹在屏幕方向 60k 与 60(k+1) 之间,对应 `HEX_DIRS[(6-k)%6]` 与 `HEX_DIRS[(5-k)%6]`。 +- 状态不存:`spinnerPos(sp, t) = (phase + t) mod 3`,t = 已走格数。`HexBoard.canStep(a, b, t)` / `adjAt(a, t)` 在静态邻接之上再过滤。`Game.canAppend` 传 `path.length`,求解器 DFS 传当前 step。撤销 / 重开天然一致。 +- BFS 连通剪枝仍用静态邻接(转墙只挡一条边,这是有效的松弛)。 +- `wallHit` 事件多了 `spinner` 字段;视图给转墙做 120° 指数逼近的旋转动画(`SPIN_SPEED 16`,约 120ms),轴心画深色圆 + 黄点。 +- 校验:`spinnerReport()` 断言每个转墙「去掉后不唯一」且「钉成任意一条静态墙后不唯一」;`generate --spinners n` 只在作者解每一步都不被挡的顶点上放。 +- 9 关:2-1~2-3 各一个转墙(教规则 / 算拍子 / 配静态墙),2-4~2-9 逐步加到 2、2、3、3、3、4 个转墙,格数 7→19。全部触摸仿真滑通。同一顶点不允许放两个转墙(构造时按轴心坐标去重);两个转墙共用一条候选边是允许的。 +- Web 选关页已支持多包(拼成一条线性序列,包间线性解锁,分组标题)。 + +### 1.3 视觉 + +浅色单主题(`src/theme.ts`):未走 `#E4E6EE`、已走靛蓝 `#4F5BD5`(过关 `#6672F0`)、线索描边近黑、冲突红 `#E5484D`、墙黑底 `#1F1F27` + 黄斜纹 `#FFC826`、提示橙 `#FF7A1A`(刻意避开墙黄)。桌面浏览器 ≥ 520px 宽时显示手机框。选关页瓦片用 CSS `clip-path` 做六边形。 + +--- + +## 2. 如何实现 + +### 2.1 技术选型与理由 + +目标是“迁 Cocos Creator 3.x 不重写”。与玩法一保持同一套决策,但代码各写一份(隔离规则): + +| 决策 | 理由 | +|---|---| +| `src/` 纯 TS,零 DOM / 零 Cocos | Cocos 脚本就是 TS;`tsconfig.src.json`(`lib: ["ES2020"]`, `types: []`)编译一遍,谁往 `src/` 写 `document` 就报错 | +| import 无后缀 | Cocos 不允许 `import './foo.ts'` | +| 渲染走 `Painter` 五原语:`fillPolygon / strokePolygon / line / fillCircle / text` | **所有多边形(含圆角、斜纹裁剪)在 `src/geom.ts` 用纯数学算好**,实现方只需 moveTo/lineTo/close。Cocos `Graphics` 没有 `arcTo`,所以不把圆角交给画笔 | +| 输入层只认“指针压在哪个格” | `hitTest` 做像素 → 轴坐标(立方取整)+ 六边形死区;Cocos 只换坐标来源 | +| 存档走 `KeyValueStore` | Web `localStorage`,Cocos `sys.localStorage`,测试 `MemoryStore` | +| esbuild 单文件 IIFE | `file://` 可开;关卡 JSON 打进 bundle | + +### 2.2 六边形几何要点(`src/layout.ts`) + +- 点顶六边形,`size` = 外接圆半径。中心 `x = √3·size·(q + r/2)`,`y = 1.5·size·r`。 +- `fitBoard`:取所有格外接矩形,按短边缩放到容器,`maxSize` 上限(`web/main.ts`:N ≤ 6 用 52,否则 60)。 +- 缝 `gap = 0.12·size`;绘制半径 `drawRadius = size − gap/√3`(内切圆各缩 gap/2)。 +- `hitTest`:`pixelToAxial` 立方取整得到候选格,再用点顶六边形内部判定 `|dx| ≤ R√3/2 且 |dx| + √3|dy| ≤ √3R`(R = size·(1−0.14))做死区。 +- `sharedEdge(a,b)`:边中点 = 两中心中点,法向 n = a→b 单位向量,边向 u ⊥ n,边长 = size。不用查顶点表。 +- 桥 `bridgePolygon`:沿边居中、宽 0.5 边长、跨 `gap + 2px` 的矩形。墙 `wallBand`:长 0.92 size、厚 0.3 size 的带;底用圆头 `line`,斜纹用 `stripesInBand`(45° 平行四边形 Sutherland–Hodgman 裁到带内)。 + +### 2.3 数据流 + +``` +PointerEvent (web/main.ts) → 容器像素坐标;pointermove 按 size·0.4 插值采样 + ▼ layout.hitTest → cellIndex | -1 + ▼ PointerInput.down/move/up → Command + ▼ Game.apply → GameEvent[] step / unstep / reset / rejectStart / wallHit / deadEnd / cleared + ▼ handleEvents: view.onEvent(动效)+ sfx/vibrate + HUD + 过关计时跳关 +每帧 rAF:view.update(dt);isAnimating 时 clearRect + view.draw(painter) +``` + +`Game.append` 自己区分三种情况:可走 → `step`;几何相邻但有墙且未走 → `wallHit{from,to,wall}`;其它 → 无事件。`PointerInput.move` 因此不需要知道墙。`hoverCell` 去抖保证停在墙外只报一次。 + +### 2.4 求解器与工具 + +- `solve()`:回溯 + BFS 连通剪枝 + 线索距离剪枝 + Warnsdorff 排序;`random` 选项给生成器随机化候选顺序。 +- `wallsMatter(board)`:`solve(board.withoutWalls(), {limit: 2}).length !== 1`。`redundantWalls(board)`:逐堵删掉仍唯一的墙。 +- `tools/validate.ts` 断言:连通、恰好 1 解、第 3 关起 ≥ 1 墙且去墙不唯一;打印无线索路径数、去墙解数、可删墙。 +- `tools/design.ts`:`--minimize`(线索可删性)、`--walls`、`--suggest k --path`。 +- `tools/generate.ts`:GDD §8.2 五步。`--shape hex1|hex2|tri4|tri5|para3x4|rect4x3|rect5x3...` `--holes` `--walls` `--clues` `--seed` `--count`;只输出“唯一 + 去墙不唯一 + 无冗余墙”的候选。本包 1-4~1-10 都是先生成再人工挑选、改线索、重编 id。 + +### 2.5 测试 + +30 个 node:test 用例:`board`(六邻接、墙、连通、非法盘)、`game`(起手、撞墙、冲突、过关锁定、流转)、`input`(滑动/撤销/撞墙一次/点着走/非法起手/过关失效)、`solver`(10 关唯一 + 墙参与 + 无冗余 + 前 8 关有 1、提示四态、随机模式)、`layout`(适配、像素往返、死区、共用边、几何工具)、`progress`。 + +浏览器触摸仿真(ego-browser / 任意 CDP,390×844)已跑通:10 关沿唯一解滑通、`debugState().status === 'cleared'`、存档内容正确;1-3 直滑进墙路径不变;走错 → 冲突红底角标 → 提示回退高亮并压暗。脚本片段与玩法一 HANDOFF §2.7 相同,只是句柄换成 `window.__hexWalls`,多一个 `debugStart(id)`。建议落成 `tests/e2e/`。 + +--- + +## 3. 与 GDD 的偏差 + +| 项 | GDD | 实际 | 原因 | +|---|---|---|---| +| 包大小 | 教学 8 + 3 包 × 8 | 第一包 10 关 | 用户要求 10 关;1-9/1-10 相当于 GDD 的 2 包前两关 | +| 原有 `levels/pack-1-tutorial.json` 的 1-4 | 线索 1,3,5 + 墙 a\|b | 重做为 7 格花形 | 原盘去墙后仍唯一,不满足 §4.6“墙必须参与唯一性” | +| 字幕 | 只 1-1、1-3 | 1-1~1-4 | 帮第一次玩的人 | +| 冗余墙 | 未要求 | 额外断言每堵墙都必要 | 让“墙就是推理”成立 | +| 撞墙反馈 | 墙闪白 100ms | 闪白 + 红描边 + 沿法向轻推 + 闷响 + 振动 | 只闪白在浅色底上不够醒目 | +| 菜单 / 音效振动开关 | 壳 | 左上返回 + 选关页 chip | 壳不存在 | +| 提示色 | 未定 | 橙 | 避免与墙黄混淆 | +| `hintPath` 烘焙 | 可选 | 运行时 `uniqueSolution()` | 19 格 1.5ms | + +GDD §18 留白的“对外名称”暂用「六边一笔」;墙画法定为黑底黄斜纹圆头带,色弱下靠黑黄明度差 + 形状(条纹带)可辨,未做“缺口”变体。 + +--- + +## 4. 待办 + +### 4.1 已知不足 + +1. 没有真机、真人数据:死区 14%、插值 0.4 size、墙厚 0.3 size、过关 1.06s 跳关,都要真机摸过再定。 +2. 只支持一个包;M3 的多包选关和包间解锁未做(`Progress.isUnlocked` 已能按 id 数组判断)。 +3. 生成器的“漂亮度”过滤没有(重复折返、线索挤在一起),靠人挑。 +4. 无线索 `1` 的关一个都没有(按 GDD 留到第 13 关后);`hint` 的 `restart` 态界面上未真正触发过。 +5. 高对比模式、多语言、存档迁移(已有 `v: 1`)未做。 +6. 19 格盘在 390 宽屏上受宽度限制,size ≈ 42px;更大的盘(hex3)需要考虑横向滚动或缩小 gap。 + +### 4.2 接下来 + +**A. 收尾 M2**:真机跑一遍;找 3~5 人试玩看 1-3 是否理解“墙是边”、1-7 是否出现停顿、2-1 是否看懂“墙会转”;把触摸脚本落成 Playwright。转墙若验证通过,按 GDD 附录 A 出一整包 8~10 关(可用生成器 `--spinners`),并考虑“转到盘外暂时消失”的边界顶点关。 + +**B. M3 内容**:用 `npm run generate` 出 2~3 包,每包 8~10 关,人工挑 + `design.ts` 调线索;选关页改包列表。 + +**C. 壳接入**:把 `web/main.ts` 的 `App` 改造成 `PuzzleMode { id: '03-hex-walls', mount, unmount }`,存档/音效/振动从 `ShellContext` 读。 + +**D. M4 迁 Cocos Creator 3.x** +1. `assets/modes/03-hex-walls/scripts/` ← 拷 `src/` 全部文件,不改一行。 +2. 关卡 JSON 放 `resources/levels/`,`resources.load(..., JsonAsset)` → `parsePack/loadPack`。 +3. 写 `GraphicsPainter implements Painter`:`fillPolygon/strokePolygon` = `moveTo` 第一点、`lineTo` 其余、`close`、`fill/stroke`;`line` = `moveTo/lineTo/stroke`(`lineCap = ROUND`);`fillCircle` = `circle + fill`;`text` = `Label` 对象池(每帧先全部隐藏再按需取用,`setPosition` 到中心)。Cocos y 轴向上:在 Painter 内 `y' = H − y`。 +4. `BoardController` 组件:`onLoad` 建 `Game / HexBoardView / PointerInput`;`update(dt)` 调 `view.update(dt)`,`isAnimating` 时 `graphics.clear()` + `view.draw(painter)`;`Node.on(TOUCH_START/MOVE/END/CANCEL)` → `UITransform.convertToNodeSpaceAR` → 翻 y → `hitTest` → `PointerInput`。TOUCH_MOVE 里照搬 `web/main.ts` 的插值采样。 +5. `Progress(new CocosStore())`,`CocosStore` 包 `sys.localStorage`。 +6. HUD、选关(六边形瓦片用 Sprite 或 Graphics)、字幕、toast 用 UI 节点复刻 `index.html`;音效换 `AudioSource` 五段短音(step/wall/conflict/cleared/reject)。 +7. 用 §2.5 的路径数据在真机上把 10 关滑一遍。 +8. 不搬:`web/`、`tools/`、`tests/`(测试继续在 Node 跑,只依赖 `src/`)。 + +--- + +## 附:常用命令 + +```bash +npm run dev # 起服务 +npm run check # 全部检查 +npm run validate -- 1-10 # 看某关盘面/解/去墙解数 +npm run design -- 1-10 --minimize --walls +npm run generate -- --shape hex2 --holes 2 --walls 4 --clues 3 --seed 82 --count 3 +``` diff --git a/modes/03-hex-walls/levels/pack-1-tutorial.json b/modes/03-hex-walls/levels/pack-1-tutorial.json new file mode 100644 index 0000000..cd24342 --- /dev/null +++ b/modes/03-hex-walls/levels/pack-1-tutorial.json @@ -0,0 +1,204 @@ +{ + "mode": "03-hex-walls", + "pack": 1, + "title": "教学", + "orientation": "pointy", + "notes": "轴坐标 (q, r),点顶六边形。walls 是格 id 对,必须是真实共用边。格 id 按阅读顺序(先 r 再 q)编号。teach 是设计备注不显示,caption 显示在对局顶部。", + "levels": [ + { + "id": "1-1", + "teach": "六边形滑动,邻格自动出步号。无墙。", + "caption": "从 1 滑到旁边的格子", + "cells": [ + { "id": "a", "q": 0, "r": 0, "clue": 1 }, + { "id": "b", "q": 1, "r": 0 }, + { "id": "c", "q": 2, "r": 0 } + ], + "walls": [] + }, + { + "id": "1-2", + "teach": "必须走完所有格,数字要对上。", + "caption": "走完每一格,数字要对上", + "cells": [ + { "id": "a", "q": 0, "r": 0, "clue": 1 }, + { "id": "b", "q": 0, "r": 1 }, + { "id": "c", "q": 0, "r": 2 }, + { "id": "d", "q": 1, "r": 2 }, + { "id": "e", "q": 2, "r": 2, "clue": 5 } + ], + "walls": [] + }, + { + "id": "1-3", + "teach": "第一堵墙。a 和 b 看起来相邻,滑不过去,必须经 c 绕到 b。去墙后有两条解。", + "caption": "条纹是墙,绕开它", + "cells": [ + { "id": "a", "q": 0, "r": 0, "clue": 1 }, + { "id": "b", "q": 1, "r": 0 }, + { "id": "c", "q": 0, "r": 1 }, + { "id": "d", "q": 1, "r": 1, "clue": 4 } + ], + "walls": [ + ["a", "b"] + ] + }, + { + "id": "1-4", + "teach": "花形 7 格。1 在左下 f,墙 f|g 封住它右边那格;中心 d 是 7 只能最后进,所以第一步只能往上走 c。绕外圈一周最后落中心。去墙后 2 条解。", + "caption": "先看墙和数字,再决定第一步", + "cells": [ + { "id": "a", "q": 0, "r": -1 }, + { "id": "b", "q": 1, "r": -1 }, + { "id": "c", "q": -1, "r": 0 }, + { "id": "d", "q": 0, "r": 0, "clue": 7 }, + { "id": "e", "q": 1, "r": 0 }, + { "id": "f", "q": -1, "r": 1, "clue": 1 }, + { "id": "g", "q": 0, "r": 1 } + ], + "walls": [ + ["f", "g"] + ] + }, + { + "id": "1-5", + "teach": "中段线索。3 就在 1 右边,却不能直接过去(那样 b=2):必须先下到 e 再折回 b。墙 b|c 堵住“3 之后直走”,逼出 a,e,b,f,c,g,h,d 的之字形。3 个线索全部必要。", + "cells": [ + { "id": "a", "q": 0, "r": 0, "clue": 1 }, + { "id": "b", "q": 1, "r": 0, "clue": 3 }, + { "id": "c", "q": 2, "r": 0 }, + { "id": "d", "q": 3, "r": 0, "clue": 8 }, + { "id": "e", "q": 0, "r": 1 }, + { "id": "f", "q": 1, "r": 1 }, + { "id": "g", "q": 2, "r": 1 }, + { "id": "h", "q": 3, "r": 1 } + ], + "walls": [ + ["b", "c"] + ] + }, + { + "id": "1-6", + "teach": "空洞 + 两堵墙。左上 1 的下方是洞,只能往右;顶行走到 c 后被墙 b|e 迫使从 c 下到 e,再 f、回 d、g,右侧下到 k,底行从右往左收,9 在 j。去墙后 7 条解。", + "cells": [ + { "id": "a", "q": 0, "r": 0, "clue": 1 }, + { "id": "b", "q": 1, "r": 0 }, + { "id": "c", "q": 2, "r": 0 }, + { "id": "d", "q": 3, "r": 0 }, + { "id": "e", "q": 1, "r": 1 }, + { "id": "f", "q": 2, "r": 1 }, + { "id": "g", "q": 3, "r": 1 }, + { "id": "h", "q": -1, "r": 2 }, + { "id": "i", "q": 0, "r": 2 }, + { "id": "j", "q": 1, "r": 2, "clue": 9 }, + { "id": "k", "q": 2, "r": 2 } + ], + "walls": [ + ["e", "j"], + ["b", "e"] + ] + }, + { + "id": "1-7", + "teach": "先想再画。1 在底行中间 k,旁边的 h 被两堵墙围住(h|i、h|j),直觉先进 h 会把 j 孤立;正解是先往右 l 绕整圈,最后 e=9 → h → g → j。无线索路径 320 条。", + "cells": [ + { "id": "a", "q": 0, "r": 0 }, + { "id": "b", "q": 1, "r": 0 }, + { "id": "c", "q": 2, "r": 0 }, + { "id": "d", "q": 0, "r": 1 }, + { "id": "e", "q": 1, "r": 1, "clue": 9 }, + { "id": "f", "q": 2, "r": 1 }, + { "id": "g", "q": 0, "r": 2 }, + { "id": "h", "q": 1, "r": 2 }, + { "id": "i", "q": 2, "r": 2 }, + { "id": "j", "q": 0, "r": 3 }, + { "id": "k", "q": 1, "r": 3, "clue": 1 }, + { "id": "l", "q": 2, "r": 3 } + ], + "walls": [ + ["h", "i"], + ["h", "j"] + ] + }, + { + "id": "1-8", + "teach": "综合:12 格、3 堵墙、3 个线索。左下 i=1 起手,左上 a=12 收尾;右下 l=6 决定先走底行。e 被 b|e、e|f 夹住只剩上下两个口。去墙后 13 条解。", + "cells": [ + { "id": "a", "q": 0, "r": 0, "clue": 12 }, + { "id": "b", "q": 1, "r": 0 }, + { "id": "c", "q": 2, "r": 0 }, + { "id": "d", "q": 3, "r": 0 }, + { "id": "e", "q": 0, "r": 1 }, + { "id": "f", "q": 1, "r": 1 }, + { "id": "g", "q": 2, "r": 1 }, + { "id": "h", "q": 3, "r": 1 }, + { "id": "i", "q": -1, "r": 2, "clue": 1 }, + { "id": "j", "q": 0, "r": 2 }, + { "id": "k", "q": 1, "r": 2 }, + { "id": "l", "q": 2, "r": 2, "clue": 6 } + ], + "walls": [ + ["f", "k"], + ["b", "e"], + ["e", "f"] + ] + }, + { + "id": "1-9", + "teach": "15 格。1 在 g,五个方向都能起手;a=15 说明终点在左上,l=11 在底行决定绕行方向。c|g、i|n、e|i 三堵墙把右侧切成只能单向通过的走廊。去墙后 16 条解。", + "cells": [ + { "id": "a", "q": 0, "r": 0, "clue": 15 }, + { "id": "b", "q": 1, "r": 0 }, + { "id": "c", "q": 2, "r": 0 }, + { "id": "d", "q": 3, "r": 0 }, + { "id": "e", "q": 4, "r": 0 }, + { "id": "f", "q": 0, "r": 1 }, + { "id": "g", "q": 1, "r": 1, "clue": 1 }, + { "id": "h", "q": 2, "r": 1 }, + { "id": "i", "q": 3, "r": 1 }, + { "id": "j", "q": 4, "r": 1 }, + { "id": "k", "q": -1, "r": 2 }, + { "id": "l", "q": 0, "r": 2, "clue": 11 }, + { "id": "m", "q": 1, "r": 2 }, + { "id": "n", "q": 2, "r": 2 }, + { "id": "o", "q": 3, "r": 2 } + ], + "walls": [ + ["c", "g"], + ["i", "n"], + ["e", "i"] + ] + }, + { + "id": "1-10", + "teach": "小考:19 格满花盘、4 堵墙、3 个线索。i=1 在内圈左侧,k=14 与 p=10 在右侧内外圈。先上绕到顶行,顺右侧下来,穿过底部回到内圈,最后从左侧收尾到 h。去墙后 26 条解。", + "cells": [ + { "id": "a", "q": 0, "r": -2 }, + { "id": "b", "q": 1, "r": -2 }, + { "id": "c", "q": 2, "r": -2 }, + { "id": "d", "q": -1, "r": -1 }, + { "id": "e", "q": 0, "r": -1 }, + { "id": "f", "q": 1, "r": -1 }, + { "id": "g", "q": 2, "r": -1 }, + { "id": "h", "q": -2, "r": 0 }, + { "id": "i", "q": -1, "r": 0, "clue": 1 }, + { "id": "j", "q": 0, "r": 0 }, + { "id": "k", "q": 1, "r": 0, "clue": 14 }, + { "id": "l", "q": 2, "r": 0 }, + { "id": "m", "q": -2, "r": 1 }, + { "id": "n", "q": -1, "r": 1 }, + { "id": "o", "q": 0, "r": 1 }, + { "id": "p", "q": 1, "r": 1, "clue": 10 }, + { "id": "q", "q": -2, "r": 2 }, + { "id": "r", "q": -1, "r": 2 }, + { "id": "s", "q": 0, "r": 2 } + ], + "walls": [ + ["f", "k"], + ["d", "i"], + ["k", "l"], + ["b", "f"] + ] + } + ] +} diff --git a/modes/03-hex-walls/levels/pack-2-spinner.json b/modes/03-hex-walls/levels/pack-2-spinner.json new file mode 100644 index 0000000..77430b1 --- /dev/null +++ b/modes/03-hex-walls/levels/pack-2-spinner.json @@ -0,0 +1,222 @@ +{ + "mode": "03-hex-walls", + "pack": 2, + "title": "转墙", + "orientation": "pointy", + "notes": "实验包。spinners = 绕顶点转的墙:{cell, corner, phase},顶点是 cell 的第 corner 个角(30° 起顺时针 0..5),三条候选边顺时针编号,已走 t 格时墙在第 (phase+t) mod 3 条边。格 id 按阅读顺序编号。", + "levels": [ + { + "id": "2-1", + "teach": "教转墙。1 在中心,轴心在左下三格交汇的顶点。起手后墙正好挡住 1 的左邻,直觉往左会撞墙;绕右侧走一圈回来时墙已经转开。去墙后 2 解。", + "caption": "这堵墙会转:每走一格转一下", + "cells": [ + { "id": "a", "q": 0, "r": -1 }, + { "id": "b", "q": 1, "r": -1, "clue": 7 }, + { "id": "c", "q": -1, "r": 0 }, + { "id": "d", "q": 0, "r": 0, "clue": 1 }, + { "id": "e", "q": 1, "r": 0 }, + { "id": "f", "q": -1, "r": 1 }, + { "id": "g", "q": 0, "r": 1 } + ], + "walls": [], + "spinners": [ + { "cell": "c", "corner": 0, "phase": 1 } + ] + }, + { + "id": "2-2", + "teach": "算拍子。4 在 1 的正上方,直接上去是 2,绕一格是 3,只有绕两格(g,h,d,c)才是 4;到了 4 再往左时墙刚好转开。去墙后 6 解。", + "cells": [ + { "id": "a", "q": 0, "r": 0 }, + { "id": "b", "q": 1, "r": 0 }, + { "id": "c", "q": 2, "r": 0, "clue": 4 }, + { "id": "d", "q": 3, "r": 0 }, + { "id": "e", "q": 0, "r": 1 }, + { "id": "f", "q": 1, "r": 1 }, + { "id": "g", "q": 2, "r": 1, "clue": 1 }, + { "id": "h", "q": 3, "r": 1 } + ], + "walls": [], + "spinners": [ + { "cell": "b", "corner": 0, "phase": 2 } + ] + }, + { + "id": "2-3", + "teach": "静态墙 + 转墙综合。1 在右下角,12 就在旁边,说明要绕整盘一圈最后从上方进 k。左下角三格 e/i/j 的转墙决定绕到左侧时能不能顺利下去,拍子不对就得走错。去墙后 9 解。", + "cells": [ + { "id": "a", "q": 0, "r": 0 }, + { "id": "b", "q": 1, "r": 0 }, + { "id": "c", "q": 2, "r": 0 }, + { "id": "d", "q": 3, "r": 0 }, + { "id": "e", "q": 0, "r": 1 }, + { "id": "f", "q": 1, "r": 1 }, + { "id": "g", "q": 2, "r": 1 }, + { "id": "h", "q": 3, "r": 1 }, + { "id": "i", "q": -1, "r": 2 }, + { "id": "j", "q": 0, "r": 2 }, + { "id": "k", "q": 1, "r": 2, "clue": 12 }, + { "id": "l", "q": 2, "r": 2, "clue": 1 } + ], + "walls": [ + ["d", "h"] + ], + "spinners": [ + { "cell": "j", "corner": 3, "phase": 0 } + ] + }, + { + "id": "2-4", + "teach": "两个转墙,轴心相邻。1、2 并排在顶行,2 之后要下到中心再折向左;两堵墙一前一后决定下去和折回的拍子。去墙后 8 解。", + "cells": [ + { "id": "a", "q": 0, "r": -1 }, + { "id": "b", "q": 1, "r": -1 }, + { "id": "c", "q": -1, "r": 0 }, + { "id": "d", "q": 0, "r": 0 }, + { "id": "e", "q": 1, "r": 0 }, + { "id": "f", "q": -1, "r": 1, "clue": 2 }, + { "id": "g", "q": 0, "r": 1, "clue": 1 } + ], + "walls": [], + "spinners": [ + { "cell": "d", "corner": 5, "phase": 0 }, + { "cell": "a", "corner": 0, "phase": 1 } + ] + }, + { + "id": "2-5", + "teach": "三角盘 10 格,两个转墙一个在顶行右侧、一个在底尖。1 在第二行右端,6 在第二行左端:先绕下面的小三角(f,i,j,h)再到 6,最后走顶行。去墙后 8 解。", + "cells": [ + { "id": "a", "q": 0, "r": 0 }, + { "id": "b", "q": 1, "r": 0 }, + { "id": "c", "q": 2, "r": 0 }, + { "id": "d", "q": 3, "r": 0 }, + { "id": "e", "q": 0, "r": 1, "clue": 6 }, + { "id": "f", "q": 1, "r": 1 }, + { "id": "g", "q": 2, "r": 1, "clue": 1 }, + { "id": "h", "q": 0, "r": 2 }, + { "id": "i", "q": 1, "r": 2 }, + { "id": "j", "q": 0, "r": 3 } + ], + "walls": [], + "spinners": [ + { "cell": "c", "corner": 2, "phase": 1 }, + { "cell": "h", "corner": 0, "phase": 1 } + ] + }, + { + "id": "2-6", + "teach": "三个转墙围着中心 g:g 的三条边轮流被挡,什么时候进、什么时候出都要算。去墙后 20 解。", + "cells": [ + { "id": "a", "q": 0, "r": 0 }, + { "id": "b", "q": 1, "r": 0 }, + { "id": "c", "q": 2, "r": 0 }, + { "id": "d", "q": 3, "r": 0 }, + { "id": "e", "q": 0, "r": 1, "clue": 11 }, + { "id": "f", "q": 1, "r": 1 }, + { "id": "g", "q": 2, "r": 1 }, + { "id": "h", "q": 3, "r": 1 }, + { "id": "i", "q": -1, "r": 2, "clue": 1 }, + { "id": "j", "q": 0, "r": 2 }, + { "id": "k", "q": 1, "r": 2 }, + { "id": "l", "q": 2, "r": 2 } + ], + "walls": [], + "spinners": [ + { "cell": "c", "corner": 0, "phase": 1 }, + { "cell": "k", "corner": 4, "phase": 2 }, + { "cell": "g", "corner": 3, "phase": 0 } + ] + }, + { + "id": "2-7", + "teach": "15 格,一堵静态墙加三个转墙挤在右侧。左半盘先走完,进右侧时三堵墙的拍子要同时对上。去墙后 49 解。", + "cells": [ + { "id": "a", "q": 0, "r": 0 }, + { "id": "b", "q": 1, "r": 0, "clue": 1 }, + { "id": "c", "q": 2, "r": 0 }, + { "id": "d", "q": 3, "r": 0 }, + { "id": "e", "q": 4, "r": 0 }, + { "id": "f", "q": 0, "r": 1 }, + { "id": "g", "q": 1, "r": 1, "clue": 7 }, + { "id": "h", "q": 2, "r": 1 }, + { "id": "i", "q": 3, "r": 1 }, + { "id": "j", "q": 4, "r": 1 }, + { "id": "k", "q": -1, "r": 2 }, + { "id": "l", "q": 0, "r": 2 }, + { "id": "m", "q": 1, "r": 2 }, + { "id": "n", "q": 2, "r": 2 }, + { "id": "o", "q": 3, "r": 2 } + ], + "walls": [ + ["d", "e"] + ], + "spinners": [ + { "cell": "h", "corner": 0, "phase": 1 }, + { "cell": "i", "corner": 0, "phase": 2 }, + { "cell": "n", "corner": 5, "phase": 2 } + ] + }, + { + "id": "2-8", + "teach": "17 格,三个转墙分布三处。1 在右侧内圈,先上顶行绕左半盘,最后从底部回右侧。去墙解 52。", + "cells": [ + { "id": "a", "q": 0, "r": -2 }, + { "id": "b", "q": 1, "r": -2 }, + { "id": "c", "q": 2, "r": -2 }, + { "id": "d", "q": -1, "r": -1 }, + { "id": "e", "q": 0, "r": -1 }, + { "id": "f", "q": 1, "r": -1 }, + { "id": "g", "q": -2, "r": 0 }, + { "id": "h", "q": -1, "r": 0, "clue": 1 }, + { "id": "i", "q": 0, "r": 0 }, + { "id": "j", "q": 1, "r": 0 }, + { "id": "k", "q": 2, "r": 0, "clue": 10 }, + { "id": "l", "q": -2, "r": 1 }, + { "id": "m", "q": -1, "r": 1 }, + { "id": "n", "q": 0, "r": 1 }, + { "id": "o", "q": 1, "r": 1 }, + { "id": "p", "q": -1, "r": 2 }, + { "id": "q", "q": 0, "r": 2 } + ], + "walls": [], + "spinners": [ + { "cell": "h", "corner": 3, "phase": 0 }, + { "cell": "m", "corner": 0, "phase": 2 }, + { "cell": "k", "corner": 2, "phase": 2 } + ] + }, + { + "id": "2-9", + "teach": "19 格满花盘,四个转墙。1 在内圈下方,8 在内圈上方,17 在最左;两次穿过内圈都要踩准拍子。去墙后 41 解。", + "cells": [ + { "id": "a", "q": 0, "r": -2 }, + { "id": "b", "q": 1, "r": -2 }, + { "id": "c", "q": 2, "r": -2 }, + { "id": "d", "q": -1, "r": -1 }, + { "id": "e", "q": 0, "r": -1 }, + { "id": "f", "q": 1, "r": -1, "clue": 8 }, + { "id": "g", "q": 2, "r": -1 }, + { "id": "h", "q": -2, "r": 0, "clue": 17 }, + { "id": "i", "q": -1, "r": 0 }, + { "id": "j", "q": 0, "r": 0 }, + { "id": "k", "q": 1, "r": 0 }, + { "id": "l", "q": 2, "r": 0 }, + { "id": "m", "q": -2, "r": 1 }, + { "id": "n", "q": -1, "r": 1, "clue": 1 }, + { "id": "o", "q": 0, "r": 1 }, + { "id": "p", "q": 1, "r": 1 }, + { "id": "q", "q": -2, "r": 2 }, + { "id": "r", "q": -1, "r": 2 }, + { "id": "s", "q": 0, "r": 2 } + ], + "walls": [], + "spinners": [ + { "cell": "n", "corner": 4, "phase": 1 }, + { "cell": "o", "corner": 4, "phase": 2 }, + { "cell": "r", "corner": 3, "phase": 0 }, + { "cell": "f", "corner": 0, "phase": 1 } + ] + } + ] +} diff --git a/modes/03-hex-walls/package-lock.json b/modes/03-hex-walls/package-lock.json new file mode 100644 index 0000000..1fee5dc --- /dev/null +++ b/modes/03-hex-walls/package-lock.json @@ -0,0 +1,1033 @@ +{ + "name": "hex-walls", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "hex-walls", + "version": "0.1.0", + "devDependencies": { + "@types/node": "^22.7.0", + "esbuild": "^0.24.0", + "tsx": "^4.19.0", + "typescript": "^5.6.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz", + "integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz", + "integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz", + "integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz", + "integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz", + "integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz", + "integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz", + "integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz", + "integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz", + "integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz", + "integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz", + "integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz", + "integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz", + "integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz", + "integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz", + "integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz", + "integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz", + "integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz", + "integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz", + "integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz", + "integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz", + "integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz", + "integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz", + "integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz", + "integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz", + "integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "22.20.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.2.tgz", + "integrity": "sha512-xlvWf4Vs9n1PEVYwP1n4vvG07M6y8WgvJ2t0vbrWTmijsIHp1cS+uJ2kMIRdY3nHZK0nCYKrPeD171+SzF4/zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/esbuild": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz", + "integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.24.2", + "@esbuild/android-arm": "0.24.2", + "@esbuild/android-arm64": "0.24.2", + "@esbuild/android-x64": "0.24.2", + "@esbuild/darwin-arm64": "0.24.2", + "@esbuild/darwin-x64": "0.24.2", + "@esbuild/freebsd-arm64": "0.24.2", + "@esbuild/freebsd-x64": "0.24.2", + "@esbuild/linux-arm": "0.24.2", + "@esbuild/linux-arm64": "0.24.2", + "@esbuild/linux-ia32": "0.24.2", + "@esbuild/linux-loong64": "0.24.2", + "@esbuild/linux-mips64el": "0.24.2", + "@esbuild/linux-ppc64": "0.24.2", + "@esbuild/linux-riscv64": "0.24.2", + "@esbuild/linux-s390x": "0.24.2", + "@esbuild/linux-x64": "0.24.2", + "@esbuild/netbsd-arm64": "0.24.2", + "@esbuild/netbsd-x64": "0.24.2", + "@esbuild/openbsd-arm64": "0.24.2", + "@esbuild/openbsd-x64": "0.24.2", + "@esbuild/sunos-x64": "0.24.2", + "@esbuild/win32-arm64": "0.24.2", + "@esbuild/win32-ia32": "0.24.2", + "@esbuild/win32-x64": "0.24.2" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/tsx": { + "version": "4.23.13", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.13.tgz", + "integrity": "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/modes/03-hex-walls/package.json b/modes/03-hex-walls/package.json new file mode 100644 index 0000000..1ef4aec --- /dev/null +++ b/modes/03-hex-walls/package.json @@ -0,0 +1,27 @@ +{ + "name": "hex-walls", + "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", + "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" + }, + "allowScripts": { + "esbuild@0.24.2": true, + "esbuild@0.28.2": true + } +} diff --git a/modes/03-hex-walls/scripts/build.mjs b/modes/03-hex-walls/scripts/build.mjs new file mode 100644 index 0000000..f4071ea --- /dev/null +++ b/modes/03-hex-walls/scripts/build.mjs @@ -0,0 +1,42 @@ +// 打包 web/main.ts → web/dist/app.js(IIFE,可 file:// 直接打开)。 +// --serve 时起本地服务并监听改动,手机同一 Wi-Fi 下访问启动时打印的地址。 +// 端口:--port 4000 或 PORT=4000,不给就从 8100 起自动找空闲端口。 +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) : 8100; + +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 }); + 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`); +} diff --git a/modes/03-hex-walls/src/board.ts b/modes/03-hex-walls/src/board.ts new file mode 100644 index 0000000..2dc2f97 --- /dev/null +++ b/modes/03-hex-walls/src/board.ts @@ -0,0 +1,280 @@ +// 盘面模型:点顶六边形、轴坐标、六邻接减去墙。规则来源 GDD §4.1–4.2。 +import type { HexCellDef, LevelDef, SpinnerDef, WallDef } from './types'; + +/** 轴坐标六邻接方向,顺序:东、东北、西北、西、西南、东南 */ +export const HEX_DIRS: ReadonlyArray = [ + [1, 0], + [1, -1], + [0, -1], + [-1, 0], + [-1, 1], + [0, 1], +]; + +export interface HexCell { + /** 在 HexBoard.cells 中的下标,运行时统一用下标引用格 */ + index: number; + id: string; + q: number; + r: number; + clue: number | null; +} + +export interface Wall { + /** 在 HexBoard.walls 中的下标 */ + index: number; + /** 两侧格下标,a < b */ + a: number; + b: number; +} + +/** 转墙(GDD 实验机制):绕一个顶点转,三条候选边顺时针编号,每走一格转到下一条 */ +export interface Spinner { + index: number; + /** 轴心所在格与角号 */ + cell: number; + corner: number; + phase: number; + /** + * 三条候选边(两侧格下标,a < b);边不在盘上(空洞 / 边界)为 null,墙转到那里时相当于消失。 + * 顺序:0 = 背离 cell 的边,1 = cell 与顺时针下一邻格,2 = cell 与上一邻格。 + */ + edges: ReadonlyArray; +} + +/** 已走 t 格时转墙占哪条边 */ +export function spinnerPos(sp: Spinner, t: number): number { + return (((sp.phase + t) % 3) + 3) % 3; +} + +const coordKey = (q: number, r: number): string => `${q},${r}`; +const pairKey = (a: number, b: number): number => (a < b ? a * 4096 + b : b * 4096 + a); + +export class HexBoard { + readonly cells: ReadonlyArray; + readonly n: number; + /** adj[i] = 与 i 相邻且无墙的格下标(路径用) */ + readonly adj: ReadonlyArray>; + /** geoAdj[i] = 几何上共边的格下标(含被墙隔开的,画墙 / 撞墙判定用) */ + readonly geoAdj: ReadonlyArray>; + readonly walls: ReadonlyArray; + /** 线索步号 → 格下标 */ + readonly clueCellOfStep: ReadonlyMap; + /** 线索 1 所在格,没有则 -1 */ + readonly startClueCell: number; + + readonly spinners: ReadonlyArray; + + private readonly coordMap: Map; + private readonly wallByPair: Map; + + private constructor(cells: HexCell[], wallPairs: Array<[number, number]>, spinnerDefs: Array<{ cell: number; corner: number; phase: number }> = []) { + this.cells = cells; + this.n = cells.length; + this.coordMap = new Map(); + for (const c of cells) this.coordMap.set(coordKey(c.q, c.r), c.index); + + const geoAdj: number[][] = cells.map(() => []); + for (const c of cells) { + for (const [dq, dr] of HEX_DIRS) { + const j = this.coordMap.get(coordKey(c.q + dq, c.r + dr)); + if (j !== undefined) geoAdj[c.index].push(j); + } + } + this.geoAdj = geoAdj; + + const walls: Wall[] = []; + const wallByPair = new Map(); + for (const [x, y] of wallPairs) { + const a = Math.min(x, y); + const b = Math.max(x, y); + if (a === b) throw new Error(`墙两侧是同一格: ${cells[a].id}`); + if (!geoAdj[a].includes(b)) throw new Error(`墙 ${cells[a].id}-${cells[b].id} 不在共用边上`); + const k = pairKey(a, b); + if (wallByPair.has(k)) throw new Error(`墙重复: ${cells[a].id}-${cells[b].id}`); + const w: Wall = { index: walls.length, a, b }; + walls.push(w); + wallByPair.set(k, w); + } + this.walls = walls; + this.wallByPair = wallByPair; + + this.adj = geoAdj.map((list, i) => list.filter((j) => !wallByPair.has(pairKey(i, j)))); + + const spinners: Spinner[] = []; + const pivotKeys = new Set(); + for (const d of spinnerDefs) { + if (!Number.isInteger(d.corner) || d.corner < 0 || d.corner > 5) throw new Error(`转墙角号 ${d.corner} 必须在 0..5`); + const c = cells[d.cell]; + // 角 k 夹在方向 k(角 30°+60k 之前)与方向 k+1 之间;HEX_DIRS 按 0°、-60°… 排列,屏幕顺时针方向 60k 对应 HEX_DIRS[(6 - k) % 6] + const dirA = HEX_DIRS[(6 - d.corner) % 6]; + const dirB = HEX_DIRS[(5 - d.corner) % 6]; + const n1 = this.coordMap.get(coordKey(c.q + dirA[0], c.r + dirA[1])); + const n2 = this.coordMap.get(coordKey(c.q + dirB[0], c.r + dirB[1])); + const pair = (x: number | undefined, y: number | undefined): readonly [number, number] | null => + x === undefined || y === undefined ? null : x < y ? [x, y] : [y, x]; + const edges = [pair(n1, n2), pair(c.index, n2), pair(c.index, n1)]; + if (edges.filter(Boolean).length < 2) throw new Error(`转墙 ${c.id}/${d.corner} 周围可用的边少于 2 条`); + for (const e of edges) if (e && wallByPair.has(pairKey(e[0], e[1]))) throw new Error(`转墙 ${c.id}/${d.corner} 与静态墙重叠`); + // 同一个顶点只能有一个转墙(不同格的不同角可能是同一个顶点) + const ang = Math.PI / 6 + (d.corner * Math.PI) / 3; + const px = Math.sqrt(3) * (c.q + c.r / 2) + Math.cos(ang); + const py = 1.5 * c.r + Math.sin(ang); + const pk = `${Math.round(px * 1000)},${Math.round(py * 1000)}`; + if (pivotKeys.has(pk)) throw new Error(`转墙 ${c.id}/${d.corner} 与另一个转墙共用顶点`); + pivotKeys.add(pk); + spinners.push({ index: spinners.length, cell: c.index, corner: d.corner, phase: ((d.phase % 3) + 3) % 3, edges }); + } + this.spinners = spinners; + + const clueMap = new Map(); + 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): HexBoard { + return HexBoard.fromCells(level.cells, level.walls ?? [], level.spinners ?? []); + } + + static fromCells(defs: HexCellDef[], wallDefs: WallDef[] = [], spinnerDefs: SpinnerDef[] = []): HexBoard { + if (defs.length === 0) throw new Error('盘面为空'); + const ids = new Map(); + const coords = new Set(); + const cells: HexCell[] = defs.map((d, index) => { + if (ids.has(d.id)) throw new Error(`格 id 重复: ${d.id}`); + ids.set(d.id, index); + if (!Number.isInteger(d.q) || !Number.isInteger(d.r)) throw new Error(`格 ${d.id} 坐标必须是整数`); + const ck = coordKey(d.q, d.r); + if (coords.has(ck)) throw new Error(`格 ${d.id} 与其它格坐标重叠 (${d.q},${d.r})`); + coords.add(ck); + const clue = d.clue ?? null; + if (clue !== null && (!Number.isInteger(clue) || clue < 1 || clue > defs.length)) { + throw new Error(`格 ${d.id} 线索 ${clue} 超出 1..${defs.length}`); + } + return { index, id: d.id, q: d.q, r: d.r, clue }; + }); + const wallPairs: Array<[number, number]> = wallDefs.map(([x, y]) => { + const a = ids.get(x); + const b = ids.get(y); + if (a === undefined || b === undefined) throw new Error(`墙引用了不存在的格: ${x}-${y}`); + return [a, b]; + }); + const spinners = spinnerDefs.map((d) => { + const cell = ids.get(d.cell); + if (cell === undefined) throw new Error(`转墙引用了不存在的格: ${d.cell}`); + return { cell, corner: d.corner, phase: d.phase ?? 0 }; + }); + return new HexBoard(cells, wallPairs, spinners); + } + + /** 轴坐标 → 格下标;空洞返回 -1 */ + cellAt(q: number, r: number): number { + return this.coordMap.get(coordKey(q, r)) ?? -1; + } + + cellById(id: string): HexCell { + const c = this.cells.find((x) => x.id === id); + if (!c) throw new Error(`没有格 ${id}`); + return c; + } + + /** 可走的邻接(共边且无墙) */ + areAdjacent(a: number, b: number): boolean { + return this.adj[a].includes(b); + } + + /** 几何共边(不管墙) */ + isGeoNeighbor(a: number, b: number): boolean { + return this.geoAdj[a].includes(b); + } + + wallBetween(a: number, b: number): Wall | null { + return this.wallByPair.get(pairKey(a, b)) ?? null; + } + + /** 静态墙 */ + isWalled(a: number, b: number): boolean { + return this.wallByPair.has(pairKey(a, b)); + } + + /** 已走 t 格时,哪个转墙正挡在 a-b 之间;没有返回 -1 */ + spinnerBlocking(a: number, b: number, t: number): number { + const lo = Math.min(a, b); + const hi = Math.max(a, b); + for (const sp of this.spinners) { + const e = sp.edges[spinnerPos(sp, t)]; + if (e && e[0] === lo && e[1] === hi) return sp.index; + } + return -1; + } + + /** 已走 t 格时能否从 a 走到 b(共边、无静态墙、转墙没挡着) */ + canStep(a: number, b: number, t: number): boolean { + return this.areAdjacent(a, b) && this.spinnerBlocking(a, b, t) === -1; + } + + /** 已走 t 格时 a 的可走邻居 */ + adjAt(a: number, t: number): number[] { + if (this.spinners.length === 0) return this.adj[a] as number[]; + return this.adj[a].filter((b) => this.spinnerBlocking(a, b, t) === -1); + } + + get hasSpinners(): boolean { + return this.spinners.length > 0; + } + + private spinnerDefs(): Array<{ cell: number; corner: number; phase: number }> { + return this.spinners.map((s) => ({ cell: s.cell, corner: s.corner, phase: s.phase })); + } + + /** 去掉全部墙(含转墙)的同一盘面(校验“墙是否参与唯一性”用) */ + withoutWalls(): HexBoard { + return new HexBoard(this.cells.map((c) => ({ ...c })), []); + } + + /** 去掉指定静态墙的盘面 */ + withoutWall(wallIndex: number): HexBoard { + const pairs = this.walls.filter((w) => w.index !== wallIndex).map((w) => [w.a, w.b] as [number, number]); + return new HexBoard(this.cells.map((c) => ({ ...c })), pairs, this.spinnerDefs()); + } + + /** 去掉指定转墙的盘面 */ + withoutSpinner(index: number): HexBoard { + const pairs = this.walls.map((w) => [w.a, w.b] as [number, number]); + return new HexBoard(this.cells.map((c) => ({ ...c })), pairs, this.spinnerDefs().filter((_, i) => i !== index)); + } + + /** 把指定转墙换成一堵钉在其第 pos 条边上的静态墙(校验“会转”是否参与推理)。该边不存在返回 null */ + withSpinnerFrozen(index: number, pos: number): HexBoard | null { + const e = this.spinners[index].edges[pos]; + if (!e) return null; + const pairs = this.walls.map((w) => [w.a, w.b] as [number, number]); + pairs.push([e[0], e[1]]); + return new HexBoard(this.cells.map((c) => ({ ...c })), pairs, this.spinnerDefs().filter((_, i) => i !== index)); + } + + /** 是否连通(去墙之后的图)。不连通的盘不可能有解 */ + 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; + } +} diff --git a/modes/03-hex-walls/src/game.ts b/modes/03-hex-walls/src/game.ts new file mode 100644 index 0000000..d30f68b --- /dev/null +++ b/modes/03-hex-walls/src/game.ts @@ -0,0 +1,192 @@ +// 对局状态机:路径、冲突、撞墙、过关。规则来源 GDD §4.3–4.5、§5。 +// 输入层把手势翻译成 Command,视图层消费 GameEvent;本文件不关心两者的实现。 +import type { HexBoard } from './board'; + +export type Status = 'empty' | 'drawing' | 'idle' | 'cleared'; + +export type Command = + | { type: 'start'; cell: number } + | { type: 'append'; cell: number } + | { type: 'undo' } + | { 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: 'reset' } + /** 起手被拒:有线索 1 时只能从它起手 */ + | { type: 'rejectStart'; cell: number; clueCell: number } + /** 想从末端滑进被墙隔开的邻格:不追加,只反馈。wall = 静态墙下标或 -1,spinner = 转墙下标或 -1 */ + | { type: 'wallHit'; from: number; to: number; wall: number; spinner: number } + /** 无格可走,但还没过关(信息性,不是失败) */ + | { type: 'deadEnd'; cell: number } + | { type: 'cleared' }; + +export class Game { + readonly board: HexBoard; + /** 已走格下标,按步序 */ + readonly path: number[] = []; + status: Status = 'empty'; + + /** stepOfCell[i] = 第几步走到格 i,0 表示未走 */ + private readonly stepOfCell: Int16Array; + + constructor(board: HexBoard) { + 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; + } + + /** 已走且步号与线索不符 */ + isConflict(cell: number): boolean { + const clue = this.board.cells[cell].clue; + const step = this.stepOfCell[cell]; + return clue !== null && step !== 0 && step !== clue; + } + + hasConflict(): boolean { + for (const c of this.path) if (this.isConflict(c)) return true; + return false; + } + + /** 合法起点:有线索 1 只能是它;否则任意格 */ + isLegalStart(cell: number): boolean { + const s = this.board.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.canStep(last, cell, this.path.length); + } + + /** 从当前末端滑向 cell 是否会撞墙(几何相邻、未走、但有静态墙或转墙)。返回 {wall, spinner},都为 -1 表示不撞 */ + wallTo(cell: number): { wall: number; spinner: number } { + const none = { wall: -1, spinner: -1 }; + const last = this.last; + if (last === -1 || this.status === 'cleared' || this.isVisited(cell)) return none; + if (!this.board.isGeoNeighbor(last, cell)) return none; + const w = this.board.wallBetween(last, cell); + if (w) return { wall: w.index, spinner: -1 }; + const sp = this.board.spinnerBlocking(last, cell, this.path.length); + return sp >= 0 ? { wall: -1, spinner: sp } : none; + } + + /** 当前末端所有可追加的格 */ + legalMoves(): number[] { + const last = this.last; + if (last === -1) return []; + return this.board.adjAt(last, this.path.length).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 '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.board.startClueCell }]; + } + this.status = 'drawing'; + return this.push(cell); + } + + private append(cell: number): GameEvent[] { + if (!this.canAppend(cell)) { + const hit = this.wallTo(cell); + if (hit.wall >= 0 || hit.spinner >= 0) return [{ type: 'wallHit', from: this.last, to: cell, wall: hit.wall, spinner: hit.spinner }]; + 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 clue = this.board.cells[cell].clue; + const events: GameEvent[] = [ + { + type: 'step', + cell, + step, + conflict: clue !== null && clue !== step, + clueHit: clue !== null && clue === step, + }, + ]; + 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 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' }]; + } +} diff --git a/modes/03-hex-walls/src/geom.ts b/modes/03-hex-walls/src/geom.ts new file mode 100644 index 0000000..5484f6b --- /dev/null +++ b/modes/03-hex-walls/src/geom.ts @@ -0,0 +1,153 @@ +// 纯几何工具:点、多边形圆角、凸多边形裁剪、局部坐标变换。 +// 目的:让 Painter 只需要 moveTo/lineTo 级别的原语(Cocos Graphics 没有 arcTo)。 + +export interface Point { + x: number; + y: number; +} + +/** 把多边形每个角换成 segments 段折线逼近的圆弧 */ +export function roundPolygon(pts: Point[], radius: number, segments = 4): Point[] { + const n = pts.length; + if (n < 3 || radius <= 0) return pts; + const out: Point[] = []; + for (let i = 0; i < n; i++) { + const p0 = pts[(i + n - 1) % n]; + const p1 = pts[i]; + const p2 = pts[(i + 1) % n]; + const v1 = norm(p0.x - p1.x, p0.y - p1.y); + const v2 = norm(p2.x - p1.x, p2.y - p1.y); + const len1 = Math.hypot(p0.x - p1.x, p0.y - p1.y); + const len2 = Math.hypot(p2.x - p1.x, p2.y - p1.y); + // 内角一半 + const cosA = v1.x * v2.x + v1.y * v2.y; + const half = Math.acos(Math.max(-1, Math.min(1, cosA))) / 2; + const tan = Math.tan(half); + if (tan < 1e-6) { + out.push(p1); + continue; + } + // 切点离顶点的距离,不超过相邻边的一半 + const cut = Math.min(radius / tan, len1 / 2, len2 / 2); + const r = cut * tan; + const a: Point = { x: p1.x + v1.x * cut, y: p1.y + v1.y * cut }; + const b: Point = { x: p1.x + v2.x * cut, y: p1.y + v2.y * cut }; + // 圆心在角平分线上 + const bis = norm(v1.x + v2.x, v1.y + v2.y); + const dist = r / Math.sin(half); + const c: Point = { x: p1.x + bis.x * dist, y: p1.y + bis.y * dist }; + const a0 = Math.atan2(a.y - c.y, a.x - c.x); + let a1 = Math.atan2(b.y - c.y, b.x - c.x); + // 走短弧 + let d = a1 - a0; + while (d > Math.PI) d -= Math.PI * 2; + while (d < -Math.PI) d += Math.PI * 2; + a1 = a0 + d; + for (let s = 0; s <= segments; s++) { + const t = a0 + (d * s) / segments; + out.push({ x: c.x + Math.cos(t) * r, y: c.y + Math.sin(t) * r }); + } + } + return out; +} + +function norm(x: number, y: number): Point { + const l = Math.hypot(x, y) || 1; + return { x: x / l, y: y / l }; +} + +/** Sutherland–Hodgman:用凸多边形 clip(顶点顺时针或逆时针皆可)裁剪 subject */ +export function clipPolygon(subject: Point[], clip: Point[]): Point[] { + let output = subject; + const m = clip.length; + // 判断 clip 的绕向,统一“内侧”的符号 + let area = 0; + for (let i = 0; i < m; i++) { + const a = clip[i]; + const b = clip[(i + 1) % m]; + area += a.x * b.y - b.x * a.y; + } + const sign = area >= 0 ? 1 : -1; + for (let i = 0; i < m && output.length; i++) { + const a = clip[i]; + const b = clip[(i + 1) % m]; + const input = output; + output = []; + const inside = (p: Point): boolean => sign * ((b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x)) >= 0; + const intersect = (p: Point, q: Point): Point => { + const dx = q.x - p.x; + const dy = q.y - p.y; + const ex = b.x - a.x; + const ey = b.y - a.y; + const den = dx * ey - dy * ex; + if (Math.abs(den) < 1e-9) return q; + const t = ((a.x - p.x) * ey - (a.y - p.y) * ex) / den; + return { x: p.x + dx * t, y: p.y + dy * t }; + }; + for (let j = 0; j < input.length; j++) { + const cur = input[j]; + const prev = input[(j + input.length - 1) % input.length]; + const curIn = inside(cur); + const prevIn = inside(prev); + if (curIn) { + if (!prevIn) output.push(intersect(prev, cur)); + output.push(cur); + } else if (prevIn) { + output.push(intersect(prev, cur)); + } + } + } + return output; +} + +/** + * 一条“带”:中心 c,沿 u 方向长 len,沿法向 n 宽 thick。 + * 返回四个角(矩形)。u、n 应为单位向量且垂直。 + */ +export function bandRect(c: Point, u: Point, n: Point, len: number, thick: number): Point[] { + const hl = len / 2; + const ht = thick / 2; + return [ + { x: c.x - u.x * hl - n.x * ht, y: c.y - u.y * hl - n.y * ht }, + { x: c.x + u.x * hl - n.x * ht, y: c.y + u.y * hl - n.y * ht }, + { x: c.x + u.x * hl + n.x * ht, y: c.y + u.y * hl + n.y * ht }, + { x: c.x - u.x * hl + n.x * ht, y: c.y - u.y * hl + n.y * ht }, + ]; +} + +/** + * 带内的斜纹:每条纹是与带长轴成 45° 的平行四边形,裁到带矩形内。 + * period = 相邻两条纹起点间距,width = 纹宽。返回若干多边形。 + */ +export function stripesInBand( + c: Point, + u: Point, + n: Point, + len: number, + thick: number, + period: number, + width: number, + phase = 0, +): Point[][] { + const rect = bandRect(c, u, n, len, thick); + const out: Point[][] = []; + const hl = len / 2; + const ht = thick / 2; + // 局部坐标 (s 沿 u, t 沿 n):纹带覆盖 s ∈ [k*period + t + phase, +width] + const toWorld = (s: number, t: number): Point => ({ x: c.x + u.x * s + n.x * t, y: c.y + u.y * s + n.y * t }); + const start = -hl - thick - period; + for (let s0 = start + (phase % period); s0 < hl + thick; s0 += period) { + const poly = [toWorld(s0, -ht), toWorld(s0 + width, -ht), toWorld(s0 + width + thick, ht), toWorld(s0 + thick, ht)]; + const clipped = clipPolygon(poly, rect); + if (clipped.length >= 3) out.push(clipped); + } + return out; +} + +export function scalePolygon(pts: Point[], center: Point, s: number): Point[] { + return pts.map((p) => ({ x: center.x + (p.x - center.x) * s, y: center.y + (p.y - center.y) * s })); +} + +export function translatePolygon(pts: Point[], dx: number, dy: number): Point[] { + return pts.map((p) => ({ x: p.x + dx, y: p.y + dy })); +} diff --git a/modes/03-hex-walls/src/input.ts b/modes/03-hex-walls/src/input.ts new file mode 100644 index 0000000..0b232f5 --- /dev/null +++ b/modes/03-hex-walls/src/input.ts @@ -0,0 +1,97 @@ +// 手势 → 命令。GDD §5。 +// 这里只认“指针现在压在哪个格上”(由 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.path.length >= 2 && cell === g.path[g.path.length - 2]) { + // 点末端的前一格 = 撤销一步,然后手指停在新末端上继续 + const ev = this.run({ type: 'undo' }); + 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; + } + const hit = g.wallTo(cell); + if (hit.wall >= 0 || hit.spinner >= 0) { + // 点被墙隔开的邻格:只反馈撞墙 + return this.run({ type: 'append', cell }); + } + 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 (g.path.length >= 2 && cell === g.path[g.path.length - 2]) { + return this.run({ type: 'undo' }); + } + // append 自己会区分“可走 / 撞墙 / 忽略” + const ev = this.run({ type: 'append', cell }); + if (g.isCleared) this.drawing = false; + return ev; + } + + /** 松手 */ + 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); + } +} diff --git a/modes/03-hex-walls/src/layout.ts b/modes/03-hex-walls/src/layout.ts new file mode 100644 index 0000000..8cba4f2 --- /dev/null +++ b/modes/03-hex-walls/src/layout.ts @@ -0,0 +1,194 @@ +// 布局:点顶六边形铺进给定区域;格中心、六边形顶点、共用边、桥、墙带、命中测试。 +// GDD §9「按短边缩放」。纯数学,Web 与 Cocos 共用(Cocos y 轴向上,Painter 内翻 y 即可)。 +import type { HexBoard } from './board'; +import { bandRect, roundPolygon, type Point } from './geom'; + +const SQRT3 = Math.sqrt(3); + +export interface HexLayout { + /** 六边形外接圆半径(中心到顶点),也是相邻中心距的 1/√3 */ + size: number; + /** 轴坐标 (0,0) 的中心在容器里的像素位置 */ + originX: number; + originY: number; + /** 相邻格之间的缝 */ + gap: number; + /** 绘制用六边形半径(已扣掉缝) */ + drawRadius: number; + /** 圆角 */ + corner: number; + /** 盘面总像素尺寸 */ + width: number; + height: number; +} + +export interface FitOptions { + /** size 上限,避免 3 格的教学关铺满全屏 */ + maxSize?: number; + /** 容器四周留白 */ + padding?: number; + /** 缝占 size 的比例 */ + gapRatio?: number; + cornerRatio?: number; +} + +/** 轴坐标 → 以 size=1 计的中心(未加 origin) */ +export function axialToUnit(q: number, r: number): Point { + return { x: SQRT3 * (q + r / 2), y: 1.5 * r }; +} + +export function fitBoard(board: HexBoard, availW: number, availH: number, opts: FitOptions = {}): HexLayout { + const padding = opts.padding ?? 16; + const maxSize = opts.maxSize ?? 60; + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (const c of board.cells) { + const p = axialToUnit(c.q, c.r); + minX = Math.min(minX, p.x - SQRT3 / 2); + maxX = Math.max(maxX, p.x + SQRT3 / 2); + minY = Math.min(minY, p.y - 1); + maxY = Math.max(maxY, p.y + 1); + } + const unitW = maxX - minX; + const unitH = maxY - minY; + const w = Math.max(0, availW - padding * 2); + const h = Math.max(0, availH - padding * 2); + const size = Math.max(6, Math.min(maxSize, w / unitW, h / unitH)); + const width = unitW * size; + const height = unitH * size; + const gap = size * (opts.gapRatio ?? 0.12); + return { + size, + originX: (availW - width) / 2 - minX * size, + originY: (availH - height) / 2 - minY * size, + gap, + // 内切圆半径缩小 gap/2 → 外接圆半径缩小 gap/√3 + drawRadius: size - gap / SQRT3, + corner: size * (opts.cornerRatio ?? 0.16), + width, + height, + }; +} + +export function cellCenter(board: HexBoard, cell: number, L: HexLayout): Point { + const c = board.cells[cell]; + const p = axialToUnit(c.q, c.r); + return { x: L.originX + p.x * L.size, y: L.originY + p.y * L.size }; +} + +/** 点顶六边形顶点,从 30° 起逆时针(屏幕坐标 y 向下,视觉上是顺时针) */ +export function hexCorners(center: Point, radius: number): Point[] { + const pts: Point[] = []; + for (let k = 0; k < 6; k++) { + const a = Math.PI / 6 + (k * Math.PI) / 3; + pts.push({ x: center.x + Math.cos(a) * radius, y: center.y + Math.sin(a) * radius }); + } + return pts; +} + +/** 绘制用六边形(扣缝、圆角),scale 用于动效缩放 */ +export function cellPolygon(board: HexBoard, cell: number, L: HexLayout, scale = 1, shrink = 0): Point[] { + const c = cellCenter(board, cell, L); + const r = Math.max(1, (L.drawRadius - shrink) * scale); + return roundPolygon(hexCorners(c, r), L.corner * scale, 3); +} + +export interface SharedEdge { + /** 边中点 */ + mid: Point; + /** 沿边的单位向量 */ + u: Point; + /** 从 a 指向 b 的单位向量(边的法向) */ + n: Point; + /** 边长(= size) */ + len: number; +} + +/** 几何相邻两格的共用边。不相邻返回 null */ +export function sharedEdge(board: HexBoard, a: number, b: number, L: HexLayout): SharedEdge | null { + if (!board.isGeoNeighbor(a, b)) return null; + const A = cellCenter(board, a, L); + const B = cellCenter(board, b, L); + const dx = B.x - A.x; + const dy = B.y - A.y; + const d = Math.hypot(dx, dy) || 1; + const n = { x: dx / d, y: dy / d }; + return { mid: { x: (A.x + B.x) / 2, y: (A.y + B.y) / 2 }, u: { x: -n.y, y: n.x }, n, len: L.size }; +} + +/** + * 连接相邻两步的“桥”:横跨缝、沿共用边居中,宽为边长的一部分。 + * 与两侧格同色时,视觉上就是一条连续的路径。 + */ +export function bridgePolygon(board: HexBoard, a: number, b: number, L: HexLayout, widthRatio = 0.5, grow = 1): Point[] | null { + const e = sharedEdge(board, a, b, L); + if (!e) return null; + const span = (L.gap + 2) * grow; + return bandRect(e.mid, e.u, e.n, e.len * widthRatio * grow, span); +} + +/** 墙带的几何:中心、方向、长、厚 */ +export interface WallBand { + mid: Point; + u: Point; + n: Point; + len: number; + thick: number; +} + +export function wallBand(board: HexBoard, wall: number, L: HexLayout): WallBand { + const w = board.walls[wall]; + const e = sharedEdge(board, w.a, w.b, L)!; + return { mid: e.mid, u: e.u, n: e.n, len: L.size * 0.92, thick: Math.max(6, L.size * 0.3) }; +} + +/** 转墙轴心的像素位置:格 cell 的第 corner 个角 */ +export function spinnerPivot(board: HexBoard, spinner: number, L: HexLayout): Point { + const sp = board.spinners[spinner]; + const c = cellCenter(board, sp.cell, L); + const a = Math.PI / 6 + (sp.corner * Math.PI) / 3; + return { x: c.x + Math.cos(a) * L.size, y: c.y + Math.sin(a) * L.size }; +} + +/** 转墙第 pos 条边从轴心出发的方向角(弧度,屏幕坐标)。pos 0 = 背离格心,之后每条顺时针 +120° */ +export function spinnerAngle(board: HexBoard, spinner: number, pos: number): number { + const sp = board.spinners[spinner]; + return Math.PI / 6 + (sp.corner * Math.PI) / 3 + (pos * Math.PI * 2) / 3; +} + +/** 像素 → 最近的轴坐标(立方取整) */ +export function pixelToAxial(L: HexLayout, px: number, py: number): { q: number; r: number } { + const x = (px - L.originX) / L.size; + const y = (py - L.originY) / L.size; + const qf = (SQRT3 / 3) * x - y / 3; + const rf = (2 / 3) * y; + const sf = -qf - rf; + let q = Math.round(qf); + let r = Math.round(rf); + const s = Math.round(sf); + const dq = Math.abs(q - qf); + const dr = Math.abs(r - rf); + const ds = Math.abs(s - sf); + if (dq > dr && dq > ds) q = -r - s; + else if (dr > ds) r = -q - s; + return { q, r }; +} + +/** + * 像素点 → 格下标。inset 是每个格边缘的“死区”比例(相对 size), + * 手指扫过格与格的交界时不会误触;落在死区返回 -1(调用方保持上一格)。 + */ +export function hitTest(board: HexBoard, L: HexLayout, px: number, py: number, insetRatio = 0.14): number { + const { q, r } = pixelToAxial(L, px, py); + const cell = board.cellAt(q, r); + if (cell < 0) return -1; + const c = cellCenter(board, cell, L); + const R = L.size * (1 - insetRatio); + const dx = Math.abs(px - c.x); + const dy = Math.abs(py - c.y); + // 点顶六边形内部:|dx| ≤ R√3/2 且 |dx| + √3|dy| ≤ √3 R + if (dx > (R * SQRT3) / 2 || dx + SQRT3 * dy > SQRT3 * R) return -1; + return cell; +} diff --git a/modes/03-hex-walls/src/levels.ts b/modes/03-hex-walls/src/levels.ts new file mode 100644 index 0000000..d179da3 --- /dev/null +++ b/modes/03-hex-walls/src/levels.ts @@ -0,0 +1,39 @@ +// 关卡包读取与校验。运行时(Web / Cocos)和工具链共用同一份解析。 +import { HexBoard } from './board'; +import type { LevelDef, PackDef } from './types'; + +export interface LoadedLevel { + def: LevelDef; + board: HexBoard; + pack: number; + /** 包内序号,从 1 起 */ + index: number; +} + +export function parsePack(raw: unknown): PackDef { + if (!raw || typeof raw !== 'object') throw new Error('关卡包不是对象'); + const p = raw as Partial; + 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)}`); + if (lv.walls !== undefined && !Array.isArray(lv.walls)) throw new Error(`关卡 ${lv.id} 的 walls 不是数组`); + } + return { + mode: String(p.mode ?? '03-hex-walls'), + pack: p.pack, + title: String(p.title ?? `包 ${p.pack}`), + orientation: 'pointy', + notes: p.notes, + levels: p.levels, + }; +} + +/** 把包展开成可玩关卡(构建 HexBoard 时会校验坐标 / 墙 / 线索) */ +export function loadPack(pack: PackDef): LoadedLevel[] { + return pack.levels.map((def, i) => ({ + def, + board: HexBoard.fromLevel(def), + pack: pack.pack, + index: i + 1, + })); +} diff --git a/modes/03-hex-walls/src/painter.ts b/modes/03-hex-walls/src/painter.ts new file mode 100644 index 0000000..ddafd94 --- /dev/null +++ b/modes/03-hex-walls/src/painter.ts @@ -0,0 +1,14 @@ +// 绘制接口:HexBoardView 只调用这几个原语,不知道底下是 Canvas 2D 还是 Cocos Graphics。 +// 全部多边形已在几何层算好(含圆角折线),实现方只需 moveTo/lineTo/close + fill/stroke。 +// Web 实现见 web/canvas-painter.ts;Cocos 用 Graphics(多边形/圆/线)+ Label(文字)。 +import type { Point } from './geom'; + +export interface Painter { + fillPolygon(pts: Point[], color: string, alpha?: number): void; + strokePolygon(pts: Point[], color: string, lineWidth: number, alpha?: number): void; + /** 圆头线段 */ + line(x1: number, y1: number, x2: number, y2: number, width: number, color: string, 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; +} diff --git a/modes/03-hex-walls/src/progress.ts b/modes/03-hex-walls/src/progress.ts new file mode 100644 index 0000000..3bfe153 --- /dev/null +++ b/modes/03-hex-walls/src/progress.ts @@ -0,0 +1,112 @@ +// 存档:本地、按玩法分桶(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:03-hex-walls'; + +interface Saved { + v: number; + 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(); + 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): void { + this.prefs = { ...this.prefs, ...p }; + this.save(); + } + + /** 线性解锁:第 i 关(0 起)解锁 ⇔ i === 0 或前一关已通 */ + 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; + 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 = { v: 1, 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(); + 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); + } +} diff --git a/modes/03-hex-walls/src/solver.ts b/modes/03-hex-walls/src/solver.ts new file mode 100644 index 0000000..cc57291 --- /dev/null +++ b/modes/03-hex-walls/src/solver.ts @@ -0,0 +1,213 @@ +// 求解器:枚举满足全部线索的哈密顿路径;墙有效性检查;提示逻辑。GDD §8、§10。 +// N ≤ 20,回溯 + 连通性 / 线索距离剪枝,毫秒级。 +import type { HexBoard } from './board'; + +export interface SolveOptions { + /** 最多找几条就停(唯一性检查用 2) */ + limit?: number; + /** 忽略线索,枚举全部哈密顿路径(设计工具用) */ + ignoreClues?: boolean; + /** 只从这些格起手(默认按线索 1 / 任意) */ + starts?: number[]; + /** 候选顺序随机化(生成器用),传 0..1 的随机函数 */ + random?: () => number; +} + +export function solve(board: HexBoard, 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); + const rnd = opts.random; + + /** 从 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]; + // 当前已走 step 格,转墙按 step 判定 + if (forced !== -1) { + if (visited[forced] || !board.canStep(cur, forced, step)) return false; + return visit(forced, nextStep); + } + const cands = board.adjAt(cur, step).filter((v) => !visited[v] && clueOf[v] === 0); + if (cands.length > 1) { + if (rnd) { + for (let i = cands.length - 1; i > 0; i--) { + const j = Math.floor(rnd() * (i + 1)); + [cands[i], cands[j]] = [cands[j], cands[i]]; + } + } else { + // 出口少的先走(Warnsdorff),找第一条解更快 + 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 { + 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.slice(); + else if (cellOfStep[1] !== -1) starts = [cellOfStep[1]]; + else starts = board.cells.map((c) => c.index); + if (rnd && starts.length > 1) { + for (let i = starts.length - 1; i > 0; i--) { + const j = Math.floor(rnd() * (i + 1)); + [starts[i], starts[j]] = [starts[j], starts[i]]; + } + } + + 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: HexBoard): number[] | null { + const paths = solve(board, { limit: 2 }); + return paths.length === 1 ? paths[0] : null; +} + +/** + * 墙是否参与唯一性(GDD §4.6):去掉全部墙(含转墙)后解不再唯一,才算合格的“墙关”。 + * 没有墙的盘返回 false。 + */ +export function wallsMatter(board: HexBoard): boolean { + if (board.walls.length === 0 && board.spinners.length === 0) return false; + return solve(board.withoutWalls(), { limit: 2 }).length !== 1; +} + +export interface SpinnerReport { + index: number; + /** 去掉它仍唯一 → 冗余 */ + removable: boolean; + /** 换成钉在第 pos 条边上的静态墙仍唯一 → “会转”没参与推理 */ + freezable: number[]; +} + +/** 每个转墙:是否冗余、是否可用静态墙替代 */ +export function spinnerReport(board: HexBoard): SpinnerReport[] { + return board.spinners.map((sp) => { + const removable = solve(board.withoutSpinner(sp.index), { limit: 2 }).length === 1; + const freezable: number[] = []; + for (let pos = 0; pos < 3; pos++) { + let b: HexBoard | null; + try { + b = board.withSpinnerFrozen(sp.index, pos); + } catch { + // 钉上去的静态墙与另一个转墙的候选边重叠:这种替代本身不合法,跳过 + continue; + } + if (b && solve(b, { limit: 2 }).length === 1) freezable.push(pos); + } + return { index: sp.index, removable, freezable }; + }); +} + +/** 每一堵墙单独去掉后是否仍唯一;返回“可删的墙”下标(设计工具用) */ +export function redundantWalls(board: HexBoard): number[] { + const out: number[] = []; + for (const w of board.walls) { + if (solve(board.withoutWall(w.index), { limit: 2 }).length === 1) out.push(w.index); + } + return out; +} + +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 }; +} diff --git a/modes/03-hex-walls/src/theme.ts b/modes/03-hex-walls/src/theme.ts new file mode 100644 index 0000000..1ac4f12 --- /dev/null +++ b/modes/03-hex-walls/src/theme.ts @@ -0,0 +1,28 @@ +// 配色 V1(浅色)。GDD §6:未走中灰、已走蓝紫、墙黄黑斜纹、背景白。不要每关换主题色。 + +export const THEME = { + /** 页面背景 */ + bg: '#FFFFFF', + /** 未走格 */ + cell: '#E4E6EE', + /** 未走线索格的数字与描边(近黑) */ + clueInk: '#20223A', + /** 已走主色(靛蓝,和黄色墙对比) */ + visited: '#4F5BD5', + /** 过关后提亮 */ + visitedBright: '#6672F0', + /** 已走格上的数字 */ + visitedInk: '#FFFFFF', + /** 冲突 */ + conflict: '#E5484D', + conflictInk: '#FFFFFF', + /** 墙:底色与斜纹 */ + wallBase: '#1F1F27', + wallStripe: '#FFC826', + /** 提示高亮(琥珀偏橙,避免和墙黄混淆) */ + hint: '#FF7A1A', + /** 线索踩对 / 撞墙时闪的颜色 */ + flash: '#FFFFFF', +} as const; + +export type Theme = typeof THEME; diff --git a/modes/03-hex-walls/src/types.ts b/modes/03-hex-walls/src/types.ts new file mode 100644 index 0000000..102393d --- /dev/null +++ b/modes/03-hex-walls/src/types.ts @@ -0,0 +1,45 @@ +// 关卡文件结构(levels/*.json),见 GDD §8。 +// 本文件与整个 src/ 都不依赖 DOM / Cocos,可原样搬入 Cocos Creator。 + +export interface HexCellDef { + id: string; + /** 轴坐标,pointy-top */ + q: number; + r: number; + /** 线索:路径走到本格时步号必须等于它 */ + clue?: number; +} + +/** 墙:两个相邻格 id。无向 */ +export type WallDef = [string, string]; + +/** + * 转墙:绕某个顶点转动的墙。顶点用「格 id + 该格第几个角」标识(角从 30° 起顺时针 0..5,见 layout.hexCorners)。 + * 顶点周围三条边按顺时针编号 0/1/2:0 = 背离该格的那条边(两个邻格之间),1 = 该格与顺时针下一个邻格的边,2 = 该格与上一个邻格的边。 + * 已走 t 格时,墙在第 (phase + t) mod 3 条边上;每走一格顺时针转 120°。 + */ +export interface SpinnerDef { + cell: string; + corner: number; + phase?: number; +} + +export interface LevelDef { + id: string; + /** 教学备注(设计用,不显示) */ + teach?: string; + /** 对局顶部一行文案(只有教学关有) */ + caption?: string; + cells: HexCellDef[]; + walls?: WallDef[]; + spinners?: SpinnerDef[]; +} + +export interface PackDef { + mode: string; + pack: number; + title: string; + orientation?: 'pointy'; + notes?: string; + levels: LevelDef[]; +} diff --git a/modes/03-hex-walls/src/view.ts b/modes/03-hex-walls/src/view.ts new file mode 100644 index 0000000..0a95f63 --- /dev/null +++ b/modes/03-hex-walls/src/view.ts @@ -0,0 +1,381 @@ +// 盘面视图:六边形、桥、数字、墙、冲突、提示、过关动画。GDD §6。 +// 纯 TS:只依赖 Game 状态和 Painter 接口。宿主每帧调用 update(dt) + draw(painter)。 +import type { Game, GameEvent } from './game'; +import { bandRect, scalePolygon, stripesInBand, translatePolygon, type Point } from './geom'; +import { spinnerPos } from './board'; +import { bridgePolygon, cellCenter, cellPolygon, spinnerAngle, spinnerPivot, wallBand, type HexLayout } from './layout'; +import type { Painter } from './painter'; +import type { Hint } from './solver'; +import { THEME } from './theme'; + +/** 单格动效状态 */ +interface CellAnim { + scale: number; + scaleVel: number; + /** 描边闪白 0..1 */ + flash: number; + /** 抖动剩余秒数 */ + shake: number; + /** 步号淡入 0..1 */ + numberAlpha: number; + /** 过关扫光 0..1 */ + sweep: number; +} + +interface WallAnim { + /** 撞墙闪白 0..1 */ + flash: number; + /** 撞墙时沿法向的推挤(像素比例) */ + push: number; + pushDir: 1 | -1; +} + +interface SpinnerAnim { + /** 当前显示的方向角(未取模,便于连续转) */ + shown: number; + target: number; + flash: number; +} + +const SPIN_STEP = (Math.PI * 2) / 3; +const SPIN_SPEED = 16; // 指数逼近速率,约 120ms 转到位 +const POP_IMPULSE = 2.6; +const SPRING_K = 260; +const SPRING_DAMP = 18; +const FLASH_DECAY = 3.2; +const WALL_FLASH_DECAY = 6; +const SHAKE_TIME = 0.28; +const NUMBER_FADE = 8; +const BRIDGE_GROW = 10; +const HINT_TIME = 2.4; +const CLEARED_SWEEP_TIME = 0.36; + +export class HexBoardView { + readonly game: Game; + layout: HexLayout | null = null; + + private readonly cells: CellAnim[]; + private readonly walls: WallAnim[]; + private readonly spinners: SpinnerAnim[]; + /** bridges[i] = 第 i 步与第 i+1 步之间的桥的出现进度 0..1 */ + private bridges: number[] = []; + private hintState: { hint: Hint; t: number } | null = null; + private cleared = false; + private clearedT = 0; + private animating = true; + + constructor(game: Game) { + this.game = game; + this.cells = game.board.cells.map(() => ({ scale: 1, scaleVel: 0, flash: 0, shake: 0, numberAlpha: 0, sweep: 0 })); + this.walls = game.board.walls.map(() => ({ flash: 0, push: 0, pushDir: 1 })); + this.spinners = game.board.spinners.map((sp, i) => { + const a = spinnerAngle(game.board, i, spinnerPos(sp, 0)); + return { shown: a, target: a, flash: 0 }; + }); + } + + get isAnimating(): boolean { + return this.animating; + } + + /** 过关动画时长(秒),宿主据此安排跳下一关 */ + get clearedDuration(): number { + return CLEARED_SWEEP_TIME + 0.7; + } + + invalidate(): void { + this.animating = true; + } + + onEvent(e: GameEvent): void { + this.animating = true; + this.hintState = null; + switch (e.type) { + case 'step': { + for (const sp of this.spinners) sp.target += SPIN_STEP; + const a = this.cells[e.cell]; + a.scaleVel = POP_IMPULSE; + a.numberAlpha = 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] = 0; + break; + } + case 'unstep': { + for (const sp of this.spinners) sp.target -= SPIN_STEP; + const a = this.cells[e.cell]; + a.numberAlpha = 0; + a.flash = 0; + a.scale = 1; + a.scaleVel = 0; + this.bridges.length = Math.max(0, e.step - 2); + break; + } + case 'reset': + for (const a of this.cells) { + a.flash = 0; + a.numberAlpha = 0; + a.scale = 1; + a.scaleVel = 0; + a.sweep = 0; + } + this.bridges.length = 0; + this.cleared = false; + this.clearedT = 0; + this.game.board.spinners.forEach((sp, i) => { + const a = spinnerAngle(this.game.board, i, spinnerPos(sp, 0)); + this.spinners[i].shown = a; + this.spinners[i].target = a; + }); + break; + case 'rejectStart': + if (e.clueCell >= 0) this.cells[e.clueCell].shake = SHAKE_TIME; + break; + case 'wallHit': { + if (e.wall >= 0) { + const w = this.walls[e.wall]; + w.flash = 1; + w.push = 1; + // 从 from 推向 to:墙的 n 是 a→b,若 from 是 b 则反向 + w.pushDir = this.game.board.walls[e.wall].a === e.from ? 1 : -1; + } + if (e.spinner >= 0) this.spinners[e.spinner].flash = 1; + break; + } + case 'deadEnd': + this.cells[e.cell].shake = SHAKE_TIME; + break; + case 'cleared': + this.cleared = true; + this.clearedT = 0; + break; + } + } + + showHint(h: Hint): void { + this.hintState = h.kind === 'none' ? null : { hint: h, t: HINT_TIME }; + 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) { + if (a.scaleVel !== 0 || Math.abs(a.scale - 1) > 0.0005) { + const acc = -SPRING_K * (a.scale - 1) - SPRING_DAMP * 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 - FLASH_DECAY * dt); + 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 + NUMBER_FADE * dt); + busy = true; + } + } + for (const w of this.walls) { + if (w.flash > 0) { + w.flash = Math.max(0, w.flash - WALL_FLASH_DECAY * dt); + busy = true; + } + if (w.push > 0) { + w.push = Math.max(0, w.push - 7 * dt); + busy = true; + } + } + for (const sp of this.spinners) { + const d = sp.target - sp.shown; + if (Math.abs(d) > 0.002) { + sp.shown += d * Math.min(1, SPIN_SPEED * dt); + busy = true; + } else sp.shown = sp.target; + if (sp.flash > 0) { + sp.flash = Math.max(0, sp.flash - WALL_FLASH_DECAY * dt); + 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] + BRIDGE_GROW * dt); + 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) * CLEARED_SWEEP_TIME; + const local = (this.clearedT - start) / 0.28; + this.cells[path[i]].sweep = Math.max(0, Math.min(1, local)); + } + if (this.clearedT < this.clearedDuration) busy = true; + } + this.animating = busy; + } + + /** 某格当前绘制多边形(含抖动 / 缩放) */ + private polyOf(cell: number, L: HexLayout, shrink = 0): Point[] { + const a = this.cells[cell]; + let poly = cellPolygon(this.game.board, cell, L, a.scale, shrink); + if (a.shake > 0) { + const k = a.shake / SHAKE_TIME; + poly = translatePolygon(poly, Math.sin(k * Math.PI * 6) * L.size * 0.08 * k, 0); + } + return poly; + } + + draw(p: Painter): void { + const L = this.layout; + if (!L) return; + const g = this.game; + const board = g.board; + const path = g.path; + + // 1. 底板:所有格 + for (const c of board.cells) { + const a = this.cells[c.index]; + const poly = this.polyOf(c.index, L); + const visited = g.stepOf(c.index) !== 0; + const conflict = g.isConflict(c.index); + let fill: string = THEME.cell; + if (conflict) fill = THEME.conflict; + else if (visited) fill = this.cleared ? THEME.visitedBright : THEME.visited; + p.fillPolygon(poly, fill); + if (visited && a.sweep > 0 && a.sweep < 1) { + p.fillPolygon(poly, THEME.flash, 0.6 * Math.sin(a.sweep * Math.PI)); + } + } + + // 2. 桥:连接相邻两步(画在格上面,同色即融为一体) + for (let i = 0; i < path.length - 1; i++) { + const k = this.bridges[i] ?? 1; + if (k <= 0) continue; + const poly = bridgePolygon(board, path[i], path[i + 1], L, 0.5, k); + if (!poly) continue; + const anyConflict = g.isConflict(path[i]) || g.isConflict(path[i + 1]); + const color = anyConflict ? THEME.conflict : this.cleared ? THEME.visitedBright : THEME.visited; + p.fillPolygon(poly, color); + } + + // 3. 线索描边、数字、冲突角标 + const fontSize = L.size * 0.64; + for (const c of board.cells) { + const a = this.cells[c.index]; + const center = this.centerOf(c.index, L); + const step = g.stepOf(c.index); + const visited = step !== 0; + const conflict = g.isConflict(c.index); + const isClue = c.clue !== null; + + if (isClue) { + const lw = Math.max(2, L.size * 0.09); + const ring = this.polyOf(c.index, L, lw * 0.62); + const ringColor = visited ? (conflict ? THEME.conflictInk : THEME.visitedInk) : THEME.clueInk; + p.strokePolygon(ring, ringColor, lw, visited ? 0.85 : 1); + if (a.flash > 0) p.strokePolygon(ring, THEME.flash, lw * 1.5, a.flash); + } + + if (visited) { + const ink = conflict ? THEME.conflictInk : THEME.visitedInk; + p.text(String(step), center.x, center.y, fontSize, ink, true, a.numberAlpha); + if (conflict) { + // 角标:应该是几。不只靠颜色表达冲突 + const br = Math.max(7, L.size * 0.22); + const bx = center.x + L.size * 0.46; + const by = center.y - L.size * 0.5; + p.fillCircle(bx, by, br, THEME.clueInk); + p.text(String(c.clue), bx, by + br * 0.03, br * 1.25, THEME.conflictInk, true); + } + } else if (isClue) { + p.text(String(c.clue), center.x, center.y, fontSize, THEME.clueInk, true); + } + } + + // 4. 墙:黑底圆头 + 黄斜纹,永远画在格与桥之上 + for (const w of board.walls) { + const anim = this.walls[w.index]; + const band = wallBand(board, w.index, L); + let mid = band.mid; + if (anim.push > 0) { + const k = Math.sin(anim.push * Math.PI) * L.size * 0.08 * anim.pushDir; + mid = { x: mid.x + band.n.x * k, y: mid.y + band.n.y * k }; + } + const half = band.len / 2; + p.line(mid.x - band.u.x * half, mid.y - band.u.y * half, mid.x + band.u.x * half, mid.y + band.u.y * half, band.thick, THEME.wallBase); + const stripes = stripesInBand(mid, band.u, band.n, band.len, band.thick, band.thick * 1.05, band.thick * 0.5, 0); + for (const s of stripes) p.fillPolygon(s, THEME.wallStripe); + if (anim.flash > 0) { + const rect = bandRect(mid, band.u, band.n, band.len + band.thick, band.thick); + p.fillPolygon(rect, THEME.flash, anim.flash * 0.85); + p.strokePolygon(scalePolygon(rect, mid, 1.15), THEME.conflict, Math.max(2, L.size * 0.06), anim.flash * 0.9); + } + } + + // 4b. 转墙:从轴心伸出的条纹带 + 轴心圆点;转动中用当前插值角 + for (const sp of board.spinners) { + const anim = this.spinners[sp.index]; + const P = spinnerPivot(board, sp.index, L); + const len = L.size * 0.95; + const thick = Math.max(6, L.size * 0.3); + const u = { x: Math.cos(anim.shown), y: Math.sin(anim.shown) }; + const n = { x: -u.y, y: u.x }; + const mid = { x: P.x + u.x * len * 0.5, y: P.y + u.y * len * 0.5 }; + p.line(P.x, P.y, P.x + u.x * len, P.y + u.y * len, thick, THEME.wallBase); + const stripes = stripesInBand(mid, u, n, len, thick, thick * 1.05, thick * 0.5, 0); + for (const st of stripes) p.fillPolygon(st, THEME.wallStripe); + // 轴心:深色圆 + 亮点,标出“会转的那一端” + p.fillCircle(P.x, P.y, thick * 0.72, THEME.wallBase); + p.fillCircle(P.x, P.y, thick * 0.3, THEME.wallStripe); + if (anim.flash > 0) { + const rect = bandRect(mid, u, n, len + thick, thick); + p.fillPolygon(rect, THEME.flash, anim.flash * 0.85); + p.strokePolygon(scalePolygon(rect, mid, 1.15), THEME.conflict, Math.max(2, L.size * 0.06), anim.flash * 0.9); + } + } + + // 5. 提示 + if (this.hintState) { + const { hint, t } = this.hintState; + const pulse = 0.55 + 0.45 * Math.sin((HINT_TIME - t) * 7); + const alpha = Math.min(1, t / 0.4) * pulse; + const target = hint.kind === 'none' ? -1 : hint.cell; + if (target >= 0) { + const lw = Math.max(3, L.size * 0.1); + const outer = cellPolygon(board, target, L, 1, -lw * 0.9); + p.strokePolygon(outer, THEME.hint, lw, alpha); + } + if (hint.kind === 'backtrack') { + const from = path.indexOf(hint.cell) + 1; + for (let i = from; i < path.length; i++) { + p.fillPolygon(cellPolygon(board, path[i], L), THEME.bg, 0.5 * pulse); + } + } + } + } + + private centerOf(cell: number, L: HexLayout): Point { + const c = cellCenter(this.game.board, cell, L); + const a = this.cells[cell]; + if (a.shake > 0) { + const k = a.shake / SHAKE_TIME; + return { x: c.x + Math.sin(k * Math.PI * 6) * L.size * 0.08 * k, y: c.y }; + } + return c; + } +} diff --git a/modes/03-hex-walls/tests/board.test.ts b/modes/03-hex-walls/tests/board.test.ts new file mode 100644 index 0000000..9e6db47 --- /dev/null +++ b/modes/03-hex-walls/tests/board.test.ts @@ -0,0 +1,57 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { HexBoard } from '../src/board'; +import type { HexCellDef } from '../src/types'; + +const c = (id: string, q: number, r: number, clue?: number): HexCellDef => ({ id, q, r, clue }); + +test('六邻接:中心格有 6 个邻居,角落只有共边的', () => { + const cells: HexCellDef[] = [c('o', 0, 0)]; + const dirs: Array<[number, number]> = [[1, 0], [1, -1], [0, -1], [-1, 0], [-1, 1], [0, 1]]; + dirs.forEach(([q, r], i) => cells.push(c(`n${i}`, q, r))); + const b = HexBoard.fromCells(cells); + assert.equal(b.adj[0].length, 6); + // 外圈每格:中心 + 两个相邻外圈格 + for (let i = 1; i <= 6; i++) assert.equal(b.adj[i].length, 3); + // (1,1) 不是 (0,0) 的邻居 + const b2 = HexBoard.fromCells([c('a', 0, 0), c('b', 1, 1)]); + assert.equal(b2.areAdjacent(0, 1), false); + assert.equal(b2.isConnected(), false); +}); + +test('墙去掉一条邻接,几何邻接不变', () => { + const b = HexBoard.fromCells([c('a', 0, 0), c('b', 1, 0), c('c', 0, 1)], [['a', 'b']]); + assert.equal(b.areAdjacent(0, 1), false); + assert.equal(b.isGeoNeighbor(0, 1), true); + assert.equal(b.isWalled(0, 1), true); + assert.equal(b.isWalled(1, 0), true); + assert.equal(b.areAdjacent(0, 2), true); + assert.equal(b.areAdjacent(1, 2), true); + assert.equal(b.wallBetween(1, 0)?.index, 0); + assert.equal(b.withoutWalls().areAdjacent(0, 1), true); + assert.equal(b.isConnected(), true); +}); + +test('墙把盘切开 → 不连通', () => { + const b = HexBoard.fromCells([c('a', 0, 0), c('b', 1, 0)], [['a', 'b']]); + assert.equal(b.isConnected(), false); +}); + +test('非法盘抛错:坐标重叠、id 重复、墙不在共边、墙重复、线索越界', () => { + assert.throws(() => HexBoard.fromCells([c('a', 0, 0), c('b', 0, 0)]), /重叠/); + assert.throws(() => HexBoard.fromCells([c('a', 0, 0), c('a', 1, 0)]), /重复/); + assert.throws(() => HexBoard.fromCells([c('a', 0, 0), c('b', 1, 1)], [['a', 'b']]), /共用边/); + assert.throws(() => HexBoard.fromCells([c('a', 0, 0), c('b', 1, 0)], [['a', 'b'], ['b', 'a']]), /重复/); + assert.throws(() => HexBoard.fromCells([c('a', 0, 0), c('b', 1, 0)], [['a', 'x']]), /不存在/); + assert.throws(() => HexBoard.fromCells([c('a', 0, 0, 3), c('b', 1, 0)]), /超出/); + assert.throws(() => HexBoard.fromCells([c('a', 0, 0, 1), c('b', 1, 0, 1)]), /线索重复/); +}); + +test('cellAt / cellById / 线索索引', () => { + const b = HexBoard.fromCells([c('a', -1, 2, 1), c('b', 0, 2, 2)]); + assert.equal(b.cellAt(-1, 2), 0); + assert.equal(b.cellAt(5, 5), -1); + assert.equal(b.cellById('b').index, 1); + assert.equal(b.startClueCell, 0); + assert.equal(b.clueCellOfStep.get(2), 1); +}); diff --git a/modes/03-hex-walls/tests/game.test.ts b/modes/03-hex-walls/tests/game.test.ts new file mode 100644 index 0000000..6e3e94b --- /dev/null +++ b/modes/03-hex-walls/tests/game.test.ts @@ -0,0 +1,114 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { HexBoard } from '../src/board'; +import { Game } from '../src/game'; +import type { HexCellDef } from '../src/types'; + +const c = (id: string, q: number, r: number, clue?: number): HexCellDef => ({ id, q, r, clue }); + +// 1-3:a(1) b / c d(4),墙 a|b +const level13 = () => HexBoard.fromCells([c('a', 0, 0, 1), c('b', 1, 0), c('c', 0, 1), c('d', 1, 1, 4)], [['a', 'b']]); + +test('有线索 1 时只能从它起手', () => { + const g = new Game(level13()); + assert.deepEqual(g.apply({ type: 'start', cell: 1 }), [{ 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(HexBoard.fromCells([c('a', 0, 0), c('b', 1, 0, 2)])); + assert.equal(g.apply({ type: 'start', cell: 0 })[0].type, 'step'); +}); + +test('穿墙 → wallHit 事件,不追加', () => { + const b = level13(); + const g = new Game(b); + g.apply({ type: 'start', cell: 0 }); + const ev = g.apply({ type: 'append', cell: 1 }); + assert.deepEqual(ev, [{ type: 'wallHit', from: 0, to: 1, wall: 0, spinner: -1 }]); + assert.equal(g.path.length, 1); + assert.deepEqual(g.wallTo(1), { wall: 0, spinner: -1 }); + assert.equal(g.canAppend(1), false); + assert.equal(g.canAppend(2), true); +}); + +test('追加只能到相邻无墙未走格;冲突不打断;撤销恢复', () => { + const b = level13(); + const g = new Game(b); + const id = (s: string) => b.cellById(s).index; + g.apply({ type: 'start', cell: id('a') }); + assert.deepEqual(g.apply({ type: 'append', cell: id('d') }), []); // (1,1) 与 (0,0) 不相邻 + g.apply({ type: 'append', cell: id('c') }); + const ev = g.apply({ type: 'append', cell: id('d') }); // d=3,冲突 + assert.equal(ev[0].type, 'step'); + assert.equal((ev[0] as { conflict: boolean }).conflict, true); + assert.equal(g.isConflict(id('d')), true); + const last = g.apply({ type: 'append', cell: id('b') }); + assert.equal(g.path.length, 4); + assert.equal(g.status, 'drawing', '填满但有冲突不算过关'); + assert.ok(last.some((e) => e.type === 'deadEnd')); + g.apply({ type: 'undo' }); + const un = g.apply({ type: 'undo' }); + assert.deepEqual(un, [{ type: 'unstep', cell: id('d'), step: 3 }]); + assert.equal(g.isConflict(id('d')), false); +}); + +test('全走完且线索全对 → cleared,之后锁住', () => { + const b = level13(); + const g = new Game(b); + const id = (s: string) => b.cellById(s).index; + g.apply({ type: 'start', cell: id('a') }); + g.apply({ type: 'append', cell: id('c') }); + g.apply({ type: 'append', cell: id('b') }); + const ev = g.apply({ type: 'append', cell: id('d') }); + assert.ok(ev.some((e) => e.type === 'cleared')); + assert.ok(ev.some((e) => e.type === 'step' && e.clueHit)); + assert.equal(g.status, 'cleared'); + assert.deepEqual(g.apply({ type: 'undo' }), []); + assert.deepEqual(g.apply({ type: 'append', cell: 0 }), []); +}); + +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.deepEqual(g.apply({ type: 'reset' }), [], '空盘重开不产生事件'); + g.apply({ type: 'start', cell: 0 }); + g.apply({ type: 'undo' }); + assert.equal(g.status, 'empty'); +}); + +test('转墙:每走一格转 120°,按已走格数判定', () => { + // 花形 7 格,轴心在中心格 o 的第 4 个角(顶部),周围三格 o / nw / ne + const cells = [c('o', 0, 0, 1), c('e', 1, 0), c('ne', 1, -1), c('nw', 0, -1), c('w', -1, 0), c('sw', -1, 1), c('se', 0, 1)]; + const b = HexBoard.fromCells(cells, [], [{ cell: 'o', corner: 4, phase: 0 }]); + const sp = b.spinners[0]; + const id = (s: string) => b.cellById(s).index; + // 角 4 夹在方向 240°(nw) 与 300°(ne) 之间:边 0 = nw-ne,边 1 = o-ne,边 2 = o-nw + assert.deepEqual(sp.edges[0], [Math.min(id('nw'), id('ne')), Math.max(id('nw'), id('ne'))]); + assert.deepEqual(sp.edges[1], [id('o'), id('ne')]); + assert.deepEqual(sp.edges[2], [id('o'), id('nw')]); + // t=0 挡 nw-ne;t=1 挡 o-ne;t=2 挡 o-nw + assert.equal(b.spinnerBlocking(id('nw'), id('ne'), 0), 0); + assert.equal(b.spinnerBlocking(id('o'), id('ne'), 1), 0); + assert.equal(b.spinnerBlocking(id('o'), id('nw'), 2), 0); + assert.equal(b.spinnerBlocking(id('o'), id('ne'), 0), -1); + const g = new Game(b); + g.apply({ type: 'start', cell: id('o') }); // 已走 1 格 → 墙挡 o-ne + const hit = g.apply({ type: 'append', cell: id('ne') }); + assert.deepEqual(hit, [{ type: 'wallHit', from: id('o'), to: id('ne'), wall: -1, spinner: 0 }]); + assert.equal(g.apply({ type: 'append', cell: id('nw') })[0].type, 'step'); // t=1 时 o-nw 开着 + // 现在已走 2 格 → 墙挡 o-nw(已走,无所谓);nw-ne 开着 + assert.equal(g.apply({ type: 'append', cell: id('ne') })[0].type, 'step'); + g.apply({ type: 'undo' }); + g.apply({ type: 'undo' }); + assert.equal(g.path.length, 1); + assert.deepEqual(g.apply({ type: 'append', cell: id('ne') })[0].type, 'wallHit', '撤销后墙转回去'); +}); diff --git a/modes/03-hex-walls/tests/input.test.ts b/modes/03-hex-walls/tests/input.test.ts new file mode 100644 index 0000000..bb1d9c4 --- /dev/null +++ b/modes/03-hex-walls/tests/input.test.ts @@ -0,0 +1,87 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { HexBoard } from '../src/board'; +import { Game } from '../src/game'; +import { PointerInput } from '../src/input'; +import type { HexCellDef } from '../src/types'; + +const c = (id: string, q: number, r: number, clue?: number): HexCellDef => ({ id, q, r, clue }); +const level13 = () => HexBoard.fromCells([c('a', 0, 0, 1), c('b', 1, 0), c('c', 0, 1), c('d', 1, 1, 4)], [['a', 'b']]); + +function setup() { + const b = level13(); + const g = new Game(b); + const input = new PointerInput(g); + const id = (s: string) => b.cellById(s).index; + return { b, g, input, id }; +} + +test('按下起点、滑到邻格追加、滑回撤销、松手保留', () => { + const { g, input, id } = setup(); + assert.equal(input.down(id('a'))[0].type, 'step'); + assert.ok(input.isDrawing); + assert.equal(input.move(id('c'))[0].type, 'step'); + assert.deepEqual(input.move(id('c')), [], '同一格不重复触发'); + assert.equal(input.move(id('a'))[0].type, 'unstep'); + assert.equal(g.path.length, 1); + input.move(id('c')); + input.up(); + assert.equal(g.status, 'idle'); + assert.equal(g.path.length, 2); +}); + +test('滑向有墙的邻格 → wallHit 一次,路径不变', () => { + const { g, input, id } = setup(); + input.down(id('a')); + const ev = input.move(id('b')); + assert.equal(ev.length, 1); + assert.equal(ev[0].type, 'wallHit'); + assert.deepEqual(input.move(id('b')), [], '停在同一格不重复报'); + assert.equal(g.path.length, 1); + // 指针逻辑上仍在末端:继续滑到 c 可追加 + assert.equal(input.move(id('c'))[0].type, 'step'); +}); + +test('点末端继续、点前一格撤销、点旁边空格追加、点墙外邻格报撞墙', () => { + const { g, input, id } = setup(); + input.down(id('a')); + input.move(id('c')); + input.up(); + assert.equal(input.down(id('c')).length, 0); + assert.ok(input.isDrawing); + input.up(); + assert.equal(input.down(id('a'))[0].type, 'unstep'); + assert.equal(g.path.length, 1); + input.up(); + assert.equal(input.down(id('c'))[0].type, 'step', '点着走'); + input.up(); + // 路径 a,c;c 的邻居 b 无墙 → 追加;再从 b 去 a 已走 → 忽略 + assert.equal(input.down(id('b'))[0].type, 'step'); + input.up(); + // 从 a 起手时点 b:撞墙反馈 + input.cancel(); + g.apply({ type: 'reset' }); + input.down(id('a')); + input.up(); + assert.equal(input.down(id('b'))[0].type, 'wallHit'); +}); + +test('非法起手与空白按下', () => { + const { input, id } = setup(); + assert.equal(input.down(id('b'))[0].type, 'rejectStart'); + assert.ok(!input.isDrawing); + assert.deepEqual(input.down(-1), []); + assert.deepEqual(input.move(id('a')), [], '没在画,move 无效'); +}); + +test('过关后手势失效', () => { + const { g, input, id } = setup(); + input.down(id('a')); + input.move(id('c')); + input.move(id('b')); + const ev = input.move(id('d')); + assert.ok(ev.some((e) => e.type === 'cleared')); + assert.ok(!input.isDrawing); + assert.equal(g.isCleared, true); + assert.deepEqual(input.down(id('d')), []); +}); diff --git a/modes/03-hex-walls/tests/layout.test.ts b/modes/03-hex-walls/tests/layout.test.ts new file mode 100644 index 0000000..72a62c3 --- /dev/null +++ b/modes/03-hex-walls/tests/layout.test.ts @@ -0,0 +1,79 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { HexBoard } from '../src/board'; +import { clipPolygon, roundPolygon, stripesInBand } from '../src/geom'; +import { bridgePolygon, cellCenter, fitBoard, hitTest, pixelToAxial, sharedEdge, wallBand } from '../src/layout'; +import type { HexCellDef } from '../src/types'; + +const c = (id: string, q: number, r: number, clue?: number): HexCellDef => ({ id, q, r, clue }); +const flower = () => + HexBoard.fromCells([c('o', 0, 0), c('e', 1, 0), c('ne', 1, -1), c('nw', 0, -1), c('w', -1, 0), c('sw', -1, 1), c('se', 0, 1)], [['o', 'e']]); + +test('fitBoard 按短边缩放并居中,不超过 maxSize', () => { + const b = flower(); + const L = fitBoard(b, 400, 600, { padding: 10, maxSize: 60 }); + assert.ok(L.size <= 60); + // 花形宽 = 3 × √3 × size,应贴合 380 宽或受 maxSize 限制 + assert.ok(L.width <= 380 + 1e-6); + const o = cellCenter(b, 0, L); + assert.ok(Math.abs(o.x - 200) < 1e-6); + assert.ok(Math.abs(o.y - 300) < 1e-6); + const small = fitBoard(b, 100, 100, { padding: 0 }); + assert.ok(small.width <= 100 + 1e-6 && small.height <= 100 + 1e-6); +}); + +test('像素 ↔ 轴坐标往返;命中中心;死区与空洞返回 -1', () => { + const b = flower(); + const L = fitBoard(b, 400, 600, { padding: 10, maxSize: 60 }); + for (const cell of b.cells) { + const p = cellCenter(b, cell.index, L); + assert.deepEqual(pixelToAxial(L, p.x, p.y), { q: cell.q, r: cell.r }); + assert.equal(hitTest(b, L, p.x, p.y), cell.index); + } + const o = cellCenter(b, 0, L); + const e = cellCenter(b, 1, L); + // 两格中点在缝里 → 死区 + assert.equal(hitTest(b, L, (o.x + e.x) / 2, (o.y + e.y) / 2), -1); + // 盘外 + assert.equal(hitTest(b, L, 5, 5), -1); + // (2,0) 不存在 + const far = { x: o.x + Math.sqrt(3) * L.size * 2, y: o.y }; + assert.equal(hitTest(b, L, far.x, far.y), -1); +}); + +test('共用边、桥、墙带的几何', () => { + const b = flower(); + const L = fitBoard(b, 400, 600, { padding: 10, maxSize: 60 }); + const e = sharedEdge(b, 0, 1, L)!; + const o = cellCenter(b, 0, L); + const ea = cellCenter(b, 1, L); + assert.ok(Math.abs(e.mid.x - (o.x + ea.x) / 2) < 1e-6); + assert.ok(Math.abs(e.n.x - 1) < 1e-6, '东邻的法向是 +x'); + assert.ok(Math.abs(e.u.y - 1) < 1e-6, '共用边竖直'); + assert.equal(sharedEdge(b, 1, 3, L), null, 'ne 与 w 不相邻'); + const br = bridgePolygon(b, 0, 1, L)!; + assert.equal(br.length, 4); + const wb = wallBand(b, 0, L); + assert.ok(wb.thick > 0 && wb.len > 0); +}); + +test('几何工具:圆角多边形顶点数、凸裁剪、斜纹都在带内', () => { + const sq = [ + { x: 0, y: 0 }, + { x: 10, y: 0 }, + { x: 10, y: 10 }, + { x: 0, y: 10 }, + ]; + assert.equal(roundPolygon(sq, 2, 3).length, 16); + const tri = [ + { x: -5, y: 5 }, + { x: 15, y: 5 }, + { x: 5, y: 20 }, + ]; + const clipped = clipPolygon(tri, sq); + assert.ok(clipped.length >= 3); + for (const p of clipped) assert.ok(p.x >= -1e-6 && p.x <= 10 + 1e-6 && p.y >= -1e-6 && p.y <= 10 + 1e-6); + const stripes = stripesInBand({ x: 0, y: 0 }, { x: 1, y: 0 }, { x: 0, y: 1 }, 40, 8, 8, 4); + assert.ok(stripes.length >= 4); + for (const s of stripes) for (const p of s) assert.ok(Math.abs(p.x) <= 20 + 1e-6 && Math.abs(p.y) <= 4 + 1e-6); +}); diff --git a/modes/03-hex-walls/tests/progress.test.ts b/modes/03-hex-walls/tests/progress.test.ts new file mode 100644 index 0000000..8fcb8cd --- /dev/null +++ b/modes/03-hex-walls/tests/progress.test.ts @@ -0,0 +1,41 @@ +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); + p.markCleared('1-1'); + assert.equal(p.isUnlocked(ids, 1), true); + 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.equal(STORAGE_KEY, 'mode:03-hex-walls'); + assert.ok(store.get(STORAGE_KEY)); + const p2 = new Progress(store); + assert.equal(p2.isCleared('1-1'), true); + assert.equal(p2.currentId, '1-2'); + assert.equal(p2.prefs.sound, false); + assert.equal(p2.prefs.haptics, true); + p2.resetAll(); + assert.equal(new Progress(store).clearedCount(), 0); +}); + +test('坏存档不炸', () => { + const store = new MemoryStore(); + store.set(STORAGE_KEY, '{not json'); + const p = new Progress(store); + assert.equal(p.clearedCount(), 0); +}); diff --git a/modes/03-hex-walls/tests/solver.test.ts b/modes/03-hex-walls/tests/solver.test.ts new file mode 100644 index 0000000..55db0bf --- /dev/null +++ b/modes/03-hex-walls/tests/solver.test.ts @@ -0,0 +1,97 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { test } from 'node:test'; +import { HexBoard } from '../src/board'; +import { loadPack, parsePack } from '../src/levels'; +import { hint, redundantWalls, solve, spinnerReport, uniqueSolution, wallsMatter } from '../src/solver'; +import type { HexCellDef } from '../src/types'; + +const c = (id: string, q: number, r: number, clue?: number): HexCellDef => ({ id, q, r, clue }); + +const pack = parsePack(JSON.parse(readFileSync(new URL('../levels/pack-1-tutorial.json', import.meta.url), 'utf8'))); +const levels = loadPack(pack); + +test('第一包恰好 10 关,每关连通且唯一解', () => { + assert.equal(levels.length, 10); + for (const lv of levels) assert.equal(lv.board.spinners.length, 0, '第一包不含转墙'); + for (const lv of levels) { + assert.ok(lv.board.isConnected(), `${lv.def.id} 不连通`); + const paths = solve(lv.board, { limit: 2 }); + assert.equal(paths.length, 1, `${lv.def.id} 解数 ${paths.length}`); + assert.equal(paths[0].length, lv.board.n); + // 解满足线索 + paths[0].forEach((cell, i) => { + const clue = lv.board.cells[cell].clue; + if (clue !== null) assert.equal(clue, i + 1, `${lv.def.id} 线索不符`); + }); + } +}); + +test('第 3 关起每关至少 1 堵墙,且墙参与唯一性、无冗余墙', () => { + for (const lv of levels) { + if (lv.index < 3) continue; + assert.ok(lv.board.walls.length >= 1, `${lv.def.id} 没有墙`); + assert.ok(wallsMatter(lv.board), `${lv.def.id} 去墙后仍唯一`); + assert.deepEqual(redundantWalls(lv.board), [], `${lv.def.id} 有冗余墙`); + } +}); + +test('前 8 关都有线索 1', () => { + for (const lv of levels) if (lv.index <= 8) assert.notEqual(lv.board.startClueCell, -1, lv.def.id); +}); + +test('墙改变解集合:同一盘有墙 1 解、无墙 2 解', () => { + const b = HexBoard.fromCells([c('a', 0, 0, 1), c('b', 1, 0), c('c', 0, 1), c('d', 1, 1, 4)], [['a', 'b']]); + assert.deepEqual(uniqueSolution(b), [0, 2, 1, 3]); + assert.equal(solve(b.withoutWalls()).length, 2); +}); + +test('无线索 1 时枚举全部起点;无解 / 多解返回 null', () => { + const b = HexBoard.fromCells([c('a', 0, 0), c('b', 1, 0), c('c', 2, 0)]); + assert.equal(solve(b).length, 2); + assert.equal(uniqueSolution(b), null); + const b2 = HexBoard.fromCells([c('a', 0, 0), c('b', 1, 0), c('c', 2, 0, 2)]); + assert.equal(solve(b2).length, 0); +}); + +test('随机模式也能找到解', () => { + const b = levels[9].board; + let seed = 1; + const rnd = () => (seed = (seed * 16807) % 2147483647) / 2147483647; + assert.equal(solve(b, { limit: 1, random: rnd }).length, 1); +}); + +test('提示四态', () => { + const sol = [0, 2, 1, 3]; + assert.deepEqual(hint(sol, []), { kind: 'start', cell: 0 }); + assert.deepEqual(hint(sol, [0, 2]), { kind: 'next', cell: 1 }); + assert.deepEqual(hint(sol, [0, 1, 3]), { kind: 'backtrack', cell: 0, undoCount: 2 }); + assert.deepEqual(hint(sol, [2, 0]), { kind: 'restart', cell: 0 }); + assert.deepEqual(hint(sol, sol), { kind: 'none' }); +}); + +const pack2 = parsePack(JSON.parse(readFileSync(new URL('../levels/pack-2-spinner.json', import.meta.url), 'utf8'))); +const levels2 = loadPack(pack2); + +test('第二包(转墙实验):每关唯一解,转墙不冗余且不能用静态墙替代', () => { + assert.ok(levels2.length >= 3); + for (const lv of levels2) { + assert.ok(lv.board.spinners.length >= 1, `${lv.def.id} 没有转墙`); + assert.equal(solve(lv.board, { limit: 2 }).length, 1, `${lv.def.id} 不唯一`); + assert.ok(wallsMatter(lv.board), `${lv.def.id} 去墙后仍唯一`); + for (const r of spinnerReport(lv.board)) { + assert.equal(r.removable, false, `${lv.def.id} 转墙 ${r.index} 冗余`); + assert.deepEqual(r.freezable, [], `${lv.def.id} 转墙 ${r.index} 可用静态墙替代`); + } + } +}); + +test('求解器按步号判定转墙:同一盘不同相位 2 解 / 1 解 / 0 解', () => { + // a(0,0)=1, b(1,0), d(1,-1);转墙轴在 b 的第 3 个角(左上),周围三格 a/b/d:边 0 = a|d,1 = b|d,2 = a|b + const mk = (phase: number) => HexBoard.fromCells([c('a', 0, 0, 1), c('b', 1, 0), c('d', 1, -1)], [], [{ cell: 'b', corner: 3, phase }]); + const b0 = mk(0); + assert.deepEqual(b0.spinners[0].edges, [[0, 2], [1, 2], [0, 1]]); + assert.equal(solve(b0).length, 2, '相位 0:a,b,d 与 a,d,b 都通'); + assert.deepEqual(solve(mk(1)), [[0, 2, 1]], '相位 1:起手时 a|b 被挡,只能 a,d,b'); + assert.equal(solve(mk(2)).length, 0, '相位 2:a→b 后 b|d 被挡,无解'); +}); diff --git a/modes/03-hex-walls/tools/ascii.ts b/modes/03-hex-walls/tools/ascii.ts new file mode 100644 index 0000000..7b8a95f --- /dev/null +++ b/modes/03-hex-walls/tools/ascii.ts @@ -0,0 +1,53 @@ +// 终端字符盘面:点顶六边形按行错位显示。每格 4 字符宽,相邻行错开 2 字符。 +import type { HexBoard } from '../src/board'; + +export function renderAscii(board: HexBoard, path?: number[]): string { + const stepOf = new Map(); + if (path) path.forEach((c, i) => stepOf.set(c, i + 1)); + let minR = Infinity; + let maxR = -Infinity; + let minX = Infinity; + for (const c of board.cells) { + minR = Math.min(minR, c.r); + maxR = Math.max(maxR, c.r); + minX = Math.min(minX, 2 * c.q + c.r); + } + const lines: string[] = []; + for (let r = minR; r <= maxR; r++) { + const row = board.cells.filter((c) => c.r === r).sort((a, b) => a.q - b.q); + let line = ''; + for (const c of row) { + const col = (2 * c.q + c.r - minX) * 2; + while (line.length < col) line += ' '; + let tok: string; + if (path) { + const s = stepOf.get(c.index); + tok = s === undefined ? ' . ' : String(s).padStart(2) + ' '; + if (c.clue !== null && s !== undefined) tok = String(s).padStart(2) + (s === c.clue ? '*' : '!'); + } else { + tok = c.clue !== null ? `[${c.clue}]`.padStart(3) : ` ${c.id.slice(0, 2).padEnd(2)}`; + } + line += tok.padEnd(4); + } + lines.push(line.replace(/\s+$/, '')); + } + if (board.walls.length) { + lines.push('墙: ' + board.walls.map((w) => `${board.cells[w.a].id}|${board.cells[w.b].id}`).join(' ')); + } + if (board.spinners.length) { + lines.push( + '转墙: ' + + board.spinners + .map((sp) => { + const e = sp.edges.map((x) => (x ? `${board.cells[x[0]].id}|${board.cells[x[1]].id}` : '外')).join(' → '); + return `${board.cells[sp.cell].id}/${sp.corner} 相位${sp.phase} [${e}]`; + }) + .join(' '), + ); + } + return lines.join('\n'); +} + +export function pathToIds(board: HexBoard, path: number[]): string { + return path.map((i) => board.cells[i].id).join(','); +} diff --git a/modes/03-hex-walls/tools/design.ts b/modes/03-hex-walls/tools/design.ts new file mode 100644 index 0000000..edfcc27 --- /dev/null +++ b/modes/03-hex-walls/tools/design.ts @@ -0,0 +1,113 @@ +// 关卡设计辅助: +// npm run design -- 1-7 打印盘面、无线索路径数、当前解 +// npm run design -- 1-7 --minimize 当前线索里哪些可删(删掉仍唯一) +// npm run design -- 1-7 --walls 哪些墙可删(删掉仍唯一) +// npm run design -- 1-7 --suggest 3 --path a,b,c,... 给作者解找最少线索(≤3 个) +// npm run design -- /path/draft.json --level 1-7 ... 也可以指定临时关卡文件 +import { readdirSync, readFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { HexBoard } from '../src/board'; +import { loadPack, parsePack, type LoadedLevel } from '../src/levels'; +import { redundantWalls, solve, spinnerReport, wallsMatter } from '../src/solver'; +import type { LevelDef } from '../src/types'; +import { pathToIds, renderAscii } from './ascii'; + +const here = dirname(fileURLToPath(import.meta.url)); +const argv = process.argv.slice(2); +const flag = (name: string): string | undefined => { + const i = argv.indexOf(name); + return i >= 0 ? argv[i + 1] : undefined; +}; + +function findLevel(): LoadedLevel { + const target = argv[0]; + if (!target) throw new Error('用法: npm run design -- <关卡id | 文件> [--level id] [--minimize|--walls|--suggest k --path ...]'); + const files = target.endsWith('.json') + ? [target] + : readdirSync(join(here, '..', 'levels')) + .filter((f) => f.endsWith('.json')) + .map((f) => join(here, '..', 'levels', f)); + const wantId = target.endsWith('.json') ? flag('--level') : target; + for (const f of files) { + const pack = parsePack(JSON.parse(readFileSync(f, 'utf8'))); + const levels = loadPack(pack); + const lv = wantId ? levels.find((l) => l.def.id === wantId) : levels[0]; + if (lv) return lv; + } + throw new Error(`找不到关卡 ${wantId ?? target}`); +} + +function withClues(def: LevelDef, clueOf: Map): HexBoard { + return HexBoard.fromCells( + def.cells.map((c) => ({ id: c.id, q: c.q, r: c.r, clue: clueOf.get(c.id) })), + def.walls ?? [], + def.spinners ?? [], + ); +} + +const lv = findLevel(); +const b = lv.board; +console.log(`\n${lv.def.id} N=${b.n} 墙=${b.walls.length}`); +console.log(renderAscii(b).replace(/^/gm, ' ')); +const free = solve(b, { ignoreClues: true, limit: 2000 }); +console.log(`无线索哈密顿路径: ${free.length >= 2000 ? '2000+' : free.length}`); +const cur = solve(b, { limit: 3 }); +console.log(`当前线索下的解: ${cur.length}${cur.length === 1 ? '(唯一)' : ''}`); +for (const p of cur) console.log(' ' + pathToIds(b, p)); +if (cur.length === 1) { + console.log(renderAscii(b, cur[0]).replace(/^/gm, ' ')); + console.log(`去墙后解: ${solve(b.withoutWalls(), { limit: 100 }).length} 墙参与唯一性: ${wallsMatter(b) ? '是' : '否'}`); + for (const r of spinnerReport(b)) { + console.log(`转墙 ${r.index}: ${r.removable ? '冗余' : '必要'};${r.freezable.length ? `可换成静态墙(边 ${r.freezable.join('/')})` : '不能用静态墙替代'}`); + } +} + +if (argv.includes('--minimize') && cur.length === 1) { + const clues = b.cells.filter((c) => c.clue !== null); + for (const c of clues) { + const m = new Map(clues.filter((x) => x !== c).map((x) => [x.id, x.clue!])); + const n = solve(withClues(lv.def, m), { limit: 2 }).length; + console.log(` 删掉 ${c.id}=${c.clue}: ${n === 1 ? '仍唯一(可删)' : n === 0 ? '无解' : '多解(必须留)'}`); + } +} + +if (argv.includes('--walls') && cur.length === 1) { + const r = redundantWalls(b); + console.log(r.length ? `可删的墙: ${r.map((i) => `${b.cells[b.walls[i].a].id}|${b.cells[b.walls[i].b].id}`).join(', ')}` : '每堵墙都必要'); +} + +const suggestK = flag('--suggest'); +if (suggestK) { + const pathIds = (flag('--path') ?? '').split(',').filter(Boolean); + const path = pathIds.length ? pathIds.map((id) => b.cellById(id).index) : cur[0]; + if (!path || path.length !== b.n) throw new Error('--path 必须是覆盖全部格的作者解'); + const k = Number(suggestK); + const stepOf = new Map(); + path.forEach((c, i) => stepOf.set(c, i + 1)); + const cells = b.cells.map((c) => c.index); + const results: Array<{ ids: string[]; score: number }> = []; + const rec = (start: number, chosen: number[]): void => { + if (chosen.length > 0) { + const m = new Map(chosen.map((c) => [b.cells[c].id, stepOf.get(c)!])); + const n = solve(withClues(lv.def, m), { limit: 2 }).length; + if (n === 1) { + const steps = chosen.map((c) => stepOf.get(c)!).sort((x, y) => x - y); + // 偏好:线索少、少用端点、分布均匀 + let score = chosen.length * 10; + for (const s of steps) if (s === 1 || s === b.n) score += 1; + for (let i = 1; i < steps.length; i++) score += Math.abs(steps[i] - steps[i - 1] - b.n / (steps.length + 1)) * 0.1; + results.push({ ids: chosen.map((c) => `${b.cells[c].id}=${stepOf.get(c)}`), score }); + return; + } + } + if (chosen.length === k) return; + for (let i = start; i < cells.length; i++) rec(i + 1, [...chosen, cells[i]]); + }; + rec(0, []); + results.sort((x, y) => x.score - y.score); + console.log(`\n作者解: ${pathToIds(b, path)}`); + console.log(`≤${k} 个线索可唯一的组合(前 12):`); + for (const r of results.slice(0, 12)) console.log(' ' + r.ids.join(' ')); + if (!results.length) console.log(' 没有'); +} diff --git a/modes/03-hex-walls/tools/generate.ts b/modes/03-hex-walls/tools/generate.ts new file mode 100644 index 0000000..291a3b2 --- /dev/null +++ b/modes/03-hex-walls/tools/generate.ts @@ -0,0 +1,189 @@ +// 生成器(GDD §8.2):铺一块六边形聚落 → 随机作者解 → 在非解边上插墙 → 挑线索直到唯一 → 去墙不唯一检查。 +// 只用来出候选,人过一眼再进包。 +// npm run generate -- --shape hex2 --holes 2 --walls 3 --clues 3 --seed 7 --count 5 +// npm run generate -- --from draft.json --level 1-9 --walls 2 --clues 2 +// --spinners 1 额外放转墙(放在作者解不会被挡的顶点上;候选必须“去掉转墙不唯一、换静态墙也不唯一”) +// shape: hex1(7) hex2(19) tri4(10) tri5(15) para3x4(12) para4x4(16) rect4x3(12) rect5x3(15) rect4x4(16) +import { readFileSync } from 'node:fs'; +import { HexBoard } from '../src/board'; +import { loadPack, parsePack } from '../src/levels'; +import { redundantWalls, solve, spinnerReport, wallsMatter } from '../src/solver'; +import type { HexCellDef, SpinnerDef, WallDef } from '../src/types'; +import { pathToIds, renderAscii } from './ascii'; + +const argv = process.argv.slice(2); +const flag = (name: string, def: string): string => { + const i = argv.indexOf(name); + return i >= 0 ? argv[i + 1] : def; +}; + +function mulberry32(seed: number): () => number { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) >>> 0; + let t = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +function shape(name: string): Array<[number, number]> { + const out: Array<[number, number]> = []; + const m = /^(hex|tri|para|rect)(\d)(?:x(\d))?$/.exec(name); + if (!m) throw new Error(`未知形状 ${name}`); + const a = Number(m[2]); + const b = Number(m[3] ?? a); + if (m[1] === 'hex') { + for (let q = -a; q <= a; q++) for (let r = Math.max(-a, -q - a); r <= Math.min(a, -q + a); r++) out.push([q, r]); + } else if (m[1] === 'tri') { + for (let r = 0; r < a; r++) for (let q = 0; q < a - r; q++) out.push([q, r]); + } else if (m[1] === 'para') { + for (let r = 0; r < b; r++) for (let q = 0; q < a; q++) out.push([q, r]); + } else { + // 矩形(偶行错位):每行 a 个,共 b 行 + for (let r = 0; r < b; r++) for (let x = 0; x < a; x++) out.push([x - Math.floor(r / 2), r]); + } + return out; +} + +const rnd = mulberry32(Number(flag('--seed', String(Date.now() % 100000)))); +const wantWalls = Number(flag('--walls', '2')); +const wantClues = Number(flag('--clues', '3')); +const holes = Number(flag('--holes', '0')); +const wantSpinners = Number(flag('--spinners', '0')); +const count = Number(flag('--count', '3')); +const maxTries = Number(flag('--tries', '400')); +const from = flag('--from', ''); + +let baseCells: HexCellDef[]; +if (from) { + const pack = parsePack(JSON.parse(readFileSync(from, 'utf8'))); + const lv = loadPack(pack).find((l) => l.def.id === flag('--level', pack.levels[0].id))!; + baseCells = lv.def.cells.map((c) => ({ id: c.id, q: c.q, r: c.r })); +} else { + const ids = 'abcdefghijklmnopqrstuvwxyz'; + baseCells = shape(flag('--shape', 'hex1')).map(([q, r], i) => ({ id: ids[i] ?? `c${i}`, q, r })); +} + +interface Candidate { + cells: HexCellDef[]; + walls: WallDef[]; + spinners: SpinnerDef[]; + path: number[]; + freePaths: number; + noWallPaths: number; + board: HexBoard; +} + +const found: Candidate[] = []; +let tries = 0; +while (found.length < count && tries < maxTries) { + tries++; + // 1. 挖洞(保证连通) + let cells = baseCells.map((c) => ({ ...c })); + for (let h = 0; h < holes; h++) { + for (let attempt = 0; attempt < 20; attempt++) { + const i = Math.floor(rnd() * cells.length); + const trial = cells.filter((_, j) => j !== i); + if (HexBoard.fromCells(trial).isConnected()) { + cells = trial; + break; + } + } + } + const plain = HexBoard.fromCells(cells); + // 2. 随机作者解 + const p = solve(plain, { ignoreClues: true, limit: 1, random: rnd }); + if (!p.length) continue; + const path = p[0]; + const onPath = new Set(); + for (let i = 0; i + 1 < path.length; i++) onPath.add(`${Math.min(path[i], path[i + 1])}-${Math.max(path[i], path[i + 1])}`); + // 3. 在非解边上插墙 + const edges: Array<[number, number]> = []; + for (let i = 0; i < plain.n; i++) for (const j of plain.geoAdj[i]) if (i < j && !onPath.has(`${i}-${j}`)) edges.push([i, j]); + for (let i = edges.length - 1; i > 0; i--) { + const j = Math.floor(rnd() * (i + 1)); + [edges[i], edges[j]] = [edges[j], edges[i]]; + } + const walls: WallDef[] = edges.slice(0, wantWalls).map(([a, b]) => [plain.cells[a].id, plain.cells[b].id]); + // 3b. 转墙:随机顶点 + 相位,要求作者解每一步都不被挡 + const spinners: SpinnerDef[] = []; + if (wantSpinners > 0) { + const stepOfCell = new Map(); + path.forEach((c, i) => stepOfCell.set(c, i + 1)); + for (let attempt = 0; attempt < 60 && spinners.length < wantSpinners; attempt++) { + const cell = plain.cells[Math.floor(rnd() * plain.n)]; + const def: SpinnerDef = { cell: cell.id, corner: Math.floor(rnd() * 6), phase: Math.floor(rnd() * 3) }; + let b: HexBoard; + try { + b = HexBoard.fromCells(cells, walls, [...spinners, def]); + } catch { + continue; + } + // 作者解是否仍合法 + let legal = true; + for (let i = 0; i + 1 < path.length && legal; i++) legal = b.canStep(path[i], path[i + 1], i + 1); + if (!legal) continue; + // 至少要挡到作者解经过的某个格周围的边才有意义:三条边至少两条在盘上 + spinners.push(def); + } + if (spinners.length < wantSpinners) continue; + } + // 4. 挑线索:从含 1 开始,逐个加最“区分”的线索直到唯一 + const stepOf = new Map(); + path.forEach((c, i) => stepOf.set(c, i + 1)); + const chosen = new Set([path[0]]); + const build = (): HexBoard => + HexBoard.fromCells( + cells.map((c, i) => ({ ...c, clue: chosen.has(i) ? stepOf.get(i) : undefined })), + walls, + spinners, + ); + let board = build(); + let ok = solve(board, { limit: 2 }).length === 1; + while (!ok && chosen.size < wantClues) { + let best = -1; + let bestCount = Infinity; + const order = cells.map((_, i) => i).sort(() => rnd() - 0.5); + for (const i of order) { + if (chosen.has(i)) continue; + chosen.add(i); + const n = solve(build(), { limit: 50 }).length; + chosen.delete(i); + if (n < bestCount) { + bestCount = n; + best = i; + } + if (n === 1) break; + } + if (best < 0) break; + chosen.add(best); + board = build(); + ok = bestCount === 1; + } + if (!ok) continue; + // 5. 去墙不唯一 + 每堵墙都必要 + if ((walls.length || spinners.length) && !wallsMatter(board)) continue; + if (redundantWalls(board).length) continue; + if (spinnerReport(board).some((r) => r.removable || r.freezable.length)) continue; + found.push({ + cells: board.cells.map((c) => ({ id: c.id, q: c.q, r: c.r, ...(c.clue !== null ? { clue: c.clue } : {}) })), + walls, + spinners, + path, + freePaths: solve(plain, { ignoreClues: true, limit: 500 }).length, + noWallPaths: solve(board.withoutWalls(), { limit: 500 }).length, + board, + }); +} + +console.log(`尝试 ${tries} 次,得到 ${found.length} 个候选\n`); +for (const c of found) { + console.log(`--- N=${c.cells.length} 墙=${c.walls.length}${c.spinners.length ? '+转' + c.spinners.length : ''} 线索=${c.cells.filter((x) => x.clue).length} 无线索路径=${c.freePaths} 去墙解=${c.noWallPaths}`); + console.log(renderAscii(c.board).replace(/^/gm, ' ')); + console.log(renderAscii(c.board, c.path).replace(/^/gm, ' ')); + console.log(' 解: ' + pathToIds(c.board, c.path)); + console.log(' JSON: ' + JSON.stringify({ cells: c.cells, walls: c.walls, spinners: c.spinners })); + console.log(); +} diff --git a/modes/03-hex-walls/tools/validate.ts b/modes/03-hex-walls/tools/validate.ts new file mode 100644 index 0000000..fd2d9e0 --- /dev/null +++ b/modes/03-hex-walls/tools/validate.ts @@ -0,0 +1,73 @@ +// 发布前校验:levels/*.json 每一关必须连通、恰好 1 条满足线索的哈密顿路径; +// 第 3 关起至少 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 { redundantWalls, solve, spinnerReport, wallsMatter } 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 clues = b.cells.filter((c) => c.clue !== null).map((c) => c.clue!).sort((a, z) => a - z); + const t0 = performance.now(); + const paths = solve(b, { limit: 3 }); + const ms = (performance.now() - t0).toFixed(1); + const free = solve(b, { ignoreClues: true, limit: 500 }).length; + const noWall = solve(b.withoutWalls(), { 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})`); + const wallCount = b.walls.length + b.spinners.length; + if (lv.index >= 3 || pack.pack >= 2) { + if (wallCount === 0) problems.push('第 3 关起每关至少 1 堵墙'); + else if (paths.length === 1 && !wallsMatter(b)) problems.push('去墙后仍唯一,墙没参与推理'); + } + const redundant = paths.length === 1 ? redundantWalls(b) : []; + if (paths.length === 1) { + for (const r of spinnerReport(b)) { + if (r.removable) problems.push(`转墙 ${r.index} 冗余`); + if (r.freezable.length) problems.push(`转墙 ${r.index} 可换成静态墙(边 ${r.freezable.join('/')})`); + } + } + 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)} 墙=${b.walls.length}${b.spinners.length ? '+转' + b.spinners.length : ''} 线索=${clues.join(',').padEnd(10)} 无线索路径=${String(free >= 500 ? '500+' : free).padStart(4)} 去墙解=${String(noWall >= 500 ? '500+' : noWall).padStart(4)} ${ms}ms` + + (redundant.length ? ` 可删墙=${redundant.map((i) => `${b.cells[b.walls[i].a].id}|${b.cells[b.walls[i].b].id}`).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); diff --git a/modes/03-hex-walls/tsconfig.json b/modes/03-hex-walls/tsconfig.json new file mode 100644 index 0000000..6978032 --- /dev/null +++ b/modes/03-hex-walls/tsconfig.json @@ -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"] +} diff --git a/modes/03-hex-walls/tsconfig.src.json b/modes/03-hex-walls/tsconfig.src.json new file mode 100644 index 0000000..69a64f1 --- /dev/null +++ b/modes/03-hex-walls/tsconfig.src.json @@ -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"] +} diff --git a/modes/03-hex-walls/web/audio.ts b/modes/03-hex-walls/web/audio.ts new file mode 100644 index 0000000..7ef5cde --- /dev/null +++ b/modes/03-hex-walls/web/audio.ts @@ -0,0 +1,68 @@ +// 音效:WebAudio 合成,不需要资源文件。落子短音(音高随步号上升,12 后循环)、撞墙闷响、冲突、过关和弦。 +// Cocos 版换成 AudioSource 播放几段短音即可,接口保持 step/wall/conflict/cleared/reject。 + +export interface Sfx { + unlock(): void; + step(n: number): void; + wall(): 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, slideTo?: number): 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); + if (slideTo) osc.frequency.exponentialRampToValueAtTime(slideTo, t0 + dur); + 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); + } + + /** 撞墙:短促的低频“咚” */ + wall(): void { + this.tone(180, 0.09, 'square', 0.07, 0, 90); + } + + 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); + } +} diff --git a/modes/03-hex-walls/web/canvas-painter.ts b/modes/03-hex-walls/web/canvas-painter.ts new file mode 100644 index 0000000..75bbf88 --- /dev/null +++ b/modes/03-hex-walls/web/canvas-painter.ts @@ -0,0 +1,80 @@ +// Painter 的 Canvas 2D 实现。Cocos 版本换成 Graphics + Label 即可,HexBoardView 不用改。 +import type { Point } from '../src/geom'; +import type { Painter } from '../src/painter'; + +export class CanvasPainter implements Painter { + private readonly ctx: CanvasRenderingContext2D; + private readonly fontFamily = + '"SF Pro Rounded", "PingFang SC", "Helvetica Neue", "Segoe UI", system-ui, -apple-system, sans-serif'; + + constructor(ctx: CanvasRenderingContext2D) { + this.ctx = ctx; + } + + private path(pts: Point[]): void { + const ctx = this.ctx; + ctx.beginPath(); + ctx.moveTo(pts[0].x, pts[0].y); + for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y); + ctx.closePath(); + } + + fillPolygon(pts: Point[], color: string, alpha = 1): void { + if (alpha <= 0 || pts.length < 3) return; + const ctx = this.ctx; + ctx.globalAlpha = alpha; + ctx.fillStyle = color; + this.path(pts); + ctx.fill(); + ctx.globalAlpha = 1; + } + + strokePolygon(pts: Point[], color: string, lineWidth: number, alpha = 1): void { + if (alpha <= 0 || pts.length < 2) return; + const ctx = this.ctx; + ctx.globalAlpha = alpha; + ctx.strokeStyle = color; + ctx.lineWidth = lineWidth; + ctx.lineJoin = 'round'; + this.path(pts); + ctx.stroke(); + ctx.globalAlpha = 1; + } + + line(x1: number, y1: number, x2: number, y2: number, width: number, color: string, alpha = 1): void { + if (alpha <= 0) return; + const ctx = this.ctx; + ctx.globalAlpha = alpha; + ctx.strokeStyle = color; + ctx.lineWidth = width; + ctx.lineCap = 'round'; + ctx.beginPath(); + ctx.moveTo(x1, y1); + ctx.lineTo(x2, y2); + 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 = alpha; + ctx.fillStyle = color; + ctx.beginPath(); + ctx.arc(cx, cy, radius, 0, Math.PI * 2); + ctx.fill(); + ctx.globalAlpha = 1; + } + + 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 = alpha; + ctx.fillStyle = color; + ctx.font = `${bold ? '700' : '500'} ${size}px ${this.fontFamily}`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(s, cx, cy + size * 0.04); + ctx.globalAlpha = 1; + } +} diff --git a/modes/03-hex-walls/web/index.html b/modes/03-hex-walls/web/index.html new file mode 100644 index 0000000..e7f9afa --- /dev/null +++ b/modes/03-hex-walls/web/index.html @@ -0,0 +1,69 @@ + + + + + + + + + + 六边一笔 + + + +
+ +
+
+ +

六边一笔

+

六边形上一笔走遍,条纹墙挡住那条路

+
+
+
教学
+
+
+ +
+ + + +
+
+ + + +
+ + + diff --git a/modes/03-hex-walls/web/main.ts b/modes/03-hex-walls/web/main.ts new file mode 100644 index 0000000..f195037 --- /dev/null +++ b/modes/03-hex-walls/web/main.ts @@ -0,0 +1,490 @@ +// Web 原型入口:DOM / Canvas / PointerEvent / localStorage 的粘合层。 +// 规则、输入语义、动效状态都在 ../src 里;这里只做“把浏览器事件喂给 src,把 src 的状态画到屏幕上”。 +import pack1Json from '../levels/pack-1-tutorial.json'; +import pack2Json from '../levels/pack-2-spinner.json'; +import type { GameEvent } from '../src/game'; +import { Game } from '../src/game'; +import { PointerInput } from '../src/input'; +import { cellCenter, fitBoard, hitTest, type HexLayout } 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 { HexBoardView } from '../src/view'; +import { WebAudioSfx } from './audio'; +import { CanvasPainter } from './canvas-painter'; + +const $ = (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: HexBoardView; + input: PointerInput; + solution: number[] | null; + layout: HexLayout | null; +} + +class App { + private readonly packs = [parsePack(pack1Json), parsePack(pack2Json)].filter((p) => p.levels.length > 0); + /** 所有包的关卡拼成一条线性序列,跨包线性解锁 */ + private readonly levels: LoadedLevel[] = this.packs.flatMap((p) => loadPack(p)); + 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 canvas = $('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 lastPos = { x: 0, y: 0 }; + private advanceTimer = 0; + private toastTimer = 0; + private cssSize = { w: 0, h: 0 }; + + constructor() { + this.sfx.enabled = this.progress.prefs.sound; + this.bindUI(); + new ResizeObserver(() => this.resize()).observe(this.boardWrap); + this.renderSelect(); + this.showSelect(); + if (this.devUnlock) { + (window as unknown as { __hexWalls: App }).__hexWalls = this; + } + } + + /** 调试:某格中心的视口坐标(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 r = this.canvas.getBoundingClientRect(); + const c = cellCenter(s.level.board, cell.index, s.layout); + return { x: r.left + c.x, y: r.top + c.y }; + } + + /** 调试:当前路径(格 id)、状态与唯一解 */ + debugState(): { id: string; path: string[]; status: string; remaining: number; solution: string[] | null } | 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, + }; + } + + /** 调试:直接进某关 */ + debugStart(id: string): void { + const i = this.ids.indexOf(id); + if (i >= 0) this.startLevel(i); + } + + // ---------- 屏幕切换 ---------- + + private showSelect(): void { + this.stopLoop(); + $('screen-play').classList.add('hidden'); + $('screen-select').classList.remove('hidden'); + this.renderSelect(); + } + + private showPlay(): void { + $('screen-select').classList.add('hidden'); + $('screen-play').classList.remove('hidden'); + this.resize(); + this.startLoop(); + } + + // ---------- 选关 ---------- + + private renderSelect(): void { + $('pack-name').textContent = this.packs.map((p) => p.title).join(' · '); + $('pack-progress').textContent = `${this.progress.clearedCount()} / ${this.levels.length}`; + const grid = $('level-grid'); + grid.innerHTML = ''; + const currentId = this.progress.firstUncleared(this.ids); + const allCleared = this.progress.clearedCount() >= this.levels.length; + let lastPack = -1; + this.levels.forEach((lv, i) => { + const id = lv.def.id; + if (lv.pack !== lastPack) { + lastPack = lv.pack; + const head = document.createElement('div'); + head.className = 'pack-head'; + head.textContent = this.packs.find((p) => p.pack === lv.pack)?.title ?? `包 ${lv.pack}`; + grid.appendChild(head); + } + const tile = document.createElement('button'); + tile.className = 'level-tile'; + tile.innerHTML = `${lv.index}`; + const unlocked = this.devUnlock || 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); + }); + 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 { + window.clearTimeout(this.advanceTimer); + const level = this.levels[i]; + const game = new Game(level.board); + const view = new HexBoardView(game); + this.session = { + level, + game, + view, + input: new PointerInput(game), + solution: uniqueSolution(level.board), + layout: null, + }; + this.progress.setCurrent(level.def.id); + $('level-title').textContent = level.def.id; + $('caption').textContent = level.def.caption ?? ''; + $('btn-hint').classList.remove('pulse'); + $('btn-restart').classList.remove('pulse'); + this.setButtonsEnabled(true); + this.updateHud(); + this.showPlay(); + } + + 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.toastOnSelect('已通关全部 ' + this.levels.length + ' 关'); + } + } + + private updateHud(): void { + if (!this.session) return; + $('remaining-n').textContent = String(this.session.game.remaining); + } + + private setButtonsEnabled(on: boolean): void { + ($('btn-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 'wallHit': + this.sfx.wall(); + this.vibrate(25); + break; + case 'rejectStart': + this.sfx.reject(); + this.vibrate(15); + break; + case 'deadEnd': + this.vibrate([10, 30, 10]); + 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); + this.advanceTimer = window.setTimeout(() => this.nextLevel(), s.view.clearedDuration * 1000); + } + + private restart(): void { + const s = this.session; + if (!s || s.game.isCleared) return; + s.input.cancel(); + this.activePointer = null; + 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; + 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) { + // 小盘不要铺满:六边形半径最大约 58px;大盘按短边缩放 + const maxSize = s.level.board.n <= 6 ? 52 : 60; + s.layout = fitBoard(s.level.board, rect.width, rect.height, { padding: 12, maxSize }); + 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); + } + + 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; + 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 step = this.session.layout.size * 0.4; + 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 toastOnSelect(msg: string): void { + const el = $('pack-progress'); + const old = el.textContent; + el.textContent = msg; + window.setTimeout(() => (el.textContent = old), 2200); + } + + // ---------- 事件绑定 ---------- + + 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-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(); diff --git a/modes/03-hex-walls/web/style.css b/modes/03-hex-walls/web/style.css new file mode 100644 index 0000000..4182663 --- /dev/null +++ b/modes/03-hex-walls/web/style.css @@ -0,0 +1,444 @@ +:root { + --bg: #ffffff; + --ink: #20223a; + --ink-2: #686a80; + --ink-3: #a3a5b5; + --cell: #e4e6ee; + --primary: #4f5bd5; + --primary-soft: #e9ebfb; + --danger: #e5484d; + --wall: #1f1f27; + --wall-stripe: #ffc826; + --hex: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%); + --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: #f2f2f6; + color: var(--ink); + font-family: "SF Pro Rounded", "PingFang SC", "Helvetica Neue", "Segoe UI", system-ui, -apple-system, sans-serif; + -webkit-font-smoothing: antialiased; + 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) + 12px) calc(var(--safe-right) + 20px) calc(var(--safe-bottom) + 16px) + calc(var(--safe-left) + 20px); +} + +.screen.hidden { + display: none; +} + +button { + font: inherit; + color: inherit; + background: none; + border: 0; + padding: 0; + cursor: pointer; + -webkit-touch-callout: none; +} + +button:active { + transform: scale(0.97); +} + +/* ---------- 选关 ---------- */ + +.select-header { + padding: 24px 4px 8px; +} + +.logo { + display: flex; + gap: 4px; + margin-bottom: 14px; +} + +.logo-hex { + width: 26px; + height: 30px; + background: var(--cell); + clip-path: var(--hex); +} + +.logo-hex.on { + background: var(--primary); +} + +.logo-hex.wall { + background: repeating-linear-gradient(135deg, var(--wall) 0 5px, var(--wall-stripe) 5px 10px); +} + +.select-header h1 { + margin: 0; + font-size: 34px; + font-weight: 800; + letter-spacing: 0.02em; +} + +.select-header .sub { + margin: 8px 0 0; + color: var(--ink-2); + font-size: 15px; +} + +.pack { + margin-top: 24px; +} + +.pack-title { + display: flex; + align-items: baseline; + justify-content: space-between; + padding: 0 4px 12px; + font-size: 17px; + font-weight: 700; +} + +.pack-progress { + color: var(--ink-3); + font-size: 14px; + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.level-grid { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 10px 12px; +} + +.pack-head { + grid-column: 1 / -1; + padding: 10px 4px 0; + color: var(--ink-3); + font-size: 13px; + font-weight: 700; + letter-spacing: 0.06em; +} + +.pack-head:first-child { + padding-top: 0; +} + +.level-tile { + position: relative; + aspect-ratio: 0.9; + clip-path: var(--hex); + background: var(--cell); + color: var(--ink); + font-size: 20px; + font-weight: 700; + font-variant-numeric: tabular-nums; + display: flex; + align-items: center; + justify-content: center; + transition: transform 0.12s ease; +} + +.level-tile.cleared { + background: var(--primary); + color: #fff; +} + +.level-tile.cleared::after { + content: ""; + position: absolute; + right: 22%; + top: 22%; + width: 7px; + height: 7px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.85); +} + +.level-tile.current { + background: var(--primary-soft); + color: var(--primary); +} + +.level-tile.current::before { + content: ""; + position: absolute; + inset: 0; + clip-path: var(--hex); + background: var(--primary); + z-index: -1; +} + +.level-tile.current span { + position: relative; +} + +.level-tile.current { + isolation: isolate; +} + +.level-tile.current::after { + content: ""; + position: absolute; + inset: 3px; + clip-path: var(--hex); + background: var(--primary-soft); + z-index: -1; +} + +.level-tile.locked { + color: var(--ink-3); + background: #f0f0f4; +} + +.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 0.3s ease; +} + +.primary { + margin-top: auto; + height: 54px; + border-radius: 16px; + background: var(--primary); + color: #fff; + font-size: 18px; + font-weight: 700; + box-shadow: 0 6px 18px rgba(79, 91, 213, 0.28); +} + +.select-footer { + display: flex; + justify-content: center; + gap: 10px; + padding-top: 18px; +} + +.chip { + height: 34px; + padding: 0 14px; + border-radius: 17px; + background: #f0f0f4; + color: var(--ink-2); + font-size: 13px; + font-weight: 600; +} + +.chip.on { + background: var(--primary-soft); + color: var(--primary); +} + +.chip.danger { + color: var(--danger); +} + +/* ---------- 对局 ---------- */ + +.topbar { + display: grid; + grid-template-columns: 44px 1fr 44px; + align-items: center; + height: 48px; +} + +.topbar .icon { + width: 44px; + height: 44px; + display: flex; + align-items: center; + justify-content: center; + color: var(--ink-2); + border-radius: 22px; +} + +.level-title { + text-align: center; + font-size: 20px; + font-weight: 800; + letter-spacing: 0.04em; + font-variant-numeric: tabular-nums; +} + +.remaining { + justify-self: end; + display: flex; + align-items: center; + gap: 6px; + color: var(--ink-2); + font-size: 15px; + font-weight: 700; + font-variant-numeric: tabular-nums; + min-width: 44px; + justify-content: flex-end; +} + +.remaining .hex-dot { + width: 13px; + height: 15px; + clip-path: var(--hex); + background: var(--ink-3); +} + +.caption { + height: 40px; + margin-top: 4px; + display: flex; + align-items: center; + justify-content: center; + color: var(--ink-2); + font-size: 15px; + text-align: center; + transition: opacity 0.2s; +} + +.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: 14px; + padding-top: 12px; +} + +.action { + flex: 1; + max-width: 170px; + height: 56px; + border-radius: 18px; + background: #f0f0f4; + color: var(--ink); + font-size: 16px; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + transition: background 0.15s; +} + +.action.pulse { + animation: pulse 0.9s ease infinite; +} + +.action:disabled { + opacity: 0.4; + pointer-events: none; +} + +.toast { + position: absolute; + left: 50%; + bottom: calc(var(--safe-bottom) + 92px); + transform: translate(-50%, 8px); + padding: 10px 16px; + border-radius: 12px; + background: rgba(32, 34, 58, 0.92); + color: #fff; + font-size: 14px; + font-weight: 600; + white-space: nowrap; + opacity: 0; + pointer-events: none; + transition: opacity 0.18s, transform 0.18s; +} + +.toast.show { + opacity: 1; + transform: translate(-50%, 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(255, 122, 26, 0); + } + 50% { + box-shadow: 0 0 0 5px rgba(255, 122, 26, 0.35); + } +} + +@media (min-width: 520px) { + body { + padding: 24px 0; + } + #app { + height: calc(100vh - 48px); + height: calc(100dvh - 48px); + border-radius: 28px; + box-shadow: 0 20px 60px rgba(20, 20, 40, 0.14); + } +} diff --git a/releases/数字一笔-试玩版.html b/releases/数字一笔-试玩版.html new file mode 100644 index 0000000..d9de42b --- /dev/null +++ b/releases/数字一笔-试玩版.html @@ -0,0 +1,80 @@ + + + + + + + + + + 数字一笔 · 试玩版 + + + +
+ +
+
+

数字一笔

+

全部关卡开放,自由选关试玩

+
+
+ 0 / 0 +
+
+
+ +
+ + + +
+
+ + + +
+
+ + + diff --git a/shared/README.md b/shared/README.md new file mode 100644 index 0000000..5d129a1 --- /dev/null +++ b/shared/README.md @@ -0,0 +1,19 @@ +# shared · 壳 + +三个玩法共用的外壳,不含任何过关规则。 + +可以放: + +- 玩法注册表(id、标题、mount) +- 首页「选玩法」 +- 设置:音效、振动、高对比 +- 存档索引(按玩法 id 分桶) +- 广告位、安全区、通用按钮皮肤 + +不可以放: + +- 邻接、路径、线索、提示算法 +- 关卡 JSON +- 某个玩法的求解器 + +当前还没有代码。至少两个玩法有可玩原型之后再做首页。玩法一的原型先跑在 `modes/01-number-stroke/web/`,不经过本目录。