移除玩法二(连连气球),产品收敛为两个玩法

- 删除 modes/02-connect-1234 全部内容(历史见首个提交 8026e46)
- 重写根 README:共同规则、两玩法差异、仓库结构、快速开始、关卡、工程约定、文档索引、路线
- PRODUCT.md / design.md / shared/README / 两份 GDD / 玩法一 README 去掉玩法二引用
- 保留 01 / 03 目录编号并注明原因(存档桶名、Cocos 资源路径)
This commit is contained in:
cabinw
2026-09-16 14:47:04 +08:00
parent 8026e4628d
commit 56ac6f317f
86 changed files with 182 additions and 6390 deletions
+1 -1
View File
@@ -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)。
关卡、资源、文案全部原创,不使用第三方题库或素材。
+2 -2
View File
@@ -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. 留给实现的缺口
-5
View File
@@ -1,5 +0,0 @@
node_modules/
web/dist/
release/
cocos/export/
.DS_Store
-57
View File
@@ -1,57 +0,0 @@
# 玩法二 · 连连气球
竖屏触摸连消合成游戏。连接顺号或同号气球,松手变大,周围和新掉落的气球自动接力。当前版本以连续合成、造大数、放飞为主循环,无关卡、步数或计时限制。
## 直接玩
```bash
cd modes/02-connect-1234
npm install
npm run dev
```
开发预览 **http://localhost:8002**;当前正式包 **http://localhost:8003**。手机同一 Wi-Fi 访问终端打印的局域网地址。可用 `npm run dev -- --port 8012` 改端口。
```bash
npm run check # 类型、规则、连续游玩抽样、物理、生产构建
npm run cocos:check # 导出核心和组件,检查本机 Creator 真实类型
```
`npm run build` 生成独立 `release/`,可以静态托管或离线打开 `release/index.html`。重新启动正式包预览:`python3 -m http.server 8003 --directory release`。无运行时第三方依赖、网络素材或服务器接口。
## 现在怎么玩
- 连接至少 3 颗相邻气球:`1→2→3`、`3→2→1` 或 `2→2→2`;两种规则不能混在一条里。
- 松手在末端合成最大数 +1。新气球能接上周围或刚掉下来的数字时,自动继续,最多 3 连消。
- 长链与连消攒满彩虹能量,接下来 5 次手势双倍得分,最多 4 连消;不限时间。
- 合成 10 自动放飞,额外 +10,000 分,腾出空间继续。
- 补球按连续数字成袋准备,试算落点后优先选择容易接力的位置;无路可连时自动微风补救。
- 提示、撤销、吹口气均免费。滑回上一颗撤回,取消触摸不会提交。键盘 `H` 提示、`Z` 撤销、`Esc` 关闭面板或取消手势。
- 本地保存最高分、最大气球、最高连消及单局放飞纪录;不保存中途盘面。
## 可移植结构
```text
src/flow.ts 畅玩状态、合成与掉落时间线、能量、完整撤销
src/flow-rules.ts 顺号/同号规则与合法链搜索
src/cascade.ts 自动接力和连消分数
src/supply.ts 成袋配号、落点选择、微风救场
src/flow-animation.ts 合成与物理下落重放
src/physics.ts 固定步长圆形物理
src/input.ts 可注入规则的手势控制器
src/view.ts 气球的跨平台绘制指令
src/record.ts v2 纪录(通过 StoragePort 读写)
web/ Canvas、触摸、声音和 HTML UI
cocos/ Creator 3.8 组件与导出包
```
核心是无 DOM / Node / Cocos 依赖的 TypeScript,`tsconfig.core.json` 持续检查隔离。两端共用规则、数字补给、手势、物理和动画,移植时重建宿主 UI 即可。与玩法一、三互不引用。
## 文档
- [当前玩法设计](docs/GDD.md)
- [Cocos 接入说明](docs/HANDOFF.md)
- [验证记录](docs/QA.md) · [连续游玩原始统计](docs/qa-v2/validation.json)
- [竖屏截图](docs/qa-v2/mobile.png)
旧关卡代码、题库和测试保留为历史实验,不进入当前 Web 入口及 Cocos 导出包。历史文档在 [archive-v1](docs/archive-v1/GDD.md),旧回放可运行 `npm run validate:legacy`。旧存档不会被新纪录覆盖。
@@ -1,151 +0,0 @@
/** Creator 3.8 适配组件;通过 npm run cocos:export 导出后放入 assets/modes/02-connect-1234。 */
import { _decorator, Component, EventTouch, Graphics, Label, Node, UITransform, Vec3, Color,
HorizontalTextAlignment, VerticalTextAlignment } from 'cc';
import { FlowGame, type FlowResult } from '../src/flow';
import { canFlowAppend } from '../src/flow-rules';
import { Gesture } from '../src/input';
import { WIDTH, HEIGHT } from '../src/config';
import { drawBoard, Draw } from '../src/view';
import { FlowAnimation } from '../src/flow-animation';
import type { Point } from '../src/types';
const { ccclass, property, requireComponent } = _decorator;
@ccclass('Connect1234Board')
@requireComponent(UITransform)
export class Connect1234Board extends Component {
@property({ tooltip: "开局种子;相同种子和操作得到相同盘面" }) seed = 1234;
private game!: FlowGame;
private gesture!: Gesture;
private graphics!: Graphics;
private surface!: Node;
private labels: Label[] = [];
private touchId: number | null = null;
private time = 0;
private hints: number[] = [];
private animation: FlowAnimation | null = null;
private seenReaction = 0;
onLoad(): void {
this.surface = new Node('BalloonDrawing'); this.surface.layer = this.node.layer; this.node.addChild(this.surface);
this.surface.addComponent(UITransform).setContentSize(WIDTH, HEIGHT);
this.graphics = this.surface.addComponent(Graphics);
this.startNew(this.seed);
}
onEnable(): void {
this.node.on(Node.EventType.TOUCH_START, this.onStart, this);
this.node.on(Node.EventType.TOUCH_MOVE, this.onMove, this);
this.node.on(Node.EventType.TOUCH_END, this.onEnd, this);
this.node.on(Node.EventType.TOUCH_CANCEL, this.cancel, this);
}
onDisable(): void { this.node.targetOff(this); this.cancel(); }
onDestroy(): void { this.surface?.destroy(); }
startNew(seed = Date.now() >>> 0): void {
this.seed = seed; this.game = new FlowGame(seed);
this.gesture = new Gesture(() => this.game.balls, canFlowAppend);
this.animation = null; this.touchId = null; this.hints = this.game.hint(); this.emitHUD();
}
retry(): void { this.startNew(this.seed); }
undo(): void { if (!this.animation && this.game.undo()) { this.cancel(); this.hints = []; this.emitHUD(); } }
showHint(): void { if (!this.animation) { this.cancel(); this.hints = this.game.hint(); } }
shuffle(): void { if (!this.animation) { this.cancel(); this.begin(this.game.shuffle()); } }
snapshot() { return this.game.snapshot(); }
private emitHUD(): void {
this.node.emit('balloon-hud', { snapshot: this.game.snapshot(), canUndo: this.game.canUndo,
busy: !!this.animation, fever: this.game.fever });
}
private begin(result: FlowResult): void {
this.hints = []; this.seenReaction = 0; this.animation = new FlowAnimation(result, this.time);
this.node.emit('balloon-merge', result); this.emitHUD();
}
private toPoint(e: EventTouch): Point {
const p = e.getUILocation();
const local = this.surface.getComponent(UITransform)!.convertToNodeSpaceAR(new Vec3(p.x, p.y, 0));
return { x: local.x + WIDTH / 2, y: HEIGHT / 2 - local.y };
}
private onStart(e: EventTouch): void {
if (this.touchId !== null || this.animation) return;
this.touchId = e.getID(); this.hints = []; this.gesture.down(this.toPoint(e));
}
private onMove(e: EventTouch): void { if (e.getID() === this.touchId) this.gesture.move(this.toPoint(e)); }
private onEnd(e: EventTouch): void {
if (e.getID() !== this.touchId) return;
this.gesture.move(this.toPoint(e)); const path = this.gesture.up(); this.touchId = null;
const merged = this.game.commit(path);
if (merged) this.begin(merged);
}
private cancel(e?: EventTouch): void {
if (e && e.getID() !== this.touchId) return;
this.touchId = null; this.gesture?.cancel();
}
update(dt: number): void {
this.time += dt * 1000;
const ui = this.node.getComponent(UITransform)!;
const scale = Math.min(ui.width / WIDTH, ui.height / HEIGHT);
this.surface.setScale(scale, scale, 1);
// node 可以使用任意 anchor,绘图始终居中于 UITransform 内容区域。
this.surface.setPosition((.5 - ui.anchorX) * ui.width, (.5 - ui.anchorY) * ui.height, 0);
let balls = this.game.balls;
if (this.animation) {
const sample = this.animation.sample(this.time);
if (sample) {
balls = sample;
const current = this.animation.reaction;
if (current && current.index > this.seenReaction) {
for (const stage of this.animation.result.stages.filter(s => s.index > this.seenReaction && s.index <= current.index))
this.node.emit('balloon-reaction', stage, this.animation.result.doubled);
this.seenReaction = current.index;
}
} else {
const result = this.animation.result; this.animation = null; this.emitHUD();
this.node.emit('balloon-settled', result, this.game.snapshot());
}
}
this.render(drawBoard({ balls, chain: this.animation ? this.animation.activePath : this.gesture.chain,
hint: this.animation ? [] : this.hints, time: this.time / 1000, fever: this.game.fever }));
}
private color(hex: string, alpha = 1): Color {
const value = parseInt(hex.slice(1), 16);
return new Color((value >> 16) & 255, (value >> 8) & 255, value & 255, Math.round(alpha * 255));
}
private render(commands: Draw[]): void {
const g = this.graphics; g.clear(); let labelIndex = 0;
for (const d of commands) {
if (d.type === 'text') {
let label = this.labels[labelIndex++];
if (!label) {
const node = new Node('BalloonLabel'); node.layer = this.node.layer; this.surface.addChild(node);
node.addComponent(UITransform).setContentSize(340, 60);
label = node.addComponent(Label); label.horizontalAlign = HorizontalTextAlignment.CENTER;
label.verticalAlign = VerticalTextAlignment.CENTER; label.fontFamily = 'Arial';
this.labels.push(label);
}
label.node.active = true; label.node.setPosition(d.x - WIDTH / 2, HEIGHT / 2 - d.y, 0);
label.string = d.text; label.fontSize = d.size; label.lineHeight = d.size * 1.3;
label.isBold = (d.weight || 600) >= 650; label.color = this.color(d.fill || '#40678e', d.alpha);
label.enableOutline = !!d.stroke; label.outlineWidth = d.width || 1;
label.outlineColor = this.color(d.stroke || '#ffffff', d.alpha);
continue;
}
g.fillColor = this.color(d.fill || '#ffffff', d.alpha);
g.strokeColor = this.color(d.stroke || '#ffffff', d.alpha); g.lineWidth = d.width || 1;
if (d.type === 'circle') g.circle(d.x - WIDTH / 2, HEIGHT / 2 - d.y, d.r);
else if (d.type === 'ellipse') g.ellipse(d.x - WIDTH / 2, HEIGHT / 2 - d.y, d.rx, d.ry);
else if (d.dash) {
// Graphics 没有 Canvas 的 setLineDash;逐段画虚线。
for (let i = 1; i < d.points.length; i++) {
const a = d.points[i - 1], b = d.points[i], length = Math.hypot(b.x - a.x, b.y - a.y);
for (let t = 0; t < length; t += d.dash[0] + d.dash[1]) {
const end = Math.min(length, t + d.dash[0]);
g.moveTo(a.x + (b.x - a.x) * t / length - WIDTH / 2, HEIGHT / 2 - a.y - (b.y - a.y) * t / length);
g.lineTo(a.x + (b.x - a.x) * end / length - WIDTH / 2, HEIGHT / 2 - a.y - (b.y - a.y) * end / length);
}
}
} else {
d.points.forEach((p, i) => i ? g.lineTo(p.x - WIDTH / 2, HEIGHT / 2 - p.y) : g.moveTo(p.x - WIDTH / 2, HEIGHT / 2 - p.y));
if (d.close) g.close();
}
if (d.fill) g.fill(); if (d.stroke) g.stroke();
}
for (let i = labelIndex; i < this.labels.length; i++) this.labels[i].node.active = false;
}
}
-54
View File
@@ -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)。它用于排查玩法断档,不代表真实玩家留存或主观爽感;后续可以基于实机试玩调整落点评分、能量阈值与节奏。
-78
View File
@@ -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 与目标小游戏平台触摸及性能验收。
-65
View File
@@ -1,65 +0,0 @@
# 连连气球 · v2 验证记录
日期:2026-09-15。当前版本为无关卡连消畅玩版。历史关卡验收见 [v1 存档](archive-v1/QA.md)。
## 自动检查
- TypeScript Web 和纯核心检查通过;核心检查不加载 DOM / Node 类型。
- **37 项测试通过**:14 项覆盖当前顺号/同号、连消分数、补球接力、彩虹五次、放飞、预告、撤销与随机确定性、微风救场、动画跳帧、存档和拥挤物理回归;23 项保留 v1 及共用代码回归。
- **3,200 次操作、3,232 个稳定盘面**通过:唯一 ID、合法数字、边界、圆形重叠和至少一条可连路线。
- 最大数值穿透 **1.174 逻辑像素**,阈值为 2px;气球外观比碰撞圆略窄。针对种子 6559 的拥挤盘面保留回归数据,补球稳定阶段增加了约束迭代。
- 物理先判断平方距离,仅对接触球开方,降低补球试算成本。
- 生产包构建成功,JS **29,024 字节**(未 gzip),没有开发观察接口。
### 连续游玩抽样
每局连续操作 100 次;不调用免费工具跳过难盘。无路时由核心自动微风处理,次数列在表内。
| 策略 | 局数 / 操作数 | 触发连消 | 每局放飞 | 自动微风 | 本机提交耗时中位数 / P95 |
|---|---:|---:|---:|---:|---:|
| 从合法链随机抽取 | 24 / 2,400 | 812,33.8% | 3–5 颗 | 161 | 8.5 / 16.7 ms |
| 沿提示路线 | 8 / 800 | 499,62.4% | 5–8 颗 | 46 | 8.5 / 15.3 ms |
32 局都玩满 100 次,全部至少合成过 10。提示策略每次还通过 `Gesture` 实际路线采样,验证链条确实能按球心滑出。
这是固定种子算法抽样,不是玩家行为统计,也不是无死局的数学证明。随机策略对搜索出的合法链抽样;提示策略更偏向长链和即时连消。耗时来自当前开发机,手机端需另测。
完整种子、分数、放飞和性能数据见 [validation.json](qa-v2/validation.json)。复现:`npm run validate`。
## 浏览器与正式包
使用 ego-browser Chromium,390 × 844 移动视口,CDP `Input.dispatchTouchEvent` 输入:
- 实际连续完成 **24 次触摸合成**,达到 **317,500 分**,最高数字 **10**,放飞 **1 颗**;触发彩虹双倍及多连消,过程未弹出关卡/失败面板。
- 验证不足三颗、触摸取消、划回上一颗均不提交。
- 合成后撤销并重走,完整盘面、队列、随机数、分数与能量相同。
- 微风不扣分或能量,撤销恢复原局面。
- 页面错误和未处理 Promise 拒绝为 **0**。
- 帮助、纪录、重开和 Escape 关闭面板可用。
- 系统减少动态效果时,手势可正常提交,立即显示最终状态;横幅不依赖动画才能可见。
- **正式包 http://localhost:8003** 使用真实触摸完成首轮合成,得到 **10,000 分**;刷新后纪录保留,开发观察接口不存在。验收后恢复验收前的 v2 纪录,旧 v1 存档保持原样。
结果:[browser.json](qa-v2/browser.json) · [production.json](qa-v2/production.json)。浏览器长局检查在物理平方距离优化前完成;优化后重新完成全部 3,200 次核心抽样、减少动态效果交互及正式包触摸检查。
## 布局
| 视口 | 结果 |
|---|---|
| 320 × 568 | 无横向溢出,页面高 650,可滚动到工具栏 |
| 360 × 740 | 完整布局 |
| 390 × 844 | 完整竖屏,主触摸验收尺寸 |
| 430 × 932 | 完整布局 |
| 844 × 390 | 保持竖屏面板,可纵向滚动 |
| 1280 × 900 | 居中面板与桌面气球插画 |
上述尺寸下主要按钮均至少 44 × 44 CSS 像素。数值见 [layout.json](qa-v2/layout.json)。
截图:[当前开局](qa-v2/mobile.png) · [3 连消](qa-v2/first-merge.png) · [彩虹](qa-v2/rainbow.png) · [放飞后](qa-v2/flight.png) · [帮助](qa-v2/help.png) · [桌面](qa-v2/desktop.png)。
浏览器验收脚本为 `tools/browser-flow*.mjs`。其中空间 ID 4 是本次已有 QA 空间;重新验收时应先创建自己的一个 QA 空间并替换 ID,再通过 `ego-browser nodejs < 脚本` 执行,不能接管已交还用户的空间。正式包脚本只恢复它自己产生的测试纪录。
## Cocos
`npm run cocos:check` 通过:导出当前核心与 `Connect1234Board`,使用本机 **Creator 3.8.8 的真实 cc.d.ts** 检查触摸、坐标转换、Graphics、Label 描边、事件和状态接口。导出目录不含旧关卡模块或题库。
尚未验收:Creator 场景运行、实体手机触摸与性能、Android/iOS 和小游戏平台原生包。接入步骤及宿主 UI 工作见 [HANDOFF](HANDOFF.md)。
@@ -1,67 +0,0 @@
> 历史存档:关卡版 v1,已由连消畅玩版替换。当前规则见 [GDD](../GDD.md)。
# 连号花园 · 玩法二实现规格
2026-09-15:按本轮需求,从最初的无尽分数设计演化为 **10 关挑战 + 自由生长**。本文件是 v1 规则。
## 核心规则
- 圆球物理堆积;相邻圆心距 ≤ 半径之和 × 1.35。
- 单指按住球,连接至少 3 个严格连续数字;升序、降序均合法,不能换向或重复用球。
- 滑回上一颗撤回一步;快速滑动分段采样;触摸取消、窗口失焦不会提交合成。
- 松手合成 `max + 1`,结果落在链尾;计分 `100 × 新数字 × 链长`。
- 球半径 `min(82, 22 + 6 × (n−1))`,保留变大的空间压力;高数封顶保证自由模式仍可触摸。
- 每次补到本关目标球数,面积最多占槽的 59%;物理稳定后才接收下一次操作。下落期间顶部开放,稳定收尾加入顶部约束,保证所有球可见且可触摸。
- 无计时,非法手势不扣步。挑战每次有效合成消耗一步,所有目标达成即过关(最后一步先检查胜利)。
- 没有合法链时允许撤销或使用本关唯一一次打乱;步数用尽可撤销或重试。
## 原创演化
- **固定种子与补球队列**:同一关重试能复盘,撤销连随机状态一并恢复。
- **10 关目标制**:三连、倒序、四连、双目标、造大数逐步教学;有限步数,不限思考时间。
- **容错**:免费提示、连续撤销、每关一次免费打乱。打乱保留球的数字、不耗步;无可行重排不扣次数。
- **提示**:开局及沿标准解操作时显示经验证的下一步;偏离后寻找当前合法链,不保证整局最优。
- **星级**:不打乱且在标准解步数以内为 3 星;标准步数 +2 以内为 2 星,其余通关为 1 星。提示、撤销不扣星。
- **自由生长**:保留无尽分数循环,用掉落权重抽样,无关卡目标和步数上限。
## 10 关
完整配置、种子、补球队列、通关轨迹见 [campaign.json](../levels/campaign.json)。
| 关 | 名称 | 主要训练 | 目标 | 步数 |
|---|---|---|---|---|
| 1 | 初芽 | 三连松手 | 合成 4 | 3 |
| 2 | 回转 | 降序与落点 | 倒序合成一次 | 4 |
| 3 | 伸展 | 长链 | 一次连 4 颗 | 5 |
| 4 | 双生 | 材料预留 | 合成两颗 5 | 6 |
| 5 | 丰收 | 长链得分 | 6,500 分 | 7 |
| 6 | 抽叶 | 两个目标 | 合成两颗 6,四连 | 8 |
| 7 | 繁枝 | 大球与落点 | 合成 7 | 9 |
| 8 | 花径 | 弯曲长链 | 五连,14,000 分 | 10 |
| 9 | 并蒂 | 连续筹备 | 合成两颗 7 | 12 |
| 10 | 满园 | 综合挑战 | 合成 8,22,000 分 | 14 |
标准解由有界搜索找到并回放验算,不宣称唯一解或最优解。自动验证证明可通关,实际玩家难度仍可继续调参。
## UI 与触摸
- 对外名称「连号花园」,奶油纸色、鼠尾草绿、柔和彩球;6 以上长叶,8 以上小花。
- 竖屏从上到下:品牌/声音/帮助 → 关卡入口 → 目标与步数 → 球槽 → 当前连线预览 → 撤销/提示/打乱/重来。
- 数字在球心,颜色不参与判定。按钮触摸区至少 44px。
- 适配安全区、320px 窄屏和横屏;球槽维持逻辑宽高比,短屏允许页面滚动,槽内禁止浏览器拖动。
- 连线显示将生成的新数和分数;合成收拢、下落、得分浮字、过关结果;支持关闭音效与减少动态效果。
- 选关底部面板,完成后解锁下一关;末关展示整套成绩并可转自由生长。
## 技术边界
`src/` 仅使用 ES2020 标准库;第二份 tsconfig 排除 DOM 和 Node 类型,持续证明无平台依赖。
- `game / rules / physics / random`:确定性状态机、规则、固定步长圆形物理。
- `input`:平台无关手势状态机。
- `goals / progress / levels`:目标、存档数据与关卡。
- `view`:输出基础绘制指令,由 Canvas 或 Cocos Graphics/Label 适配。
- `solver`:离线设计搜索、运行时轻量提示。
- `web/`:浏览器输入、Canvas、音效、HTML 界面与本地存储。
- `cocos/`:Creator 3.x 场景接入组件示例,迁移说明见 HANDOFF。
所有代码与玩法一、三隔离;不引入 React、DOM 规则、第三方物理引擎、服务端或远程素材。
@@ -1,114 +0,0 @@
> 历史存档:关卡版 v1,已由连消畅玩版替换。当前规则见 [GDD](../GDD.md)。
# 连号花园 · Cocos Creator 接手说明
## 当前交付边界
Web 版已完成十关、自由模式、动画、音效、提示、撤销、打乱、结算与本地进度。Cocos 交付的是 **可导出的同一套核心代码 + 球槽组件**;已经用本机 Creator 3.8.8 的真实 `cc.d.ts` 通过类型检查,尚未在 Creator 场景内完成运行验收。
后续移植需要在 Cocos 场景搭建顶栏、目标、按钮、选关和结算面板,绑定本文列出的事件。HTML/CSS 不作为游戏逻辑带入引擎。
## 1. 导出
在 `modes/02-connect-1234` 执行:
```bash
npm run cocos:export
```
输出 `cocos/export/02-connect-1234/`。将整个目录复制到 Creator 项目的 `assets/modes/02-connect-1234/`。也可显式指定输出路径:
```bash
npm run cocos:export -- /你的工程/assets/modes/02-connect-1234
```
导出时仅将 JSON 导入替换成生成的等价 TypeScript 数据模块,避免依赖 Creator 对 JSON module 的打包行为。规则、物理、手势、绘制指令、动画、目标、存档结构保持同一份源码。
不要手动修改导出目录;改 `src/`、`levels/` 或 `cocos/Connect1234Board.ts`,重新导出即可。
## 2. 创建球槽
1. 新建本玩法的 2D 场景,Canvas 设计分辨率建议 **720 × 1280**。
2. 在 Canvas 下创建 `Board` 节点,加 `UITransform`,尺寸可用 **640 × 740**。
3. 将导出包的 `adapters/Connect1234Board.ts` 挂到 `Board`。组件自动创建 Graphics 与复用的 Label 节点。
4. 在 Inspector 设置 `levelNumber` 为 1–10;运行后即可对球槽进行触摸连号。
5. 为 Board 的绘图区域配置矩形 Mask,裁掉从顶部掉入的球。背景颜色、圆角容器由场景美术完成。
核心坐标为 **360 × 416,左上角原点,Y 向下**。适配组件自动等比缩放并用 `UITransform.convertToNodeSpaceAR` 转换触摸坐标,不需要改变球的半径、物理或邻接算法。
## 3. 绑定 UI
组件公开:
| 方法 | 行为 |
|---|---|
| `loadLevel(number)` | 加载第 1–10 关 |
| `retry()` | 按固定种子重开当前关 |
| `undo()` | 撤回一次合成或打乱 |
| `showHint()` | 显示提示连线 |
| `shuffle()` | 每关一次重排,数字保留、不扣步 |
| `snapshot()` | 获取完整状态副本 |
在 Board 节点监听:
```ts
board.on('garden-hud', ({ level, snapshot, goals, canUndo }) => {
// 刷新标题、分数、步数、目标、工具按钮。
});
board.on('garden-merge', (result) => {
// 播放音效、振动、得分浮字。球槽自身已播放合成和下落动画。
});
board.on('garden-result', (status, snapshot) => {
// won: 成功;lost: 步数用完;stuck: 无合法链。
// 核心不会自动弹面板或跳关,由壳/UI 决定。
});
```
玩法切场景时由组件生命周期解绑触摸;只接受一根活动手指,TOUCH_CANCEL 会取消手势。宿主弹模态面板或进后台时应禁用 Board 组件,触发取消。
组件示例接入的是挑战模式。自由模式的逻辑已经在 `new Game(level, true)` 内实现,按 Web 的入口方式补一个场景按钮即可;其随机开局应确保至少有一条合法链。
## 4. 存档和音效
`src/progress.ts` 仅接受 `StoragePort` 接口。Cocos 实现:
```ts
import { sys } from 'cc';
const storage = {
read: (key: string) => sys.localStorage.getItem(key),
write: (key: string, value: string) => sys.localStorage.setItem(key, value),
};
```
存档键 `mode:02-connect-1234:v1`,保存各关最高分/星级及最高数字。不存中途盘面。音效使用 Cocos `AudioSource`,订阅 `garden-merge`;Web 的音效是本地合成的短音,不需要下载音频素材。
## 5. 物理与性能
- 继续使用核心固定步长圆形分离物理,可保证 Web 与 Cocos 的固定关卡解一致。
- `Game.commit` 先算稳定状态;`MergeAnimation` 负责重放收拢和物理过程,宿主只传入毫秒时间。
- 核心不会在每个显示帧重算关卡。最多 18 个球,稳定盘面冻住,操作时不会追着移动的球连线。
- Graphics 适配器是接入示例;发布时可将彩球换为 Sprite + Label 预制体池,保留相同球 ID 和坐标。无需变更规则。
- 如果未来换 Box2D,请保留逻辑判定盘面与显示物理解耦,或重新验证全部关卡。直接替换积分器可能改变邻接和落点。
## 6. 检查
```bash
npm run check
npm run cocos:check
```
`cocos:check` 自动寻找 macOS `/Applications/Cocos/Creator/` 已安装版本,也可提供声明文件路径:
```bash
COCOS_TYPES=/路径/cc.d.ts npm run cocos:check
```
Creator 内还需验收:实际场景字体、触摸坐标、Mask、UI 安全区、音频、Android/iOS 原生包,以及微信等目标平台的生命周期。
## 参考接口
- [Cocos 3.8 节点触摸事件](https://docs.cocos.com/creator/3.8/manual/en/engine/event/event-node.html)
- [UITransform 坐标转换](https://docs.cocos.com/creator/3.8/api/en/class/UITransform)
- [Graphics 图形接口](https://docs.cocos.com/creator/3.8/api/en/class/GraphicsComponent)
实现及类型检查以本机 Creator 3.8.8 的声明文件为准。
@@ -1,60 +0,0 @@
> 历史存档:关卡版 v1,已由连消畅玩版替换。当前规则见 [GDD](../GDD.md)。
# 验证记录
日期:2026-09-15。当前实现的验证范围如下;未将浏览器触摸模拟称为实体手机测试。
## 自动检查
`npm run check` 全部通过:
- 两套 TypeScript 检查通过,其中 `src/` 不含 DOM / Node 类型。
- **23 项测试**通过:规则、升降序、拒绝非法链、落点、最后一步成功、确定性重试、撤销、快速划线、取消、打乱、死局、失败、存档容错、十关触摸路线、帧率无关动画。
- **10 / 10 关**使用固定种子逐步回放通过;验证同时经过 `Gesture`,避免“规则上合法、实际划线无法选中”的假解。
- **1,225 个稳定盘面**通过边界与圆形重叠检查,含 **1,046 次随机合成**;最大数值穿透 **0.772 逻辑像素**(验收阈值 2px)。随机自由局自然结束;这批样本中最长连续局为 18 次合成。
- 所有挑战关的开局与补球数字都在 1–4,最终通过正常合成得到 8。
- 生产构建成功,JS 约 **33 KB**(未 gzip),不包含开发观察端口。
- 独立 `release/` 正式包在端口 8003 实际完成第一关触摸合成(1,200 分,余 2 步),确认无开发观察端口,并已清理测试成绩、恢复第一关开局。见 [production.json](../qa/production.json)。
详细关卡逐步数字、球 ID、分数、步数与压力检查统计见 [validation.json](../qa/validation.json)。搜索给出的是一条可行路线,非唯一解、非最优解证明。
## 浏览器触摸验收
Ego Chromium,390 × 844 移动视口,使用 CDP `Input.dispatchTouchEvent` 产生浏览器输入:
- 从 **第 1 关实际连续游玩到第 10 关**,没有直接修改分数、盘面或过关状态。
- 验证两颗松手不扣步、TOUCH_CANCEL 不提交、滑回上一颗撤回。
- 确认十关依次解锁、最终结果为 10 / 10、存档记录 30 / 30 星。
- 页面错误和未处理 Promise 拒绝记录为 **0**。
- 后续刷新验证成绩保留;实际点按钮验证撤销和重走完全一致、打乱不扣步且保留数字、撤销恢复打乱库存。
- 自由模式实际完成合成且步数保持无限;帮助面板可用 Escape 关闭。
- 抽取动画为跨平台模块后,再次通过浏览器验证合成、撤销、重走和打乱。
结果见 [browser.json](../qa/browser.json)、[layout.json](../qa/layout.json)。`tools/browser-*.mjs` 是本次验收脚本,使用 `GARDEN_SPACE` 指定当前已有的 Ego QA 空间、`GARDEN_QA_DIR` 指定输出目录;应使用独立测试存档,先跑通关脚本再跑布局脚本。
## 尺寸检查
| 视口 | 结果 |
|---|---|
| 320 × 568 | 无横向溢出;页面高 650px,可纵向滚动触及底栏 |
| 360 × 740 | 完整布局,主要按钮 ≥ 44px |
| 390 × 844 | 完整布局,触摸通关主测试尺寸 |
| 430 × 932 | 完整布局,主要按钮 ≥ 44px |
| 844 × 390 | 横屏仍可操作,可纵向滚动 |
| 1280 × 900 | 居中竖屏游戏面板与桌面背景 |
截图:[开局](../qa/mobile-initial.png) · [后段关卡](../qa/mobile-level8.png) · [帮助](../qa/mobile-help.png) · [选关](../qa/mobile-level-select.png) · [全通关](../qa/mobile-all-clear.png) · [桌面](../qa/desktop.png)。
## Cocos 迁移检查
`npm run cocos:check` 通过:
- 生成独立的 Creator 导入目录。
- 使用本机 **Cocos Creator 3.8.8 的真实 `cc.d.ts`** 检查核心、数据与适配组件,非手写模拟类型。
- 球槽坐标转换、Graphics、Label、触摸 API 均通过类型检查。
**仍需后续移植阶段完成**:Creator 场景实际运行、Cocos UI 面板搭建、原生 Android/iOS 与目标小游戏平台验收。当前不宣称这些工作已完成。
## 难度判断的范围
难度梯度由教学目标、解的步数、容错余量、长链和多轮合成要求制定,已验证全部关卡可通关。尚未做多人试玩统计,平均通关耗时、提示使用率、玩家卡关分布需要后续试玩采样。
@@ -1,205 +0,0 @@
{
"viewport": [
390,
844
],
"input": "CDP touch events",
"invalidCancelBacktrack": true,
"undoDeterministic": true,
"breezeUndo": true,
"moves": [
{
"turn": 1,
"score": 10000,
"combo": 3,
"peak": 6,
"flown": 0,
"feverLeft": 0
},
{
"turn": 2,
"score": 19000,
"combo": 3,
"peak": 6,
"flown": 0,
"feverLeft": 5
},
{
"turn": 3,
"score": 31000,
"combo": 2,
"peak": 7,
"flown": 0,
"feverLeft": 4
},
{
"turn": 4,
"score": 43000,
"combo": 2,
"peak": 7,
"flown": 0,
"feverLeft": 3
},
{
"turn": 5,
"score": 62200,
"combo": 3,
"peak": 7,
"flown": 0,
"feverLeft": 2
},
{
"turn": 6,
"score": 77800,
"combo": 2,
"peak": 9,
"flown": 0,
"feverLeft": 1
},
{
"turn": 7,
"score": 84200,
"combo": 2,
"peak": 9,
"flown": 0,
"feverLeft": 0
},
{
"turn": 8,
"score": 93800,
"combo": 3,
"peak": 9,
"flown": 0,
"feverLeft": 0
},
{
"turn": 9,
"score": 100700,
"combo": 2,
"peak": 9,
"flown": 0,
"feverLeft": 0
},
{
"turn": 10,
"score": 108300,
"combo": 2,
"peak": 9,
"flown": 0,
"feverLeft": 0
},
{
"turn": 11,
"score": 120200,
"combo": 3,
"peak": 9,
"flown": 0,
"feverLeft": 5
},
{
"turn": 12,
"score": 142200,
"combo": 1,
"peak": 10,
"flown": 1,
"feverLeft": 4
},
{
"turn": 13,
"score": 184200,
"combo": 4,
"peak": 10,
"flown": 1,
"feverLeft": 3
},
{
"turn": 14,
"score": 198000,
"combo": 2,
"peak": 10,
"flown": 1,
"feverLeft": 2
},
{
"turn": 15,
"score": 217200,
"combo": 3,
"peak": 10,
"flown": 1,
"feverLeft": 1
},
{
"turn": 16,
"score": 221200,
"combo": 1,
"peak": 10,
"flown": 1,
"feverLeft": 0
},
{
"turn": 17,
"score": 236200,
"combo": 3,
"peak": 10,
"flown": 1,
"feverLeft": 0
},
{
"turn": 18,
"score": 242200,
"combo": 3,
"peak": 10,
"flown": 1,
"feverLeft": 0
},
{
"turn": 19,
"score": 249100,
"combo": 2,
"peak": 10,
"flown": 1,
"feverLeft": 0
},
{
"turn": 20,
"score": 253300,
"combo": 2,
"peak": 10,
"flown": 1,
"feverLeft": 5
},
{
"turn": 21,
"score": 267100,
"combo": 2,
"peak": 10,
"flown": 1,
"feverLeft": 4
},
{
"turn": 22,
"score": 275500,
"combo": 2,
"peak": 10,
"flown": 1,
"feverLeft": 3
},
{
"turn": 23,
"score": 305500,
"combo": 3,
"peak": 10,
"flown": 1,
"feverLeft": 2
},
{
"turn": 24,
"score": 317500,
"combo": 2,
"peak": 10,
"flown": 1,
"feverLeft": 1
}
],
"errors": []
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

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

Before

Width:  |  Height:  |  Size: 88 KiB

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

Before

Width:  |  Height:  |  Size: 90 KiB

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

Before

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

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

Before

Width:  |  Height:  |  Size: 299 KiB

-241
View File
@@ -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
}
]
}
]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

@@ -1,10 +0,0 @@
{
"url": "http://localhost:8003",
"debugPortAbsent": true,
"touch": {
"status": "won",
"score": "1,200 分",
"moves": "2"
},
"resetToCleanLevel1": true
}
@@ -1,883 +0,0 @@
{
"generatedAt": "2026-09-15T09:43:46.893Z",
"levels": [
{
"id": 1,
"title": "初芽",
"seed": 1234,
"budget": 3,
"solutionMoves": 1,
"spareMoves": 2,
"score": 1200,
"openings": 18,
"goals": [
{
"text": "合成 1 颗「4」",
"current": 1,
"target": 1,
"complete": true
}
],
"moves": [
{
"ids": [
3,
11,
10
],
"numbers": [
3,
2,
1
],
"created": 4,
"score": 1200
}
]
},
{
"id": 2,
"title": "回转",
"seed": 2212,
"budget": 4,
"solutionMoves": 1,
"spareMoves": 3,
"score": 1200,
"openings": 14,
"goals": [
{
"text": "完成 1 次倒序合成",
"current": 1,
"target": 1,
"complete": true
}
],
"moves": [
{
"ids": [
3,
8,
1
],
"numbers": [
3,
2,
1
],
"created": 4,
"score": 1200
}
]
},
{
"id": 3,
"title": "伸展",
"seed": 3421,
"budget": 5,
"solutionMoves": 1,
"spareMoves": 4,
"score": 2000,
"openings": 28,
"goals": [
{
"text": "一次连起 4 颗",
"current": 4,
"target": 4,
"complete": true
}
],
"moves": [
{
"ids": [
1,
2,
7,
8
],
"numbers": [
1,
2,
3,
4
],
"created": 5,
"score": 2000
}
]
},
{
"id": 4,
"title": "双生",
"seed": 4517,
"budget": 6,
"solutionMoves": 2,
"spareMoves": 4,
"score": 3500,
"openings": 38,
"goals": [
{
"text": "合成 2 颗「5」",
"current": 2,
"target": 2,
"complete": true
}
],
"moves": [
{
"ids": [
4,
9,
5,
8
],
"numbers": [
4,
3,
2,
1
],
"created": 5,
"score": 2000
},
{
"ids": [
2,
3,
7
],
"numbers": [
2,
3,
4
],
"created": 5,
"score": 3500
}
]
},
{
"id": 5,
"title": "丰收",
"seed": 5603,
"budget": 7,
"solutionMoves": 4,
"spareMoves": 3,
"score": 7700,
"openings": 4,
"goals": [
{
"text": "获得 6,500 分",
"current": 7700,
"target": 6500,
"complete": true
}
],
"moves": [
{
"ids": [
1,
2,
10
],
"numbers": [
1,
2,
3
],
"created": 4,
"score": 1200
},
{
"ids": [
11,
7,
18,
15
],
"numbers": [
4,
3,
2,
1
],
"created": 5,
"score": 3200
},
{
"ids": [
12,
14,
19,
20,
15
],
"numbers": [
1,
2,
3,
4,
5
],
"created": 6,
"score": 6200
},
{
"ids": [
8,
13,
9
],
"numbers": [
4,
3,
2
],
"created": 5,
"score": 7700
}
]
},
{
"id": 6,
"title": "抽叶",
"seed": 6723,
"budget": 8,
"solutionMoves": 4,
"spareMoves": 4,
"score": 8800,
"openings": 40,
"goals": [
{
"text": "合成 2 颗「6」",
"current": 2,
"target": 2,
"complete": true
},
{
"text": "一次连起 4 颗",
"current": 4,
"target": 4,
"complete": true
}
],
"moves": [
{
"ids": [
1,
9,
6,
4
],
"numbers": [
1,
2,
3,
4
],
"created": 5,
"score": 2000
},
{
"ids": [
2,
3,
7,
4
],
"numbers": [
2,
3,
4,
5
],
"created": 6,
"score": 4400
},
{
"ids": [
15,
17,
22,
20
],
"numbers": [
4,
3,
2,
1
],
"created": 5,
"score": 6400
},
{
"ids": [
18,
21,
11,
20
],
"numbers": [
2,
3,
4,
5
],
"created": 6,
"score": 8800
}
]
},
{
"id": 7,
"title": "繁枝",
"seed": 7841,
"budget": 9,
"solutionMoves": 4,
"spareMoves": 5,
"score": 8700,
"openings": 12,
"goals": [
{
"text": "合成 1 颗「7」",
"current": 1,
"target": 1,
"complete": true
}
],
"moves": [
{
"ids": [
4,
3,
2
],
"numbers": [
4,
3,
2
],
"created": 5,
"score": 1500
},
{
"ids": [
17,
9,
11,
2
],
"numbers": [
2,
3,
4,
5
],
"created": 6,
"score": 3900
},
{
"ids": [
7,
15,
19,
8
],
"numbers": [
4,
3,
2,
1
],
"created": 5,
"score": 5900
},
{
"ids": [
12,
21,
8,
2
],
"numbers": [
3,
4,
5,
6
],
"created": 7,
"score": 8700
}
]
},
{
"id": 8,
"title": "花径",
"seed": 8917,
"budget": 10,
"solutionMoves": 6,
"spareMoves": 4,
"score": 14200,
"openings": 18,
"goals": [
{
"text": "一次连起 5 颗",
"current": 5,
"target": 5,
"complete": true
},
{
"text": "获得 14,000 分",
"current": 14200,
"target": 14000,
"complete": true
}
],
"moves": [
{
"ids": [
4,
3,
5,
12
],
"numbers": [
4,
3,
2,
1
],
"created": 5,
"score": 2000
},
{
"ids": [
16,
17,
13,
11,
12
],
"numbers": [
1,
2,
3,
4,
5
],
"created": 6,
"score": 5000
},
{
"ids": [
15,
10,
23,
25
],
"numbers": [
4,
3,
2,
1
],
"created": 5,
"score": 7000
},
{
"ids": [
20,
21,
7,
25
],
"numbers": [
2,
3,
4,
5
],
"created": 6,
"score": 9400
},
{
"ids": [
19,
9,
18,
22
],
"numbers": [
1,
2,
3,
4
],
"created": 5,
"score": 11400
},
{
"ids": [
12,
22,
32,
31
],
"numbers": [
6,
5,
4,
3
],
"created": 7,
"score": 14200
}
]
},
{
"id": 9,
"title": "并蒂",
"seed": 9123,
"budget": 12,
"solutionMoves": 8,
"spareMoves": 4,
"score": 20500,
"openings": 16,
"goals": [
{
"text": "合成 2 颗「7」",
"current": 2,
"target": 2,
"complete": true
}
],
"moves": [
{
"ids": [
1,
2,
3
],
"numbers": [
2,
3,
4
],
"created": 5,
"score": 1500
},
{
"ids": [
3,
10,
6,
8,
17
],
"numbers": [
5,
4,
3,
2,
1
],
"created": 6,
"score": 4500
},
{
"ids": [
4,
12,
13,
7
],
"numbers": [
1,
2,
3,
4
],
"created": 5,
"score": 6500
},
{
"ids": [
22,
15,
9,
14,
7,
17
],
"numbers": [
1,
2,
3,
4,
5,
6
],
"created": 7,
"score": 10700
},
{
"ids": [
21,
16,
20,
27
],
"numbers": [
4,
3,
2,
1
],
"created": 5,
"score": 12700
},
{
"ids": [
11,
25,
19,
24,
27
],
"numbers": [
1,
2,
3,
4,
5
],
"created": 6,
"score": 15700
},
{
"ids": [
33,
29,
23,
28
],
"numbers": [
1,
2,
3,
4
],
"created": 5,
"score": 17700
},
{
"ids": [
30,
39,
28,
27
],
"numbers": [
3,
4,
5,
6
],
"created": 7,
"score": 20500
}
]
},
{
"id": 10,
"title": "满园",
"seed": 10429,
"budget": 14,
"solutionMoves": 10,
"spareMoves": 4,
"score": 23400,
"openings": 16,
"goals": [
{
"text": "合成 1 颗「8」",
"current": 1,
"target": 1,
"complete": true
},
{
"text": "获得 22,000 分",
"current": 23400,
"target": 22000,
"complete": true
}
],
"moves": [
{
"ids": [
1,
6,
3
],
"numbers": [
2,
3,
4
],
"created": 5,
"score": 1500
},
{
"ids": [
3,
7,
9,
5,
4
],
"numbers": [
5,
4,
3,
2,
1
],
"created": 6,
"score": 4500
},
{
"ids": [
10,
20,
19,
23
],
"numbers": [
4,
3,
2,
1
],
"created": 5,
"score": 6500
},
{
"ids": [
8,
13,
18,
23,
4
],
"numbers": [
2,
3,
4,
5,
6
],
"created": 7,
"score": 10000
},
{
"ids": [
14,
30,
31,
29
],
"numbers": [
4,
3,
2,
1
],
"created": 5,
"score": 12000
},
{
"ids": [
22,
16,
25,
29
],
"numbers": [
2,
3,
4,
5
],
"created": 6,
"score": 14400
},
{
"ids": [
27,
24,
33
],
"numbers": [
2,
3,
4
],
"created": 5,
"score": 15900
},
{
"ids": [
4,
29,
33,
37,
32
],
"numbers": [
7,
6,
5,
4,
3
],
"created": 8,
"score": 19900
},
{
"ids": [
28,
2,
15,
35
],
"numbers": [
4,
3,
2,
1
],
"created": 5,
"score": 21900
},
{
"ids": [
39,
38,
40
],
"numbers": [
2,
3,
4
],
"created": 5,
"score": 23400
}
]
}
],
"physics": {
"checkedStates": 1225,
"randomMerges": 1046,
"longestRun": 18,
"maxOverlap": 0.7719946571965153
}
}
-744
View File
@@ -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
]
]
}
]
}
-12
View File
@@ -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 }
}
File diff suppressed because it is too large Load Diff
-24
View File
@@ -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"
}
}
-27
View File
@@ -1,27 +0,0 @@
import * as esbuild from 'esbuild';
import { networkInterfaces } from 'node:os';
import { mkdir, copyFile } from 'node:fs/promises';
const args = process.argv.slice(2);
const serve = args.includes('--serve');
const index = args.indexOf('--port');
const port = index >= 0 ? Number(args[index + 1]) : Number(process.env.PORT || 8002);
const ctx = await esbuild.context({
entryPoints: ['web/main.ts'], bundle: true, format: 'iife', target: 'es2020',
outfile: serve ? 'web/dist/app.js' : 'release/dist/app.js', minify: !serve, sourcemap: serve, logLevel: 'info',
define: { __DEV__: JSON.stringify(serve) },
});
if (serve) {
await ctx.watch();
const result = await ctx.serve({ host: '0.0.0.0', port, servedir: 'web' });
console.log(`\n连连气球 http://localhost:${result.port}`);
for (const item of Object.values(networkInterfaces()).flat()) {
if (item?.family === 'IPv4' && !item.internal) console.log(`手机同一 Wi-Fi: http://${item.address}:${result.port}`);
}
} else {
await mkdir('release', { recursive: true });
for (const file of ['index.html', 'style.css']) await copyFile(`web/${file}`, `release/${file}`);
await ctx.rebuild();
await ctx.dispose();
console.log('离线 / 静态发布目录:release/');
}
@@ -1,22 +0,0 @@
import { access, readdir, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { spawnSync } from 'node:child_process';
let declarations = process.argv[2] || process.env.COCOS_TYPES;
if (!declarations) {
const root = '/Applications/Cocos/Creator';
try {
for (const version of (await readdir(root)).sort((a, b) => b.localeCompare(a, undefined, { numeric: true }))) {
const candidate = `${root}/${version}/CocosCreator.app/Contents/Resources/resources/3d/engine/bin/.declarations/cc.d.ts`;
try { await access(candidate); declarations = candidate; break; } catch {}
}
} catch {}
}
if (!declarations) throw new Error('请用 COCOS_TYPES 或命令参数提供 Creator 的 cc.d.ts 路径。');
const target = resolve('cocos/export/02-connect-1234');
const config = resolve('cocos/export/tsconfig.check.json');
await writeFile(config, JSON.stringify({ compilerOptions: { target: 'ES2020', module: 'ESNext', moduleResolution: 'Bundler',
lib: ['ES2020', 'DOM'], strict: true, experimentalDecorators: true, useDefineForClassFields: false,
noEmit: true, skipLibCheck: true, types: [], baseUrl: '.', paths: { cc: [resolve(declarations)] } }, include: [target] }, null, 2));
console.log(`检查真实 Creator 类型:${declarations}`);
const result = spawnSync(process.execPath, ['node_modules/typescript/bin/tsc', '-p', config], { stdio: 'inherit' });
process.exit(result.status ?? 1);
@@ -1,20 +0,0 @@
import { mkdir, rm, readFile, writeFile, copyFile } from 'node:fs/promises';
import { resolve, join } from 'node:path';
const target = resolve(process.argv[2] || 'cocos/export/02-connect-1234');
// 默认目录是可再生构建产物,清掉 v1 遗留;自定义导出目录不删除用户文件。
if (!process.argv[2]) await rm(target, {recursive:true, force:true});
for (const dir of ['src', 'levels', 'adapters']) await mkdir(join(target, dir), { recursive: true });
const active = ['flow.ts', 'flow-rules.ts', 'cascade.ts', 'supply.ts', 'flow-animation.ts', 'record.ts', 'input.ts', 'rules.ts', 'types.ts', 'config.ts', 'physics.ts', 'random.ts', 'view.ts', 'storage.ts'];
for (const name of active) {
if (!name.endsWith('.ts')) continue;
let source = await readFile(join('src', name), 'utf8');
// Creator 不必依赖 bundler 的 JSON module 支持,导出时生成等价 TypeScript 数据模块。
source = source.replace("'../levels/spawn.json'", "'../levels/spawn-data'").replace("'../levels/campaign.json'", "'../levels/campaign-data'");
await writeFile(join(target, 'src', name), source);
}
for (const name of ['spawn']) {
const json = await readFile(`levels/${name}.json`, 'utf8');
await writeFile(join(target, 'levels', `${name}-data.ts`), `// 由 export-cocos.mjs 生成;在原始 levels/${name}.json 编辑。\nexport default ${json.trim()};\n`);
}
await copyFile('cocos/Connect1234Board.ts', join(target, 'adapters/Connect1234Board.ts'));
console.log(`Cocos 迁移包已导出:${target}`);
-32
View File
@@ -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;
}
}
-46
View File
@@ -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 };
}
-18
View File
@@ -1,18 +0,0 @@
import spawn from '../levels/spawn.json';
export const SPAWN = spawn;
export const WIDTH = 360;
export const HEIGHT = 416;
export const SLACK = spawn.connectSlack;
export const radius = (n: number): number => Math.min(82, spawn.radius.base + spawn.radius.perLevel * (n - 1));
export const PALETTE = [
{ fill: '#74cfae', ink: '#2d8e71', edge: '#5abc9a' },
{ fill: '#ff8a9b', ink: '#c94e72', edge: '#ec768e' },
{ fill: '#79bafa', ink: '#3e88d0', edge: '#62a7eb' },
{ fill: '#f9d16c', ink: '#c69738', edge: '#ecc059' },
{ fill: '#b29aef', ink: '#8062bb', edge: '#a184e0' },
{ fill: '#ffac79', ink: '#d67f45', edge: '#f29a67' },
{ fill: '#77d3e1', ink: '#3ba2ba', edge: '#62c3d5' },
{ fill: '#ec94cd', ink: '#ba60a0', edge: '#dc7ebe' },
{ fill: '#94a6fa', ink: '#667bce', edge: '#8095ed' },
{ fill: '#ffbb62', ink: '#d8943c', edge: '#f5a94d' },
];
@@ -1,50 +0,0 @@
import { copyBalls, type Reaction } from './cascade';
import { HEIGHT } from './config';
import type { FlowResult } from './flow';
import { step } from './physics';
import type { Ball } from './types';
/** 合成→下落→接力→再下落的有序时间线。Web / Cocos 共用。 */
export class FlowAnimation {
private frame = 0;
private beat = -1;
private falling: Ball[] = [];
reaction: Reaction | null = null;
activePath: number[] = [];
constructor(readonly result: FlowResult, private start: number) {}
private durationAt(index: number): number { return this.result.beats[index].kind === 'merge' ? 155 : this.result.kind === 'breeze' ? 390 : 215; }
get duration(): number { return this.result.beats.reduce((sum, _, i) => sum + this.durationAt(i), 0); }
sample(now: number): Ball[] | null {
let elapsed = Math.max(0, now - this.start), index = 0;
while (index < this.result.beats.length && elapsed >= this.durationAt(index)) elapsed -= this.durationAt(index++);
if (index >= this.result.beats.length) { this.activePath = []; this.reaction = null; return null; }
const event = this.result.beats[index];
if (index !== this.beat) {
this.beat = index; this.frame = 0;
if (event.kind === 'fall') this.falling = copyBalls(event.supply.falling);
}
const t = elapsed / this.durationAt(index);
if (event.kind === 'merge') {
const stage = event.stage, chain = stage.path, eased = 1 - (1 - t) ** 3;
this.reaction = stage; this.activePath = chain.map(b => b.id);
const others = stage.before.filter(b => !this.activePath.includes(b.id));
const ghosts = chain.slice(0, -1).map((b, i) => {
const p = i + (chain.length - 1 - i) * eased, a = Math.floor(p), fraction = p - a;
const first = chain[a], last = chain[Math.min(a + 1, chain.length - 1)];
return { ...b, x: first.x + (last.x - first.x) * fraction, y: first.y + (last.y - first.y) * fraction, r: b.r * (1 - eased * .96) };
});
const fresh = t > .48 ? { ...stage.created, r: stage.created.r * (1 + Math.sin((t - .48) / .52 * Math.PI) * .12) } : chain[chain.length - 1];
return [...others, ...ghosts, fresh];
}
this.reaction = null; this.activePath = [];
const target = Math.floor(t * (event.supply.frames + event.supply.relaxFrames));
while (this.frame < target) {
const base = event.supply.frames;
step(this.falling, this.frame < base ? 1 / 60 : 0, this.frame >= base);
if (this.frame === base - 1) for (const b of this.falling) { b.vx = 0; b.vy = 0; }
this.frame++;
}
if (event.flight) return [...this.falling, { ...event.flight, y: event.flight.y - t * t * HEIGHT * 1.8, x: event.flight.x + Math.sin(t * 5) * 12 }];
return this.falling;
}
}
-27
View File
@@ -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;
}
-106
View File
@@ -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(); }
}
-132
View File
@@ -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';
}
}
-13
View File
@@ -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); }
-35
View File
@@ -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];
}
}
-3
View File
@@ -1,3 +0,0 @@
import campaign from '../levels/campaign.json';
import type { Level } from './types';
export const LEVELS = campaign.levels as Level[];
-51
View File
@@ -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);
}
-33
View File
@@ -1,33 +0,0 @@
export const SAVE_KEY = 'mode:02-connect-1234:v1';
export interface RecordEntry { stars: number; score: number }
export interface Progress { version: 1; levels: Record<number, RecordEntry>; bestScore: number; bestNumber: number; sound: boolean }
import type { StoragePort } from './storage';
export type { StoragePort } from './storage';
export function fresh(): Progress { return { version: 1, levels: {}, bestScore: 0, bestNumber: 0, sound: true }; }
export function loadProgress(storage: StoragePort): Progress {
try {
const p = JSON.parse(storage.read(SAVE_KEY) || 'null');
const next = fresh();
if (!p || p.version !== 1) return next;
if (typeof p.sound === 'boolean') next.sound = p.sound;
for (let i = 1; i <= 10; i++) {
const v = p.levels?.[i];
if (v && Number.isInteger(v.stars) && v.stars >= 1 && v.stars <= 3 && Number.isFinite(v.score) && v.score >= 0)
next.levels[i] = { stars: v.stars, score: v.score };
}
if (Number.isFinite(p.bestScore) && p.bestScore >= 0) next.bestScore = p.bestScore;
if (Number.isInteger(p.bestNumber) && p.bestNumber >= 0) next.bestNumber = p.bestNumber;
return next;
} catch { return fresh(); }
}
export function saveProgress(storage: StoragePort, progress: Progress): boolean {
try { storage.write(SAVE_KEY, JSON.stringify(progress)); return true; } catch { return false; }
}
export function unlocked(p: Progress): number {
let level = 1;
while (level < 10 && p.levels[level]) level++;
return level;
}
export function starsFor(merges: number, par: number, usedShuffle: boolean): number {
return merges <= par && !usedShuffle ? 3 : merges <= par + 2 ? 2 : 1;
}
-15
View File
@@ -1,15 +0,0 @@
/** 可序列化的 PRNG。重试、撤销、Web / Cocos 使用同一序列。 */
export class Random {
constructor(public state: number) { this.state >>>= 0; }
next(): number {
this.state = (Math.imul(1664525, this.state) + 1013904223) >>> 0;
return this.state / 4294967296;
}
shuffle<T>(items: T[]): T[] {
for (let i = items.length - 1; i > 0; i--) {
const j = Math.floor(this.next() * (i + 1));
[items[i], items[j]] = [items[j], items[i]];
}
return items;
}
}
-25
View File
@@ -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; }
}
-30
View File
@@ -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;
}
-59
View File
@@ -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] || [];
}
-2
View File
@@ -1,2 +0,0 @@
/** 宿主存储接口,Web / Cocos 各自注入实现。 */
export interface StoragePort { read(key: string): string | null; write(key: string, value: string): void }
-91
View File
@@ -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 };
}
-17
View File
@@ -1,17 +0,0 @@
/** 平台无关;所有坐标以左上为原点,逻辑像素。 */
export interface Point { x: number; y: number }
export interface Ball extends Point { id: number; n: number; r: number; vx: number; vy: number }
export interface Goal { kind: 'score' | 'number' | 'chain' | 'merges' | 'reverse'; value: number; count?: number }
export interface Level {
id: number; title: string; subtitle: string; lesson: string; difficulty: number;
seed: number; moves: number; par: number; count: number; initial: number[];
refill: number[]; goals: Goal[]; solution?: number[][];
}
export interface Stats { score: number; merges: number; maxNumber: number; longest: number; reverse: number; made: Record<number, number> }
export type Status = 'playing' | 'won' | 'lost' | 'stuck';
export interface Snapshot {
balls: Ball[]; stats: Stats; movesLeft: number; nextId: number;
refillIndex: number; rng: number; shuffles: number; status: Status; history: number[][];
}
export interface MergeResult { before: Ball[]; after: Ball[]; path: Ball[]; created: Ball; points: number; spawned: Ball[] }
export interface GoalView { text: string; current: number; target: number; complete: boolean }
-60
View File
@@ -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;
}
-139
View File
@@ -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);
});
-117
View File
@@ -1,117 +0,0 @@
// v1 关卡版回归:保留历史实验,当前 Web / Cocos 主入口使用 FlowGame。
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { Game } from '../src/game';
import { Gesture } from '../src/input';
import { LEVELS } from '../src/levels';
import { WIDTH, HEIGHT, radius } from '../src/config';
import { adjacent, canAppend, findChains, legal, resultNumber } from '../src/rules';
import { fresh, loadProgress, saveProgress, starsFor, unlocked } from '../src/progress';
import { settle } from '../src/physics';
import { hint } from '../src/solver';
import type { Ball } from '../src/types';
const ball = (n: number, id = n, x = n * 45, y = 120): Ball => ({ id, n, x, y, r: radius(n), vx: 0, vy: 0 });
test('正反连续链得到同一个最大数 +1;不能同号、跳号、换向或重复', () => {
const chain = [ball(1), ball(2), ball(3), ball(4)];
assert(legal(chain)); assert(legal([...chain].reverse()));
assert.equal(resultNumber(chain), 5); assert.equal(resultNumber([...chain].reverse()), 5);
assert(!legal(chain.slice(0, 2))); assert(!legal([chain[0], chain[1], chain[3]]));
assert(!canAppend(chain.slice(0, 3), ball(2, 9, 150)));
assert(!legal([ball(2, 1, 100), ball(2, 2, 140), ball(2, 3, 180)]));
assert(!canAppend([chain[0], chain[1]], chain[0]));
});
test('邻接只看圆心距离,边界是半径和的 1.35 倍', () => {
const a = ball(1, 1, 0), b = ball(2, 2, (22 + 28) * 1.35);
assert(adjacent(a, b)); b.x += .001; assert(!adjacent(a, b));
});
test('合成位置取链尾,最后一步目标完成优先于步数失败', () => {
const g = new Game({ ...LEVELS[0], moves: 1 });
const ids = hint(g), tail = g.balls.find(b => b.id === ids[ids.length - 1])!;
const x = tail.x, y = tail.y, r = tail.r;
const result = g.commit(ids)!;
assert.equal(result.created.id, tail.id); assert.equal(result.created.x, x); assert.equal(result.created.y, y);
assert(result.created.r > r); assert.equal(g.stats.score, 1200); assert.equal(g.movesLeft, 0); assert.equal(g.status, 'won');
});
test('非法和过时 ID 不改变盘面、步数和随机数', () => {
const g = new Game(LEVELS[2]), before = g.snapshot();
assert.equal(g.commit([1, 2]), null); assert.equal(g.commit([991, 992, 993]), null);
assert.deepEqual(g.snapshot(), before); assert.equal(g.canUndo, false);
});
test('同一种子、撤销重走后,补球、坐标、分数都完全一致', () => {
const g = new Game(LEVELS[4]), ids = hint(g), initial = g.snapshot();
assert.deepEqual(initial, new Game(LEVELS[4]).snapshot());
assert(g.commit(ids)); const after = g.snapshot();
assert(g.undo()); assert.deepEqual(g.snapshot(), initial);
assert(g.commit(ids)); assert.deepEqual(g.snapshot(), after);
});
test('快滑采样中间球;回到上一颗撤回,取消不提交', () => {
const balls = [ball(1, 1, 40), ball(2, 2, 90), ball(3, 3, 145)];
const input = new Gesture(() => balls);
input.down(balls[0]); input.move(balls[2]); assert.deepEqual(input.chain, [1, 2, 3]);
input.move(balls[1]); assert.deepEqual(input.chain, [1, 2]);
input.move(balls[2]); assert.deepEqual(input.up(), [1, 2, 3]);
assert.equal(input.active, false); assert.deepEqual(input.up(), []);
input.down(balls[0]); input.move(balls[2]); input.cancel(); assert.deepEqual(input.up(), []);
});
test('打乱保留数字、不扣步且只用一次;撤销恢复打乱之前', () => {
const g = new Game(LEVELS[4]), before = g.snapshot(), numbers = g.balls.map(b => b.n).sort();
assert(g.shuffle()); assert.equal(g.shuffles, 0); assert.equal(g.movesLeft, before.movesLeft);
assert.deepEqual(g.balls.map(b => b.n).sort(), numbers); assert(!g.shuffle());
assert(g.undo()); assert.deepEqual(g.snapshot(), before);
});
test('无解数字不会空扣打乱次数', () => {
const g = new Game(LEVELS[0]); g.balls.forEach(b => { b.n = 1; b.r = 22; }); g.status = 'stuck';
const before = g.snapshot(); assert(!g.shuffle()); assert.deepEqual(g.snapshot(), before);
});
test('目标没完成而步数耗尽,进入失败且允许撤销', () => {
const g = new Game({ ...LEVELS[0], moves: 1, goals: [{ kind: 'number', value: 8 }] });
assert(g.commit(findChains(g.balls)[0])); assert.equal(g.status, 'lost');
assert(g.undo()); assert.equal(g.status, 'playing'); assert.equal(g.movesLeft, 1);
});
test('自由模式不检查关卡目标、不扣步数', () => {
const g = new Game(LEVELS[0], true); assert(g.commit(findChains(g.balls)[0]));
assert.notEqual(g.status, 'won'); assert.equal(g.movesLeft, -1);
});
test('物理将重叠同心球分开并限制左右与底部边界', () => {
const balls = Array.from({ length: 18 }, (_, i) => ball(i % 4 + 1, i + 1, 180, 100)); settle(balls, 400);
for (const a of balls) {
assert(Number.isFinite(a.x) && Number.isFinite(a.y));
assert(a.x >= a.r && a.x <= WIDTH - a.r); assert(a.y <= HEIGHT - a.r);
for (const b of balls) if (a.id !== b.id) assert(Math.hypot(a.x - b.x, a.y - b.y) >= a.r + b.r - 1.5);
}
});
test('损坏/不可用存档不阻塞游戏,非法成绩被过滤', () => {
assert.deepEqual(loadProgress({ read: () => '{broken', write() {} }), fresh());
assert.deepEqual(loadProgress({ read() { throw Error('blocked'); }, write() {} }), fresh());
assert.equal(saveProgress({ read: () => null, write() { throw Error('quota'); } }, fresh()), false);
const p = loadProgress({ read: () => JSON.stringify({ version: 1, levels: { 1: { stars: 3, score: 1200 }, 2: { stars: 99, score: -1 } }, sound: false }), write() {} });
assert.equal(unlocked(p), 2); assert.equal(p.sound, false); assert.equal(p.levels[2], undefined);
assert.equal(starsFor(3, 3, false), 3); assert.equal(starsFor(3, 3, true), 2); assert.equal(starsFor(6, 3, false), 1);
});
for (const level of LEVELS) test(`第 ${level.id} 关:标准解通过真实手势控制器逐步回放`, () => {
const g = new Game(level), input = new Gesture(() => g.balls);
assert(level.solution?.length); assert.equal(g.status, 'playing');
for (const path of level.solution!) {
assert.deepEqual(hint(g), path);
const targets = path.map(id => g.balls.find(b => b.id === id)!);
input.down(targets[0]); for (const target of targets.slice(1)) input.move(target);
assert.deepEqual(input.chain, path, `第 ${g.stats.merges + 1} 步不能直接划到`);
assert(g.commit(input.up()));
for (const b of g.balls) {
assert(b.y >= b.r - 2, `球 ${b.id} 超出顶部`);
assert(b.x >= b.r && b.x <= WIDTH - b.r && b.y <= HEIGHT - b.r);
}
}
assert.equal(g.status, 'won'); assert(g.stats.merges <= level.moves);
});
test('动画不改变规则状态,跳帧与逐帧采样得到相同物理画面', async () => {
const { MergeAnimation } = await import('../src/animation');
const g = new Game(LEVELS[7]), result = g.commit(hint(g))!, saved = g.snapshot();
const a = new MergeAnimation(result, 0), b = new MergeAnimation(result, 0);
for (let t = 0; t < 700; t += 16) a.sample(t);
assert.deepEqual(a.sample(700), b.sample(700));
assert.deepEqual(g.snapshot(), saved);
assert.equal(a.sample(760), null);
});
@@ -1,33 +0,0 @@
import assert from 'node:assert/strict';
import {writeFile} from 'node:fs/promises';
// 本次任务已有的空间;新的验收任务请改成已创建的空间 ID。
const task=await taskSpace(4),page=task.page('p1');
const dir='/Users/wukaibing/Grok/number-swipe/modes/02-connect-1234/docs/qa-v2';
await page.keyboard.press('Escape');
assert(await page.evaluate(()=>document.querySelector('#modal').hidden));
await page.click('#records'); await page.screenshot({path:dir+'/records.png'});
await page.keyboard.press('Escape');
const layouts=[];
for(const [width,height] of [[320,568],[360,740],[390,844],[430,932],[844,390],[1280,900]]){
await page.cdp('Emulation.setDeviceMetricsOverride',{width,height,deviceScaleFactor:2,mobile:width<600});
await page.waitForFunction(w=>innerWidth===w,width,{timeout:3000});
const layout=await page.evaluate(()=>({width:innerWidth,height:innerHeight,scrollWidth:document.documentElement.scrollWidth,scrollHeight:document.documentElement.scrollHeight,
buttons:[...document.querySelectorAll('#app button')].map(b=>{const r=b.getBoundingClientRect();return {id:b.id,w:r.width,h:r.height,bottom:r.bottom}}),stageHeight:document.querySelector('#stage').getBoundingClientRect().height}));
assert.equal(layout.scrollWidth,width); assert(layout.buttons.every(b=>b.w>=44&&b.h>=44)); layouts.push(layout);
if(width===320)await page.screenshot({path:dir+'/small-phone.png'});
if(width===1280)await page.screenshot({path:dir+'/desktop.png'});
}
await page.cdp('Emulation.setDeviceMetricsOverride',{width:390,height:844,deviceScaleFactor:2,mobile:true});
await page.cdp('Emulation.setEmulatedMedia',{features:[{name:'prefers-reduced-motion',value:'reduce'}]});
await page.click('#restart'); await page.click('button[data-action="new"]');
assert.equal(await page.evaluate(()=>window.__balloons.snapshot().stats.turns),0);
const points=await page.evaluate(()=>{const api=window.__balloons,s=api.snapshot(),t=api.transform(),r=document.querySelector('#board').getBoundingClientRect();return api.hint().map(id=>{const b=s.balls.find(b=>b.id===id);return{x:r.left+t.x+b.x*t.scale,y:r.top+t.y+b.y*t.scale,id:0}})});
for(let i=0;i<points.length;i++)await page.cdp('Input.dispatchTouchEvent',{type:i?'touchMove':'touchStart',touchPoints:[points[i]]});
await page.cdp('Input.dispatchTouchEvent',{type:'touchEnd',touchPoints:[]});
assert.equal(await page.evaluate(()=>window.__balloons.snapshot().stats.turns),1);assert(!await page.evaluate(()=>window.__balloons.animating()));
// 减少动态效果下的横幅仍可显示(不依赖 CSS keyframes)。
const bannerStyle=await page.evaluate(()=>{const el=document.querySelector('#combo-banner');el.classList.add('show');const style=getComputedStyle(el);return{opacity:style.opacity,animation:style.animationName};});
assert.equal(bannerStyle.opacity,'1');assert.equal(bannerStyle.animation,'none');
await page.cdp('Emulation.setEmulatedMedia',{features:[]});
await writeFile(dir+'/layout.json',JSON.stringify({layouts,reducedMotion:true,keyboardModal:true},null,2)+'\n');
console.log(JSON.stringify({layouts:layouts.map(l=>({width:l.width,height:l.height,scrollHeight:l.scrollHeight,stageHeight:l.stageHeight})),reducedMotion:true}));
@@ -1,34 +0,0 @@
import assert from 'node:assert/strict';
import {writeFile} from 'node:fs/promises';
const task=await taskSpace(4),page=task.page('p1');
const dir='/Users/wukaibing/Grok/number-swipe/modes/02-connect-1234/docs/qa-v2';
await page.goto('http://localhost:8002');
const opening=await page.evaluate(()=>({state:window.__balloons.snapshot(),hint:window.__balloons.hint()}));
await page.goto('http://localhost:8003');
await page.waitForFunction(()=>document.querySelector('#app').dataset.mode==='flow',undefined,{timeout:5000});
const key='mode:02-connect-1234:balloons-v2';
const backup=await page.evaluate(k=>({v2:localStorage.getItem(k),v1:localStorage.getItem('mode:02-connect-1234:v1')}),key);
assert.equal(await page.evaluate(()=>typeof window.__balloons),'undefined');
await page.evaluate(()=>{window.qaErrors=[];addEventListener('error',e=>window.qaErrors.push(e.message));addEventListener('unhandledrejection',e=>window.qaErrors.push(String(e.reason)));});
const coords=await page.evaluate(data=>{const r=document.querySelector('#board').getBoundingClientRect(),s=Math.min(r.width/360,r.height/416);return data.hint.map(id=>{const b=data.state.balls.find(b=>b.id===id);return{x:r.left+(r.width-360*s)/2+b.x*s,y:r.top+(r.height-416*s)/2+b.y*s,id:0};});},opening);
for(let i=0;i<coords.length;i++)await page.cdp('Input.dispatchTouchEvent',{type:i?'touchMove':'touchStart',touchPoints:[coords[i]]});
await page.cdp('Input.dispatchTouchEvent',{type:'touchEnd',touchPoints:[]});
await page.waitForFunction(()=>document.querySelector('#app').dataset.animating==='false',undefined,{timeout:7000});
const result=await page.evaluate(()=>({turns:Number(document.querySelector('#app').dataset.turns),score:document.querySelector('#score').textContent,errors:window.qaErrors,debugExposed:typeof window.__balloons!=='undefined'}));
assert.equal(result.turns,1);assert(Number(result.score.replaceAll(',',''))>0);assert.deepEqual(result.errors,[]);
await page.screenshot({path:dir+'/production-merge.png'});
await page.reload();assert.equal(await page.evaluate(()=>document.querySelector('#best-score').textContent),result.score);
await page.click('#help');
await page.waitForFunction(()=>document.querySelector('#sheet').getAnimations().every(a=>a.playState==='finished'),undefined,{timeout:3000});
await page.screenshot({path:dir+'/help.png'});await page.click('button[aria-label="关闭面板"]');
// 仅恢复本次正式包验收前的 v2 纪录;保留其它存档。
await page.evaluate(({key,v2})=>{if(v2===null)localStorage.removeItem(key);else localStorage.setItem(key,v2);},{key,v2:backup.v2});
assert.equal(await page.evaluate(()=>localStorage.getItem('mode:02-connect-1234:v1')),backup.v1);
await page.reload();
await page.screenshot({path:dir+'/mobile.png'});
await page.cdp('Emulation.setDeviceMetricsOverride',{width:1280,height:900,deviceScaleFactor:2,mobile:false});
await page.waitForFunction(()=>innerWidth===1280,undefined,{timeout:3000});await page.screenshot({path:dir+'/desktop.png'});
await page.cdp('Emulation.setDeviceMetricsOverride',{width:390,height:844,deviceScaleFactor:2,mobile:true});
await page.waitForFunction(()=>innerWidth===390,undefined,{timeout:3000});
await writeFile(dir+'/production.json',JSON.stringify({...result,persistence:true,originalRecordRestored:true,legacyRecordPreserved:true,url:await page.url()},null,2)+'\n');
console.log({...result,persistence:true,originalRecordRestored:true,url:await page.url()});
@@ -1,45 +0,0 @@
// 本次任务已有空间为 4;新验收任务需改成已创建的空间 ID。
// 只读调试快照作为触摸定位依据,不改游戏状态。
import assert from 'node:assert/strict';
import { writeFile } from 'node:fs/promises';
const task = await taskSpace(4);
const page = task.page('p1');
const dir = process.env.BALLOON_QA_DIR || '/Users/wukaibing/Grok/number-swipe/modes/02-connect-1234/docs/qa-v2';
await page.reload();
await page.evaluate(() => {window.qaErrors=[];addEventListener('error',e=>window.qaErrors.push(e.message));addEventListener('unhandledrejection',e=>window.qaErrors.push(String(e.reason)));});
const snapshot = () => page.evaluate(() => window.__balloons.snapshot());
const targets = () => page.evaluate(() => {const api=window.__balloons, s=api.snapshot(), t=api.transform(), r=document.querySelector('#board').getBoundingClientRect();return api.hint().map(id=>{const b=s.balls.find(b=>b.id===id);return {x:r.left+t.x+b.x*t.scale,y:r.top+t.y+b.y*t.scale,id:0,radiusX:5,radiusY:5,force:1};});});
const touch = (type, points=[]) => page.cdp('Input.dispatchTouchEvent',{type,touchPoints:points});
const swipe = async(points,cancel=false) => {
for(let i=0;i<points.length;i++) await touch(i?'touchMove':'touchStart',[points[i]]);
await touch(cancel?'touchCancel':'touchEnd');
};
const opening=await snapshot(), points=await targets();
await swipe(points.slice(0,2)); assert.deepEqual(await snapshot(),opening);
await swipe(points,true); assert.deepEqual(await snapshot(),opening);
await swipe([points[0],points[1],points[2],points[1]]); assert.deepEqual(await snapshot(),opening);
const moves=[];
for(let i=0;i<24;i++){
const before=await snapshot(); await swipe(await targets());
await page.waitForFunction(()=>!window.__balloons.animating(),undefined,{timeout:7000});
const after=await snapshot(); assert.equal(after.stats.turns,before.stats.turns+1); assert(after.stats.score>before.stats.score);
assert(await page.evaluate(()=>document.querySelector('#modal').hidden));
moves.push({turn:after.stats.turns,score:after.stats.score,combo:after.stats.lastCombo,peak:after.stats.maxNumber,flown:after.stats.flown,feverLeft:after.feverLeft});
if(i===0){
await page.screenshot({path:dir+'/first-merge.png'});
await page.click('#undo'); assert.deepEqual(await snapshot(),before);
await swipe(await targets()); await page.waitForFunction(()=>!window.__balloons.animating(),undefined,{timeout:7000});
assert.deepEqual(await snapshot(),after);
}
if(after.feverLeft===5 && !moves.slice(0,-1).some(m=>m.feverLeft===5)) await page.screenshot({path:dir+'/rainbow.png'});
if(after.stats.flown===1 && !moves.slice(0,-1).some(m=>m.flown>0)) await page.screenshot({path:dir+'/flight.png'});
}
const beforeBreeze=await snapshot(); await page.click('#shuffle');
await page.waitForFunction(()=>!window.__balloons.animating(),undefined,{timeout:7000});
assert.deepEqual((await snapshot()).stats,beforeBreeze.stats);
await page.click('#undo'); assert.deepEqual(await snapshot(),beforeBreeze);
const errors=await page.evaluate(()=>window.qaErrors); assert.deepEqual(errors,[]);
assert(moves.some(m=>m.combo>=3)); assert(moves.some(m=>m.flown>0)); assert(moves.some(m=>m.feverLeft>0));
await writeFile(dir+'/browser.json',JSON.stringify({viewport:[390,844],input:'CDP touch events',invalidCancelBacktrack:true,undoDeterministic:true,breezeUndo:true,moves,errors},null,2)+'\n');
console.log(JSON.stringify({gestures:moves.length,final:moves.at(-1),errors}));
console.log(await page.snapshot());
@@ -1,54 +0,0 @@
const space = Number(process.env.GARDEN_SPACE);
if (!space || !process.env.GARDEN_QA_DIR) throw new Error('请设置 GARDEN_SPACE 与 GARDEN_QA_DIR,复用当前 QA 空间。');
const task = await taskSpace(space), page = task.page('p1');
const fs = await import('node:fs/promises');
const root = process.env.GARDEN_QA_DIR;
await page.reload();
await page.waitForFunction(() => !!window.__garden);
const saved = await page.evaluate(() => JSON.parse(localStorage.getItem('mode:02-connect-1234:v1')));
if (Object.keys(saved.levels).length !== 10) throw new Error('completion not persisted');
let before = await page.evaluate(() => window.__garden.snapshot());
const touchHint = async () => {
const data = await page.evaluate(() => {
const g=window.__garden, s=g.snapshot(), t=g.transform(), r=document.getElementById('board').getBoundingClientRect();
return { merges:s.stats.merges, points:g.hint().map(id=>{const b=s.balls.find(b=>b.id===id);return {x:r.left+t.x+b.x*t.scale,y:r.top+t.y+b.y*t.scale};})};
});
for (let i=0;i<data.points.length;i++) await page.cdp('Input.dispatchTouchEvent',{type:i?'touchMove':'touchStart',touchPoints:[{...data.points[i],id:0,radiusX:5,radiusY:5,force:1}]});
await page.cdp('Input.dispatchTouchEvent',{type:'touchEnd',touchPoints:[]});
await page.waitForFunction(n=>window.__garden.snapshot().stats.merges===n+1&&!window.__garden.animating(),data.merges,{timeout:5000});
};
await touchHint();
const after = await page.evaluate(() => window.__garden.snapshot());
await page.click('#undo');
if (JSON.stringify(await page.evaluate(()=>window.__garden.snapshot()))!==JSON.stringify(before)) throw new Error('undo not exact');
await touchHint();
if (JSON.stringify(await page.evaluate(()=>window.__garden.snapshot()))!==JSON.stringify(after)) throw new Error('redo not deterministic');
await page.click('#undo');
await page.click('#shuffle');
const shuffled = await page.evaluate(()=>window.__garden.snapshot());
if (shuffled.shuffles!==0 || shuffled.movesLeft!==before.movesLeft || JSON.stringify(shuffled.balls.map(b=>b.n).sort())!==JSON.stringify(before.balls.map(b=>b.n).sort())) throw new Error('shuffle mismatch');
await page.click('#undo');
if (JSON.stringify(await page.evaluate(()=>window.__garden.snapshot()))!==JSON.stringify(before)) throw new Error('shuffle undo mismatch');
await page.click('#levels'); await page.click('[data-action="endless"]');
await touchHint();
if ((await page.evaluate(()=>window.__garden.snapshot())).movesLeft!==-1) throw new Error('endless consumed move');
await page.click('#levels'); await page.click('[data-level="8"]');
const layouts=[];
for (const [width,height,mobile] of [[320,568,true],[360,740,true],[390,844,true],[430,932,true],[844,390,true],[1280,900,false]]) {
await page.cdp('Emulation.setDeviceMetricsOverride',{width,height,deviceScaleFactor:2,mobile});
await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))));
const layout=await page.evaluate(()=>({width:innerWidth,height:innerHeight,overflow:document.documentElement.scrollWidth>innerWidth+1,bodyHeight:document.documentElement.scrollHeight,buttons:[...document.querySelectorAll('.tools button,.header-actions button')].map(b=>({id:b.id,w:b.getBoundingClientRect().width,h:b.getBoundingClientRect().height}))}));
if(layout.overflow)throw new Error(`horizontal overflow at ${width}`);
if(layout.buttons.some(b=>b.w<44||b.h<44))throw new Error(`small touch target at ${width}`);
layouts.push(layout);
if(width===320||width===1280)await page.screenshot({path:`${root}/${width===320?'mobile-320':'desktop'}.png`});
}
await page.cdp('Emulation.setDeviceMetricsOverride',{width:390,height:844,deviceScaleFactor:2,mobile:true});
await page.click('#help');
await page.waitForFunction(()=>!document.getElementById('sheet').getAnimations().some(a=>a.playState==='running'));
await page.screenshot({path:`${root}/mobile-help.png`});
await page.keyboard.press('Escape');
if(!(await page.evaluate(()=>document.getElementById('modal').hidden)))throw new Error('escape not closed');
await fs.writeFile(`${root}/layout.json`,JSON.stringify({checks:['刷新保留十关成绩','撤销精确恢复盘面及补球','重走结果确定一致','打乱保留数字和步数','撤销恢复打乱库存','自由模式无步数限制','帮助可用 Escape 关闭'],layouts},null,2));
console.log(JSON.stringify(layouts));
console.log(await page.snapshot());
@@ -1,59 +0,0 @@
// 通过 ego-browser nodejs < tools/browser-smoke.mjs 运行;仅访问本地测试页面。
// 空间编号由首次 browser QA 建立,同一任务一直复用该空间。
const space = Number(process.env.GARDEN_SPACE);
if (!space || !process.env.GARDEN_QA_DIR) throw new Error('请设置 GARDEN_SPACE 与 GARDEN_QA_DIR,复用当前 QA 空间。');
const task = await taskSpace(space), page = task.page('p1');
await page.reload();
await page.waitForFunction(() => !!window.__garden);
const fs = await import('node:fs/promises');
const root = process.env.GARDEN_QA_DIR;
const report = { checks: [], levels: [] };
await page.evaluate(() => { window.__qaErrors = []; window.addEventListener('error', e => window.__qaErrors.push(e.message)); window.addEventListener('unhandledrejection', e => window.__qaErrors.push(String(e.reason))); });
const info = () => page.evaluate(() => {
const g = window.__garden, s = g.snapshot(), t = g.transform(), r = document.getElementById('board').getBoundingClientRect();
return { snapshot: s, level: g.level(), hint: g.hint(), points: g.hint().map(id => { const b = s.balls.find(b => b.id === id); return { x: r.left + t.x + b.x * t.scale, y: r.top + t.y + b.y * t.scale }; }) };
});
const send = async (type, points) => page.cdp('Input.dispatchTouchEvent', { type, touchPoints: points.map((p, id) => ({...p, id, radiusX: 5, radiusY: 5, force: 1})) });
let initial = await info();
if (initial.level.id !== 1) throw new Error('QA expects an untouched campaign');
// 两颗松手、系统取消、回滑撤回均不得扣步。
await send('touchStart', [initial.points[0]]); await send('touchMove', [initial.points[1]]); await send('touchEnd', []);
if ((await info()).snapshot.stats.merges !== 0) throw new Error('short gesture committed');
report.checks.push('两颗松手不扣步');
await send('touchStart', [initial.points[0]]); await send('touchMove', [initial.points[1]]); await send('touchCancel', []);
if ((await info()).snapshot.stats.merges !== 0) throw new Error('cancel committed');
report.checks.push('触摸取消不提交');
await send('touchStart', [initial.points[0]]); await send('touchMove', [initial.points[1]]); await send('touchMove', [initial.points[2]]); await send('touchMove', [initial.points[1]]); await send('touchEnd', []);
if ((await info()).snapshot.stats.merges !== 0) throw new Error('backtrack failed');
report.checks.push('回滑撤回');
await page.click('#sound');
await page.click('#hint');
await page.screenshot({path: `${root}/mobile-initial.png`});
for (let expected = 1; expected <= 10; expected++) {
let data = await info();
if (data.level.id !== expected) throw new Error(`Expected L${expected}, got ${data.level.id}`);
for (let turn = 0; turn < data.level.moves; turn++) {
data = await info();
if (data.snapshot.status === 'won') break;
if (data.snapshot.status !== 'playing') throw new Error(`L${expected} prematurely ${data.snapshot.status}`);
const previous = data.snapshot.stats.merges;
await send('touchStart', [data.points[0]]);
for (const p of data.points.slice(1)) await send('touchMove', [p]);
await send('touchEnd', []);
await page.waitForFunction(previous => window.__garden.snapshot().stats.merges > previous && !window.__garden.animating(), previous, { timeout: 5000 });
}
data = await info();
if (data.snapshot.status !== 'won') throw new Error(`L${expected} not won`);
report.levels.push({ level: expected, moves: data.snapshot.stats.merges, score: data.snapshot.stats.score });
console.log(`触摸通关 L${expected} ${data.snapshot.stats.merges} 步 ${data.snapshot.stats.score} 分`);
if (expected === 7) await page.screenshot({path: `${root}/mobile-result.png`});
if (expected < 10) {
await page.click('[data-action="next"]');
if (expected === 7) await page.screenshot({path: `${root}/mobile-level8.png`});
}
}
await page.screenshot({path: `${root}/mobile-all-clear.png`});
report.errors = await page.evaluate(() => window.__qaErrors);
if (report.errors.length) throw new Error(JSON.stringify(report.errors));
await fs.writeFile(`${root}/browser.json`, JSON.stringify(report, null, 2));
console.log(await page.snapshot());
-25
View File
@@ -1,25 +0,0 @@
import { readFileSync, writeFileSync } from 'node:fs';
import { Game } from '../src/game';
import { solve } from '../src/solver';
import type { Level } from '../src/types';
const file = new URL('../levels/campaign.json', import.meta.url);
const pack = JSON.parse(readFileSync(file, 'utf8')) as { levels: Level[] };
for (const level of pack.levels) {
if (level.solution?.length) continue;
let found = false;
for (let trial = 0; trial < 24; trial++) {
const start = Date.now();
const game = new Game(level);
const solution = solve(game, level.id < 7 ? 12 : 18);
console.log(`L${level.id} seed=${level.seed} ${solution ? `WIN ${solution.path.length} moves / ${solution.visited} states` : 'no proof'} ${Date.now() - start}ms`);
if (solution) {
level.solution = solution.path;
level.par = solution.path.length;
writeFileSync(file, JSON.stringify(pack, null, 2) + '\n');
found = true; break;
}
level.seed += 137;
}
if (!found) throw new Error(`Unable to find L${level.id}`);
}
@@ -1,53 +0,0 @@
import assert from 'node:assert/strict';
import { mkdirSync, writeFileSync } from 'node:fs';
import { FlowGame } from '../src/flow';
import { findFlowChains, canFlowAppend } from '../src/flow-rules';
import { Gesture } from '../src/input';
import { HEIGHT, WIDTH } from '../src/config';
import { Random } from '../src/random';
let states = 0, maxOverlap = 0;
function check(game: FlowGame): void {
states++;
assert(game.balls.length <= 24); assert.equal(new Set(game.balls.map(b => b.id)).size, game.balls.length);
for (const a of game.balls) {
assert(Number.isFinite(a.x) && Number.isFinite(a.y)); assert(a.n >= 1 && a.n < 10);
assert(a.x >= a.r && a.x <= WIDTH - a.r && a.y >= a.r - 2 && a.y <= HEIGHT - a.r, `outside: ${JSON.stringify(a)}`);
for (const b of game.balls) if (a.id < b.id) {
const overlap = a.r + b.r - Math.hypot(a.x - b.x, a.y - b.y);
maxOverlap = Math.max(maxOverlap, overlap); assert(overlap < 2, `overlap ${overlap}`);
}
}
assert(findFlowChains(game.balls).length > 0, 'dead board');
}
const report = [];
for (const strategy of ['random', 'guided'] as const) {
const sessions = strategy === 'random' ? 24 : 8;
let combos = 0, reactions = 0, flown = 0, breezes = 0, minChoices = Infinity;
const times: number[] = [], runs = [];
for (let seed = 1; seed <= sessions; seed++) {
const game = new FlowGame(seed * 937), rng = new Random(seed), input = new Gesture(() => game.balls, canFlowAppend);
check(game); let comboGestures = 0;
for (let turn = 0; turn < 100; turn++) {
const paths = findFlowChains(game.balls); minChoices = Math.min(minChoices, paths.length);
const ids = strategy === 'guided' ? game.hint() : paths[Math.floor(rng.next() * paths.length)];
assert(ids.length >= 3, 'hint missing');
if (strategy === 'guided') {
const targets = ids.map(id => game.balls.find(b => b.id === id)!);
input.down(targets[0]); targets.slice(1).forEach(b => input.move(b)); assert.deepEqual(input.up(), ids, 'unplayable hint');
}
const started = performance.now(), result = game.commit(ids); times.push(performance.now() - started);
assert(result); if (result.stages.length > 1) {combos++;comboGestures++;} check(game);
assert(result.stages.length <= (result.doubled ? 4 : 3));
}
flown += game.stats.flown; reactions += game.stats.reactions; breezes += game.breezes;
runs.push({seed:seed * 937,score:game.stats.score,peak:game.stats.maxNumber,flown:game.stats.flown,comboGestures,breezes:game.breezes});
}
times.sort((a,b)=>a-b);
const metrics = {strategy,sessions,gestures:sessions*100,comboGestures:combos,comboRate:combos/(sessions*100),reactions,flown,breezes,minPaths:minChoices,
commitMs:{median:times[Math.floor(times.length*.5)],p95:times[Math.floor(times.length*.95)],max:times[times.length-1]}};
report.push({...metrics,runs}); console.log(JSON.stringify(metrics));
}
const result = {generatedAt:new Date().toISOString(),note:'固定种子算法抽样;随机策略从合法链均匀抽取,提示策略通过真实手势控制器。连消率不是玩家数据。',states,maxOverlap,strategies:report};
mkdirSync(new URL('../docs/qa-v2/',import.meta.url),{recursive:true});
writeFileSync(new URL('../docs/qa-v2/validation.json',import.meta.url),JSON.stringify(result,null,2)+'\n');
console.log(`检查 ${states} 个稳定盘面;最大重叠 ${maxOverlap.toFixed(3)}px。`);
-57
View File
@@ -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`);
-9
View File
@@ -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"]
}
-9
View File
@@ -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"]
}
-23
View File
@@ -1,23 +0,0 @@
export class Sound {
enabled = true;
private context?: AudioContext;
unlock(): void {
if (!this.enabled) return;
try { this.context ||= new AudioContext(); if (this.context.state === 'suspended') void this.context.resume().catch(() => {}); } catch { /* 无音频设备仍能玩 */ }
}
note(n: number, merge = false): void {
if (!this.enabled || !this.context) return;
try {
const c = this.context, time = c.currentTime, gain = c.createGain(), oscillator = c.createOscillator();
oscillator.type = 'sine'; oscillator.frequency.value = 220 * Math.pow(2, (n + (merge ? 5 : 0)) / 12);
gain.gain.setValueAtTime(0, time); gain.gain.linearRampToValueAtTime(0.09, time + 0.015);
gain.gain.exponentialRampToValueAtTime(0.001, time + (merge ? 0.32 : 0.13));
oscillator.connect(gain); gain.connect(c.destination); oscillator.start(); oscillator.stop(time + 0.36);
} catch { /* 音效不是游戏运行前提 */ }
}
pop(n: number, combo: number, fever: boolean): void {
this.note(n + combo * 2 + (fever ? 3 : 0), true);
if (combo > 1) window.setTimeout(() => this.note(n + combo * 2 + 5, true), 45);
}
}
@@ -1,22 +0,0 @@
import type { Draw } from '../src/view';
export function paint(ctx: CanvasRenderingContext2D, commands: Draw[]): void {
for (const d of commands) {
ctx.save(); ctx.globalAlpha = d.alpha ?? 1;
ctx.fillStyle = d.fill || 'transparent'; ctx.strokeStyle = d.stroke || 'transparent';
ctx.lineWidth = d.width || 1; ctx.lineCap = 'round'; ctx.lineJoin = 'round';
ctx.beginPath();
if (d.type === 'circle') ctx.arc(d.x, d.y, Math.max(0, d.r), 0, Math.PI * 2);
else if (d.type === 'ellipse') ctx.ellipse(d.x, d.y, d.rx, d.ry, 0, 0, Math.PI * 2);
else if (d.type === 'path') {
if (d.dash) ctx.setLineDash(d.dash);
d.points.forEach((p, i) => i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y));
if (d.close) ctx.closePath();
} else {
ctx.font = `${d.weight || 600} ${d.size}px "Avenir Next", "Nunito", "PingFang SC", sans-serif`;
ctx.textAlign = d.align || 'center'; ctx.textBaseline = 'middle';
if (d.stroke) ctx.strokeText(d.text, d.x, d.y);
ctx.fillText(d.text, d.x, d.y); ctx.restore(); continue;
}
if (d.fill) ctx.fill(); if (d.stroke) ctx.stroke(); ctx.restore();
}
}
-61
View File
@@ -1,61 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<meta name="theme-color" content="#f3f9ff">
<meta name="description" content="连连气球:顺号连,同号也能连。自动连消、彩虹双倍、明亮气球,轻轻一划一直玩。">
<title>连连气球 · POP & LINK</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Cellipse cx='32' cy='27' rx='22' ry='26' fill='%2379bafa'/%3E%3Cpath d='m32 50-5 8h10Z' fill='%2362a7eb'/%3E%3Cellipse cx='24' cy='14' rx='7' ry='3' fill='white'/%3E%3C/svg%3E">
<link rel="stylesheet" href="style.css">
</head>
<body>
<svg class="symbols" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><defs><symbol id="i-balloon" viewBox="0 0 24 24"><ellipse cx="12" cy="9" rx="6.5" ry="8"/><path d="m12 17-2 3h4l-2-3M12 20c-4 2 3 2 0 4M9 5l-1 2"/></symbol><symbol id="i-star" viewBox="0 0 24 24"><path d="m12 3 2.8 5.8 6.4.9-4.6 4.5 1.1 6.3-5.7-3-5.7 3 1.1-6.3-4.6-4.5 6.4-.9Z"/></symbol>
<symbol id="i-leaf" viewBox="0 0 24 24"><path d="M12 21V11M12 14C3 15 3 7 3 5c6 0 10 3 9 9ZM12 10c0-6 4-8 9-8 0 6-3 10-9 10"/></symbol>
<symbol id="i-grid" viewBox="0 0 24 24"><rect x="4" y="4" width="6" height="6" rx="2"/><rect x="14" y="4" width="6" height="6" rx="2"/><rect x="4" y="14" width="6" height="6" rx="2"/><rect x="14" y="14" width="6" height="6" rx="2"/></symbol>
<symbol id="i-sound" viewBox="0 0 24 24"><path d="m11 5-5 4H3v6h3l5 4V5ZM15 8c3 2 3 6 0 8M18 5c5 4 5 10 0 14"/></symbol>
<symbol id="i-muted" viewBox="0 0 24 24"><path d="m11 5-5 4H3v6h3l5 4V5ZM16 9l5 6m0-6-5 6"/></symbol>
<symbol id="i-help" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M9 9a3 3 0 0 1 6 0c0 2-3 2-3 5M12 17h.01"/></symbol>
<symbol id="i-undo" viewBox="0 0 24 24"><path d="m8 4-5 5 5 5M3 9h11a6 6 0 0 1 0 12h-3"/></symbol>
<symbol id="i-hint" viewBox="0 0 24 24"><path d="M8 16c0-3-3-3-3-7a7 7 0 0 1 14 0c0 4-3 4-3 7H8ZM9 20h6M10 23h4M10 11l2 2 2-2M12 13v3"/></symbol>
<symbol id="i-shuffle" viewBox="0 0 24 24"><path d="M3 6h3c5 0 6 12 11 12h4m-4-4 4 4-4 4M3 18h3c2 0 4-3 6-6s3-6 5-6h4m-4-4 4 4-4 4"/></symbol>
<symbol id="i-restart" viewBox="0 0 24 24"><path d="M4 9a8 8 0 1 1 0 7M4 3v6h6"/></symbol>
<symbol id="i-arrow" viewBox="0 0 24 24"><path d="M4 12h16m-6-6 6 6-6 6"/></symbol>
<symbol id="i-close" viewBox="0 0 24 24"><path d="m6 6 12 12M6 18 18 6"/></symbol>
<symbol id="i-check" viewBox="0 0 24 24"><path d="m5 12 4 4L19 6"/></symbol>
</defs></svg>
<aside class="desktop-note" aria-hidden="true"><div class="floating-balloon b-one">1</div><div class="floating-balloon b-two">2</div><div class="floating-balloon b-three">3</div><p>POP. LINK. REPEAT.</p><h2>轻轻一划,<br>快乐就连起来了。</h2><span>让数字长大,让心情放飞。</span></aside>
<main class="app" id="app">
<header class="brand-bar">
<div class="brand"><span class="brand-mark"><svg><use href="#i-balloon"/></svg></span><div><h1>连连气球</h1><span>POP & LINK</span></div></div>
<div class="header-actions"><button class="icon-button" id="sound" aria-label="关闭音效"><svg><use href="#i-sound"/></svg></button><button class="icon-button" id="help" aria-label="玩法说明"><svg><use href="#i-help"/></svg></button></div>
</header>
<section class="scoreboard" aria-label="本局成绩">
<div class="score-main"><span class="eyebrow">本局得分</span><strong id="score">0</strong><span class="best-line">最佳 <b id="best-score">0</b></span></div>
<button class="collection" id="records" aria-label="查看放飞记录"><span class="best-balloon" id="peak">4</span><span>最大气球</span></button>
</section>
<section class="flow-bar" aria-label="补给和双倍能量">
<div class="fever-block"><div class="fever-label"><span id="fever-label">彩虹能量</span><b id="fever-count">0 / 10</b></div><div class="fever-track" role="progressbar" id="fever-meter" aria-label="彩虹能量" aria-valuemin="0" aria-valuemax="10" aria-valuenow="0"><i id="fever-fill"></i></div></div>
<div class="upcoming"><span>接下来</span><div id="queue" aria-label="接下来掉落的数字"></div></div>
</section>
<section class="stage" id="stage" aria-label="气球游乐场">
<canvas id="board" aria-label="按住气球,连接至少三个连续数字或三个相同数字,再松手。新球会自动接着合成。" role="img"></canvas>
<div class="welcome" id="welcome"><span>1 · 2 · 3 <i>或</i> 2 · 2 · 2</span><p>顺号连,同号也能连</p></div>
<div class="combo-banner" id="combo-banner" aria-live="polite"></div>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<span class="stage-caption" id="stage-caption">合成 10,放飞一颗快乐</span>
</section>
<div class="chain-preview" id="chain-preview" aria-live="polite">连起 3 颗,看看能接出几连消</div>
<nav class="tools" aria-label="游戏工具">
<button id="undo" disabled><svg><use href="#i-undo"/></svg><span>撤一步</span></button>
<button id="hint" class="hint-button"><svg><use href="#i-hint"/></svg><span>找一串</span></button>
<button id="shuffle"><svg><use href="#i-shuffle"/></svg><span>吹口气</span></button>
<button id="restart"><svg><use href="#i-restart"/></svg><span>新一局</span></button>
</nav>
<footer class="game-footer"><span id="footer-text">不计时,不限步,开心一直连。</span><span id="flight-count">已放飞 0 颗</span></footer>
</main>
<div class="modal-backdrop" id="modal" hidden><section class="sheet" id="sheet" role="dialog" aria-modal="true" aria-labelledby="sheet-title" tabindex="-1"></section></div>
<noscript>请启用 JavaScript 来玩连连气球。</noscript>
<script src="dist/app.js"></script>
</body>
</html>
-215
View File
@@ -1,215 +0,0 @@
import { WIDTH, HEIGHT, PALETTE } from '../src/config';
import { FlowGame, FEVER_TARGET, type FlowResult } from '../src/flow';
import { FlowAnimation } from '../src/flow-animation';
import { canFlowAppend } from '../src/flow-rules';
import { Gesture } from '../src/input';
import { drawBoard } from '../src/view';
import { loadRecord, saveRecord, recordRound } from '../src/record';
import type { Reaction } from '../src/cascade';
import type { Point } from '../src/types';
import { paint } from './canvas-painter';
import { Sound } from './audio';
declare const __DEV__: boolean;
const $ = <T extends HTMLElement = HTMLElement>(id: string): T => document.getElementById(id) as T;
const icon = (name: string) => `<svg aria-hidden="true"><use href="#i-${name}"/></svg>`;
const format = (n: number) => Math.round(n).toLocaleString('en-US');
const canvas = $<HTMLCanvasElement>('board'), ctx = canvas.getContext('2d')!;
const modal = $('modal'), sheet = $('sheet');
const storage = { read: (key: string) => localStorage.getItem(key), write: (key: string, value: string) => localStorage.setItem(key, value) };
const record = loadRecord(storage), sound = new Sound(); sound.enabled = record.sound;
const reduced = matchMedia('(prefers-reduced-motion: reduce)');
let game = new FlowGame(1234), gesture = new Gesture(() => game.balls, canFlowAppend);
let hints = game.hint(), hintUntil = Infinity;
let transform = { scale: 1, x: 0, y: 0, dpr: 1 };
let animation: FlowAnimation | null = null, seenReaction = 0;
let pointer: number | null = null, previousFocus: HTMLElement | null = null;
let toastTimer = 0, bannerTimer = 0, previewKey = '', saveAvailable = true;
let displayScore = 0, scoreTarget = 0, lastFrame = 0;
function save(): void { recordRound(record, game.stats); saveAvailable = saveRecord(storage, record); }
function resize(): void {
const r = canvas.getBoundingClientRect(), dpr = Math.min(3, devicePixelRatio || 1), scale = Math.min(r.width / WIDTH, r.height / HEIGHT);
canvas.width = Math.round(r.width * dpr); canvas.height = Math.round(r.height * dpr);
transform = { scale, x: (r.width - WIDTH * scale) / 2, y: (r.height - HEIGHT * scale) / 2, dpr };
}
function coordinates(e: PointerEvent): Point {
const r = canvas.getBoundingClientRect();
return { x: (e.clientX - r.left - transform.x) / transform.scale, y: (e.clientY - r.top - transform.y) / transform.scale };
}
function cancel(): void {
gesture.cancel(); const id = pointer; pointer = null;
if (id !== null && canvas.hasPointerCapture(id)) canvas.releasePointerCapture(id);
updatePreview();
}
function toast(message: string): void {
clearTimeout(toastTimer); $('toast').textContent = message; $('toast').classList.add('show');
toastTimer = window.setTimeout(() => $('toast').classList.remove('show'), 2100);
}
function banner(title: string, subtitle: string, rainbow = false): void {
clearTimeout(bannerTimer); const el = $('combo-banner');
el.className = `combo-banner${rainbow ? ' rainbow' : ''}`; el.innerHTML = `<strong>${title}</strong><span>${subtitle}</span>`;
void el.offsetWidth; el.classList.add('show');
bannerTimer = window.setTimeout(() => el.classList.remove('show'), 1100);
}
function updateSound(): void {
$('sound').innerHTML = icon(sound.enabled ? 'sound' : 'muted');
$('sound').setAttribute('aria-label', sound.enabled ? '关闭音效' : '开启音效'); $('sound').setAttribute('aria-pressed', String(sound.enabled));
}
function updateHUD(): void {
$('app').dataset.mode = 'flow'; $('app').dataset.turns = String(game.stats.turns); $('app').dataset.animating = String(!!animation);
$('app').classList.toggle('fever', game.fever);
$('peak').textContent = String(game.stats.maxNumber);
const p = PALETTE[(game.stats.maxNumber - 1) % PALETTE.length]; $('peak').style.backgroundColor = p.fill;
$('best-score').textContent = format(record.bestScore);
$('fever-label').textContent = game.fever ? '彩虹时刻 · 双倍得分' : '彩虹能量';
$('fever-count').textContent = game.fever ? `剩 ${game.feverLeft} 次` : `${game.feverCharge} / ${FEVER_TARGET}`;
$('fever-fill').style.width = `${game.fever ? game.feverLeft / 5 * 100 : game.feverCharge / FEVER_TARGET * 100}%`;
$('fever-meter').setAttribute('aria-valuenow', String(game.fever ? game.feverLeft * 2 : game.feverCharge));
$('fever-meter').setAttribute('aria-valuetext', game.fever ? `双倍得分,剩 ${game.feverLeft} 次合成` : `${game.feverCharge} / 10`);
$('queue').innerHTML = game.queue.slice(0, 3).map(n => `<span class="next-ball" style="background:${PALETTE[n - 1].fill}">${n}</span>`).join('');
$('queue').setAttribute('aria-label', `接下来掉落 ${game.queue.slice(0, 3).join('、')}`);
$<HTMLButtonElement>('undo').disabled = !game.canUndo || !!animation;
$<HTMLButtonElement>('hint').disabled = !!animation; $<HTMLButtonElement>('shuffle').disabled = !!animation;
$('welcome').classList.toggle('hidden', game.stats.turns > 0 || !!animation);
$('welcome').setAttribute('aria-hidden', String(game.stats.turns > 0 || !!animation));
$('flight-count').textContent = `已放飞 ${game.stats.flown} 颗`;
$('footer-text').textContent = saveAvailable ? '不计时,不限步,开心一直连。' : '浏览器未允许保存,成绩暂存本次';
$('stage-caption').textContent = game.fever ? '彩虹时刻 · 最多 4 连消' : '合成 10,放飞一颗快乐';
updatePreview();
}
function updatePreview(): void {
const ids = gesture.chain, key = `${ids.join(',')}/${game.stats.turns}/${!!animation}`;
if (key === previewKey) return; previewKey = key;
const el = $('chain-preview');
if (!ids.length) {
el.innerHTML = animation ? '啵!还有下一连…' : game.stats.turns < 2 ? '连起 3 颗,看看能接出几连消' : '顺号或同号都能连 · 新球还能自动接力';
return;
}
const balls = ids.map(id => game.balls.find(b => b.id === id)!);
const result = game.preview(ids), visible = balls.length > 5 ? [...balls.slice(0, 2), null, ...balls.slice(-2)] : balls;
el.innerHTML = visible.map(b => b ? `<span class="mini-ball" style="background:${PALETTE[(b.n - 1) % PALETTE.length].fill}">${b.n}</span>` : '<span>…</span>').join('<span>·</span>') +
(result ? `<span>→</span><b>${result.stages[result.stages.length - 1].created.n}</b>${result.stages.length > 1 ? `<span class="predict">${result.stages.length} 连消</span>` : ''}<span class="gain">+${format(result.score)} 起</span>` : `<span class="gain">再连 ${Math.max(0, 3 - ids.length)} 颗</span>`);
}
function feedback(stage: Reaction): void {
const x = Math.max(65, Math.min($('stage').clientWidth - 65, transform.x + stage.created.x * transform.scale)), y = transform.y + stage.created.y * transform.scale;
const el = document.createElement('span'); el.className = 'score-pop'; el.textContent = `+${format(stage.points)}`;
el.style.left = `${x}px`; el.style.top = `${y - 16}px`; $('stage').append(el); setTimeout(() => el.remove(), 750);
sound.pop(stage.created.n, stage.index, animation?.result.doubled || false);
if (stage.index >= 2) banner(`${stage.index} 连消!`, stage.index >= 3 ? '一口气,停不下来' : '接上了,再长大一点');
if (stage.created.n >= 10) banner('放飞啦!', '10 号气球 · 额外 +10,000', true);
if (!reduced.matches) for (let i = 0; i < 10; i++) {
const spark = document.createElement('i'); spark.className = 'spark'; spark.style.left = `${x}px`; spark.style.top = `${y}px`;
const angle = i / 10 * Math.PI * 2;
spark.style.setProperty('--dx', `${Math.cos(angle) * (30 + stage.index * 7)}px`);
spark.style.setProperty('--dy', `${Math.sin(angle) * (30 + stage.index * 7)}px`);
spark.style.setProperty('--spark-color', PALETTE[i % PALETTE.length].fill);
$('stage').append(spark); setTimeout(() => spark.remove(), 700);
}
}
function begin(result: FlowResult): void {
hints = []; seenReaction = 0; animation = new FlowAnimation(result, performance.now());
scoreTarget = game.stats.score - result.score;
if (result.kind === 'breeze') toast('呼——换个位置,继续连。');
updateHUD();
if (reduced.matches) finish();
}
function finish(): void {
const result = animation?.result; animation = null;
scoreTarget = game.stats.score; displayScore = game.stats.score;
save(); updateHUD();
if (result?.feverStarted) banner('彩虹时刻!', '接下来 5 次合成 · 双倍得分', true);
else if (result?.supply.breeze && result.kind !== 'breeze') toast('微风送来新气球,接着连。');
}
function commit(ids: number[]): void {
const result = game.commit(ids);
if (result) begin(result);
else { if (ids.length) toast('至少连 3 颗顺号或同号气球。'); updatePreview(); }
}
function frame(now: number): void {
const dt = Math.min(60, now - lastFrame || 16); lastFrame = now;
let balls = game.balls;
if (animation) {
const sample = animation.sample(now);
if (sample) {
balls = sample;
const reaction = animation.reaction;
if (reaction && reaction.index > seenReaction) {
for (const event of animation.result.stages.filter(s => s.index > seenReaction && s.index <= reaction.index)) { scoreTarget += event.points; feedback(event); }
seenReaction = reaction.index;
}
} else finish();
}
displayScore += (scoreTarget - displayScore) * (1 - Math.exp(-dt / 95));
if (Math.abs(displayScore - scoreTarget) < 1) displayScore = scoreTarget;
const score = format(displayScore); if ($('score').textContent !== score) $('score').textContent = score;
if (now > hintUntil) hints = [];
const t = transform;
ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.setTransform(t.dpr * t.scale, 0, 0, t.dpr * t.scale, t.dpr * t.x, t.dpr * t.y);
paint(ctx, drawBoard({ balls, chain: animation ? animation.activePath : gesture.chain, hint: animation ? [] : hints, time: now / 1000, fever: game.fever, reducedMotion: reduced.matches }));
requestAnimationFrame(frame);
}
function undo(): void {
if (animation) return; cancel();
if (game.undo()) { hints = []; displayScore = scoreTarget = game.stats.score; previewKey = ''; updateHUD(); toast('已撤回,补球和彩虹能量一起还原。'); }
}
function newRound(): void {
cancel(); animation = null; clearTimeout(bannerTimer); clearTimeout(toastTimer);
$('stage').querySelectorAll('.spark,.score-pop').forEach(el => el.remove()); $('combo-banner').classList.remove('show'); $('toast').classList.remove('show');
game = new FlowGame(Date.now() >>> 0); gesture = new Gesture(() => game.balls, canFlowAppend);
hints = game.hint(); hintUntil = performance.now() + 4500; displayScore = scoreTarget = 0; previewKey = '';
closeSheet(); updateHUD();
}
function openSheet(html: string): void {
cancel(); if (animation) finish();
if (modal.hidden) previousFocus = document.activeElement as HTMLElement;
sheet.innerHTML = html; modal.hidden = false; $('app').inert = true; sheet.focus();
}
function closeSheet(): void { modal.hidden = true; $('app').inert = false; previousFocus?.focus(); previousFocus = null; }
const closeButton = `<button class="icon-button close" data-action="close" aria-label="关闭面板">${icon('close')}</button>`;
function showHelp(): void {
openSheet(`<div class="sheet-head"><div><span class="eyebrow">A LITTLE POP, A LOT OF JOY</span><h2 id="sheet-title">一划,就连起来</h2></div>${closeButton}</div><div class="help-chain">${[1, 2, 3, 4].map((n, i) => `${i ? `<span>${i === 3 ? '→' : '·'}</span>` : ''}<span class="help-ball" style="background:${PALETTE[n - 1].fill}">${n}</span>`).join('')}</div><div class="help-rule"><span>1</span><p><b>顺号连,同号也能连。</b><br>连接至少 3 颗相邻气球:1→2→3、3→2→1,或 2→2→2。顺号和同号不能混在一条里。</p></div><div class="help-rule"><span>2</span><p><b>松手变大,自动接着合成。</b><br>新数字是最大数 +1,留在连线末端。周围或新掉落的气球能接上,就自动连消,最多 3 连!</p></div><div class="help-rule"><span>3</span><p><b>攒满彩虹,双倍快乐。</b><br>长链和连消积攒能量,满 10 格后,接下来 5 次合成双倍得分,最多 4 连消。没有倒计时。</p></div><div class="help-rule"><span>4</span><p><b>合成 10,让它飞向天空。</b><br>10 号气球自动放飞,奖励 10,000 分,腾出空间继续玩。没有关卡和步数限制,提示、撤销、吹口气都免费。</p></div><button class="primary" data-action="close">知道了,接着连 ${icon('arrow')}</button>`,);
}
function showRecords(restart = false): void {
save();
openSheet(`<div class="sheet-head"><div><span class="eyebrow">YOUR BALLOON SKY</span><h2 id="sheet-title">${restart ? '这一局,飞得不错' : '你的放飞记录'}</h2></div>${closeButton}</div><div class="record-hero"><strong>${format(game.stats.score)}</strong><span>本局得分 · 最佳 ${format(record.bestScore)}</span></div><div class="record-grid"><div><strong>${game.stats.bestCombo}</strong><span>本局最高连消</span></div><div><strong>${game.stats.reactions}</strong><span>已完成合成</span></div><div><strong>${game.stats.flown}</strong><span>已放飞气球</span></div></div><div class="sky-collection">${[4, 6, 8, 10].map(n => `<div class="sky-item ${record.bestNumber >= n ? 'earned' : ''}"><span class="help-ball" style="background:${PALETTE[n - 1].fill}">${n}</span><span>${record.bestNumber >= n ? '已点亮' : '等你来连'}</span></div>`).join('')}</div><p class="small-note">最高 ${record.bestCombo} 连消 · 单局最多放飞 ${record.bestFlown} 颗<br>想继续就继续,想重来就换一片天空。</p><button class="primary" data-action="${restart ? 'new' : 'close'}">${restart ? '开始新一局' : '回去接着连'} ${icon('arrow')}</button><button class="secondary" data-action="${restart ? 'close' : 'new'}">${restart ? '继续这一局' : '换一片天空 · 新一局'}</button>${saveAvailable ? '' : '<p class="save-note">浏览器未允许保存,记录暂存本次会话。</p>'}`);
}
canvas.addEventListener('pointerdown', e => {
if (e.button !== 0 || !e.isPrimary || pointer !== null || animation || !modal.hidden) return;
e.preventDefault(); sound.unlock(); pointer = e.pointerId; canvas.setPointerCapture(pointer); hints = [];
gesture.down(coordinates(e)); updatePreview();
if (gesture.chain.length) sound.note(game.balls.find(b => b.id === gesture.chain[0])!.n);
});
canvas.addEventListener('pointermove', e => {
if (e.pointerId !== pointer || animation) return;
const old = gesture.chain.join(','); gesture.move(coordinates(e));
if (old !== gesture.chain.join(',')) { const n = game.balls.find(b => b.id === gesture.chain[gesture.chain.length - 1])?.n; if (n) sound.note(n); updatePreview(); }
});
canvas.addEventListener('pointerup', e => {
if (e.pointerId !== pointer) return; gesture.move(coordinates(e)); const ids = gesture.up(); pointer = null;
if (canvas.hasPointerCapture(e.pointerId)) canvas.releasePointerCapture(e.pointerId); commit(ids);
});
canvas.addEventListener('pointercancel', e => { if (e.pointerId === pointer) cancel(); });
canvas.addEventListener('lostpointercapture', e => { if (e.pointerId === pointer) cancel(); });
window.addEventListener('blur', cancel); window.addEventListener('resize', () => { cancel(); resize(); });
document.addEventListener('visibilitychange', () => { if (document.hidden) cancel(); });
new ResizeObserver(resize).observe($('stage'));
$('sound').addEventListener('click', () => { sound.enabled = !sound.enabled; record.sound = sound.enabled; sound.unlock(); updateSound(); save(); });
$('help').addEventListener('click', showHelp); $('records').addEventListener('click', () => showRecords());
$('undo').addEventListener('click', undo); $('restart').addEventListener('click', () => showRecords(true));
$('shuffle').addEventListener('click', () => { if (!animation) { cancel(); begin(game.shuffle()); } });
$('hint').addEventListener('click', () => { if (animation) return; cancel(); hints = game.hint(); hintUntil = performance.now() + 7000; if (hints.length) toast('沿白色标记连起来,松手看它自动接力。'); else toast('吹口气,换个位置试试。'); });
modal.addEventListener('click', e => { if (e.target === modal) closeSheet(); });
sheet.addEventListener('click', e => { const action = (e.target as HTMLElement).closest<HTMLButtonElement>('button')?.dataset.action; if (action === 'close') closeSheet(); if (action === 'new') newRound(); });
document.addEventListener('keydown', e => {
if (!modal.hidden) {
if (e.key === 'Escape') { e.preventDefault(); closeSheet(); }
if (e.key === 'Tab') { const buttons = [...sheet.querySelectorAll<HTMLButtonElement>('button:not(:disabled)')], first = buttons[0], last = buttons[buttons.length - 1];
if (e.shiftKey && (document.activeElement === first || document.activeElement === sheet)) { e.preventDefault(); last?.focus(); }
else if (!e.shiftKey && (document.activeElement === last || document.activeElement === sheet)) { e.preventDefault(); first?.focus(); } }
} else if (!e.ctrlKey && !e.metaKey && !e.altKey) {
if (e.key.toLowerCase() === 'z') undo(); if (e.key.toLowerCase() === 'h') $('hint').click(); if (e.key === 'Escape') cancel();
}
});
updateSound(); updateHUD(); resize(); requestAnimationFrame(frame);
if (__DEV__) Object.assign(window, { __balloons: { snapshot: () => game.snapshot(), hint: () => game.hint(), transform: () => ({ ...transform }), animating: () => !!animation } });
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,6 +1,6 @@
# 玩法三 · 六边一笔
本文只描述**玩法三**。玩法一、玩法二有自己的目录和 GDD,见仓库 [`docs/PRODUCT.md`](../../../docs/PRODUCT.md)。
本文只描述**玩法三**。玩法一(数字一笔)有自己的目录和 GDD,见仓库 [`docs/PRODUCT.md`](../../../docs/PRODUCT.md)。
在六边形拼板上画一条哈密顿路径。格子之间的边上可以有墙,墙挡住那一条邻接。预先写好的数字,必须刚好在那一步走到。