diff --git a/README.md b/README.md index 5bc37cb..ed01699 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,168 @@ # number-swipe -多玩法休闲 puzzle。三个玩法共用一个壳,规则和关卡彼此独立。 +多玩法休闲 puzzle(工作名,上架名未定)。目前两个玩法,都属于「顺序一笔画」路径推理:用一次滑动走遍盘面上每一格,预先写好的数字必须刚好在那一步踩到。两者几何与障碍不同,共用一个壳,规则代码彼此独立。 + +手机竖屏、单机、离线可玩;无计时、无体力、无分数。先做 Web 触摸原型验证玩法,再迁 Cocos Creator。 | 玩法 | 目录 | 一句话 | 状态 | |---|---|---|---| -| 一 · 数字一笔 | [`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 关 | +| 一 · 数字一笔 | [`modes/01-number-stroke`](modes/01-number-stroke) | 矩形拼板一笔画,大格只计一步,数字 / 奇偶线索必须踩对 | Web 竖屏原型可玩,26 关(入门 10 + 进阶 10 + 奇与偶 6),有单文件试玩版 | +| 三 · 六边一笔 | [`modes/03-hex-walls`](modes/03-hex-walls) | 六边形一笔画,墙贴在边上挡住那一条邻接 | Web 竖屏原型可玩,10 关 + 转墙实验包 9 关 | -产品级约定在 [`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//` | 未开始,迁移顺序写在各 HANDOFF | + +已知的规格与实现偏差(各 HANDOFF §3 有全表):两份 GDD 的教学关数与内容计划(玩法一 12 关 / 44 关,玩法三 8 关 / 32 关)仍是早期目标值,实际为 10 关 / 26 关与 10 关。 diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index 6526f89..570f421 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -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/ 玩法一 · 数字一笔 docs/GDD.md levels/ - 02-connect-1234/ 玩法二 · 连连气球 - docs/GDD.md - src/flow.ts 连消畅玩状态机 - src/supply.ts 成袋补给、落点评价与救场 03-hex-walls/ 玩法三 · 六边一笔 docs/GDD.md levels/ @@ -27,10 +25,10 @@ number-swipe/ ## 隔离规则 -1. **玩法之间禁止互相引用。** 三个 `modes/*` 不得互相 import。 -2. **规则、求解器、关卡、对局视图属于玩法。** 盘面模型不默认共用。玩法一是矩形格,玩法三是六边形 + 边墙,即使都是“顺序一笔画”也各写一份。玩法二是圆球 + 物理,更不能复用格子。 -3. **`shared/` 只放壳。** 允许:玩法注册表、首页选玩法、设置、存档索引、广告位、安全区、通用按钮皮肤。禁止:过关判定、邻接、提示算法、关卡 JSON、掉落表。 -4. **存档按玩法分桶。** `mode:01-number-stroke` / `mode:02-connect-1234` / `mode:03-hex-walls`。 +1. **玩法之间禁止互相引用。** `modes/*` 之间不得互相 import。 +2. **规则、求解器、关卡、对局视图属于玩法。** 盘面模型不默认共用。玩法一是矩形格,玩法三是六边形 + 边墙,即使都是“顺序一笔画”也各写一份。 +3. **`shared/` 只放壳。** 允许:玩法注册表、首页选玩法、设置、存档索引、广告位、安全区、通用按钮皮肤。禁止:过关判定、邻接、提示算法、关卡 JSON。 +4. **存档按玩法分桶。** `mode:01-number-stroke` / `mode:03-hex-walls`。 5. **原型按玩法分开跑。** 各玩法的 Web 原型进自己的 `web/`。不要求绑在同一个 index.html。 6. **Cocos 按玩法切场景。** 资源与脚本放 `assets/modes//`。 @@ -52,27 +50,26 @@ interface PuzzleMode { | id | 目录 | 类型 | 状态 | 规格 | |---|---|---|---|---| | `01-number-stroke` | `modes/01-number-stroke` | 关卡逻辑:矩形一笔画 + 序号 | M1/M2:Web 原型可玩,26 关(教学 10 + 进阶 10 + 奇偶 6) | [GDD](../modes/01-number-stroke/docs/GDD.md) | -| `02-connect-1234` | `modes/02-connect-1234` | 气球合成:顺号/同号、掉落接力、彩虹双倍 | Web 可玩,Cocos 迁移包 | [GDD](../modes/02-connect-1234/docs/GDD.md) | -| `03-hex-walls` | `modes/03-hex-walls` | 关卡逻辑:六边形一笔画 + 边墙 | M1/M2:Web 原型可玩,10 关 | [GDD](../modes/03-hex-walls/docs/GDD.md) | +| `03-hex-walls` | `modes/03-hex-walls` | 关卡逻辑:六边形一笔画 + 边墙 | M1/M2:Web 原型可玩,10 关 + 转墙实验包 9 关 | [GDD](../modes/03-hex-walls/docs/GDD.md) | -一和三同属路径推理,但几何和障碍不同,**不是 DLC 关系**。二是物理连号合成循环;2026-09-15 根据试玩反馈改成无关卡的持续连消,突出掉落接力和放飞。旧十关作为历史实验保留。 +一和三同属路径推理,但几何和障碍不同,**不是 DLC 关系**。 新玩法开工顺序:写该玩法 GDD → 锁规则 → 再写该玩法的 `src/`。 ## 共享决策(全产品) - 手机竖屏,单机,离线可玩 -- 默认无体力、无计时。玩法二当前为无关卡、无步数限制的连消畅玩模式 +- 默认无体力、无计时、无分数 - 竖直切面无广告;上线后的去广告 / 激励视频由壳统一接 - 先 Web 原型验证该玩法,再进 Cocos -- 关卡、掉落表、美术全部原创,不使用第三方题库和素材 +- 关卡、美术全部原创,不使用第三方题库和素材 ## 里程碑(产品级) | 阶段 | 内容 | |---|---| -| 现在 | 三个玩法独立可运行;一、三为关卡原型,二为气球连消畅玩版并附 Cocos 迁移包 | +| 现在 | 两个玩法独立可运行,均为关卡原型(M1/M2),未进 Cocos | | 各玩法 M1 | 只在自己的 `modes/` 里做可玩原型,互不阻塞 | -| 壳 | 至少两个玩法原型可玩之后,再做选玩法首页 | +| 壳 | 两个玩法原型都已可玩,选玩法首页可以开工 | -不要等三个玩法都做完再写代码。任意一个玩法都可以单独进 M1。 +任意一个玩法都可以单独推进,不必互相等待。 diff --git a/docs/design.md b/docs/design.md index 9cd6b28..024233d 100644 --- a/docs/design.md +++ b/docs/design.md @@ -312,7 +312,6 @@ ui-rounded, "SF Pro Rounded", "PingFang SC", "Hiragino Sans GB", "Noto Sans SC", ## 11. 与其他玩法的关系 - **玩法三「六边一笔」**:结构与本规范一致(同一套壳、同一套四档动效),但 `theme.ts` 目前是靛蓝 `#4F5BD5` + 黄黑墙。待办:把 `--primary` 统一到 `#6558F5`(需先确认与墙黄 `#FFC826` 的对比),其余令牌直接复用本文。 -- **玩法二「连连气球」**:独立的天蓝 / 气球体系(`#83B4EB` 系),不套用本文色板;动效时长与缓动可以对齐四档。 - **壳(`shared/`)**:首页选玩法、设置、通用按钮皮肤按本文 §4 / §6 的按钮、chip、toast 规格实现。 ## 12. 变更记录 diff --git a/modes/01-number-stroke/README.md b/modes/01-number-stroke/README.md index 1414221..6c3664a 100644 --- a/modes/01-number-stroke/README.md +++ b/modes/01-number-stroke/README.md @@ -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)。 关卡、资源、文案全部原创,不使用第三方题库或素材。 diff --git a/modes/01-number-stroke/docs/GDD.md b/modes/01-number-stroke/docs/GDD.md index 19d3cda..82f7f1d 100644 --- a/modes/01-number-stroke/docs/GDD.md +++ b/modes/01-number-stroke/docs/GDD.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. 已关闭的问题 -- 要不要做成 2048 / 合成数字?不要。本玩法就是顺序一笔画。其它机制留给玩法二、三。 +- 要不要做成 2048 / 合成数字?不要。本玩法就是顺序一笔画。 - 要不要 V1 就上差异化机构?不要。先证明主规则。 ## 18. 留给实现的缺口 diff --git a/modes/02-connect-1234/.gitignore b/modes/02-connect-1234/.gitignore deleted file mode 100644 index 410ec16..0000000 --- a/modes/02-connect-1234/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -node_modules/ -web/dist/ -release/ -cocos/export/ -.DS_Store diff --git a/modes/02-connect-1234/README.md b/modes/02-connect-1234/README.md deleted file mode 100644 index 0e1ae75..0000000 --- a/modes/02-connect-1234/README.md +++ /dev/null @@ -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`。旧存档不会被新纪录覆盖。 diff --git a/modes/02-connect-1234/cocos/Connect1234Board.ts b/modes/02-connect-1234/cocos/Connect1234Board.ts deleted file mode 100644 index 7099adf..0000000 --- a/modes/02-connect-1234/cocos/Connect1234Board.ts +++ /dev/null @@ -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; - } -} diff --git a/modes/02-connect-1234/docs/GDD.md b/modes/02-connect-1234/docs/GDD.md deleted file mode 100644 index 94af49b..0000000 --- a/modes/02-connect-1234/docs/GDD.md +++ /dev/null @@ -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)。它用于排查玩法断档,不代表真实玩家留存或主观爽感;后续可以基于实机试玩调整落点评分、能量阈值与节奏。 diff --git a/modes/02-connect-1234/docs/HANDOFF.md b/modes/02-connect-1234/docs/HANDOFF.md deleted file mode 100644 index c0f4c2d..0000000 --- a/modes/02-connect-1234/docs/HANDOFF.md +++ /dev/null @@ -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 与目标小游戏平台触摸及性能验收。 diff --git a/modes/02-connect-1234/docs/QA.md b/modes/02-connect-1234/docs/QA.md deleted file mode 100644 index 613d7c6..0000000 --- a/modes/02-connect-1234/docs/QA.md +++ /dev/null @@ -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)。 diff --git a/modes/02-connect-1234/docs/archive-v1/GDD.md b/modes/02-connect-1234/docs/archive-v1/GDD.md deleted file mode 100644 index 501a853..0000000 --- a/modes/02-connect-1234/docs/archive-v1/GDD.md +++ /dev/null @@ -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 规则、第三方物理引擎、服务端或远程素材。 diff --git a/modes/02-connect-1234/docs/archive-v1/HANDOFF.md b/modes/02-connect-1234/docs/archive-v1/HANDOFF.md deleted file mode 100644 index cb1c88c..0000000 --- a/modes/02-connect-1234/docs/archive-v1/HANDOFF.md +++ /dev/null @@ -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 的声明文件为准。 diff --git a/modes/02-connect-1234/docs/archive-v1/QA.md b/modes/02-connect-1234/docs/archive-v1/QA.md deleted file mode 100644 index fe385f4..0000000 --- a/modes/02-connect-1234/docs/archive-v1/QA.md +++ /dev/null @@ -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 与目标小游戏平台验收。当前不宣称这些工作已完成。 - -## 难度判断的范围 - -难度梯度由教学目标、解的步数、容错余量、长链和多轮合成要求制定,已验证全部关卡可通关。尚未做多人试玩统计,平均通关耗时、提示使用率、玩家卡关分布需要后续试玩采样。 diff --git a/modes/02-connect-1234/docs/qa-v2/browser.json b/modes/02-connect-1234/docs/qa-v2/browser.json deleted file mode 100644 index 6ebdcc1..0000000 --- a/modes/02-connect-1234/docs/qa-v2/browser.json +++ /dev/null @@ -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": [] -} diff --git a/modes/02-connect-1234/docs/qa-v2/desktop.png b/modes/02-connect-1234/docs/qa-v2/desktop.png deleted file mode 100644 index ddab67f..0000000 Binary files a/modes/02-connect-1234/docs/qa-v2/desktop.png and /dev/null differ diff --git a/modes/02-connect-1234/docs/qa-v2/first-merge.png b/modes/02-connect-1234/docs/qa-v2/first-merge.png deleted file mode 100644 index 7a6f053..0000000 Binary files a/modes/02-connect-1234/docs/qa-v2/first-merge.png and /dev/null differ diff --git a/modes/02-connect-1234/docs/qa-v2/flight.png b/modes/02-connect-1234/docs/qa-v2/flight.png deleted file mode 100644 index 1d78aca..0000000 Binary files a/modes/02-connect-1234/docs/qa-v2/flight.png and /dev/null differ diff --git a/modes/02-connect-1234/docs/qa-v2/help.png b/modes/02-connect-1234/docs/qa-v2/help.png deleted file mode 100644 index a03bb56..0000000 Binary files a/modes/02-connect-1234/docs/qa-v2/help.png and /dev/null differ diff --git a/modes/02-connect-1234/docs/qa-v2/layout.json b/modes/02-connect-1234/docs/qa-v2/layout.json deleted file mode 100644 index 0821289..0000000 --- a/modes/02-connect-1234/docs/qa-v2/layout.json +++ /dev/null @@ -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 -} diff --git a/modes/02-connect-1234/docs/qa-v2/mobile.png b/modes/02-connect-1234/docs/qa-v2/mobile.png deleted file mode 100644 index 41e7f2a..0000000 Binary files a/modes/02-connect-1234/docs/qa-v2/mobile.png and /dev/null differ diff --git a/modes/02-connect-1234/docs/qa-v2/overlap-regression.json b/modes/02-connect-1234/docs/qa-v2/overlap-regression.json deleted file mode 100644 index 3c138a0..0000000 --- a/modes/02-connect-1234/docs/qa-v2/overlap-regression.json +++ /dev/null @@ -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} \ No newline at end of file diff --git a/modes/02-connect-1234/docs/qa-v2/production-merge.png b/modes/02-connect-1234/docs/qa-v2/production-merge.png deleted file mode 100644 index 6c2a5a2..0000000 Binary files a/modes/02-connect-1234/docs/qa-v2/production-merge.png and /dev/null differ diff --git a/modes/02-connect-1234/docs/qa-v2/production.json b/modes/02-connect-1234/docs/qa-v2/production.json deleted file mode 100644 index 80ac82b..0000000 --- a/modes/02-connect-1234/docs/qa-v2/production.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "turns": 1, - "score": "10,000", - "errors": [], - "debugExposed": false, - "persistence": true, - "originalRecordRestored": true, - "legacyRecordPreserved": true, - "url": "http://localhost:8003/" -} diff --git a/modes/02-connect-1234/docs/qa-v2/rainbow.png b/modes/02-connect-1234/docs/qa-v2/rainbow.png deleted file mode 100644 index 83052cd..0000000 Binary files a/modes/02-connect-1234/docs/qa-v2/rainbow.png and /dev/null differ diff --git a/modes/02-connect-1234/docs/qa-v2/records.png b/modes/02-connect-1234/docs/qa-v2/records.png deleted file mode 100644 index cc6a15a..0000000 Binary files a/modes/02-connect-1234/docs/qa-v2/records.png and /dev/null differ diff --git a/modes/02-connect-1234/docs/qa-v2/small-phone.png b/modes/02-connect-1234/docs/qa-v2/small-phone.png deleted file mode 100644 index 1b0e285..0000000 Binary files a/modes/02-connect-1234/docs/qa-v2/small-phone.png and /dev/null differ diff --git a/modes/02-connect-1234/docs/qa-v2/validation.json b/modes/02-connect-1234/docs/qa-v2/validation.json deleted file mode 100644 index 4254bac..0000000 --- a/modes/02-connect-1234/docs/qa-v2/validation.json +++ /dev/null @@ -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 - } - ] - } - ] -} diff --git a/modes/02-connect-1234/docs/qa/browser.json b/modes/02-connect-1234/docs/qa/browser.json deleted file mode 100644 index 6eb3f3e..0000000 --- a/modes/02-connect-1234/docs/qa/browser.json +++ /dev/null @@ -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": [] -} \ No newline at end of file diff --git a/modes/02-connect-1234/docs/qa/desktop.png b/modes/02-connect-1234/docs/qa/desktop.png deleted file mode 100644 index 9f6891d..0000000 Binary files a/modes/02-connect-1234/docs/qa/desktop.png and /dev/null differ diff --git a/modes/02-connect-1234/docs/qa/layout.json b/modes/02-connect-1234/docs/qa/layout.json deleted file mode 100644 index 5fa8d60..0000000 --- a/modes/02-connect-1234/docs/qa/layout.json +++ /dev/null @@ -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 - } - ] - } - ] -} \ No newline at end of file diff --git a/modes/02-connect-1234/docs/qa/mobile-320.png b/modes/02-connect-1234/docs/qa/mobile-320.png deleted file mode 100644 index 68cfc44..0000000 Binary files a/modes/02-connect-1234/docs/qa/mobile-320.png and /dev/null differ diff --git a/modes/02-connect-1234/docs/qa/mobile-all-clear.png b/modes/02-connect-1234/docs/qa/mobile-all-clear.png deleted file mode 100644 index 6fd7802..0000000 Binary files a/modes/02-connect-1234/docs/qa/mobile-all-clear.png and /dev/null differ diff --git a/modes/02-connect-1234/docs/qa/mobile-help.png b/modes/02-connect-1234/docs/qa/mobile-help.png deleted file mode 100644 index 04518f6..0000000 Binary files a/modes/02-connect-1234/docs/qa/mobile-help.png and /dev/null differ diff --git a/modes/02-connect-1234/docs/qa/mobile-initial.png b/modes/02-connect-1234/docs/qa/mobile-initial.png deleted file mode 100644 index f6b5e8e..0000000 Binary files a/modes/02-connect-1234/docs/qa/mobile-initial.png and /dev/null differ diff --git a/modes/02-connect-1234/docs/qa/mobile-level-select.png b/modes/02-connect-1234/docs/qa/mobile-level-select.png deleted file mode 100644 index b6d376a..0000000 Binary files a/modes/02-connect-1234/docs/qa/mobile-level-select.png and /dev/null differ diff --git a/modes/02-connect-1234/docs/qa/mobile-level8.png b/modes/02-connect-1234/docs/qa/mobile-level8.png deleted file mode 100644 index 8015bfe..0000000 Binary files a/modes/02-connect-1234/docs/qa/mobile-level8.png and /dev/null differ diff --git a/modes/02-connect-1234/docs/qa/mobile-result.png b/modes/02-connect-1234/docs/qa/mobile-result.png deleted file mode 100644 index 550db8d..0000000 Binary files a/modes/02-connect-1234/docs/qa/mobile-result.png and /dev/null differ diff --git a/modes/02-connect-1234/docs/qa/production.json b/modes/02-connect-1234/docs/qa/production.json deleted file mode 100644 index 3d6180e..0000000 --- a/modes/02-connect-1234/docs/qa/production.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "url": "http://localhost:8003", - "debugPortAbsent": true, - "touch": { - "status": "won", - "score": "1,200 分", - "moves": "2" - }, - "resetToCleanLevel1": true -} \ No newline at end of file diff --git a/modes/02-connect-1234/docs/qa/validation.json b/modes/02-connect-1234/docs/qa/validation.json deleted file mode 100644 index 2b3e9fa..0000000 --- a/modes/02-connect-1234/docs/qa/validation.json +++ /dev/null @@ -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 - } -} diff --git a/modes/02-connect-1234/levels/campaign.json b/modes/02-connect-1234/levels/campaign.json deleted file mode 100644 index 9c44152..0000000 --- a/modes/02-connect-1234/levels/campaign.json +++ /dev/null @@ -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 - ] - ] - } - ] -} diff --git a/modes/02-connect-1234/levels/spawn.json b/modes/02-connect-1234/levels/spawn.json deleted file mode 100644 index d729b6d..0000000 --- a/modes/02-connect-1234/levels/spawn.json +++ /dev/null @@ -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 } -} diff --git a/modes/02-connect-1234/package-lock.json b/modes/02-connect-1234/package-lock.json deleted file mode 100644 index c7b2e06..0000000 --- a/modes/02-connect-1234/package-lock.json +++ /dev/null @@ -1,1033 +0,0 @@ -{ - "name": "connect-1234", - "version": "2.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "connect-1234", - "version": "2.0.0", - "devDependencies": { - "@types/node": "^22.7.0", - "esbuild": "^0.24.0", - "tsx": "^4.19.0", - "typescript": "^5.6.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz", - "integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz", - "integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz", - "integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz", - "integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz", - "integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz", - "integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz", - "integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz", - "integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz", - "integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz", - "integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz", - "integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz", - "integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz", - "integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz", - "integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz", - "integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz", - "integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz", - "integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz", - "integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz", - "integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz", - "integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz", - "integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", - "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz", - "integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz", - "integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz", - "integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz", - "integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@types/node": { - "version": "22.20.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.2.tgz", - "integrity": "sha512-xlvWf4Vs9n1PEVYwP1n4vvG07M6y8WgvJ2t0vbrWTmijsIHp1cS+uJ2kMIRdY3nHZK0nCYKrPeD171+SzF4/zw==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/esbuild": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz", - "integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.24.2", - "@esbuild/android-arm": "0.24.2", - "@esbuild/android-arm64": "0.24.2", - "@esbuild/android-x64": "0.24.2", - "@esbuild/darwin-arm64": "0.24.2", - "@esbuild/darwin-x64": "0.24.2", - "@esbuild/freebsd-arm64": "0.24.2", - "@esbuild/freebsd-x64": "0.24.2", - "@esbuild/linux-arm": "0.24.2", - "@esbuild/linux-arm64": "0.24.2", - "@esbuild/linux-ia32": "0.24.2", - "@esbuild/linux-loong64": "0.24.2", - "@esbuild/linux-mips64el": "0.24.2", - "@esbuild/linux-ppc64": "0.24.2", - "@esbuild/linux-riscv64": "0.24.2", - "@esbuild/linux-s390x": "0.24.2", - "@esbuild/linux-x64": "0.24.2", - "@esbuild/netbsd-arm64": "0.24.2", - "@esbuild/netbsd-x64": "0.24.2", - "@esbuild/openbsd-arm64": "0.24.2", - "@esbuild/openbsd-x64": "0.24.2", - "@esbuild/sunos-x64": "0.24.2", - "@esbuild/win32-arm64": "0.24.2", - "@esbuild/win32-ia32": "0.24.2", - "@esbuild/win32-x64": "0.24.2" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/tsx": { - "version": "4.23.13", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.13.tgz", - "integrity": "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "~0.28.0" - }, - "bin": { - "tsx": "dist/cli.mjs" - }, - "engines": { - "node": ">=18.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - } - }, - "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", - "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-arm": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", - "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", - "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", - "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", - "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/darwin-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", - "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", - "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", - "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-arm": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", - "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", - "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-ia32": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", - "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-loong64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", - "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", - "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", - "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", - "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-s390x": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", - "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", - "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", - "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", - "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", - "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", - "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/sunos-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", - "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/win32-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", - "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/win32-ia32": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", - "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/win32-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", - "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/esbuild": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", - "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.2", - "@esbuild/android-arm": "0.28.2", - "@esbuild/android-arm64": "0.28.2", - "@esbuild/android-x64": "0.28.2", - "@esbuild/darwin-arm64": "0.28.2", - "@esbuild/darwin-x64": "0.28.2", - "@esbuild/freebsd-arm64": "0.28.2", - "@esbuild/freebsd-x64": "0.28.2", - "@esbuild/linux-arm": "0.28.2", - "@esbuild/linux-arm64": "0.28.2", - "@esbuild/linux-ia32": "0.28.2", - "@esbuild/linux-loong64": "0.28.2", - "@esbuild/linux-mips64el": "0.28.2", - "@esbuild/linux-ppc64": "0.28.2", - "@esbuild/linux-riscv64": "0.28.2", - "@esbuild/linux-s390x": "0.28.2", - "@esbuild/linux-x64": "0.28.2", - "@esbuild/netbsd-arm64": "0.28.2", - "@esbuild/netbsd-x64": "0.28.2", - "@esbuild/openbsd-arm64": "0.28.2", - "@esbuild/openbsd-x64": "0.28.2", - "@esbuild/openharmony-arm64": "0.28.2", - "@esbuild/sunos-x64": "0.28.2", - "@esbuild/win32-arm64": "0.28.2", - "@esbuild/win32-ia32": "0.28.2", - "@esbuild/win32-x64": "0.28.2" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - } - } -} diff --git a/modes/02-connect-1234/package.json b/modes/02-connect-1234/package.json deleted file mode 100644 index 5448ad8..0000000 --- a/modes/02-connect-1234/package.json +++ /dev/null @@ -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" - } -} diff --git a/modes/02-connect-1234/scripts/build.mjs b/modes/02-connect-1234/scripts/build.mjs deleted file mode 100644 index c4ecd65..0000000 --- a/modes/02-connect-1234/scripts/build.mjs +++ /dev/null @@ -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/'); -} diff --git a/modes/02-connect-1234/scripts/check-cocos.mjs b/modes/02-connect-1234/scripts/check-cocos.mjs deleted file mode 100644 index 642dfbc..0000000 --- a/modes/02-connect-1234/scripts/check-cocos.mjs +++ /dev/null @@ -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); diff --git a/modes/02-connect-1234/scripts/export-cocos.mjs b/modes/02-connect-1234/scripts/export-cocos.mjs deleted file mode 100644 index 3f13455..0000000 --- a/modes/02-connect-1234/scripts/export-cocos.mjs +++ /dev/null @@ -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}`); diff --git a/modes/02-connect-1234/src/animation.ts b/modes/02-connect-1234/src/animation.ts deleted file mode 100644 index 8c80f7e..0000000 --- a/modes/02-connect-1234/src/animation.ts +++ /dev/null @@ -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; - } -} diff --git a/modes/02-connect-1234/src/cascade.ts b/modes/02-connect-1234/src/cascade.ts deleted file mode 100644 index 94a28f5..0000000 --- a/modes/02-connect-1234/src/cascade.ts +++ /dev/null @@ -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 }; -} diff --git a/modes/02-connect-1234/src/config.ts b/modes/02-connect-1234/src/config.ts deleted file mode 100644 index 00cceed..0000000 --- a/modes/02-connect-1234/src/config.ts +++ /dev/null @@ -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' }, -]; diff --git a/modes/02-connect-1234/src/flow-animation.ts b/modes/02-connect-1234/src/flow-animation.ts deleted file mode 100644 index c8e7105..0000000 --- a/modes/02-connect-1234/src/flow-animation.ts +++ /dev/null @@ -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; - } -} diff --git a/modes/02-connect-1234/src/flow-rules.ts b/modes/02-connect-1234/src/flow-rules.ts deleted file mode 100644 index 204037b..0000000 --- a/modes/02-connect-1234/src/flow-rules.ts +++ /dev/null @@ -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; -} diff --git a/modes/02-connect-1234/src/flow.ts b/modes/02-connect-1234/src/flow.ts deleted file mode 100644 index b2e0460..0000000 --- a/modes/02-connect-1234/src/flow.ts +++ /dev/null @@ -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(); } -} diff --git a/modes/02-connect-1234/src/game.ts b/modes/02-connect-1234/src/game.ts deleted file mode 100644 index 76150a1..0000000 --- a/modes/02-connect-1234/src/game.ts +++ /dev/null @@ -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'; - } -} diff --git a/modes/02-connect-1234/src/goals.ts b/modes/02-connect-1234/src/goals.ts deleted file mode 100644 index 6d7d3ba..0000000 --- a/modes/02-connect-1234/src/goals.ts +++ /dev/null @@ -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); } diff --git a/modes/02-connect-1234/src/input.ts b/modes/02-connect-1234/src/input.ts deleted file mode 100644 index bab2e5d..0000000 --- a/modes/02-connect-1234/src/input.ts +++ /dev/null @@ -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]; - } -} diff --git a/modes/02-connect-1234/src/levels.ts b/modes/02-connect-1234/src/levels.ts deleted file mode 100644 index a967d37..0000000 --- a/modes/02-connect-1234/src/levels.ts +++ /dev/null @@ -1,3 +0,0 @@ -import campaign from '../levels/campaign.json'; -import type { Level } from './types'; -export const LEVELS = campaign.levels as Level[]; diff --git a/modes/02-connect-1234/src/physics.ts b/modes/02-connect-1234/src/physics.ts deleted file mode 100644 index 27653da..0000000 --- a/modes/02-connect-1234/src/physics.ts +++ /dev/null @@ -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); -} diff --git a/modes/02-connect-1234/src/progress.ts b/modes/02-connect-1234/src/progress.ts deleted file mode 100644 index f99e09a..0000000 --- a/modes/02-connect-1234/src/progress.ts +++ /dev/null @@ -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; bestScore: number; bestNumber: number; sound: boolean } -import type { StoragePort } from './storage'; -export type { StoragePort } from './storage'; -export function fresh(): Progress { return { version: 1, levels: {}, bestScore: 0, bestNumber: 0, sound: true }; } -export function loadProgress(storage: StoragePort): Progress { - try { - const p = JSON.parse(storage.read(SAVE_KEY) || 'null'); - const next = fresh(); - if (!p || p.version !== 1) return next; - if (typeof p.sound === 'boolean') next.sound = p.sound; - for (let i = 1; i <= 10; i++) { - const v = p.levels?.[i]; - if (v && Number.isInteger(v.stars) && v.stars >= 1 && v.stars <= 3 && Number.isFinite(v.score) && v.score >= 0) - next.levels[i] = { stars: v.stars, score: v.score }; - } - if (Number.isFinite(p.bestScore) && p.bestScore >= 0) next.bestScore = p.bestScore; - if (Number.isInteger(p.bestNumber) && p.bestNumber >= 0) next.bestNumber = p.bestNumber; - return next; - } catch { return fresh(); } -} -export function saveProgress(storage: StoragePort, progress: Progress): boolean { - try { storage.write(SAVE_KEY, JSON.stringify(progress)); return true; } catch { return false; } -} -export function unlocked(p: Progress): number { - let level = 1; - while (level < 10 && p.levels[level]) level++; - return level; -} -export function starsFor(merges: number, par: number, usedShuffle: boolean): number { - return merges <= par && !usedShuffle ? 3 : merges <= par + 2 ? 2 : 1; -} diff --git a/modes/02-connect-1234/src/random.ts b/modes/02-connect-1234/src/random.ts deleted file mode 100644 index 672acc5..0000000 --- a/modes/02-connect-1234/src/random.ts +++ /dev/null @@ -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(items: T[]): T[] { - for (let i = items.length - 1; i > 0; i--) { - const j = Math.floor(this.next() * (i + 1)); - [items[i], items[j]] = [items[j], items[i]]; - } - return items; - } -} diff --git a/modes/02-connect-1234/src/record.ts b/modes/02-connect-1234/src/record.ts deleted file mode 100644 index 65def52..0000000 --- a/modes/02-connect-1234/src/record.ts +++ /dev/null @@ -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; } -} diff --git a/modes/02-connect-1234/src/rules.ts b/modes/02-connect-1234/src/rules.ts deleted file mode 100644 index 1c19dcc..0000000 --- a/modes/02-connect-1234/src/rules.ts +++ /dev/null @@ -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; -} diff --git a/modes/02-connect-1234/src/solver.ts b/modes/02-connect-1234/src/solver.ts deleted file mode 100644 index 69fe5cc..0000000 --- a/modes/02-connect-1234/src/solver.ts +++ /dev/null @@ -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] || []; -} diff --git a/modes/02-connect-1234/src/storage.ts b/modes/02-connect-1234/src/storage.ts deleted file mode 100644 index d9ed69f..0000000 --- a/modes/02-connect-1234/src/storage.ts +++ /dev/null @@ -1,2 +0,0 @@ -/** 宿主存储接口,Web / Cocos 各自注入实现。 */ -export interface StoragePort { read(key: string): string | null; write(key: string, value: string): void } diff --git a/modes/02-connect-1234/src/supply.ts b/modes/02-connect-1234/src/supply.ts deleted file mode 100644 index b715816..0000000 --- a/modes/02-connect-1234/src/supply.ts +++ /dev/null @@ -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 }; -} diff --git a/modes/02-connect-1234/src/types.ts b/modes/02-connect-1234/src/types.ts deleted file mode 100644 index de53eea..0000000 --- a/modes/02-connect-1234/src/types.ts +++ /dev/null @@ -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 } -export type Status = 'playing' | 'won' | 'lost' | 'stuck'; -export interface Snapshot { - balls: Ball[]; stats: Stats; movesLeft: number; nextId: number; - refillIndex: number; rng: number; shuffles: number; status: Status; history: number[][]; -} -export interface MergeResult { before: Ball[]; after: Ball[]; path: Ball[]; created: Ball; points: number; spawned: Ball[] } -export interface GoalView { text: string; current: number; target: number; complete: boolean } diff --git a/modes/02-connect-1234/src/view.ts b/modes/02-connect-1234/src/view.ts deleted file mode 100644 index 9f0891f..0000000 --- a/modes/02-connect-1234/src/view.ts +++ /dev/null @@ -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; -} diff --git a/modes/02-connect-1234/tests/flow.test.ts b/modes/02-connect-1234/tests/flow.test.ts deleted file mode 100644 index d904d3b..0000000 --- a/modes/02-connect-1234/tests/flow.test.ts +++ /dev/null @@ -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); -}); diff --git a/modes/02-connect-1234/tests/legacy.test.ts b/modes/02-connect-1234/tests/legacy.test.ts deleted file mode 100644 index 060e5e4..0000000 --- a/modes/02-connect-1234/tests/legacy.test.ts +++ /dev/null @@ -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); -}); diff --git a/modes/02-connect-1234/tools/browser-flow-layout.mjs b/modes/02-connect-1234/tools/browser-flow-layout.mjs deleted file mode 100644 index f44bf6b..0000000 --- a/modes/02-connect-1234/tools/browser-flow-layout.mjs +++ /dev/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;iwindow.__balloons.snapshot().stats.turns),1);assert(!await page.evaluate(()=>window.__balloons.animating())); -// 减少动态效果下的横幅仍可显示(不依赖 CSS keyframes)。 -const bannerStyle=await page.evaluate(()=>{const el=document.querySelector('#combo-banner');el.classList.add('show');const style=getComputedStyle(el);return{opacity:style.opacity,animation:style.animationName};}); -assert.equal(bannerStyle.opacity,'1');assert.equal(bannerStyle.animation,'none'); -await page.cdp('Emulation.setEmulatedMedia',{features:[]}); -await writeFile(dir+'/layout.json',JSON.stringify({layouts,reducedMotion:true,keyboardModal:true},null,2)+'\n'); -console.log(JSON.stringify({layouts:layouts.map(l=>({width:l.width,height:l.height,scrollHeight:l.scrollHeight,stageHeight:l.stageHeight})),reducedMotion:true})); diff --git a/modes/02-connect-1234/tools/browser-flow-production.mjs b/modes/02-connect-1234/tools/browser-flow-production.mjs deleted file mode 100644 index 41c4699..0000000 --- a/modes/02-connect-1234/tools/browser-flow-production.mjs +++ /dev/null @@ -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;idocument.querySelector('#app').dataset.animating==='false',undefined,{timeout:7000}); -const result=await page.evaluate(()=>({turns:Number(document.querySelector('#app').dataset.turns),score:document.querySelector('#score').textContent,errors:window.qaErrors,debugExposed:typeof window.__balloons!=='undefined'})); -assert.equal(result.turns,1);assert(Number(result.score.replaceAll(',',''))>0);assert.deepEqual(result.errors,[]); -await page.screenshot({path:dir+'/production-merge.png'}); -await page.reload();assert.equal(await page.evaluate(()=>document.querySelector('#best-score').textContent),result.score); -await page.click('#help'); -await page.waitForFunction(()=>document.querySelector('#sheet').getAnimations().every(a=>a.playState==='finished'),undefined,{timeout:3000}); -await page.screenshot({path:dir+'/help.png'});await page.click('button[aria-label="关闭面板"]'); -// 仅恢复本次正式包验收前的 v2 纪录;保留其它存档。 -await page.evaluate(({key,v2})=>{if(v2===null)localStorage.removeItem(key);else localStorage.setItem(key,v2);},{key,v2:backup.v2}); -assert.equal(await page.evaluate(()=>localStorage.getItem('mode:02-connect-1234:v1')),backup.v1); -await page.reload(); -await page.screenshot({path:dir+'/mobile.png'}); -await page.cdp('Emulation.setDeviceMetricsOverride',{width:1280,height:900,deviceScaleFactor:2,mobile:false}); -await page.waitForFunction(()=>innerWidth===1280,undefined,{timeout:3000});await page.screenshot({path:dir+'/desktop.png'}); -await page.cdp('Emulation.setDeviceMetricsOverride',{width:390,height:844,deviceScaleFactor:2,mobile:true}); -await page.waitForFunction(()=>innerWidth===390,undefined,{timeout:3000}); -await writeFile(dir+'/production.json',JSON.stringify({...result,persistence:true,originalRecordRestored:true,legacyRecordPreserved:true,url:await page.url()},null,2)+'\n'); -console.log({...result,persistence:true,originalRecordRestored:true,url:await page.url()}); diff --git a/modes/02-connect-1234/tools/browser-flow.mjs b/modes/02-connect-1234/tools/browser-flow.mjs deleted file mode 100644 index 2cf00f6..0000000 --- a/modes/02-connect-1234/tools/browser-flow.mjs +++ /dev/null @@ -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!window.__balloons.animating(),undefined,{timeout:7000}); - const after=await snapshot(); assert.equal(after.stats.turns,before.stats.turns+1); assert(after.stats.score>before.stats.score); - assert(await page.evaluate(()=>document.querySelector('#modal').hidden)); - moves.push({turn:after.stats.turns,score:after.stats.score,combo:after.stats.lastCombo,peak:after.stats.maxNumber,flown:after.stats.flown,feverLeft:after.feverLeft}); - if(i===0){ - await page.screenshot({path:dir+'/first-merge.png'}); - await page.click('#undo'); assert.deepEqual(await snapshot(),before); - await swipe(await targets()); await page.waitForFunction(()=>!window.__balloons.animating(),undefined,{timeout:7000}); - assert.deepEqual(await snapshot(),after); - } - if(after.feverLeft===5 && !moves.slice(0,-1).some(m=>m.feverLeft===5)) await page.screenshot({path:dir+'/rainbow.png'}); - if(after.stats.flown===1 && !moves.slice(0,-1).some(m=>m.flown>0)) await page.screenshot({path:dir+'/flight.png'}); -} -const beforeBreeze=await snapshot(); await page.click('#shuffle'); -await page.waitForFunction(()=>!window.__balloons.animating(),undefined,{timeout:7000}); -assert.deepEqual((await snapshot()).stats,beforeBreeze.stats); -await page.click('#undo'); assert.deepEqual(await snapshot(),beforeBreeze); -const errors=await page.evaluate(()=>window.qaErrors); assert.deepEqual(errors,[]); -assert(moves.some(m=>m.combo>=3)); assert(moves.some(m=>m.flown>0)); assert(moves.some(m=>m.feverLeft>0)); -await writeFile(dir+'/browser.json',JSON.stringify({viewport:[390,844],input:'CDP touch events',invalidCancelBacktrack:true,undoDeterministic:true,breezeUndo:true,moves,errors},null,2)+'\n'); -console.log(JSON.stringify({gestures:moves.length,final:moves.at(-1),errors})); -console.log(await page.snapshot()); diff --git a/modes/02-connect-1234/tools/browser-v1-layout.mjs b/modes/02-connect-1234/tools/browser-v1-layout.mjs deleted file mode 100644 index 4194656..0000000 --- a/modes/02-connect-1234/tools/browser-v1-layout.mjs +++ /dev/null @@ -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;iwindow.__garden.snapshot().stats.merges===n+1&&!window.__garden.animating(),data.merges,{timeout:5000}); -}; -await touchHint(); -const after = await page.evaluate(() => window.__garden.snapshot()); -await page.click('#undo'); -if (JSON.stringify(await page.evaluate(()=>window.__garden.snapshot()))!==JSON.stringify(before)) throw new Error('undo not exact'); -await touchHint(); -if (JSON.stringify(await page.evaluate(()=>window.__garden.snapshot()))!==JSON.stringify(after)) throw new Error('redo not deterministic'); -await page.click('#undo'); -await page.click('#shuffle'); -const shuffled = await page.evaluate(()=>window.__garden.snapshot()); -if (shuffled.shuffles!==0 || shuffled.movesLeft!==before.movesLeft || JSON.stringify(shuffled.balls.map(b=>b.n).sort())!==JSON.stringify(before.balls.map(b=>b.n).sort())) throw new Error('shuffle mismatch'); -await page.click('#undo'); -if (JSON.stringify(await page.evaluate(()=>window.__garden.snapshot()))!==JSON.stringify(before)) throw new Error('shuffle undo mismatch'); -await page.click('#levels'); await page.click('[data-action="endless"]'); -await touchHint(); -if ((await page.evaluate(()=>window.__garden.snapshot())).movesLeft!==-1) throw new Error('endless consumed move'); -await page.click('#levels'); await page.click('[data-level="8"]'); -const layouts=[]; -for (const [width,height,mobile] of [[320,568,true],[360,740,true],[390,844,true],[430,932,true],[844,390,true],[1280,900,false]]) { - await page.cdp('Emulation.setDeviceMetricsOverride',{width,height,deviceScaleFactor:2,mobile}); - await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))); - const layout=await page.evaluate(()=>({width:innerWidth,height:innerHeight,overflow:document.documentElement.scrollWidth>innerWidth+1,bodyHeight:document.documentElement.scrollHeight,buttons:[...document.querySelectorAll('.tools button,.header-actions button')].map(b=>({id:b.id,w:b.getBoundingClientRect().width,h:b.getBoundingClientRect().height}))})); - if(layout.overflow)throw new Error(`horizontal overflow at ${width}`); - if(layout.buttons.some(b=>b.w<44||b.h<44))throw new Error(`small touch target at ${width}`); - layouts.push(layout); - if(width===320||width===1280)await page.screenshot({path:`${root}/${width===320?'mobile-320':'desktop'}.png`}); -} -await page.cdp('Emulation.setDeviceMetricsOverride',{width:390,height:844,deviceScaleFactor:2,mobile:true}); -await page.click('#help'); -await page.waitForFunction(()=>!document.getElementById('sheet').getAnimations().some(a=>a.playState==='running')); -await page.screenshot({path:`${root}/mobile-help.png`}); -await page.keyboard.press('Escape'); -if(!(await page.evaluate(()=>document.getElementById('modal').hidden)))throw new Error('escape not closed'); -await fs.writeFile(`${root}/layout.json`,JSON.stringify({checks:['刷新保留十关成绩','撤销精确恢复盘面及补球','重走结果确定一致','打乱保留数字和步数','撤销恢复打乱库存','自由模式无步数限制','帮助可用 Escape 关闭'],layouts},null,2)); -console.log(JSON.stringify(layouts)); -console.log(await page.snapshot()); diff --git a/modes/02-connect-1234/tools/browser-v1-smoke.mjs b/modes/02-connect-1234/tools/browser-v1-smoke.mjs deleted file mode 100644 index 2cd2d5a..0000000 --- a/modes/02-connect-1234/tools/browser-v1-smoke.mjs +++ /dev/null @@ -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()); diff --git a/modes/02-connect-1234/tools/design.ts b/modes/02-connect-1234/tools/design.ts deleted file mode 100644 index afc24c1..0000000 --- a/modes/02-connect-1234/tools/design.ts +++ /dev/null @@ -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}`); -} diff --git a/modes/02-connect-1234/tools/validate-flow.ts b/modes/02-connect-1234/tools/validate-flow.ts deleted file mode 100644 index b73a640..0000000 --- a/modes/02-connect-1234/tools/validate-flow.ts +++ /dev/null @@ -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。`); diff --git a/modes/02-connect-1234/tools/validate.ts b/modes/02-connect-1234/tools/validate.ts deleted file mode 100644 index b118f67..0000000 --- a/modes/02-connect-1234/tools/validate.ts +++ /dev/null @@ -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`); diff --git a/modes/02-connect-1234/tsconfig.core.json b/modes/02-connect-1234/tsconfig.core.json deleted file mode 100644 index 9d34c07..0000000 --- a/modes/02-connect-1234/tsconfig.core.json +++ /dev/null @@ -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"] -} diff --git a/modes/02-connect-1234/tsconfig.json b/modes/02-connect-1234/tsconfig.json deleted file mode 100644 index a995437..0000000 --- a/modes/02-connect-1234/tsconfig.json +++ /dev/null @@ -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"] -} diff --git a/modes/02-connect-1234/web/audio.ts b/modes/02-connect-1234/web/audio.ts deleted file mode 100644 index 9bb3437..0000000 --- a/modes/02-connect-1234/web/audio.ts +++ /dev/null @@ -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); - } - -} diff --git a/modes/02-connect-1234/web/canvas-painter.ts b/modes/02-connect-1234/web/canvas-painter.ts deleted file mode 100644 index d940776..0000000 --- a/modes/02-connect-1234/web/canvas-painter.ts +++ /dev/null @@ -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(); - } -} diff --git a/modes/02-connect-1234/web/index.html b/modes/02-connect-1234/web/index.html deleted file mode 100644 index 383b1e6..0000000 --- a/modes/02-connect-1234/web/index.html +++ /dev/null @@ -1,61 +0,0 @@ - - - - - - - - 连连气球 · POP & LINK - - - - - - -
-
-

连连气球

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

顺号连,同号也能连

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

一划,就连起来

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

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

2

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

3

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

4

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

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

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

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

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

${saveAvailable ? '' : '

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

'}`); -} -canvas.addEventListener('pointerdown', e => { - if (e.button !== 0 || !e.isPrimary || pointer !== null || animation || !modal.hidden) return; - e.preventDefault(); sound.unlock(); pointer = e.pointerId; canvas.setPointerCapture(pointer); hints = []; - gesture.down(coordinates(e)); updatePreview(); - if (gesture.chain.length) sound.note(game.balls.find(b => b.id === gesture.chain[0])!.n); -}); -canvas.addEventListener('pointermove', e => { - if (e.pointerId !== pointer || animation) return; - const old = gesture.chain.join(','); gesture.move(coordinates(e)); - if (old !== gesture.chain.join(',')) { const n = game.balls.find(b => b.id === gesture.chain[gesture.chain.length - 1])?.n; if (n) sound.note(n); updatePreview(); } -}); -canvas.addEventListener('pointerup', e => { - if (e.pointerId !== pointer) return; gesture.move(coordinates(e)); const ids = gesture.up(); pointer = null; - if (canvas.hasPointerCapture(e.pointerId)) canvas.releasePointerCapture(e.pointerId); commit(ids); -}); -canvas.addEventListener('pointercancel', e => { if (e.pointerId === pointer) cancel(); }); -canvas.addEventListener('lostpointercapture', e => { if (e.pointerId === pointer) cancel(); }); -window.addEventListener('blur', cancel); window.addEventListener('resize', () => { cancel(); resize(); }); -document.addEventListener('visibilitychange', () => { if (document.hidden) cancel(); }); -new ResizeObserver(resize).observe($('stage')); -$('sound').addEventListener('click', () => { sound.enabled = !sound.enabled; record.sound = sound.enabled; sound.unlock(); updateSound(); save(); }); -$('help').addEventListener('click', showHelp); $('records').addEventListener('click', () => showRecords()); -$('undo').addEventListener('click', undo); $('restart').addEventListener('click', () => showRecords(true)); -$('shuffle').addEventListener('click', () => { if (!animation) { cancel(); begin(game.shuffle()); } }); -$('hint').addEventListener('click', () => { if (animation) return; cancel(); hints = game.hint(); hintUntil = performance.now() + 7000; if (hints.length) toast('沿白色标记连起来,松手看它自动接力。'); else toast('吹口气,换个位置试试。'); }); -modal.addEventListener('click', e => { if (e.target === modal) closeSheet(); }); -sheet.addEventListener('click', e => { const action = (e.target as HTMLElement).closest('button')?.dataset.action; if (action === 'close') closeSheet(); if (action === 'new') newRound(); }); -document.addEventListener('keydown', e => { - if (!modal.hidden) { - if (e.key === 'Escape') { e.preventDefault(); closeSheet(); } - if (e.key === 'Tab') { const buttons = [...sheet.querySelectorAll('button:not(:disabled)')], first = buttons[0], last = buttons[buttons.length - 1]; - if (e.shiftKey && (document.activeElement === first || document.activeElement === sheet)) { e.preventDefault(); last?.focus(); } - else if (!e.shiftKey && (document.activeElement === last || document.activeElement === sheet)) { e.preventDefault(); first?.focus(); } } - } else if (!e.ctrlKey && !e.metaKey && !e.altKey) { - if (e.key.toLowerCase() === 'z') undo(); if (e.key.toLowerCase() === 'h') $('hint').click(); if (e.key === 'Escape') cancel(); - } -}); -updateSound(); updateHUD(); resize(); requestAnimationFrame(frame); -if (__DEV__) Object.assign(window, { __balloons: { snapshot: () => game.snapshot(), hint: () => game.hint(), transform: () => ({ ...transform }), animating: () => !!animation } }); diff --git a/modes/02-connect-1234/web/style.css b/modes/02-connect-1234/web/style.css deleted file mode 100644 index 0d71ff9..0000000 --- a/modes/02-connect-1234/web/style.css +++ /dev/null @@ -1,6 +0,0 @@ -:root{font-family:"Avenir Next","PingFang SC","Microsoft YaHei",sans-serif;color:#40678e;background:#eaf5ff;font-synthesis:none;-webkit-tap-highlight-color:transparent;font-weight:500}*{box-sizing:border-box}body{margin:0;min-height:100svh;display:flex;align-items:center;justify-content:center;background:radial-gradient(ellipse at 15% 20%,#ffffff 0,transparent 48%),radial-gradient(ellipse at 84% 90%,#eae8ff 0,transparent 45%),#eaf5ff}button{font:inherit;color:inherit;cursor:pointer;border:0;-webkit-user-select:none;user-select:none}button:focus-visible{outline:3px solid #659de6;outline-offset:3px}button:disabled{opacity:.36;cursor:default}button:not(:disabled):active{transform:scale(.97)}svg{width:22px;height:22px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round;flex-shrink:0}.symbols{position:absolute;width:0;height:0;overflow:hidden}.app{width:432px;height:min(890px,calc(100svh - 32px));min-height:740px;padding:22px 24px 16px;display:flex;flex-direction:column;gap:10px;background:#f9fcff;border:1px solid #fff;border-radius:30px;box-shadow:0 24px 80px #5f8fb518;position:relative;overflow:hidden}.brand-bar{height:44px;display:flex;align-items:center;justify-content:space-between;flex-shrink:0}.brand{display:flex;gap:8px;align-items:center}.brand-mark{height:40px;width:34px;color:#77b7f5;display:grid;place-items:center}.brand-mark svg{width:27px;height:31px;stroke-width:1.8}.brand h1{margin:0;font-size:20px;letter-spacing:2px;font-weight:750;color:#46739f}.brand div>span{display:block;font-size:8px;letter-spacing:2.2px;color:#a0b6cb;margin-top:2px;font-weight:650}.header-actions{display:flex}.icon-button{width:44px;height:44px;display:grid;place-items:center;background:transparent;color:#94adc4;border-radius:50%}.icon-button:hover{background:#eaf4ff}.scoreboard{display:flex;align-items:center;justify-content:space-between;min-height:88px;padding:4px 4px 0;flex-shrink:0}.score-main{display:grid;grid-template-columns:auto 1fr;align-items:baseline;column-gap:10px}.eyebrow{font-size:10px;color:#7c94ac;letter-spacing:1px}.score-main .eyebrow{grid-column:1/-1}.score-main>strong{font-size:48px;font-weight:700;color:#477cac;line-height:1.15;letter-spacing:-2px;font-variant-numeric:tabular-nums}.best-line{font-size:9px;color:#a2b3c7;grid-column:1/-1;margin-top:1px}.best-line b{font-weight:500;font-variant-numeric:tabular-nums}.collection{background:transparent;padding:0;min-width:60px;min-height:75px;display:flex;flex-direction:column;gap:8px;align-items:center;color:#9aaec0;font-size:9px}.best-balloon{position:relative;display:grid;place-items:center;width:42px;height:46px;background:#f9d16c;border-radius:48% 48% 47% 47%;color:#fff;font-size:25px;font-weight:750;text-shadow:0 1px #d7a847}.best-balloon:before{content:"";width:12px;height:5px;position:absolute;background:#ffffffad;left:8px;top:7px;border-radius:50%;transform:rotate(-18deg)}.best-balloon:after{content:"";position:absolute;bottom:-4px;left:17px;border-left:4px solid transparent;border-right:4px solid transparent;border-bottom:6px solid #ecc059}.flow-bar{display:flex;align-items:center;gap:23px;background:#fff;border:1px solid #e7eff8;border-radius:15px;padding:12px 14px;flex-shrink:0;height:61px}.fever-block{flex:1;min-width:0}.fever-label{display:flex;justify-content:space-between;font-size:10px;color:#6d89a4;margin-bottom:7px}.fever-label b{font-weight:500;font-size:9px;white-space:nowrap;margin-left:6px}.fever-track{height:6px;border-radius:5px;background:#edf2f9;overflow:hidden}.fever-track>i{display:block;height:100%;width:0;border-radius:5px;background:linear-gradient(90deg,#83bdfa,#b79fee,#f2a0c2,#f5ce74);transition:width .35s}.upcoming{display:flex;align-items:center;gap:9px;border-left:1px solid #eaf0f7;padding-left:13px}.upcoming>span{font-size:9px;color:#a1b2c5}.upcoming>div{display:flex;gap:4px}.next-ball{height:23px;width:21px;display:inline-grid;place-items:center;position:relative;border-radius:48%;color:#fff;font-weight:700;font-size:12px;box-shadow:inset 0 1px 0 #ffffff60}.next-ball:after{position:absolute;content:"";width:4px;height:3px;border-radius:1px;background:inherit;bottom:-2px;left:8px}.stage{flex:1;min-height:300px;border:1.5px solid #dfecf9;border-radius:25px;background:linear-gradient(180deg,#f3f9ff 0,#fafdff 45%,#f0f7ff 100%);position:relative;overflow:hidden;isolation:isolate;transition:border-color .3s,background .3s}.stage:before,.stage:after{content:"";position:absolute;z-index:-1;background:#ffffffb5;height:29px;width:99px;border-radius:60%;left:-27px;top:90px;box-shadow:22px -13px 0 -1px #ffffffb5}.stage:after{left:auto;right:-28px;top:36px;width:80px;opacity:.7}canvas{display:block;width:100%;height:100%;touch-action:none;-webkit-user-select:none;user-select:none}.welcome{position:absolute;top:12%;left:50%;transform:translateX(-50%);text-align:center;white-space:nowrap;pointer-events:none;transition:opacity .3s}.welcome>span{font-size:18px;color:#86aed3;letter-spacing:2px;font-weight:650}.welcome i{font-size:10px;color:#afc2d3;font-weight:400;letter-spacing:0;padding:0 5px;font-style:normal}.welcome p{font-size:10px;color:#a3b7cb;letter-spacing:1px;margin:8px 0}.welcome.hidden{opacity:0}.stage-caption{position:absolute;top:14px;left:0;right:0;text-align:center;font-size:9px;color:#87a3bd;letter-spacing:1px;pointer-events:none}.chain-preview{height:31px;display:flex;justify-content:center;align-items:center;gap:5px;flex-shrink:0;font-size:11px;color:#6e8cab;white-space:nowrap;min-width:0}.chain-preview .mini-ball{display:inline-grid;place-items:center;border-radius:48%;color:#fff;min-width:22px;height:24px;font-size:13px;font-weight:700}.chain-preview .gain{font-size:10px;color:#aaa0cf;margin-left:5px}.chain-preview .predict{padding:4px 7px;border-radius:7px;background:#efe9fd;color:#9876c7;font-size:10px}.tools{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;flex-shrink:0}.tools button{min-height:61px;border-radius:15px;border:1px solid #e4edf7;background:#f0f6fc;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;padding:8px 1px;color:#6487a9}.tools button>span{font-size:11px}.tools svg{width:20px;height:20px}.tools .hint-button{background:#82b6ee;border-color:#82b6ee;color:#fff;box-shadow:0 3px 0 #6094cc19}.game-footer{display:flex;align-items:center;justify-content:space-between;font-size:8px;letter-spacing:.2px;color:#afbfce;min-height:13px;flex-shrink:0}.toast{position:absolute;bottom:28px;left:50%;transform:translateX(-50%) translateY(8px);border-radius:12px;padding:10px 14px;max-width:94%;width:max-content;background:#ffffffed;border:1px solid #dcebf9;color:#749abc;font-size:11px;line-height:1.6;text-align:center;pointer-events:none;opacity:0;transition:opacity .2s,transform .2s;z-index:4;box-shadow:0 4px 18px #7facd112}.toast.show{opacity:1;transform:translateX(-50%) translateY(0)}.combo-banner{position:absolute;top:17%;left:0;right:0;text-align:center;pointer-events:none;z-index:5;opacity:0}.combo-banner.show{opacity:1;animation:combo-pop .85s both}.combo-banner strong{font-size:31px;font-weight:800;letter-spacing:2px;color:#a083d5;text-shadow:0 2px 0 white;display:block}.combo-banner span{display:block;font-size:10px;color:#a7a7cd;letter-spacing:2px;margin-top:3px}.combo-banner.rainbow strong{background:linear-gradient(90deg,#6ca9e9,#b17fdf,#ef98b5,#e9b357);background-clip:text;-webkit-background-clip:text;color:transparent;text-shadow:none}.score-pop{pointer-events:none;position:absolute;color:#669bd1;font-size:24px;font-weight:750;text-shadow:0 2px white;z-index:6;animation:score-up .7s both}.spark{pointer-events:none;position:absolute;width:5px;height:9px;background:var(--spark-color);border-radius:3px;z-index:5;animation:spark .65s ease-out both}.app.fever .stage{border-color:#cfbef0;background:linear-gradient(150deg,#f7f0ff,#fafdff 45%,#fff3f4)}.app.fever .fever-label{color:#a08ac7}.app.fever .fever-track>i{background:linear-gradient(90deg,#79baff,#ba99f0,#f895bf,#f4ce70)}.desktop-note{position:absolute;right:calc(50% + 285px);top:23%;width:275px;color:#90b1cf;padding-top:140px}.desktop-note p{font-size:10px;letter-spacing:2.2px;color:#a2b9cf;font-weight:600}.desktop-note h2{font-size:26px;line-height:1.8;color:#7e9fbc;font-weight:500;letter-spacing:2px;margin:22px 0 18px}.desktop-note>span{font-size:10px;letter-spacing:1px;color:#adc3d5}.floating-balloon{position:absolute;top:8px;width:68px;height:76px;display:grid;place-items:center;border-radius:48%;font-size:34px;font-weight:700;color:#fff;background:#a3cdf5;transform:rotate(-12deg)}.floating-balloon:before{content:"";position:absolute;top:13px;left:12px;width:23px;height:8px;background:#ffffff88;border-radius:50%;transform:rotate(-18deg)}.floating-balloon:after{content:"";height:65px;width:16px;border-left:1px solid #a5b9cd;position:absolute;top:79px;left:33px;border-radius:40%}.b-two{left:78px;top:-14px;background:#f5a7b8;transform:rotate(10deg)}.b-three{left:149px;top:21px;background:#acddcc;transform:rotate(-3deg)}.modal-backdrop{position:fixed;inset:0;background:#6187ac30;z-index:20;display:flex;align-items:center;justify-content:center;padding:20px;backdrop-filter:blur(5px)}[hidden]{display:none!important}.sheet{width:405px;max-height:90svh;overflow-y:auto;border-radius:27px;padding:26px;background:#fbfdff;box-shadow:0 20px 80px #3d70902a;animation:sheet-in .2s ease-out;outline:none}.sheet-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px}.sheet h2{font-size:23px;font-weight:650;margin:0;letter-spacing:1px;color:#5b86ad}.sheet p{font-size:12px;line-height:1.9;color:#657f99}.sheet .eyebrow{font-size:9px;margin-bottom:6px;display:block}.help-chain{display:flex;justify-content:center;align-items:center;gap:9px;background:#eef6ff;border-radius:17px;padding:18px 8px;margin:18px 0}.help-ball{display:grid;place-items:center;width:39px;height:42px;border-radius:48%;font-size:22px;font-weight:700;color:#fff}.help-rule{display:flex;gap:11px;align-items:flex-start;margin:16px 0}.help-rule>span{flex-shrink:0;background:#eaf3fe;color:#7fa8d2;width:25px;height:25px;border-radius:50%;display:grid;place-items:center;font-size:11px}.help-rule p{margin:0;font-size:12px}.help-rule b{color:#507494;font-weight:650}.primary,.secondary{display:flex;align-items:center;justify-content:center;gap:9px;border-radius:14px;min-height:48px;padding:12px 16px;width:100%;font-size:13px;font-weight:600;letter-spacing:.5px}.primary{color:#fff;background:#83b4eb;box-shadow:0 3px 0 #5b90c81a}.secondary{background:#edf5fd;border:1px solid #e2edf8;color:#8ba9c6;margin-top:10px}.record-hero{text-align:center;margin:22px 0}.record-hero>strong{font-size:43px;font-weight:650;color:#5988b4;letter-spacing:-1px;display:block}.record-hero>span{font-size:10px;color:#a3b6c7}.record-grid{display:grid;grid-template-columns:repeat(3,1fr);background:#eff6fd;border-radius:16px;margin:18px 0;padding:17px 3px;text-align:center}.record-grid strong{display:block;font-size:25px;color:#769bbc;font-weight:600}.record-grid span{font-size:9px;color:#a6bacb}.sky-collection{display:flex;justify-content:space-around;gap:12px;padding:20px 8px;margin-bottom:15px}.sky-item{text-align:center;font-size:9px;color:#a9b7cc}.sky-item .help-ball{margin-bottom:8px;opacity:.25}.sky-item.earned .help-ball{opacity:1}.sheet .small-note{text-align:center;font-size:10px;line-height:1.8;margin:15px 0}.close{width:44px;height:44px}.save-note{font-size:9px!important;color:#c19c78!important}@keyframes combo-pop{0%{opacity:0;transform:translateY(8px) scale(.86)}18%{opacity:1;transform:translateY(0) scale(1.04)}65%{opacity:1;transform:translateY(0) scale(1)}100%{opacity:0;transform:translateY(-10px)}}@keyframes score-up{0%{opacity:0;transform:translate(-50%,4px) scale(.8)}20%{opacity:1;transform:translate(-50%,-4px) scale(1.05)}100%{opacity:0;transform:translate(-50%,-57px) scale(.9)}}@keyframes spark{0%{opacity:1;transform:translate(-50%,-50%) scale(1)}100%{opacity:0;transform:translate(calc(-50% + var(--dx)),calc(-50% + var(--dy))) rotate(130deg) scale(.25)}}@keyframes sheet-in{from{opacity:0;transform:translateY(18px)}to{opacity:1;transform:translateY(0)}} -@media(max-width:1090px){.desktop-note{display:none}} -@media(max-width:520px){body{align-items:flex-start;background:#f9fcff}.app{width:100%;height:100svh;min-height:740px;border-radius:0;border:0;box-shadow:none;padding:calc(13px + env(safe-area-inset-top)) 18px calc(12px + env(safe-area-inset-bottom));gap:9px}.modal-backdrop{align-items:flex-end;padding:0}.sheet{width:100%;max-width:480px;max-height:92svh;border-radius:27px 27px 0 0;padding:24px 24px calc(24px + env(safe-area-inset-bottom))}.scoreboard{min-height:80px}.score-main>strong{font-size:46px}.flow-bar{gap:12px;padding:11px 12px}} -@media(max-height:750px){.app{min-height:650px;gap:7px;padding-top:10px;padding-bottom:10px}.brand-bar{height:36px}.scoreboard{min-height:68px;padding-top:0}.score-main>strong{font-size:39px}.collection{min-height:61px}.best-balloon{width:34px;height:38px;font-size:22px}.best-balloon:after{left:13px}.flow-bar{height:53px}.tools button{min-height:55px}.chain-preview{height:27px}.game-footer{font-size:7.5px}.stage{min-height:300px}} -@media(max-width:350px){.app{padding-left:12px;padding-right:12px}.flow-bar{gap:9px;padding-left:10px;padding-right:10px}.upcoming{gap:6px;padding-left:9px}.upcoming>span{font-size:8px}.next-ball{width:19px;height:21px}.tools{gap:7px}.chain-preview{font-size:10px}.chain-preview .gain{font-size:9px}.sheet{padding-left:19px;padding-right:19px}} -@media(prefers-reduced-motion:reduce){*,*:before,*:after{animation:none!important;transition:none!important}} diff --git a/modes/03-hex-walls/docs/GDD.md b/modes/03-hex-walls/docs/GDD.md index 10c3a15..5cf31b4 100644 --- a/modes/03-hex-walls/docs/GDD.md +++ b/modes/03-hex-walls/docs/GDD.md @@ -1,6 +1,6 @@ # 玩法三 · 六边一笔 -本文只描述**玩法三**。玩法一、玩法二有自己的目录和 GDD,见仓库 [`docs/PRODUCT.md`](../../../docs/PRODUCT.md)。 +本文只描述**玩法三**。玩法一(数字一笔)有自己的目录和 GDD,见仓库 [`docs/PRODUCT.md`](../../../docs/PRODUCT.md)。 在六边形拼板上画一条哈密顿路径。格子之间的边上可以有墙,墙挡住那一条邻接。预先写好的数字,必须刚好在那一步走到。 diff --git a/shared/README.md b/shared/README.md index 5d129a1..fa807ce 100644 --- a/shared/README.md +++ b/shared/README.md @@ -1,6 +1,6 @@ # shared · 壳 -三个玩法共用的外壳,不含任何过关规则。 +两个玩法共用的外壳,不含任何过关规则。 可以放: