移除玩法二(连连气球),产品收敛为两个玩法
- 删除 modes/02-connect-1234 全部内容(历史见首个提交 8026e46) - 重写根 README:共同规则、两玩法差异、仓库结构、快速开始、关卡、工程约定、文档索引、路线 - PRODUCT.md / design.md / shared/README / 两份 GDD / 玩法一 README 去掉玩法二引用 - 保留 01 / 03 目录编号并注明原因(存档桶名、Cocos 资源路径)
@@ -1,11 +1,168 @@
|
|||||||
# number-swipe
|
# number-swipe
|
||||||
|
|
||||||
多玩法休闲 puzzle。三个玩法共用一个壳,规则和关卡彼此独立。
|
多玩法休闲 puzzle(工作名,上架名未定)。目前两个玩法,都属于「顺序一笔画」路径推理:用一次滑动走遍盘面上每一格,预先写好的数字必须刚好在那一步踩到。两者几何与障碍不同,共用一个壳,规则代码彼此独立。
|
||||||
|
|
||||||
|
手机竖屏、单机、离线可玩;无计时、无体力、无分数。先做 Web 触摸原型验证玩法,再迁 Cocos Creator。
|
||||||
|
|
||||||
| 玩法 | 目录 | 一句话 | 状态 |
|
| 玩法 | 目录 | 一句话 | 状态 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| 一 · 数字一笔 | [`modes/01-number-stroke`](modes/01-number-stroke) | 矩形一笔画,大格只计一步,序号必须踩对 | Web 原型可玩,26 关(教学 10 + 进阶 10 + 奇偶 6) |
|
| 一 · 数字一笔 | [`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 关 + 转墙实验包 9 关 |
|
||||||
| 三 · 六边一笔 | [`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)。
|
目录编号保留 `01` / `03`:曾有玩法二(连连气球),2026-09-16 放弃并移出仓库(历史在首个提交里)。编号同时是存档桶名和 Cocos 资源路径,不要重排。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 两个玩法的共同规则
|
||||||
|
|
||||||
|
- **哈密顿路径**:每格恰好经过一次,只能走到相邻且未走过的格,第 `k` 步的格子显示数字 `k`。
|
||||||
|
- **线索**:部分格预写数字 `c`,走到时步号必须等于 `c`。线索可在中段;可以没有 `1`(起点未知)。
|
||||||
|
- **允许画错,不允许锁死**:踩错线索的格变红,不打断手势;随时沿原路撤销或一键重开;没有失败弹窗。
|
||||||
|
- **解唯一**:每一关发布前由求解器断言恰好 1 条满足全部线索的路径。
|
||||||
|
- **提示不带错**:无路径时指起点;路径是解的前缀时给下一步;已偏离时高亮分叉点让玩家退回,不继续往错路上填。
|
||||||
|
- **过关**:所有格走过且所有线索正确 → 短动效后自动下一关。
|
||||||
|
|
||||||
|
## 两个玩法的差异
|
||||||
|
|
||||||
|
| | 一 · 数字一笔 | 三 · 六边一笔 |
|
||||||
|
|---|---|---|
|
||||||
|
| 格子 | 轴对齐矩形(`1×1` 到 `3×3`)和俄罗斯方块式异形块,**整块只计 1 步** | 正六边形,一格一步,无大格 |
|
||||||
|
| 邻接 | 四向共边 | 六向共边(轴坐标 `q, r`) |
|
||||||
|
| 障碍 | 空洞 | 空洞 + **边墙**(有墙的两格视为不相邻);实验:随步数旋转的转墙 |
|
||||||
|
| 额外线索 | 奇 / 偶(该格步号的奇偶性) | – |
|
||||||
|
| 难度主旋钮 | 大格出口多但只算 1 步;相邻线索的步号差 | 墙掐掉表面上的近路;墙贴在线索旁让第一步只剩一个方向 |
|
||||||
|
| 存档桶 | `mode:01-number-stroke` | `mode:03-hex-walls` |
|
||||||
|
|
||||||
|
玩法三不是玩法一的 DLC:几何、邻接、障碍都独立,两边各写一份规则代码,禁止互相 import。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 仓库结构
|
||||||
|
|
||||||
|
```
|
||||||
|
number-swipe/
|
||||||
|
README.md 本文件
|
||||||
|
docs/
|
||||||
|
PRODUCT.md 产品级约定:壳、隔离规则、玩法清单、里程碑
|
||||||
|
design.md UI 设计规范:色彩令牌、字阶、组件、四档动效(玩法一为参考实现)
|
||||||
|
design.html 设计师版:色板、组件样张、可回放动效(离线双击可看)
|
||||||
|
modes/
|
||||||
|
01-number-stroke/ 玩法一 · 数字一笔(自包含)
|
||||||
|
docs/GDD.md 规格(权威)
|
||||||
|
docs/HANDOFF.md 交接:做了什么、怎么做、与 GDD 的偏差、待办、Cocos 迁移顺序
|
||||||
|
levels/*.json 三个关卡包
|
||||||
|
src/ 纯 TS 规则层(零 DOM / 零 Cocos 依赖)
|
||||||
|
web/ 浏览器粘合层:DOM / Canvas / PointerEvent / localStorage
|
||||||
|
tools/ 求解器命令行:校验、设计辅助、随机生成
|
||||||
|
tests/ 规则层单元测试(node:test)
|
||||||
|
03-hex-walls/ 玩法三 · 六边一笔(自包含,同样的分层)
|
||||||
|
docs/GDD.md
|
||||||
|
docs/HANDOFF.md
|
||||||
|
levels/*.json
|
||||||
|
src/ web/ tools/ tests/
|
||||||
|
shared/ 壳:选玩法首页、设置、存档索引、广告位(尚无代码)
|
||||||
|
releases/
|
||||||
|
数字一笔-试玩版.html 玩法一单文件试玩版,离线可开
|
||||||
|
```
|
||||||
|
|
||||||
|
每个玩法目录都是一个独立的 npm 工程,有自己的 `package.json`、`tsconfig` 和 `node_modules`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
需要 Node.js 20+。两个玩法的命令完全一致,只是目录和端口不同。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 玩法一 · 数字一笔(默认端口 8000)
|
||||||
|
cd modes/01-number-stroke
|
||||||
|
npm install
|
||||||
|
npm run dev # 本地服务 + 监听改动,终端打印手机可访问的局域网地址
|
||||||
|
npm run check # 类型检查(含 src/ 无 DOM 校验)+ 单元测试 + 全关卡唯一解校验
|
||||||
|
|
||||||
|
# 玩法三 · 六边一笔(默认端口 8100)
|
||||||
|
cd modes/03-hex-walls
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
npm run check
|
||||||
|
```
|
||||||
|
|
||||||
|
其它脚本(两边都有):
|
||||||
|
|
||||||
|
| 命令 | 作用 |
|
||||||
|
|---|---|
|
||||||
|
| `npm run build` | 打包到 `web/dist/app.js`,之后可直接双击 `web/index.html` |
|
||||||
|
| `npm run validate` | 逐关校验:连通、恰好 1 解(玩法三额外:第 3 关起去墙后不唯一、无冗余墙) |
|
||||||
|
| `npm run validate -- 1-7` | 只看某关并打印解 |
|
||||||
|
| `npm run design -- 1-7 --minimize` | 当前线索里哪些可删 |
|
||||||
|
| `npm run generate -- ...` | 随机铺盘 + 作者解 + 挑线索,批量产候选关 |
|
||||||
|
|
||||||
|
仅玩法一有 `npm run build:standalone`,导出 `releases/数字一笔-试玩版.html`:脚本、样式、26 关数据与合成音效全部内置,发一个文件给朋友即可离线试玩,全部关卡直接可选。
|
||||||
|
|
||||||
|
开发参数:URL 加 `?dev=1` 解锁全部关卡并暴露调试句柄(`window.__numberStroke` / `window.__hexWalls`);玩法一支持 `&level=3-1` 直接跳关。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 关卡
|
||||||
|
|
||||||
|
关卡全部是 JSON,运行时只做规则模拟,不存解;解由 `tools/validate.ts` 在发布前枚举并断言唯一。
|
||||||
|
|
||||||
|
**玩法一**(`modes/01-number-stroke/levels/`)
|
||||||
|
|
||||||
|
- `pack-1-tutorial.json` 入门 10 关:每关只加一个旋钮——滑动 → 走完 → 撤销 → 大格 → 中段线索 → 空洞 → 先推再画 → 2×2 枢纽 → 长条 → 小考。
|
||||||
|
- `pack-2-advanced.json` 进阶 10 关:生成器产出 + 人工过目。大连块高频、`3×3`、异形块,难度靠相邻线索的步号差拉开;2-8 是第一关无线索 `1`。
|
||||||
|
- `pack-3-parity.json` 奇与偶 6 关:格上写「奇」/「偶」要求步号奇偶,与数字线索互斥、可与大格共用;每条奇偶条件都参与唯一性。
|
||||||
|
|
||||||
|
**玩法三**(`modes/03-hex-walls/levels/`)
|
||||||
|
|
||||||
|
- `pack-1-tutorial.json` 教学 10 关:无墙两关 → 第一堵墙 → 墙贴在 `1` 旁 → 中段线索 → 空洞 + 两墙 → 陷阱格 → 综合 → 走廊 → 满花盘 4 墙。每关断言「去掉全部墙后解不唯一」,保证墙在干活。
|
||||||
|
- `pack-2-spinner.json` 转墙 9 关(实验):绕顶点转的墙,每走一格顺时针转 120°。规则见玩法三 GDD 附录 A。
|
||||||
|
|
||||||
|
每关明细(格数、线索、去墙解数)见各玩法 README。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 工程约定
|
||||||
|
|
||||||
|
隔离规则(详见 [`docs/PRODUCT.md`](docs/PRODUCT.md)):
|
||||||
|
|
||||||
|
1. `modes/*` 之间禁止互相 import。规则、求解器、关卡、对局视图都属于玩法自己。
|
||||||
|
2. `shared/` 只放壳:玩法注册表、首页、设置、存档索引、广告位、通用皮肤。禁止出现过关判定、邻接、提示算法、关卡 JSON。
|
||||||
|
3. 存档按玩法 id 分桶,互不读写。
|
||||||
|
4. 壳看到的入口只有 `{ id, title, mount(root, ctx), unmount() }`;`ShellContext` 只给存档读写、音效、振动、返回首页。
|
||||||
|
|
||||||
|
分层约定(两个玩法一致,为迁 Cocos 设计):
|
||||||
|
|
||||||
|
- `src/` 用 `tsconfig.src.json`(`lib: ["ES2020"]`)编译,**不能碰 DOM**,import 全部无后缀。这层原样拷进 Cocos 工程。
|
||||||
|
- 绘制走 `Painter` 接口(圆角矩形 / 多边形 / 圆 / 居中文字),Web 用 Canvas 实现,Cocos 换 `Graphics` + `Label` 池实现,`View.draw()` 不改。
|
||||||
|
- 输入走 `PointerInput.down/move/up()`,事件源可替换;存档走 `KeyValueStore` 抽象,Cocos 用 `sys.localStorage` 包一层。
|
||||||
|
- 视觉令牌三处一致:`web/style.css :root`、`src/theme.ts`、`docs/design.md`,由 `tests/theme.test.ts` 校验。
|
||||||
|
|
||||||
|
内容约定:关卡、美术、文案全部原创,不使用第三方题库或素材。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 文档索引
|
||||||
|
|
||||||
|
| 文档 | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| [`docs/PRODUCT.md`](docs/PRODUCT.md) | 产品结构、隔离规则、共享决策、里程碑 |
|
||||||
|
| [`docs/design.md`](docs/design.md) / [`design.html`](docs/design.html) | UI 设计规范与设计师样张 |
|
||||||
|
| [`modes/01-number-stroke/docs/GDD.md`](modes/01-number-stroke/docs/GDD.md) | 玩法一规格:规则、交互、反馈、关卡语言、求解器、竖直切面、验收 |
|
||||||
|
| [`modes/01-number-stroke/docs/HANDOFF.md`](modes/01-number-stroke/docs/HANDOFF.md) | 玩法一交接:实现细节、与 GDD 的偏差、已知问题、Cocos 迁移步骤 |
|
||||||
|
| [`modes/03-hex-walls/docs/GDD.md`](modes/03-hex-walls/docs/GDD.md) | 玩法三规格,附录 A 为转墙规则 |
|
||||||
|
| [`modes/03-hex-walls/docs/HANDOFF.md`](modes/03-hex-walls/docs/HANDOFF.md) | 玩法三交接 |
|
||||||
|
| [`shared/README.md`](shared/README.md) | 壳的边界:能放什么、不能放什么 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 状态与路线
|
||||||
|
|
||||||
|
| 阶段 | 状态 |
|
||||||
|
|---|---|
|
||||||
|
| 各玩法 M1/M2:规则库 + 求解器 + 可玩原型 + 存档 / 提示 / 音效 / 振动 | 两个玩法均已达成 |
|
||||||
|
| 真人试玩调校:手势死区、教学梯度、关卡难度手感 | 待做,需要 3~5 个没玩过的人 |
|
||||||
|
| 壳:选玩法首页、设置、存档索引 | 未开始(两个玩法原型都已可玩,可以开工) |
|
||||||
|
| M4:迁 Cocos Creator 3.x,各玩法独立场景,资源放 `assets/modes/<id>/` | 未开始,迁移顺序写在各 HANDOFF |
|
||||||
|
|
||||||
|
已知的规格与实现偏差(各 HANDOFF §3 有全表):两份 GDD 的教学关数与内容计划(玩法一 12 关 / 44 关,玩法三 8 关 / 32 关)仍是早期目标值,实际为 10 关 / 26 关与 10 关。
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
# 产品结构
|
# 产品结构
|
||||||
|
|
||||||
这是一个**多玩法**休闲 puzzle,不是单一机制的游戏。三个玩法共用一个壳,规则、盘面模型和关卡彼此独立。
|
这是一个**多玩法**休闲 puzzle,不是单一机制的游戏。两个玩法共用一个壳,规则、盘面模型和关卡彼此独立。
|
||||||
|
|
||||||
工作名沿用目录 `number-swipe`。上架名未定。三个玩法各自有对外名称。
|
工作名沿用目录 `number-swipe`。上架名未定。两个玩法各自有对外名称。
|
||||||
|
|
||||||
|
目录编号保留 `01` / `03`:曾有玩法二(连连气球),2026-09-16 放弃并移出仓库。编号同时是存档桶名和 Cocos 资源路径,不要重排。
|
||||||
|
|
||||||
## 目录
|
## 目录
|
||||||
|
|
||||||
@@ -15,10 +17,6 @@ number-swipe/
|
|||||||
01-number-stroke/ 玩法一 · 数字一笔
|
01-number-stroke/ 玩法一 · 数字一笔
|
||||||
docs/GDD.md
|
docs/GDD.md
|
||||||
levels/
|
levels/
|
||||||
02-connect-1234/ 玩法二 · 连连气球
|
|
||||||
docs/GDD.md
|
|
||||||
src/flow.ts 连消畅玩状态机
|
|
||||||
src/supply.ts 成袋补给、落点评价与救场
|
|
||||||
03-hex-walls/ 玩法三 · 六边一笔
|
03-hex-walls/ 玩法三 · 六边一笔
|
||||||
docs/GDD.md
|
docs/GDD.md
|
||||||
levels/
|
levels/
|
||||||
@@ -27,10 +25,10 @@ number-swipe/
|
|||||||
|
|
||||||
## 隔离规则
|
## 隔离规则
|
||||||
|
|
||||||
1. **玩法之间禁止互相引用。** 三个 `modes/*` 不得互相 import。
|
1. **玩法之间禁止互相引用。** `modes/*` 之间不得互相 import。
|
||||||
2. **规则、求解器、关卡、对局视图属于玩法。** 盘面模型不默认共用。玩法一是矩形格,玩法三是六边形 + 边墙,即使都是“顺序一笔画”也各写一份。玩法二是圆球 + 物理,更不能复用格子。
|
2. **规则、求解器、关卡、对局视图属于玩法。** 盘面模型不默认共用。玩法一是矩形格,玩法三是六边形 + 边墙,即使都是“顺序一笔画”也各写一份。
|
||||||
3. **`shared/` 只放壳。** 允许:玩法注册表、首页选玩法、设置、存档索引、广告位、安全区、通用按钮皮肤。禁止:过关判定、邻接、提示算法、关卡 JSON、掉落表。
|
3. **`shared/` 只放壳。** 允许:玩法注册表、首页选玩法、设置、存档索引、广告位、安全区、通用按钮皮肤。禁止:过关判定、邻接、提示算法、关卡 JSON。
|
||||||
4. **存档按玩法分桶。** `mode:01-number-stroke` / `mode:02-connect-1234` / `mode:03-hex-walls`。
|
4. **存档按玩法分桶。** `mode:01-number-stroke` / `mode:03-hex-walls`。
|
||||||
5. **原型按玩法分开跑。** 各玩法的 Web 原型进自己的 `web/`。不要求绑在同一个 index.html。
|
5. **原型按玩法分开跑。** 各玩法的 Web 原型进自己的 `web/`。不要求绑在同一个 index.html。
|
||||||
6. **Cocos 按玩法切场景。** 资源与脚本放 `assets/modes/<id>/`。
|
6. **Cocos 按玩法切场景。** 资源与脚本放 `assets/modes/<id>/`。
|
||||||
|
|
||||||
@@ -52,27 +50,26 @@ interface PuzzleMode {
|
|||||||
| id | 目录 | 类型 | 状态 | 规格 |
|
| id | 目录 | 类型 | 状态 | 规格 |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| `01-number-stroke` | `modes/01-number-stroke` | 关卡逻辑:矩形一笔画 + 序号 | M1/M2:Web 原型可玩,26 关(教学 10 + 进阶 10 + 奇偶 6) | [GDD](../modes/01-number-stroke/docs/GDD.md) |
|
| `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 关 + 转墙实验包 9 关 | [GDD](../modes/03-hex-walls/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 根据试玩反馈改成无关卡的持续连消,突出掉落接力和放飞。旧十关作为历史实验保留。
|
一和三同属路径推理,但几何和障碍不同,**不是 DLC 关系**。
|
||||||
|
|
||||||
新玩法开工顺序:写该玩法 GDD → 锁规则 → 再写该玩法的 `src/`。
|
新玩法开工顺序:写该玩法 GDD → 锁规则 → 再写该玩法的 `src/`。
|
||||||
|
|
||||||
## 共享决策(全产品)
|
## 共享决策(全产品)
|
||||||
|
|
||||||
- 手机竖屏,单机,离线可玩
|
- 手机竖屏,单机,离线可玩
|
||||||
- 默认无体力、无计时。玩法二当前为无关卡、无步数限制的连消畅玩模式
|
- 默认无体力、无计时、无分数
|
||||||
- 竖直切面无广告;上线后的去广告 / 激励视频由壳统一接
|
- 竖直切面无广告;上线后的去广告 / 激励视频由壳统一接
|
||||||
- 先 Web 原型验证该玩法,再进 Cocos
|
- 先 Web 原型验证该玩法,再进 Cocos
|
||||||
- 关卡、掉落表、美术全部原创,不使用第三方题库和素材
|
- 关卡、美术全部原创,不使用第三方题库和素材
|
||||||
|
|
||||||
## 里程碑(产品级)
|
## 里程碑(产品级)
|
||||||
|
|
||||||
| 阶段 | 内容 |
|
| 阶段 | 内容 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 现在 | 三个玩法独立可运行;一、三为关卡原型,二为气球连消畅玩版并附 Cocos 迁移包 |
|
| 现在 | 两个玩法独立可运行,均为关卡原型(M1/M2),未进 Cocos |
|
||||||
| 各玩法 M1 | 只在自己的 `modes/<id>` 里做可玩原型,互不阻塞 |
|
| 各玩法 M1 | 只在自己的 `modes/<id>` 里做可玩原型,互不阻塞 |
|
||||||
| 壳 | 至少两个玩法原型可玩之后,再做选玩法首页 |
|
| 壳 | 两个玩法原型都已可玩,选玩法首页可以开工 |
|
||||||
|
|
||||||
不要等三个玩法都做完再写代码。任意一个玩法都可以单独进 M1。
|
任意一个玩法都可以单独推进,不必互相等待。
|
||||||
|
|||||||
@@ -312,7 +312,6 @@ ui-rounded, "SF Pro Rounded", "PingFang SC", "Hiragino Sans GB", "Noto Sans SC",
|
|||||||
## 11. 与其他玩法的关系
|
## 11. 与其他玩法的关系
|
||||||
|
|
||||||
- **玩法三「六边一笔」**:结构与本规范一致(同一套壳、同一套四档动效),但 `theme.ts` 目前是靛蓝 `#4F5BD5` + 黄黑墙。待办:把 `--primary` 统一到 `#6558F5`(需先确认与墙黄 `#FFC826` 的对比),其余令牌直接复用本文。
|
- **玩法三「六边一笔」**:结构与本规范一致(同一套壳、同一套四档动效),但 `theme.ts` 目前是靛蓝 `#4F5BD5` + 黄黑墙。待办:把 `--primary` 统一到 `#6558F5`(需先确认与墙黄 `#FFC826` 的对比),其余令牌直接复用本文。
|
||||||
- **玩法二「连连气球」**:独立的天蓝 / 气球体系(`#83B4EB` 系),不套用本文色板;动效时长与缓动可以对齐四档。
|
|
||||||
- **壳(`shared/`)**:首页选玩法、设置、通用按钮皮肤按本文 §4 / §6 的按钮、chip、toast 规格实现。
|
- **壳(`shared/`)**:首页选玩法、设置、通用按钮皮肤按本文 §4 / §6 的按钮、chip、toast 规格实现。
|
||||||
|
|
||||||
## 12. 变更记录
|
## 12. 变更记录
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
按数字顺序,一笔画走完盘面上每一格。大格只计一步。
|
按数字顺序,一笔画走完盘面上每一格。大格只计一步。
|
||||||
|
|
||||||
本目录自包含:规格、关卡、规则代码、求解器和 Web 原型。不要依赖 `modes/02` 或 `modes/03`。壳需要的东西通过 `shared/` 的玩法入口对接,见 [`docs/PRODUCT.md`](../../docs/PRODUCT.md)。
|
本目录自包含:规格、关卡、规则代码、求解器和 Web 原型。不要依赖 `modes/03`。壳需要的东西通过 `shared/` 的玩法入口对接,见 [`docs/PRODUCT.md`](../../docs/PRODUCT.md)。
|
||||||
|
|
||||||
关卡、资源、文案全部原创,不使用第三方题库或素材。
|
关卡、资源、文案全部原创,不使用第三方题库或素材。
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 玩法一 · 数字一笔
|
# 玩法一 · 数字一笔
|
||||||
|
|
||||||
本文只描述**玩法一**。玩法二(连号1234)、玩法三(六边一笔)有自己的目录和 GDD,见仓库 [`docs/PRODUCT.md`](../../../docs/PRODUCT.md)。玩法三虽然也是顺序一笔画,几何和墙都独立,不要把墙或六边形补进本玩法。
|
本文只描述**玩法一**。玩法三(六边一笔)有自己的目录和 GDD,见仓库 [`docs/PRODUCT.md`](../../../docs/PRODUCT.md)。玩法三虽然也是顺序一笔画,几何和墙都独立,不要把墙或六边形补进本玩法。
|
||||||
|
|
||||||
用一次滑动,按数字规定的顺序,把盘面上每一块都走遍。规则、交互、关卡、竖直切面都以能直接实现为准。关卡、资源、文案全部原创。
|
用一次滑动,按数字规定的顺序,把盘面上每一块都走遍。规则、交互、关卡、竖直切面都以能直接实现为准。关卡、资源、文案全部原创。
|
||||||
|
|
||||||
@@ -453,7 +453,7 @@ M1 结束时如果 1-4 的“大格只计一步”教不会,先改教学盘,
|
|||||||
|
|
||||||
## 17. 已关闭的问题
|
## 17. 已关闭的问题
|
||||||
|
|
||||||
- 要不要做成 2048 / 合成数字?不要。本玩法就是顺序一笔画。其它机制留给玩法二、三。
|
- 要不要做成 2048 / 合成数字?不要。本玩法就是顺序一笔画。
|
||||||
- 要不要 V1 就上差异化机构?不要。先证明主规则。
|
- 要不要 V1 就上差异化机构?不要。先证明主规则。
|
||||||
|
|
||||||
## 18. 留给实现的缺口
|
## 18. 留给实现的缺口
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
node_modules/
|
|
||||||
web/dist/
|
|
||||||
release/
|
|
||||||
cocos/export/
|
|
||||||
.DS_Store
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
# 玩法二 · 连连气球
|
|
||||||
|
|
||||||
竖屏触摸连消合成游戏。连接顺号或同号气球,松手变大,周围和新掉落的气球自动接力。当前版本以连续合成、造大数、放飞为主循环,无关卡、步数或计时限制。
|
|
||||||
|
|
||||||
## 直接玩
|
|
||||||
|
|
||||||
```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`。旧存档不会被新纪录覆盖。
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
/** 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
# 连连气球 · 连消畅玩版
|
|
||||||
|
|
||||||
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)。它用于排查玩法断档,不代表真实玩家留存或主观爽感;后续可以基于实机试玩调整落点评分、能量阈值与节奏。
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
# 连连气球 · 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 与目标小游戏平台触摸及性能验收。
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
# 连连气球 · 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)。
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
> 历史存档:关卡版 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 规则、第三方物理引擎、服务端或远程素材。
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
> 历史存档:关卡版 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 的声明文件为准。
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
> 历史存档:关卡版 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 与目标小游戏平台验收。当前不宣称这些工作已完成。
|
|
||||||
|
|
||||||
## 难度判断的范围
|
|
||||||
|
|
||||||
难度梯度由教学目标、解的步数、容错余量、长链和多轮合成要求制定,已验证全部关卡可通关。尚未做多人试玩统计,平均通关耗时、提示使用率、玩家卡关分布需要后续试玩采样。
|
|
||||||
@@ -1,205 +0,0 @@
|
|||||||
{
|
|
||||||
"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": []
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 220 KiB |
|
Before Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 91 KiB |
@@ -1,312 +0,0 @@
|
|||||||
{
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 88 KiB |
@@ -1 +0,0 @@
|
|||||||
{"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}
|
|
||||||
|
Before Width: | Height: | Size: 90 KiB |
@@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"turns": 1,
|
|
||||||
"score": "10,000",
|
|
||||||
"errors": [],
|
|
||||||
"debugExposed": false,
|
|
||||||
"persistence": true,
|
|
||||||
"originalRecordRestored": true,
|
|
||||||
"legacyRecordPreserved": true,
|
|
||||||
"url": "http://localhost:8003/"
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 160 KiB |
|
Before Width: | Height: | Size: 74 KiB |
@@ -1,300 +0,0 @@
|
|||||||
{
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
{
|
|
||||||
"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": []
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 299 KiB |
@@ -1,241 +0,0 @@
|
|||||||
{
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 106 KiB |
@@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"url": "http://localhost:8003",
|
|
||||||
"debugPortAbsent": true,
|
|
||||||
"touch": {
|
|
||||||
"status": "won",
|
|
||||||
"score": "1,200 分",
|
|
||||||
"moves": "2"
|
|
||||||
},
|
|
||||||
"resetToCleanLevel1": true
|
|
||||||
}
|
|
||||||
@@ -1,883 +0,0 @@
|
|||||||
{
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,744 +0,0 @@
|
|||||||
{
|
|
||||||
"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
|
|
||||||
]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"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 }
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
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/');
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
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);
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
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}`);
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
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 };
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
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' },
|
|
||||||
];
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
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(); }
|
|
||||||
}
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
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';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
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); }
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
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];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import campaign from '../levels/campaign.json';
|
|
||||||
import type { Level } from './types';
|
|
||||||
export const LEVELS = campaign.levels as Level[];
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
export const SAVE_KEY = 'mode:02-connect-1234:v1';
|
|
||||||
export interface RecordEntry { stars: number; score: number }
|
|
||||||
export interface Progress { version: 1; levels: Record<number, RecordEntry>; 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;
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
/** 可序列化的 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<T>(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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
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; }
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
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] || [];
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
/** 宿主存储接口,Web / Cocos 各自注入实现。 */
|
|
||||||
export interface StoragePort { read(key: string): string | null; write(key: string, value: string): void }
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
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 };
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
/** 平台无关;所有坐标以左上为原点,逻辑像素。 */
|
|
||||||
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<number, number> }
|
|
||||||
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 }
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
// 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);
|
|
||||||
});
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
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;i<points.length;i++)await page.cdp('Input.dispatchTouchEvent',{type:i?'touchMove':'touchStart',touchPoints:[points[i]]});
|
|
||||||
await page.cdp('Input.dispatchTouchEvent',{type:'touchEnd',touchPoints:[]});
|
|
||||||
assert.equal(await page.evaluate(()=>window.__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}));
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
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;i<coords.length;i++)await page.cdp('Input.dispatchTouchEvent',{type:i?'touchMove':'touchStart',touchPoints:[coords[i]]});
|
|
||||||
await page.cdp('Input.dispatchTouchEvent',{type:'touchEnd',touchPoints:[]});
|
|
||||||
await page.waitForFunction(()=>document.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()});
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
// 本次任务已有空间为 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<points.length;i++) await touch(i?'touchMove':'touchStart',[points[i]]);
|
|
||||||
await touch(cancel?'touchCancel':'touchEnd');
|
|
||||||
};
|
|
||||||
const opening=await snapshot(), points=await targets();
|
|
||||||
await swipe(points.slice(0,2)); assert.deepEqual(await snapshot(),opening);
|
|
||||||
await swipe(points,true); assert.deepEqual(await snapshot(),opening);
|
|
||||||
await swipe([points[0],points[1],points[2],points[1]]); assert.deepEqual(await snapshot(),opening);
|
|
||||||
const moves=[];
|
|
||||||
for(let i=0;i<24;i++){
|
|
||||||
const before=await snapshot(); await swipe(await targets());
|
|
||||||
await page.waitForFunction(()=>!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());
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
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;i<data.points.length;i++) await page.cdp('Input.dispatchTouchEvent',{type:i?'touchMove':'touchStart',touchPoints:[{...data.points[i],id:0,radiusX:5,radiusY:5,force:1}]});
|
|
||||||
await page.cdp('Input.dispatchTouchEvent',{type:'touchEnd',touchPoints:[]});
|
|
||||||
await page.waitForFunction(n=>window.__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());
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
// 通过 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());
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
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}`);
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
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。`);
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
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`);
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2020", "module": "ESNext", "moduleResolution": "Bundler",
|
|
||||||
"lib": ["ES2020"], "strict": true, "resolveJsonModule": true,
|
|
||||||
"esModuleInterop": true, "isolatedModules": true, "noEmit": true, "types": [],
|
|
||||||
"skipLibCheck": true
|
|
||||||
},
|
|
||||||
"include": ["src"]
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"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"]
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
|
||||||
<meta name="theme-color" content="#f3f9ff">
|
|
||||||
<meta name="description" content="连连气球:顺号连,同号也能连。自动连消、彩虹双倍、明亮气球,轻轻一划一直玩。">
|
|
||||||
<title>连连气球 · POP & LINK</title>
|
|
||||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Cellipse cx='32' cy='27' rx='22' ry='26' fill='%2379bafa'/%3E%3Cpath d='m32 50-5 8h10Z' fill='%2362a7eb'/%3E%3Cellipse cx='24' cy='14' rx='7' ry='3' fill='white'/%3E%3C/svg%3E">
|
|
||||||
<link rel="stylesheet" href="style.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<svg class="symbols" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><defs><symbol id="i-balloon" viewBox="0 0 24 24"><ellipse cx="12" cy="9" rx="6.5" ry="8"/><path d="m12 17-2 3h4l-2-3M12 20c-4 2 3 2 0 4M9 5l-1 2"/></symbol><symbol id="i-star" viewBox="0 0 24 24"><path d="m12 3 2.8 5.8 6.4.9-4.6 4.5 1.1 6.3-5.7-3-5.7 3 1.1-6.3-4.6-4.5 6.4-.9Z"/></symbol>
|
|
||||||
<symbol id="i-leaf" viewBox="0 0 24 24"><path d="M12 21V11M12 14C3 15 3 7 3 5c6 0 10 3 9 9ZM12 10c0-6 4-8 9-8 0 6-3 10-9 10"/></symbol>
|
|
||||||
<symbol id="i-grid" viewBox="0 0 24 24"><rect x="4" y="4" width="6" height="6" rx="2"/><rect x="14" y="4" width="6" height="6" rx="2"/><rect x="4" y="14" width="6" height="6" rx="2"/><rect x="14" y="14" width="6" height="6" rx="2"/></symbol>
|
|
||||||
<symbol id="i-sound" viewBox="0 0 24 24"><path d="m11 5-5 4H3v6h3l5 4V5ZM15 8c3 2 3 6 0 8M18 5c5 4 5 10 0 14"/></symbol>
|
|
||||||
<symbol id="i-muted" viewBox="0 0 24 24"><path d="m11 5-5 4H3v6h3l5 4V5ZM16 9l5 6m0-6-5 6"/></symbol>
|
|
||||||
<symbol id="i-help" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M9 9a3 3 0 0 1 6 0c0 2-3 2-3 5M12 17h.01"/></symbol>
|
|
||||||
<symbol id="i-undo" viewBox="0 0 24 24"><path d="m8 4-5 5 5 5M3 9h11a6 6 0 0 1 0 12h-3"/></symbol>
|
|
||||||
<symbol id="i-hint" viewBox="0 0 24 24"><path d="M8 16c0-3-3-3-3-7a7 7 0 0 1 14 0c0 4-3 4-3 7H8ZM9 20h6M10 23h4M10 11l2 2 2-2M12 13v3"/></symbol>
|
|
||||||
<symbol id="i-shuffle" viewBox="0 0 24 24"><path d="M3 6h3c5 0 6 12 11 12h4m-4-4 4 4-4 4M3 18h3c2 0 4-3 6-6s3-6 5-6h4m-4-4 4 4-4 4"/></symbol>
|
|
||||||
<symbol id="i-restart" viewBox="0 0 24 24"><path d="M4 9a8 8 0 1 1 0 7M4 3v6h6"/></symbol>
|
|
||||||
<symbol id="i-arrow" viewBox="0 0 24 24"><path d="M4 12h16m-6-6 6 6-6 6"/></symbol>
|
|
||||||
<symbol id="i-close" viewBox="0 0 24 24"><path d="m6 6 12 12M6 18 18 6"/></symbol>
|
|
||||||
<symbol id="i-check" viewBox="0 0 24 24"><path d="m5 12 4 4L19 6"/></symbol>
|
|
||||||
</defs></svg>
|
|
||||||
<aside class="desktop-note" aria-hidden="true"><div class="floating-balloon b-one">1</div><div class="floating-balloon b-two">2</div><div class="floating-balloon b-three">3</div><p>POP. LINK. REPEAT.</p><h2>轻轻一划,<br>快乐就连起来了。</h2><span>让数字长大,让心情放飞。</span></aside>
|
|
||||||
<main class="app" id="app">
|
|
||||||
<header class="brand-bar">
|
|
||||||
<div class="brand"><span class="brand-mark"><svg><use href="#i-balloon"/></svg></span><div><h1>连连气球</h1><span>POP & LINK</span></div></div>
|
|
||||||
<div class="header-actions"><button class="icon-button" id="sound" aria-label="关闭音效"><svg><use href="#i-sound"/></svg></button><button class="icon-button" id="help" aria-label="玩法说明"><svg><use href="#i-help"/></svg></button></div>
|
|
||||||
</header>
|
|
||||||
<section class="scoreboard" aria-label="本局成绩">
|
|
||||||
<div class="score-main"><span class="eyebrow">本局得分</span><strong id="score">0</strong><span class="best-line">最佳 <b id="best-score">0</b></span></div>
|
|
||||||
<button class="collection" id="records" aria-label="查看放飞记录"><span class="best-balloon" id="peak">4</span><span>最大气球</span></button>
|
|
||||||
</section>
|
|
||||||
<section class="flow-bar" aria-label="补给和双倍能量">
|
|
||||||
<div class="fever-block"><div class="fever-label"><span id="fever-label">彩虹能量</span><b id="fever-count">0 / 10</b></div><div class="fever-track" role="progressbar" id="fever-meter" aria-label="彩虹能量" aria-valuemin="0" aria-valuemax="10" aria-valuenow="0"><i id="fever-fill"></i></div></div>
|
|
||||||
<div class="upcoming"><span>接下来</span><div id="queue" aria-label="接下来掉落的数字"></div></div>
|
|
||||||
</section>
|
|
||||||
<section class="stage" id="stage" aria-label="气球游乐场">
|
|
||||||
<canvas id="board" aria-label="按住气球,连接至少三个连续数字或三个相同数字,再松手。新球会自动接着合成。" role="img"></canvas>
|
|
||||||
<div class="welcome" id="welcome"><span>1 · 2 · 3 <i>或</i> 2 · 2 · 2</span><p>顺号连,同号也能连</p></div>
|
|
||||||
<div class="combo-banner" id="combo-banner" aria-live="polite"></div>
|
|
||||||
<div class="toast" id="toast" role="status" aria-live="polite"></div>
|
|
||||||
<span class="stage-caption" id="stage-caption">合成 10,放飞一颗快乐</span>
|
|
||||||
</section>
|
|
||||||
<div class="chain-preview" id="chain-preview" aria-live="polite">连起 3 颗,看看能接出几连消</div>
|
|
||||||
<nav class="tools" aria-label="游戏工具">
|
|
||||||
<button id="undo" disabled><svg><use href="#i-undo"/></svg><span>撤一步</span></button>
|
|
||||||
<button id="hint" class="hint-button"><svg><use href="#i-hint"/></svg><span>找一串</span></button>
|
|
||||||
<button id="shuffle"><svg><use href="#i-shuffle"/></svg><span>吹口气</span></button>
|
|
||||||
<button id="restart"><svg><use href="#i-restart"/></svg><span>新一局</span></button>
|
|
||||||
</nav>
|
|
||||||
<footer class="game-footer"><span id="footer-text">不计时,不限步,开心一直连。</span><span id="flight-count">已放飞 0 颗</span></footer>
|
|
||||||
</main>
|
|
||||||
<div class="modal-backdrop" id="modal" hidden><section class="sheet" id="sheet" role="dialog" aria-modal="true" aria-labelledby="sheet-title" tabindex="-1"></section></div>
|
|
||||||
<noscript>请启用 JavaScript 来玩连连气球。</noscript>
|
|
||||||
<script src="dist/app.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,215 +0,0 @@
|
|||||||
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 $ = <T extends HTMLElement = HTMLElement>(id: string): T => document.getElementById(id) as T;
|
|
||||||
const icon = (name: string) => `<svg aria-hidden="true"><use href="#i-${name}"/></svg>`;
|
|
||||||
const format = (n: number) => Math.round(n).toLocaleString('en-US');
|
|
||||||
const canvas = $<HTMLCanvasElement>('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 = `<strong>${title}</strong><span>${subtitle}</span>`;
|
|
||||||
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 => `<span class="next-ball" style="background:${PALETTE[n - 1].fill}">${n}</span>`).join('');
|
|
||||||
$('queue').setAttribute('aria-label', `接下来掉落 ${game.queue.slice(0, 3).join('、')}`);
|
|
||||||
$<HTMLButtonElement>('undo').disabled = !game.canUndo || !!animation;
|
|
||||||
$<HTMLButtonElement>('hint').disabled = !!animation; $<HTMLButtonElement>('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 ? `<span class="mini-ball" style="background:${PALETTE[(b.n - 1) % PALETTE.length].fill}">${b.n}</span>` : '<span>…</span>').join('<span>·</span>') +
|
|
||||||
(result ? `<span>→</span><b>${result.stages[result.stages.length - 1].created.n}</b>${result.stages.length > 1 ? `<span class="predict">${result.stages.length} 连消</span>` : ''}<span class="gain">+${format(result.score)} 起</span>` : `<span class="gain">再连 ${Math.max(0, 3 - ids.length)} 颗</span>`);
|
|
||||||
}
|
|
||||||
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 = `<button class="icon-button close" data-action="close" aria-label="关闭面板">${icon('close')}</button>`;
|
|
||||||
function showHelp(): void {
|
|
||||||
openSheet(`<div class="sheet-head"><div><span class="eyebrow">A LITTLE POP, A LOT OF JOY</span><h2 id="sheet-title">一划,就连起来</h2></div>${closeButton}</div><div class="help-chain">${[1, 2, 3, 4].map((n, i) => `${i ? `<span>${i === 3 ? '→' : '·'}</span>` : ''}<span class="help-ball" style="background:${PALETTE[n - 1].fill}">${n}</span>`).join('')}</div><div class="help-rule"><span>1</span><p><b>顺号连,同号也能连。</b><br>连接至少 3 颗相邻气球:1→2→3、3→2→1,或 2→2→2。顺号和同号不能混在一条里。</p></div><div class="help-rule"><span>2</span><p><b>松手变大,自动接着合成。</b><br>新数字是最大数 +1,留在连线末端。周围或新掉落的气球能接上,就自动连消,最多 3 连!</p></div><div class="help-rule"><span>3</span><p><b>攒满彩虹,双倍快乐。</b><br>长链和连消积攒能量,满 10 格后,接下来 5 次合成双倍得分,最多 4 连消。没有倒计时。</p></div><div class="help-rule"><span>4</span><p><b>合成 10,让它飞向天空。</b><br>10 号气球自动放飞,奖励 10,000 分,腾出空间继续玩。没有关卡和步数限制,提示、撤销、吹口气都免费。</p></div><button class="primary" data-action="close">知道了,接着连 ${icon('arrow')}</button>`,);
|
|
||||||
}
|
|
||||||
function showRecords(restart = false): void {
|
|
||||||
save();
|
|
||||||
openSheet(`<div class="sheet-head"><div><span class="eyebrow">YOUR BALLOON SKY</span><h2 id="sheet-title">${restart ? '这一局,飞得不错' : '你的放飞记录'}</h2></div>${closeButton}</div><div class="record-hero"><strong>${format(game.stats.score)}</strong><span>本局得分 · 最佳 ${format(record.bestScore)}</span></div><div class="record-grid"><div><strong>${game.stats.bestCombo}</strong><span>本局最高连消</span></div><div><strong>${game.stats.reactions}</strong><span>已完成合成</span></div><div><strong>${game.stats.flown}</strong><span>已放飞气球</span></div></div><div class="sky-collection">${[4, 6, 8, 10].map(n => `<div class="sky-item ${record.bestNumber >= n ? 'earned' : ''}"><span class="help-ball" style="background:${PALETTE[n - 1].fill}">${n}</span><span>${record.bestNumber >= n ? '已点亮' : '等你来连'}</span></div>`).join('')}</div><p class="small-note">最高 ${record.bestCombo} 连消 · 单局最多放飞 ${record.bestFlown} 颗<br>想继续就继续,想重来就换一片天空。</p><button class="primary" data-action="${restart ? 'new' : 'close'}">${restart ? '开始新一局' : '回去接着连'} ${icon('arrow')}</button><button class="secondary" data-action="${restart ? 'close' : 'new'}">${restart ? '继续这一局' : '换一片天空 · 新一局'}</button>${saveAvailable ? '' : '<p class="save-note">浏览器未允许保存,记录暂存本次会话。</p>'}`);
|
|
||||||
}
|
|
||||||
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<HTMLButtonElement>('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<HTMLButtonElement>('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 } });
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# 玩法三 · 六边一笔
|
# 玩法三 · 六边一笔
|
||||||
|
|
||||||
本文只描述**玩法三**。玩法一、玩法二有自己的目录和 GDD,见仓库 [`docs/PRODUCT.md`](../../../docs/PRODUCT.md)。
|
本文只描述**玩法三**。玩法一(数字一笔)有自己的目录和 GDD,见仓库 [`docs/PRODUCT.md`](../../../docs/PRODUCT.md)。
|
||||||
|
|
||||||
在六边形拼板上画一条哈密顿路径。格子之间的边上可以有墙,墙挡住那一条邻接。预先写好的数字,必须刚好在那一步走到。
|
在六边形拼板上画一条哈密顿路径。格子之间的边上可以有墙,墙挡住那一条邻接。预先写好的数字,必须刚好在那一步走到。
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# shared · 壳
|
# shared · 壳
|
||||||
|
|
||||||
三个玩法共用的外壳,不含任何过关规则。
|
两个玩法共用的外壳,不含任何过关规则。
|
||||||
|
|
||||||
可以放:
|
可以放:
|
||||||
|
|
||||||
|
|||||||