Kaynağa Gözat

first commit

iaun 1 ay önce
işleme
09dfddc1bc

+ 3 - 0
.gitignore

@@ -0,0 +1,3 @@
+node_modules/
+dist/
+.kiro/

+ 2 - 0
.vscode/settings.json

@@ -0,0 +1,2 @@
+{
+}

BIN
DIN1451.ttf


+ 67 - 0
README.md

@@ -0,0 +1,67 @@
+# 小球消除方块
+
+一款基于 Canvas 的弹球消方块小游戏,支持经典模式和增强模式。
+
+## 环境要求
+
+- Node.js >= 18
+
+## 安装依赖
+
+```bash
+npm install
+```
+
+## 调试(本地开发)
+
+项目使用 ES Modules,需要通过 HTTP 服务器运行。推荐使用 `npx serve`:
+
+```bash
+npx serve .
+```
+
+浏览器打开 `http://localhost:3000` 即可。
+
+也可以使用任意静态服务器,如 VS Code 的 Live Server 插件,或 Python:
+
+```bash
+python -m http.server 8080
+```
+
+## 测试
+
+```bash
+npm test
+```
+
+使用 Vitest + jsdom 运行 170 个单元测试和属性测试。
+
+## 打包(生产构建)
+
+安装 Vite 后可一键打包:
+
+```bash
+npx vite build
+```
+
+产物输出到 `dist/` 目录,可直接部署到任意静态托管服务。
+
+如需自定义打包配置,创建 `vite.config.js` 并参考 [Vite 文档](https://vitejs.dev/)。
+
+## 项目结构
+
+```
+├── index.html          # 入口页面
+├── style.css           # 样式
+├── DIN1451.ttf         # 数字字体
+├── src/
+│   ├── main.js         # 入口脚本
+│   ├── Game.js         # 游戏主控
+│   ├── constants.js    # 常量配置
+│   ├── entities/       # 实体(Ball, Block, Item)
+│   ├── systems/        # 系统(Renderer, Physics, BoardManager, GuideLine, InputHandler)
+│   ├── ui/             # UI 组件(ModeSelector)
+│   └── utils/          # 工具函数(color)
+├── tests/              # 测试文件
+└── game.js             # 原始参考实现
+```

+ 18 - 0
color.txt

@@ -0,0 +1,18 @@
+000 248 180 43
+005 196 188 54
+010 131 197 68
+015 184 160 40
+020 236 123 12
+025 232 85 56
+030 227 46 100
+035 211 40 118
+040 195 34 135
+045 169 48 143
+050 143 62 151
+055 83 90 170
+060 22 117 189
+065 16 109 180
+070 10 101 171
+075 5 127 156
+080 0 153 141
+085 0 142 127

+ 45 - 0
index.html

@@ -0,0 +1,45 @@
+<!DOCTYPE html>
+<html lang="zh">
+<head>
+    <meta charset="UTF-8">
+    <meta name="x5-fullscreen" content="true">
+    <meta name="apple-mobile-web-app-capable" content="yes"/>
+    <meta name="viewport"
+          content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no">
+    <title>小球消除方块</title>
+    <link rel="preload" href="DIN1451.ttf" type="font/ttf" crossorigin="anonymous" as="font">
+    <link rel="stylesheet" href="style.css">
+</head>
+<body>
+
+<div id="contain">
+    <div id="header" class="header">
+        <p id="level">1</p>
+    </div>
+    <canvas id="can" width="0" height="0"></canvas>
+    <div id="footer" class="footer">
+        <p id="ball-count"></p>
+    </div>
+</div>
+
+<!-- 模式选择覆盖层 -->
+<div id="mode-selector" class="overlay">
+    <div class="overlay-content">
+        <h2 class="overlay-title">选择游戏模式</h2>
+        <button class="mode-btn" data-mode="1">经典模式</button>
+        <button class="mode-btn" data-mode="2">增强模式</button>
+    </div>
+</div>
+
+<!-- 游戏结束覆盖层 -->
+<div id="game-over" class="overlay" style="display: none;">
+    <div class="overlay-content">
+        <h2 class="overlay-title">游戏结束</h2>
+        <p id="final-score" class="final-score"></p>
+        <button id="restart-btn" class="mode-btn">重新开始</button>
+    </div>
+</div>
+
+<script type="module" src="src/main.js"></script>
+</body>
+</html>

+ 2132 - 0
package-lock.json

@@ -0,0 +1,2132 @@
+{
+  "name": "ball-block-breaker",
+  "version": "1.0.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "ball-block-breaker",
+      "version": "1.0.0",
+      "devDependencies": {
+        "fast-check": "^4.1.1",
+        "jsdom": "^26.1.0",
+        "vitest": "^3.2.1"
+      }
+    },
+    "node_modules/@asamuzakjp/css-color": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz",
+      "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@csstools/css-calc": "^2.1.3",
+        "@csstools/css-color-parser": "^3.0.9",
+        "@csstools/css-parser-algorithms": "^3.0.4",
+        "@csstools/css-tokenizer": "^3.0.3",
+        "lru-cache": "^10.4.3"
+      }
+    },
+    "node_modules/@csstools/color-helpers": {
+      "version": "5.1.0",
+      "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
+      "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/csstools"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/csstools"
+        }
+      ],
+      "license": "MIT-0",
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@csstools/css-calc": {
+      "version": "2.1.4",
+      "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz",
+      "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/csstools"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/csstools"
+        }
+      ],
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "peerDependencies": {
+        "@csstools/css-parser-algorithms": "^3.0.5",
+        "@csstools/css-tokenizer": "^3.0.4"
+      }
+    },
+    "node_modules/@csstools/css-color-parser": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz",
+      "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/csstools"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/csstools"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "@csstools/color-helpers": "^5.1.0",
+        "@csstools/css-calc": "^2.1.4"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "peerDependencies": {
+        "@csstools/css-parser-algorithms": "^3.0.5",
+        "@csstools/css-tokenizer": "^3.0.4"
+      }
+    },
+    "node_modules/@csstools/css-parser-algorithms": {
+      "version": "3.0.5",
+      "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz",
+      "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/csstools"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/csstools"
+        }
+      ],
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "peerDependencies": {
+        "@csstools/css-tokenizer": "^3.0.4"
+      }
+    },
+    "node_modules/@csstools/css-tokenizer": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
+      "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/csstools"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/csstools"
+        }
+      ],
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/aix-ppc64": {
+      "version": "0.27.3",
+      "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
+      "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "aix"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-arm": {
+      "version": "0.27.3",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz",
+      "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-arm64": {
+      "version": "0.27.3",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz",
+      "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-x64": {
+      "version": "0.27.3",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz",
+      "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/darwin-arm64": {
+      "version": "0.27.3",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz",
+      "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/darwin-x64": {
+      "version": "0.27.3",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz",
+      "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/freebsd-arm64": {
+      "version": "0.27.3",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz",
+      "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/freebsd-x64": {
+      "version": "0.27.3",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz",
+      "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-arm": {
+      "version": "0.27.3",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz",
+      "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-arm64": {
+      "version": "0.27.3",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz",
+      "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-ia32": {
+      "version": "0.27.3",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz",
+      "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-loong64": {
+      "version": "0.27.3",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz",
+      "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-mips64el": {
+      "version": "0.27.3",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz",
+      "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==",
+      "cpu": [
+        "mips64el"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-ppc64": {
+      "version": "0.27.3",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz",
+      "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-riscv64": {
+      "version": "0.27.3",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz",
+      "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-s390x": {
+      "version": "0.27.3",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz",
+      "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-x64": {
+      "version": "0.27.3",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz",
+      "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/netbsd-arm64": {
+      "version": "0.27.3",
+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz",
+      "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/netbsd-x64": {
+      "version": "0.27.3",
+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz",
+      "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openbsd-arm64": {
+      "version": "0.27.3",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz",
+      "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openbsd-x64": {
+      "version": "0.27.3",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz",
+      "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openharmony-arm64": {
+      "version": "0.27.3",
+      "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz",
+      "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openharmony"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/sunos-x64": {
+      "version": "0.27.3",
+      "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz",
+      "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "sunos"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-arm64": {
+      "version": "0.27.3",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz",
+      "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-ia32": {
+      "version": "0.27.3",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz",
+      "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-x64": {
+      "version": "0.27.3",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz",
+      "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@jridgewell/sourcemap-codec": {
+      "version": "1.5.5",
+      "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+      "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@rollup/rollup-android-arm-eabi": {
+      "version": "4.57.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz",
+      "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ]
+    },
+    "node_modules/@rollup/rollup-android-arm64": {
+      "version": "4.57.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz",
+      "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ]
+    },
+    "node_modules/@rollup/rollup-darwin-arm64": {
+      "version": "4.57.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz",
+      "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@rollup/rollup-darwin-x64": {
+      "version": "4.57.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz",
+      "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@rollup/rollup-freebsd-arm64": {
+      "version": "4.57.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz",
+      "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-freebsd-x64": {
+      "version": "4.57.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz",
+      "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+      "version": "4.57.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz",
+      "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+      "version": "4.57.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz",
+      "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm64-gnu": {
+      "version": "4.57.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz",
+      "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm64-musl": {
+      "version": "4.57.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz",
+      "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-loong64-gnu": {
+      "version": "4.57.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz",
+      "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-loong64-musl": {
+      "version": "4.57.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz",
+      "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+      "version": "4.57.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz",
+      "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-ppc64-musl": {
+      "version": "4.57.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz",
+      "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+      "version": "4.57.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz",
+      "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-riscv64-musl": {
+      "version": "4.57.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz",
+      "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-s390x-gnu": {
+      "version": "4.57.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz",
+      "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-x64-gnu": {
+      "version": "4.57.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz",
+      "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-x64-musl": {
+      "version": "4.57.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz",
+      "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-openbsd-x64": {
+      "version": "4.57.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz",
+      "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-openharmony-arm64": {
+      "version": "4.57.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz",
+      "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openharmony"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-arm64-msvc": {
+      "version": "4.57.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz",
+      "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-ia32-msvc": {
+      "version": "4.57.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz",
+      "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-x64-gnu": {
+      "version": "4.57.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz",
+      "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-x64-msvc": {
+      "version": "4.57.1",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz",
+      "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@types/chai": {
+      "version": "5.2.3",
+      "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+      "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/deep-eql": "*",
+        "assertion-error": "^2.0.1"
+      }
+    },
+    "node_modules/@types/deep-eql": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+      "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/estree": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+      "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@vitest/expect": {
+      "version": "3.2.4",
+      "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
+      "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/chai": "^5.2.2",
+        "@vitest/spy": "3.2.4",
+        "@vitest/utils": "3.2.4",
+        "chai": "^5.2.0",
+        "tinyrainbow": "^2.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "node_modules/@vitest/mocker": {
+      "version": "3.2.4",
+      "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz",
+      "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@vitest/spy": "3.2.4",
+        "estree-walker": "^3.0.3",
+        "magic-string": "^0.30.17"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      },
+      "peerDependencies": {
+        "msw": "^2.4.9",
+        "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
+      },
+      "peerDependenciesMeta": {
+        "msw": {
+          "optional": true
+        },
+        "vite": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@vitest/pretty-format": {
+      "version": "3.2.4",
+      "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz",
+      "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "tinyrainbow": "^2.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "node_modules/@vitest/runner": {
+      "version": "3.2.4",
+      "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz",
+      "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@vitest/utils": "3.2.4",
+        "pathe": "^2.0.3",
+        "strip-literal": "^3.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "node_modules/@vitest/snapshot": {
+      "version": "3.2.4",
+      "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz",
+      "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@vitest/pretty-format": "3.2.4",
+        "magic-string": "^0.30.17",
+        "pathe": "^2.0.3"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "node_modules/@vitest/spy": {
+      "version": "3.2.4",
+      "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz",
+      "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "tinyspy": "^4.0.3"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "node_modules/@vitest/utils": {
+      "version": "3.2.4",
+      "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz",
+      "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@vitest/pretty-format": "3.2.4",
+        "loupe": "^3.1.4",
+        "tinyrainbow": "^2.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "node_modules/agent-base": {
+      "version": "7.1.4",
+      "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
+      "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 14"
+      }
+    },
+    "node_modules/assertion-error": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+      "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/cac": {
+      "version": "6.7.14",
+      "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
+      "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/chai": {
+      "version": "5.3.3",
+      "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
+      "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "assertion-error": "^2.0.1",
+        "check-error": "^2.1.1",
+        "deep-eql": "^5.0.1",
+        "loupe": "^3.1.0",
+        "pathval": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/check-error": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
+      "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 16"
+      }
+    },
+    "node_modules/cssstyle": {
+      "version": "4.6.0",
+      "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz",
+      "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@asamuzakjp/css-color": "^3.2.0",
+        "rrweb-cssom": "^0.8.0"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/data-urls": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
+      "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "whatwg-mimetype": "^4.0.0",
+        "whatwg-url": "^14.0.0"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/debug": {
+      "version": "4.4.3",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+      "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ms": "^2.1.3"
+      },
+      "engines": {
+        "node": ">=6.0"
+      },
+      "peerDependenciesMeta": {
+        "supports-color": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/decimal.js": {
+      "version": "10.6.0",
+      "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
+      "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/deep-eql": {
+      "version": "5.0.2",
+      "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
+      "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/entities": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
+      "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
+      "dev": true,
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=0.12"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/entities?sponsor=1"
+      }
+    },
+    "node_modules/es-module-lexer": {
+      "version": "1.7.0",
+      "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
+      "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/esbuild": {
+      "version": "0.27.3",
+      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
+      "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "bin": {
+        "esbuild": "bin/esbuild"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "optionalDependencies": {
+        "@esbuild/aix-ppc64": "0.27.3",
+        "@esbuild/android-arm": "0.27.3",
+        "@esbuild/android-arm64": "0.27.3",
+        "@esbuild/android-x64": "0.27.3",
+        "@esbuild/darwin-arm64": "0.27.3",
+        "@esbuild/darwin-x64": "0.27.3",
+        "@esbuild/freebsd-arm64": "0.27.3",
+        "@esbuild/freebsd-x64": "0.27.3",
+        "@esbuild/linux-arm": "0.27.3",
+        "@esbuild/linux-arm64": "0.27.3",
+        "@esbuild/linux-ia32": "0.27.3",
+        "@esbuild/linux-loong64": "0.27.3",
+        "@esbuild/linux-mips64el": "0.27.3",
+        "@esbuild/linux-ppc64": "0.27.3",
+        "@esbuild/linux-riscv64": "0.27.3",
+        "@esbuild/linux-s390x": "0.27.3",
+        "@esbuild/linux-x64": "0.27.3",
+        "@esbuild/netbsd-arm64": "0.27.3",
+        "@esbuild/netbsd-x64": "0.27.3",
+        "@esbuild/openbsd-arm64": "0.27.3",
+        "@esbuild/openbsd-x64": "0.27.3",
+        "@esbuild/openharmony-arm64": "0.27.3",
+        "@esbuild/sunos-x64": "0.27.3",
+        "@esbuild/win32-arm64": "0.27.3",
+        "@esbuild/win32-ia32": "0.27.3",
+        "@esbuild/win32-x64": "0.27.3"
+      }
+    },
+    "node_modules/estree-walker": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+      "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/estree": "^1.0.0"
+      }
+    },
+    "node_modules/expect-type": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
+      "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=12.0.0"
+      }
+    },
+    "node_modules/fast-check": {
+      "version": "4.5.3",
+      "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.5.3.tgz",
+      "integrity": "sha512-IE9csY7lnhxBnA8g/WI5eg/hygA6MGWJMSNfFRrBlXUciADEhS1EDB0SIsMSvzubzIlOBbVITSsypCsW717poA==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "individual",
+          "url": "https://github.com/sponsors/dubzzz"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/fast-check"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "pure-rand": "^7.0.0"
+      },
+      "engines": {
+        "node": ">=12.17.0"
+      }
+    },
+    "node_modules/fdir": {
+      "version": "6.5.0",
+      "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+      "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12.0.0"
+      },
+      "peerDependencies": {
+        "picomatch": "^3 || ^4"
+      },
+      "peerDependenciesMeta": {
+        "picomatch": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/fsevents": {
+      "version": "2.3.3",
+      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+      "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "node_modules/html-encoding-sniffer": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
+      "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "whatwg-encoding": "^3.1.1"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/http-proxy-agent": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
+      "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "agent-base": "^7.1.0",
+        "debug": "^4.3.4"
+      },
+      "engines": {
+        "node": ">= 14"
+      }
+    },
+    "node_modules/https-proxy-agent": {
+      "version": "7.0.6",
+      "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
+      "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "agent-base": "^7.1.2",
+        "debug": "4"
+      },
+      "engines": {
+        "node": ">= 14"
+      }
+    },
+    "node_modules/iconv-lite": {
+      "version": "0.6.3",
+      "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
+      "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "safer-buffer": ">= 2.1.2 < 3.0.0"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/is-potential-custom-element-name": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
+      "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/js-tokens": {
+      "version": "9.0.1",
+      "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
+      "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/jsdom": {
+      "version": "26.1.0",
+      "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz",
+      "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "cssstyle": "^4.2.1",
+        "data-urls": "^5.0.0",
+        "decimal.js": "^10.5.0",
+        "html-encoding-sniffer": "^4.0.0",
+        "http-proxy-agent": "^7.0.2",
+        "https-proxy-agent": "^7.0.6",
+        "is-potential-custom-element-name": "^1.0.1",
+        "nwsapi": "^2.2.16",
+        "parse5": "^7.2.1",
+        "rrweb-cssom": "^0.8.0",
+        "saxes": "^6.0.0",
+        "symbol-tree": "^3.2.4",
+        "tough-cookie": "^5.1.1",
+        "w3c-xmlserializer": "^5.0.0",
+        "webidl-conversions": "^7.0.0",
+        "whatwg-encoding": "^3.1.1",
+        "whatwg-mimetype": "^4.0.0",
+        "whatwg-url": "^14.1.1",
+        "ws": "^8.18.0",
+        "xml-name-validator": "^5.0.0"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "peerDependencies": {
+        "canvas": "^3.0.0"
+      },
+      "peerDependenciesMeta": {
+        "canvas": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/loupe": {
+      "version": "3.2.1",
+      "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
+      "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/lru-cache": {
+      "version": "10.4.3",
+      "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
+      "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/magic-string": {
+      "version": "0.30.21",
+      "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+      "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/sourcemap-codec": "^1.5.5"
+      }
+    },
+    "node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/nanoid": {
+      "version": "3.3.11",
+      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+      "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "bin": {
+        "nanoid": "bin/nanoid.cjs"
+      },
+      "engines": {
+        "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+      }
+    },
+    "node_modules/nwsapi": {
+      "version": "2.2.23",
+      "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz",
+      "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/parse5": {
+      "version": "7.3.0",
+      "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
+      "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "entities": "^6.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/inikulin/parse5?sponsor=1"
+      }
+    },
+    "node_modules/pathe": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+      "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/pathval": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
+      "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 14.16"
+      }
+    },
+    "node_modules/picocolors": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+      "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/picomatch": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+      "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/jonschlinkert"
+      }
+    },
+    "node_modules/postcss": {
+      "version": "8.5.6",
+      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
+      "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/postcss/"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/postcss"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "nanoid": "^3.3.11",
+        "picocolors": "^1.1.1",
+        "source-map-js": "^1.2.1"
+      },
+      "engines": {
+        "node": "^10 || ^12 || >=14"
+      }
+    },
+    "node_modules/punycode": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+      "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/pure-rand": {
+      "version": "7.0.1",
+      "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz",
+      "integrity": "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "individual",
+          "url": "https://github.com/sponsors/dubzzz"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/fast-check"
+        }
+      ],
+      "license": "MIT"
+    },
+    "node_modules/rollup": {
+      "version": "4.57.1",
+      "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz",
+      "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/estree": "1.0.8"
+      },
+      "bin": {
+        "rollup": "dist/bin/rollup"
+      },
+      "engines": {
+        "node": ">=18.0.0",
+        "npm": ">=8.0.0"
+      },
+      "optionalDependencies": {
+        "@rollup/rollup-android-arm-eabi": "4.57.1",
+        "@rollup/rollup-android-arm64": "4.57.1",
+        "@rollup/rollup-darwin-arm64": "4.57.1",
+        "@rollup/rollup-darwin-x64": "4.57.1",
+        "@rollup/rollup-freebsd-arm64": "4.57.1",
+        "@rollup/rollup-freebsd-x64": "4.57.1",
+        "@rollup/rollup-linux-arm-gnueabihf": "4.57.1",
+        "@rollup/rollup-linux-arm-musleabihf": "4.57.1",
+        "@rollup/rollup-linux-arm64-gnu": "4.57.1",
+        "@rollup/rollup-linux-arm64-musl": "4.57.1",
+        "@rollup/rollup-linux-loong64-gnu": "4.57.1",
+        "@rollup/rollup-linux-loong64-musl": "4.57.1",
+        "@rollup/rollup-linux-ppc64-gnu": "4.57.1",
+        "@rollup/rollup-linux-ppc64-musl": "4.57.1",
+        "@rollup/rollup-linux-riscv64-gnu": "4.57.1",
+        "@rollup/rollup-linux-riscv64-musl": "4.57.1",
+        "@rollup/rollup-linux-s390x-gnu": "4.57.1",
+        "@rollup/rollup-linux-x64-gnu": "4.57.1",
+        "@rollup/rollup-linux-x64-musl": "4.57.1",
+        "@rollup/rollup-openbsd-x64": "4.57.1",
+        "@rollup/rollup-openharmony-arm64": "4.57.1",
+        "@rollup/rollup-win32-arm64-msvc": "4.57.1",
+        "@rollup/rollup-win32-ia32-msvc": "4.57.1",
+        "@rollup/rollup-win32-x64-gnu": "4.57.1",
+        "@rollup/rollup-win32-x64-msvc": "4.57.1",
+        "fsevents": "~2.3.2"
+      }
+    },
+    "node_modules/rrweb-cssom": {
+      "version": "0.8.0",
+      "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz",
+      "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/safer-buffer": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+      "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/saxes": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
+      "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "xmlchars": "^2.2.0"
+      },
+      "engines": {
+        "node": ">=v12.22.7"
+      }
+    },
+    "node_modules/siginfo": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+      "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/source-map-js": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+      "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+      "dev": true,
+      "license": "BSD-3-Clause",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/stackback": {
+      "version": "0.0.2",
+      "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+      "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/std-env": {
+      "version": "3.10.0",
+      "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
+      "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/strip-literal": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz",
+      "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "js-tokens": "^9.0.1"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/antfu"
+      }
+    },
+    "node_modules/symbol-tree": {
+      "version": "3.2.4",
+      "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
+      "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/tinybench": {
+      "version": "2.9.0",
+      "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+      "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/tinyexec": {
+      "version": "0.3.2",
+      "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
+      "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/tinyglobby": {
+      "version": "0.2.15",
+      "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
+      "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "fdir": "^6.5.0",
+        "picomatch": "^4.0.3"
+      },
+      "engines": {
+        "node": ">=12.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/SuperchupuDev"
+      }
+    },
+    "node_modules/tinypool": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
+      "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": "^18.0.0 || >=20.0.0"
+      }
+    },
+    "node_modules/tinyrainbow": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz",
+      "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=14.0.0"
+      }
+    },
+    "node_modules/tinyspy": {
+      "version": "4.0.4",
+      "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz",
+      "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=14.0.0"
+      }
+    },
+    "node_modules/tldts": {
+      "version": "6.1.86",
+      "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz",
+      "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "tldts-core": "^6.1.86"
+      },
+      "bin": {
+        "tldts": "bin/cli.js"
+      }
+    },
+    "node_modules/tldts-core": {
+      "version": "6.1.86",
+      "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz",
+      "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/tough-cookie": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
+      "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
+      "dev": true,
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "tldts": "^6.1.32"
+      },
+      "engines": {
+        "node": ">=16"
+      }
+    },
+    "node_modules/tr46": {
+      "version": "5.1.1",
+      "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
+      "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "punycode": "^2.3.1"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/vite": {
+      "version": "7.3.1",
+      "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
+      "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "esbuild": "^0.27.0",
+        "fdir": "^6.5.0",
+        "picomatch": "^4.0.3",
+        "postcss": "^8.5.6",
+        "rollup": "^4.43.0",
+        "tinyglobby": "^0.2.15"
+      },
+      "bin": {
+        "vite": "bin/vite.js"
+      },
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      },
+      "funding": {
+        "url": "https://github.com/vitejs/vite?sponsor=1"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.3.3"
+      },
+      "peerDependencies": {
+        "@types/node": "^20.19.0 || >=22.12.0",
+        "jiti": ">=1.21.0",
+        "less": "^4.0.0",
+        "lightningcss": "^1.21.0",
+        "sass": "^1.70.0",
+        "sass-embedded": "^1.70.0",
+        "stylus": ">=0.54.8",
+        "sugarss": "^5.0.0",
+        "terser": "^5.16.0",
+        "tsx": "^4.8.1",
+        "yaml": "^2.4.2"
+      },
+      "peerDependenciesMeta": {
+        "@types/node": {
+          "optional": true
+        },
+        "jiti": {
+          "optional": true
+        },
+        "less": {
+          "optional": true
+        },
+        "lightningcss": {
+          "optional": true
+        },
+        "sass": {
+          "optional": true
+        },
+        "sass-embedded": {
+          "optional": true
+        },
+        "stylus": {
+          "optional": true
+        },
+        "sugarss": {
+          "optional": true
+        },
+        "terser": {
+          "optional": true
+        },
+        "tsx": {
+          "optional": true
+        },
+        "yaml": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/vite-node": {
+      "version": "3.2.4",
+      "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz",
+      "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "cac": "^6.7.14",
+        "debug": "^4.4.1",
+        "es-module-lexer": "^1.7.0",
+        "pathe": "^2.0.3",
+        "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
+      },
+      "bin": {
+        "vite-node": "vite-node.mjs"
+      },
+      "engines": {
+        "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "node_modules/vitest": {
+      "version": "3.2.4",
+      "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
+      "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/chai": "^5.2.2",
+        "@vitest/expect": "3.2.4",
+        "@vitest/mocker": "3.2.4",
+        "@vitest/pretty-format": "^3.2.4",
+        "@vitest/runner": "3.2.4",
+        "@vitest/snapshot": "3.2.4",
+        "@vitest/spy": "3.2.4",
+        "@vitest/utils": "3.2.4",
+        "chai": "^5.2.0",
+        "debug": "^4.4.1",
+        "expect-type": "^1.2.1",
+        "magic-string": "^0.30.17",
+        "pathe": "^2.0.3",
+        "picomatch": "^4.0.2",
+        "std-env": "^3.9.0",
+        "tinybench": "^2.9.0",
+        "tinyexec": "^0.3.2",
+        "tinyglobby": "^0.2.14",
+        "tinypool": "^1.1.1",
+        "tinyrainbow": "^2.0.0",
+        "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0",
+        "vite-node": "3.2.4",
+        "why-is-node-running": "^2.3.0"
+      },
+      "bin": {
+        "vitest": "vitest.mjs"
+      },
+      "engines": {
+        "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      },
+      "peerDependencies": {
+        "@edge-runtime/vm": "*",
+        "@types/debug": "^4.1.12",
+        "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+        "@vitest/browser": "3.2.4",
+        "@vitest/ui": "3.2.4",
+        "happy-dom": "*",
+        "jsdom": "*"
+      },
+      "peerDependenciesMeta": {
+        "@edge-runtime/vm": {
+          "optional": true
+        },
+        "@types/debug": {
+          "optional": true
+        },
+        "@types/node": {
+          "optional": true
+        },
+        "@vitest/browser": {
+          "optional": true
+        },
+        "@vitest/ui": {
+          "optional": true
+        },
+        "happy-dom": {
+          "optional": true
+        },
+        "jsdom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/w3c-xmlserializer": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
+      "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "xml-name-validator": "^5.0.0"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/webidl-conversions": {
+      "version": "7.0.0",
+      "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
+      "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
+      "dev": true,
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/whatwg-encoding": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
+      "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
+      "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "iconv-lite": "0.6.3"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/whatwg-mimetype": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
+      "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/whatwg-url": {
+      "version": "14.2.0",
+      "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
+      "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "tr46": "^5.1.0",
+        "webidl-conversions": "^7.0.0"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/why-is-node-running": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+      "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "siginfo": "^2.0.0",
+        "stackback": "0.0.2"
+      },
+      "bin": {
+        "why-is-node-running": "cli.js"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/ws": {
+      "version": "8.19.0",
+      "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
+      "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=10.0.0"
+      },
+      "peerDependencies": {
+        "bufferutil": "^4.0.1",
+        "utf-8-validate": ">=5.0.2"
+      },
+      "peerDependenciesMeta": {
+        "bufferutil": {
+          "optional": true
+        },
+        "utf-8-validate": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/xml-name-validator": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
+      "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/xmlchars": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
+      "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
+      "dev": true,
+      "license": "MIT"
+    }
+  }
+}

+ 13 - 0
package.json

@@ -0,0 +1,13 @@
+{
+  "name": "ball-block-breaker",
+  "version": "1.0.0",
+  "type": "module",
+  "scripts": {
+    "test": "vitest --run"
+  },
+  "devDependencies": {
+    "vitest": "^3.2.1",
+    "fast-check": "^4.1.1",
+    "jsdom": "^26.1.0"
+  }
+}

+ 551 - 0
src/Game.js

@@ -0,0 +1,551 @@
+import { GameState, GameMode, BALL_SPEED, BALL_RADIUS, BALL_COLOR, LAUNCH_INTERVAL, GRID_COLS, GRID_GAP, MIN_SWIPE_LENGTH, FIXED_TIMESTEP, TOP_PADDING_ROWS } from './constants.js';
+import { Ball } from './entities/Ball.js';
+import { Renderer } from './systems/Renderer.js';
+import { InputHandler } from './systems/InputHandler.js';
+import { BoardManager } from './systems/BoardManager.js';
+import { clampAngle, calculate as calculateGuideLine } from './systems/GuideLine.js';
+import {
+  checkBallItemCollision,
+  stepBallPhysics
+} from './systems/Physics.js';
+
+/**
+ * Game - 游戏主控
+ * 管理游戏状态机、游戏循环、发射逻辑、碰撞处理和回合管理
+ */
+export class Game {
+  /**
+   * @param {HTMLCanvasElement} canvas - 游戏画布
+   * @param {number} mode - 游戏模式 (GameMode.CLASSIC | GameMode.ENHANCED)
+   */
+  constructor(canvas, mode) {
+    this.canvas = canvas;
+    this.mode = mode;
+
+    // Calculate block size from canvas width
+    this.blockSize = (canvas.width / (typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1) - GRID_GAP * (GRID_COLS + 1)) / GRID_COLS;
+
+    // Board dimensions (logical pixels)
+    this.boardWidth = canvas.width / (typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1);
+    this.boardHeight = canvas.height / (typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1);
+
+    // Calculate max rows based on board height
+    this.maxRows = Math.floor(this.boardHeight / (this.blockSize + GRID_GAP));
+
+    // Create subsystems
+    this.renderer = new Renderer(canvas);
+    this.inputHandler = new InputHandler(canvas);
+    this.boardManager = new BoardManager(GRID_COLS, this.maxRows, this.blockSize);
+
+    // Game state
+    this.state = GameState.AIMING;
+    this.round = 1;
+    this.ballCount = 1;
+    this.pendingBalls = 0;
+    this.balls = [];
+
+    // Launch state
+    this.launchAngle = 90;
+    this.launchTimer = 0;
+    this.launchIndex = 0;
+    this.launchX = this.boardWidth / 2;
+    this.launchY = this.boardHeight - BALL_RADIUS;
+
+    // Aiming state
+    this.aimAngle = 90;
+    this.guideLinePath = null;
+
+    // User swipe line state
+    this._swipeStartX = 0;
+    this._swipeStartY = 0;
+    this._swipeCurrentX = 0;
+    this._swipeCurrentY = 0;
+    this._isSwiping = false;
+
+    // Speed acceleration state
+    this._runningElapsed = 0;
+    this._speedMultiplier = 1;
+
+    // Fixed-timestep accumulator
+    this._accumulator = 0;
+
+    // Slide-down animation state
+    this._slideStartTime = 0;
+    this._slideDuration = 200;
+    this._slideProgress = 0;
+
+    // Set up input callbacks
+    this._setupInput();
+  }
+
+  /**
+   * Set up input handler callbacks for aiming and launching
+   */
+  _setupInput() {
+      this.inputHandler.setLaunchPoint(this.launchX, this.launchY);
+
+      this.inputHandler.onAimStart((data) => {
+        if (this.state !== GameState.AIMING) return;
+        this._swipeStartX = data.startX;
+        this._swipeStartY = data.startY;
+        this._swipeCurrentX = data.x;
+        this._swipeCurrentY = data.y;
+        this._isSwiping = true;
+        this.aimAngle = clampAngle(data.angle);
+        // Don't show guide line yet until swipe is long enough
+        this.guideLinePath = null;
+      });
+
+      this.inputHandler.onAimMove((data) => {
+        if (this.state !== GameState.AIMING) return;
+        this._swipeCurrentX = data.x;
+        this._swipeCurrentY = data.y;
+        this.aimAngle = clampAngle(data.angle);
+
+        // Only show guide line when swipe is long enough
+        const dx = this._swipeCurrentX - this._swipeStartX;
+        const dy = this._swipeCurrentY - this._swipeStartY;
+        const swipeLen = Math.sqrt(dx * dx + dy * dy);
+        if (swipeLen > MIN_SWIPE_LENGTH) {
+          this._updateGuideLine();
+        } else {
+          this.guideLinePath = null;
+        }
+      });
+
+      this.inputHandler.onAimEnd((data) => {
+        if (this.state !== GameState.AIMING) return;
+        this.aimAngle = clampAngle(data.angle);
+
+        // Check swipe length - cancel if too short
+        const dx = data.x - this._swipeStartX;
+        const dy = data.y - this._swipeStartY;
+        const swipeLen = Math.sqrt(dx * dx + dy * dy);
+
+        // Reset swipe state
+        this._isSwiping = false;
+        this._swipeStartX = 0;
+        this._swipeStartY = 0;
+        this._swipeCurrentX = 0;
+        this._swipeCurrentY = 0;
+        this.guideLinePath = null;
+
+        if (swipeLen > MIN_SWIPE_LENGTH) {
+          this._startLaunch();
+        }
+      });
+    }
+
+
+  /**
+   * Update the guide line path based on current aim angle
+   */
+  _updateGuideLine() {
+    this.guideLinePath = calculateGuideLine(
+      this.launchX,
+      this.launchY,
+      this.aimAngle,
+      this.boardManager.getBlocks(),
+      this.boardWidth,
+      this.boardHeight
+    );
+  }
+
+  /**
+   * Transition from AIMING to LAUNCHING state
+   */
+  _startLaunch() {
+    this.launchAngle = this.aimAngle;
+    this.launchTimer = 0;
+    this.launchIndex = 0;
+    this.balls = [];
+    this.guideLinePath = null;
+    this.state = GameState.LAUNCHING;
+    this._runningElapsed = 0;
+    this._speedMultiplier = 1;
+    this._accumulator = 0;
+    this.inputHandler.disable();
+  }
+
+  /**
+   * Initialize and start the game
+   */
+  start() {
+    this.boardManager.generateRow(this.round, this.mode);
+    this.inputHandler.setLaunchPoint(this.launchX, this.launchY);
+    this.inputHandler.enable();
+  }
+
+  /**
+   * Main update loop - called every frame
+   * Uses fixed-timestep accumulator so physics runs at consistent speed
+   * regardless of device refresh rate (60Hz, 120Hz, etc.)
+   * @param {number} deltaTime - time since last frame in milliseconds
+   */
+  update(deltaTime) {
+    switch (this.state) {
+      case GameState.AIMING:
+        // Input handles aiming, nothing to update
+        break;
+
+      case GameState.LAUNCHING:
+      case GameState.RUNNING:
+        this._accumulator += deltaTime;
+        while (this._accumulator >= FIXED_TIMESTEP) {
+          this._fixedUpdate(FIXED_TIMESTEP);
+          this._accumulator -= FIXED_TIMESTEP;
+        }
+        break;
+
+      case GameState.ROUND_END:
+        this.nextRound();
+        break;
+
+      case GameState.SLIDING_DOWN:
+        this._updateSlideDown();
+        break;
+
+      case GameState.GAME_OVER:
+        // Nothing to update, waiting for restart
+        break;
+
+      default:
+        break;
+    }
+  }
+
+  /**
+   * Fixed-timestep update - runs at consistent rate (60 ticks/sec)
+   * @param {number} dt - fixed timestep in milliseconds
+   */
+  _fixedUpdate(dt) {
+    if (this.state === GameState.LAUNCHING) {
+      this._updateLaunching(dt);
+    } else if (this.state === GameState.RUNNING) {
+      this._updateRunning(dt);
+    }
+  }
+
+  /**
+   * Update launching state - launch balls at intervals
+   * @param {number} deltaTime
+   */
+  _updateLaunching(deltaTime) {
+    this.launchTimer += deltaTime;
+
+    // Track elapsed time for speed acceleration (starts from launch)
+    this._runningElapsed += deltaTime;
+    this._updateSpeedMultiplier();
+
+    while (this.launchTimer >= LAUNCH_INTERVAL && this.launchIndex < this.ballCount) {
+      this._launchOneBall();
+      this.launchTimer -= LAUNCH_INTERVAL;
+      this.launchIndex++;
+    }
+
+    // Update balls with speed multiplier (sub-stepped internally)
+    this._stepPhysics();
+
+    // All balls launched → transition to RUNNING
+    if (this.launchIndex >= this.ballCount) {
+      this.state = GameState.RUNNING;
+    }
+  }
+
+  /**
+   * Launch a single ball with the current launch angle
+   */
+  _launchOneBall() {
+    const rad = this.launchAngle * Math.PI / 180;
+    const ball = new Ball(this.launchX, this.launchY);
+    ball.vx = -BALL_SPEED * Math.cos(rad);
+    ball.vy = -BALL_SPEED * Math.sin(rad);
+    this.balls.push(ball);
+  }
+
+  /**
+   * Update running state - move balls, check collisions, check round end
+   * @param {number} deltaTime
+   */
+  _updateRunning(deltaTime) {
+    // Track elapsed time for speed acceleration
+    this._runningElapsed += deltaTime;
+    this._updateSpeedMultiplier();
+
+    // Update balls with speed multiplier (sub-stepped internally)
+    this._stepPhysics();
+
+    // Check if all balls are inactive → round end
+    const allInactive = this.balls.length > 0 && this.balls.every(ball => !ball.active);
+    if (allInactive) {
+      this.state = GameState.ROUND_END;
+    }
+  }
+
+  /**
+   * Run physics with speed multiplier.
+   * Uses continuous collision detection via Collision.findFirstHit
+   * (same function as GuideLine) for exact consistency.
+   */
+  _stepPhysics() {
+    const steps = this._speedMultiplier;
+    const blocks = this.boardManager.getBlocks();
+    const items = this.boardManager.getItems();
+
+    for (let i = 0; i < steps; i++) {
+      for (const ball of this.balls) {
+        if (!ball.active) continue;
+
+        // Continuous collision: cast ray from ball position, move + bounce
+        const hitBlocks = stepBallPhysics(
+          ball, BALL_SPEED, blocks, this.boardWidth, this.boardHeight
+        );
+
+        // Damage hit blocks
+        for (const { block } of hitBlocks) {
+          if (block.destroyed) continue;
+          if (ball._lastHitBlock === block) continue;
+          ball._lastHitBlock = block;
+
+          const destroyed = block.hit();
+          if (destroyed) {
+            this.renderer.playBlockDestroyAnimation(block);
+            this.boardManager.removeBlock(block);
+          }
+        }
+
+        if (hitBlocks.length === 0) {
+          ball._lastHitBlock = null;
+        }
+
+        // Check ball vs items (still uses AABB overlap — items don't need precision)
+        for (let j = items.length - 1; j >= 0; j--) {
+          const item = items[j];
+          if (checkBallItemCollision(ball, item)) {
+            if (!item._triggeredBy) item._triggeredBy = new Set();
+            if (!item._triggeredBy.has(ball)) {
+              item._triggeredBy.add(ball);
+              item.onCollect(this);
+            }
+            if (item.shouldRemove()) {
+              items.splice(j, 1);
+            }
+          } else {
+            if (item._triggeredBy) item._triggeredBy.delete(ball);
+          }
+        }
+      }
+    }
+  }
+
+  /**
+   * Update speed multiplier based on elapsed time since launch
+   */
+  _updateSpeedMultiplier() {
+    if (this._runningElapsed > 7000) {
+      this._speedMultiplier = 6;
+    } else if (this._runningElapsed > 5000) {
+      this._speedMultiplier = 4;
+    } else if (this._runningElapsed > 3000) {
+      this._speedMultiplier = 2;
+    } else {
+      this._speedMultiplier = 1;
+    }
+  }
+
+  /**
+   * Proceed to the next round:
+   * - Add pending balls to ball count
+   * - Move all blocks/items down
+   * - Generate new row
+   * - Check game over
+   */
+  nextRound() {
+    // Add pending balls from collected BallItems
+    this.ballCount += this.pendingBalls;
+    this.pendingBalls = 0;
+
+    // Advance round first so new row gets the correct count
+    this.round++;
+
+    // Generate new row one row above its final position so it slides in with everything else
+    this.boardManager.generateRow(this.round, this.mode, TOP_PADDING_ROWS - 1);
+
+    // Start slide-down animation
+    this._slideStartTime = performance.now();
+    this._slideDuration = 200;
+    this.state = GameState.SLIDING_DOWN;
+  }
+
+  /**
+   * Set DOM elements for the game over UI overlay
+   * @param {HTMLElement} overlay - The game over overlay container
+   * @param {HTMLElement} scoreEl - Element to display the final score/round
+   * @param {HTMLElement} restartBtn - Button to restart the game
+   */
+  /**
+   * Update slide-down animation
+   */
+  _updateSlideDown() {
+    const elapsed = performance.now() - this._slideStartTime;
+    const t = Math.min(elapsed / this._slideDuration, 1);
+    this._slideProgress = t;
+
+    if (t >= 1) {
+      // Animation complete — snap blocks/items to their final positions
+      this._slideProgress = 0;
+      this.boardManager.moveAllDown();
+
+      if (this.boardManager.checkGameOver()) {
+        this.gameOver();
+        return;
+      }
+
+      this.state = GameState.AIMING;
+      this.balls = [];
+      this._speedMultiplier = 1;
+      this.inputHandler.enable();
+    }
+  }
+
+  setGameOverUI(overlay, scoreEl, restartBtn) {
+    this.gameOverOverlay = overlay;
+    this.gameOverScoreEl = scoreEl;
+    this.gameOverRestartBtn = restartBtn;
+
+    if (restartBtn) {
+      restartBtn.addEventListener('click', () => {
+        this.restart();
+      });
+    }
+  }
+
+  /**
+   * Handle game over state - show game over UI
+   */
+  gameOver() {
+    this.state = GameState.GAME_OVER;
+
+    if (this.gameOverScoreEl) {
+      this.gameOverScoreEl.textContent = `第 ${this.round} 轮`;
+    }
+    if (this.gameOverOverlay) {
+      this.gameOverOverlay.style.display = 'flex';
+    }
+  }
+
+
+  /**
+   * Restart the game
+   */
+  restart() {
+    this.state = GameState.AIMING;
+    this.round = 1;
+    this.ballCount = 1;
+    this.pendingBalls = 0;
+    this.balls = [];
+    this.launchAngle = 90;
+    this.launchTimer = 0;
+    this.launchIndex = 0;
+    this.aimAngle = 90;
+    this.guideLinePath = null;
+
+    // Hide game over overlay
+    if (this.gameOverOverlay) {
+      this.gameOverOverlay.style.display = 'none';
+    }
+
+    // Reset board
+    this.boardManager = new BoardManager(GRID_COLS, this.maxRows, this.blockSize);
+
+    // Re-start
+    this.start();
+  }
+
+  /**
+   * Called by BallItem.onCollect to add a pending ball for next round
+   */
+  addPendingBall() {
+    this.pendingBalls++;
+  }
+
+  /**
+   * Called by LineClearItem.onCollect to clear a row
+   * @param {number} gridY
+   */
+  clearRow(gridY) {
+    const destroyed = this.boardManager.clearRow(gridY);
+    for (const block of destroyed) {
+      this.renderer.playBlockDestroyAnimation(block);
+    }
+    // Play horizontal line sweep animation
+    this.renderer.playLineSweepAnimation('horizontal', gridY, this.blockSize, this.boardWidth);
+  }
+
+  /**
+   * Called by LineClearItem.onCollect to clear a column
+   * @param {number} gridX
+   */
+  clearColumn(gridX) {
+    const destroyed = this.boardManager.clearColumn(gridX);
+    for (const block of destroyed) {
+      this.renderer.playBlockDestroyAnimation(block);
+    }
+    // Play vertical line sweep animation
+    this.renderer.playLineSweepAnimation('vertical', gridX, this.blockSize, this.boardHeight);
+  }
+
+  /**
+   * Main render loop - called every frame
+   */
+  render() {
+    // Clear canvas
+    this.renderer.clear();
+
+    // Calculate slide offset for SLIDING_DOWN state
+    const slideOffset = this.state === GameState.SLIDING_DOWN
+      ? this._slideProgress * (this.blockSize + GRID_GAP)
+      : 0;
+
+    // Draw all blocks (with slide offset if animating)
+    for (const block of this.boardManager.getBlocks()) {
+      this.renderer.drawBlock(block, null, slideOffset);
+    }
+
+    // Draw all items (with slide offset if animating)
+    for (const item of this.boardManager.getItems()) {
+      this.renderer.drawItem(item, slideOffset);
+    }
+
+    // Draw user swipe line when aiming
+    if (this.state === GameState.AIMING && this._isSwiping) {
+      this.renderer.drawUserLine(this._swipeStartX, this._swipeStartY, this._swipeCurrentX, this._swipeCurrentY);
+    }
+
+    // Draw guide line when aiming
+    if (this.state === GameState.AIMING && this.guideLinePath) {
+      this.renderer.drawGuideLine(this.guideLinePath);
+    }
+
+    // Draw all balls
+    for (const ball of this.balls) {
+      if (ball.active) {
+        this.renderer.drawBall(ball);
+      }
+    }
+
+    // Draw HUD
+    this.renderer.drawHUD(this.ballCount);
+
+    // Draw speed indicator when accelerated
+    this.renderer.drawSpeedIndicator(this._speedMultiplier);
+
+    // Draw animations
+    this.renderer.updateAnimations();
+
+    // Draw game over overlay
+    if (this.state === GameState.GAME_OVER) {
+      this.renderer.drawGameOver(this.round);
+    }
+  }
+}

+ 69 - 0
src/constants.js

@@ -0,0 +1,69 @@
+/**
+ * 游戏状态枚举
+ */
+export const GameState = {
+  MODE_SELECT: 'MODE_SELECT',
+  AIMING: 'AIMING',
+  LAUNCHING: 'LAUNCHING',
+  RUNNING: 'RUNNING',
+  ROUND_END: 'ROUND_END',
+  SLIDING_DOWN: 'SLIDING_DOWN',
+  GAME_OVER: 'GAME_OVER'
+};
+
+/**
+ * 游戏模式枚举
+ */
+export const GameMode = {
+  CLASSIC: 1,    // 模式一:仅 BallItem
+  ENHANCED: 2    // 模式二:BallItem + LineClearItem
+};
+
+/**
+ * 颜色表 - 18个基准色(RGB数组),来自色指定.txt
+ */
+export const COLOR_TABLE = [
+  [248, 180, 43],  // 000
+  [196, 188, 54],  // 005
+  [131, 197, 68],  // 010
+  [184, 160, 40],  // 015
+  [236, 123, 12],  // 020
+  [232, 85, 56],   // 025
+  [227, 46, 100],  // 030
+  [211, 40, 118],  // 035
+  [195, 34, 135],  // 040
+  [169, 48, 143],  // 045
+  [143, 62, 151],  // 050
+  [83, 90, 170],   // 055
+  [22, 117, 189],  // 060
+  [16, 109, 180],  // 065
+  [10, 101, 171],  // 070
+  [5, 127, 156],   // 075
+  [0, 153, 141],   // 080
+  [0, 142, 127]    // 085
+];
+
+/**
+ * 游戏配置常量
+ */
+export const GRID_COLS = 7;           // 网格列数
+export const BALL_RADIUS = 8;         // 球半径
+export const BALL_COLOR = '#fff';     // 球颜色
+export const BALL_SPEED = 20;         // 球速度(每帧像素)
+export const BLOCK_CORNER_RADIUS = 4; // 方块圆角半径
+export const GRID_GAP = 2;           // 网格间距
+export const LAUNCH_INTERVAL = 80;    // 发射间隔(毫秒)
+export const MIN_ANGLE = 15;          // 最小瞄准角度
+export const MAX_ANGLE = 165;         // 最大瞄准角度
+export const BLOCK_DOUBLE_CHANCE = 0.4;  // 方块数字翻倍概率
+// export const BALL_ITEM_CHANCE = 0.15;     // 加球道具生成概率
+export const LINE_CLEAR_ITEM_CHANCE = 0.3; // 整行消除道具生成概率
+export const BLOCK_SPAWN_CHANCE = 0.55;   // 每格生成方块概率
+export const BG_COLOR = '#232323';        // 背景色
+export const HEADER_BG_COLOR = '#2a2a2a'; // 头部背景色
+export const ITEM_SIZE_RATIO = 0.55;      // 道具尺寸相对方块的比例
+export const TOP_PADDING_ROWS = 1;        // 顶部留空行数
+export const MIN_SWIPE_LENGTH = 15;       // 最小滑动长度(低于此值取消发射)
+export const MAX_GAME_WIDTH = 500;        // 游戏最大宽度(像素)
+export const FIXED_TIMESTEP = 1000 / 60;  // 固定物理步长(毫秒),锁定60fps逻辑
+

+ 99 - 0
src/entities/Ball.js

@@ -0,0 +1,99 @@
+import { BALL_RADIUS, BALL_COLOR } from '../constants.js';
+
+/**
+ * Ball - 小球实体
+ * 由玩家发射,碰撞方块和边缘时反弹
+ */
+export class Ball {
+  /**
+   * @param {number} x - 初始 x 坐标
+   * @param {number} y - 初始 y 坐标
+   * @param {number} [radius=BALL_RADIUS] - 半径
+   * @param {string} [color=BALL_COLOR] - 颜色
+   */
+  constructor(x, y, radius = BALL_RADIUS, color = BALL_COLOR) {
+    this.x = x;
+    this.y = y;
+    this.radius = radius;
+    this.color = color;
+    this.vx = 0;
+    this.vy = 0;
+    this.active = true;
+  }
+
+  /**
+   * 每帧更新位置(子步移动,fraction 为 0~1 的比例)
+   * 处理左/右/顶部边缘反弹,底部停止
+   * @param {number} _deltaTime - 未使用,保留接口兼容
+   * @param {number} boardWidth - 面板宽度
+   * @param {number} boardHeight - 面板高度
+   * @param {number} [fraction=1] - 移动比例(用于子步进防穿透)
+   */
+  update(_deltaTime, boardWidth, boardHeight, fraction = 1) {
+    if (!this.active) return;
+
+    // 移动 fraction 比例的距离
+    this.x += this.vx * fraction;
+    this.y += this.vy * fraction;
+
+    // 左侧边缘反弹
+    if (this.x - this.radius <= 0) {
+      this.x = this.radius;
+      this.reflect('x');
+    }
+
+    // 右侧边缘反弹
+    if (this.x + this.radius >= boardWidth) {
+      this.x = boardWidth - this.radius;
+      this.reflect('x');
+    }
+
+    // 顶部边缘反弹
+    if (this.y - this.radius <= 0) {
+      this.y = this.radius;
+      this.reflect('y');
+    }
+
+    // 底部:停止运动
+    if (this.isAtBottom(boardHeight)) {
+      this.y = boardHeight - this.radius;
+      this.active = false;
+    }
+  }
+
+
+  /**
+   * 反射:反转指定轴的速度分量
+   * @param {'x'|'y'} axis - 'x' 反转 vx,'y' 反转 vy
+   */
+  reflect(axis) {
+    if (axis === 'x') {
+      this.vx = -this.vx;
+    } else if (axis === 'y') {
+      this.vy = -this.vy;
+    }
+  }
+
+  /**
+   * 判断球是否到达底部
+   * @param {number} boardHeight - 面板高度
+   * @returns {boolean}
+   */
+  isAtBottom(boardHeight) {
+    return this.y + this.radius >= boardHeight;
+  }
+
+  /**
+   * 获取碰撞矩形(AABB)
+   * @returns {{ x: number, y: number, width: number, height: number }}
+   */
+  getRect() {
+    const diameter = this.radius * 2;
+    return {
+      x: this.x - this.radius,
+      y: this.y - this.radius,
+      width: diameter,
+      height: diameter
+    };
+  }
+}

+ 72 - 0
src/entities/Block.js

@@ -0,0 +1,72 @@
+import { GRID_GAP } from '../constants.js';
+import { getColor } from '../utils/color.js';
+
+/**
+ * Block - 方块实体
+ * 带有数字标记,被小球碰撞时数字减少,归零时消除
+ */
+export class Block {
+  /**
+   * @param {number} gridX - 网格列位置 (0-6)
+   * @param {number} gridY - 网格行位置 (0-N)
+   * @param {number} count - 方块数字(剩余生命值)
+   * @param {number} size - 方块边长(像素)
+   */
+  constructor(gridX, gridY, count, size) {
+    this.gridX = gridX;
+    this.gridY = gridY;
+    this.count = count;
+    this.size = size;
+    this.destroyed = false;
+  }
+
+  /**
+   * 被小球击中,count 减 1
+   * count <= 0 时标记为已消除
+   * @returns {boolean} 是否被消除
+   */
+  hit() {
+    this.count -= 1;
+    this._hitTime = performance.now();
+    if (this.count <= 0) {
+      this.destroyed = true;
+    }
+    return this.destroyed;
+  }
+
+  /**
+   * 将网格坐标转为像素坐标的碰撞矩形
+   * @returns {{ x: number, y: number, width: number, height: number }}
+   */
+  getRect() {
+    return {
+      x: this.gridX * (this.size + GRID_GAP) + GRID_GAP,
+      y: this.gridY * (this.size + GRID_GAP) + GRID_GAP,
+      width: this.size,
+      height: this.size
+    };
+  }
+
+  /**
+   * 根据 count 计算当前颜色
+   * @returns {string} 十六进制颜色字符串
+   */
+  getColor() {
+    return getColor(this.count);
+  }
+
+  /**
+   * 判断方块是否已被消除
+   * @returns {boolean}
+   */
+  isDestroyed() {
+    return this.count <= 0;
+  }
+
+  /**
+   * 回合结束时方块向下移动一行
+   */
+  moveDown() {
+    this.gridY += 1;
+  }
+}

+ 132 - 0
src/entities/Item.js

@@ -0,0 +1,132 @@
+import { GRID_GAP, ITEM_SIZE_RATIO } from '../constants.js';
+
+/**
+ * Item - 道具基类
+ * 出现在游戏区域中,小球触碰后获得效果
+ */
+export class Item {
+  /**
+   * @param {number} gridX - 网格列位置
+   * @param {number} gridY - 网格行位置
+   * @param {string} type - 道具类型 'ball' | 'lineClear'
+   * @param {number} size - 网格方块大小(像素)
+   */
+  constructor(gridX, gridY, type, size) {
+    this.gridX = gridX;
+    this.gridY = gridY;
+    this.type = type;
+    this.size = size;
+    this.collected = false;
+  }
+
+  /**
+   * 被收集时的效果(子类实现)
+   * @param {object} game - 游戏主控实例
+   */
+  onCollect(game) {
+    // 子类实现
+  }
+
+  /**
+   * 获取碰撞矩形(像素坐标),道具尺寸缩小并居中于网格
+   * @returns {{ x: number, y: number, width: number, height: number }}
+   */
+  getRect() {
+    const itemSize = this.size * ITEM_SIZE_RATIO;
+    const offset = (this.size - itemSize) / 2;
+    return {
+      x: this.gridX * (this.size + GRID_GAP) + GRID_GAP + offset,
+      y: this.gridY * (this.size + GRID_GAP) + GRID_GAP + offset,
+      width: itemSize,
+      height: itemSize
+    };
+  }
+
+  /**
+   * 是否应被移除(子类实现)
+   * @returns {boolean}
+   */
+  shouldRemove() {
+    return false;
+  }
+
+  /**
+   * 回合结束时道具向下移动一行
+   */
+  moveDown() {
+    this.gridY += 1;
+  }
+}
+
+/**
+ * BallItem - 加球道具
+ * 小球触碰后下一轮增加一个球,触碰后从面板移除
+ */
+export class BallItem extends Item {
+  /**
+   * @param {number} gridX - 网格列位置
+   * @param {number} gridY - 网格行位置
+   * @param {number} size - 大小(像素)
+   */
+  constructor(gridX, gridY, size) {
+    super(gridX, gridY, 'ball', size);
+  }
+
+  /**
+   * 被收集时,下一轮增加一个球
+   * @param {object} game - 游戏主控实例
+   */
+  onCollect(game) {
+    this.collected = true;
+    game.addPendingBall();
+  }
+
+  /**
+   * 触碰后应被移除
+   * @returns {boolean} true
+   */
+  shouldRemove() {
+    return true;
+  }
+}
+
+/**
+ * LineClearItem - 整行/列消除道具
+ * 小球触碰后消除所在行或列的所有方块
+ * 每次碰撞触发一次消除,道具保留在面板中
+ * direction: 'horizontal' 消除行, 'vertical' 消除列
+ */
+export class LineClearItem extends Item {
+  /**
+   * @param {number} gridX - 网格列位置
+   * @param {number} gridY - 网格行位置
+   * @param {number} size - 大小(像素)
+   * @param {'horizontal'|'vertical'} direction - 消除方向
+   */
+  constructor(gridX, gridY, size, direction) {
+    super(gridX, gridY, 'lineClear', size);
+    this.direction = direction || (Math.random() < 0.5 ? 'horizontal' : 'vertical');
+  }
+
+  /**
+   * 被收集时,根据方向消除行或列
+   * 每次碰撞都会触发一次消除
+   * @param {object} game - 游戏主控实例
+   */
+  onCollect(game) {
+    this.collected = true;
+    if (this.direction === 'horizontal') {
+      game.clearRow(this.gridY);
+    } else {
+      game.clearColumn(this.gridX);
+    }
+  }
+
+  /**
+   * 道具不被移除,保留在面板中
+   * @returns {boolean}
+   */
+  shouldRemove() {
+    return false;
+  }
+}

+ 71 - 0
src/main.js

@@ -0,0 +1,71 @@
+/**
+ * 小球消除方块游戏 - 入口文件
+ * 初始化模式选择、创建 Game 实例、启动游戏循环
+ */
+import { Game } from './Game.js';
+import { ModeSelector } from './ui/ModeSelector.js';
+
+document.addEventListener('DOMContentLoaded', () => {
+  // DOM elements
+  const canvas = document.getElementById('can');
+  const modeSelectorEl = document.getElementById('mode-selector');
+  const gameOverOverlay = document.getElementById('game-over');
+  const finalScoreEl = document.getElementById('final-score');
+  const restartBtn = document.getElementById('restart-btn');
+  const levelDisplay = document.getElementById('level');
+  const ballCountDisplay = document.getElementById('ball-count');
+
+  // Canvas sizing
+  const header = document.getElementById('header');
+  const footer = document.getElementById('footer');
+  const contain = document.getElementById('contain');
+  const dpr = window.devicePixelRatio || 1;
+  const logicalWidth = Math.min(window.innerWidth, 500);
+  const logicalHeight = window.innerHeight - header.offsetHeight - footer.offsetHeight;
+
+  canvas.style.width = logicalWidth + 'px';
+  canvas.style.height = logicalHeight + 'px';
+  canvas.width = logicalWidth * dpr;
+  canvas.height = logicalHeight * dpr;
+
+  // Mode selector
+  const modeSelector = new ModeSelector(modeSelectorEl);
+
+  let game = null;
+  let animFrameId = null;
+  let lastTime = 0;
+
+  function gameLoop(timestamp) {
+    const deltaTime = lastTime ? Math.min(timestamp - lastTime, 50) : 16;
+    lastTime = timestamp;
+
+    game.update(deltaTime);
+    game.render();
+
+    // Update level display
+    levelDisplay.textContent = game.round;
+    ballCountDisplay.textContent = '×' + game.ballCount;
+
+    animFrameId = requestAnimationFrame(gameLoop);
+  }
+
+  function startGame(mode) {
+    modeSelector.hide();
+
+    // Cancel any existing loop
+    if (animFrameId) {
+      cancelAnimationFrame(animFrameId);
+      animFrameId = null;
+    }
+
+    game = new Game(canvas, mode);
+    game.setGameOverUI(gameOverOverlay, finalScoreEl, restartBtn);
+    game.start();
+
+    lastTime = 0;
+    animFrameId = requestAnimationFrame(gameLoop);
+  }
+
+  modeSelector.onSelect(startGame);
+  modeSelector.show();
+});

+ 185 - 0
src/systems/BoardManager.js

@@ -0,0 +1,185 @@
+import { Block } from '../entities/Block.js';
+import { BallItem, LineClearItem } from '../entities/Item.js';
+import {
+  BLOCK_SPAWN_CHANCE,
+  BLOCK_DOUBLE_CHANCE,
+  LINE_CLEAR_ITEM_CHANCE,
+  GameMode,
+  TOP_PADDING_ROWS
+} from '../constants.js';
+
+/**
+ * BoardManager - 面板管理
+ * 管理所有方块和道具的生成、移动、移除
+ */
+export class BoardManager {
+  /**
+   * @param {number} cols - 网格列数
+   * @param {number} rows - 网格行数(最大行数,触底判定用)
+   * @param {number} blockSize - 方块边长(像素)
+   */
+  constructor(cols, rows, blockSize) {
+    this.cols = cols;
+    this.rows = rows;
+    this.blockSize = blockSize;
+    this.blocks = [];
+    this.items = [];
+    this._rowsGenerated = 0;
+  }
+
+  /**
+   * 生成新一行方块和道具
+   * - 新行生成在 TOP_PADDING_ROWS 行(留空顶部)
+   * - 每列以 BLOCK_SPAWN_CHANCE 概率生成方块
+   * - 至少生成一个方块
+   * - 方块 count = round,约5%概率为 round * 2
+   * - 从第2行开始,每行恰好生成一个 BallItem
+   * - 模式二每行最多生成一个 LineClearItem(以 LINE_CLEAR_ITEM_CHANCE 概率)
+   * @param {number} round - 当前轮次
+   * @param {number} mode - 游戏模式 (GameMode.CLASSIC | GameMode.ENHANCED)
+   */
+  generateRow(round, mode, startRow = TOP_PADDING_ROWS) {
+    this._rowsGenerated++;
+    const newBlocks = [];
+    const newItems = [];
+    const occupied = new Set();
+
+    // Phase 1: Generate blocks
+    // Reserve at least 1 empty column for BallItem (from 2nd row onward)
+    const maxBlocks = this._rowsGenerated >= 2 ? this.cols - 1 : this.cols;
+    for (let col = 0; col < this.cols; col++) {
+      if (occupied.size >= maxBlocks) break;
+      if (Math.random() < BLOCK_SPAWN_CHANCE) {
+        const count = Math.random() < BLOCK_DOUBLE_CHANCE ? round * 2 : round;
+        newBlocks.push(new Block(col, startRow, count, this.blockSize));
+        occupied.add(col);
+      }
+    }
+
+    // Ensure at least one block
+    if (newBlocks.length === 0) {
+      const col = Math.floor(Math.random() * this.cols);
+      const count = Math.random() < BLOCK_DOUBLE_CHANCE ? round * 2 : round;
+      newBlocks.push(new Block(col, startRow, count, this.blockSize));
+      occupied.add(col);
+    }
+
+    // Phase 2: Generate items in empty columns
+    const emptyCols = [];
+    for (let col = 0; col < this.cols; col++) {
+      if (!occupied.has(col)) {
+        emptyCols.push(col);
+      }
+    }
+
+    // Generate exactly one BallItem per row (from the 2nd row onward)
+    if (this._rowsGenerated >= 2 && emptyCols.length > 0) {
+      const ballColIdx = Math.floor(Math.random() * emptyCols.length);
+      const ballCol = emptyCols[ballColIdx];
+      newItems.push(new BallItem(ballCol, startRow, this.blockSize));
+      emptyCols.splice(ballColIdx, 1);
+      occupied.add(ballCol);
+    }
+
+    // Generate at most one LineClearItem per row (mode 2 only)
+    if (mode === GameMode.ENHANCED && emptyCols.length > 0) {
+      if (Math.random() < LINE_CLEAR_ITEM_CHANCE) {
+        const idx = Math.floor(Math.random() * emptyCols.length);
+        const col = emptyCols[idx];
+        const direction = Math.random() < 0.5 ? 'horizontal' : 'vertical';
+        newItems.push(new LineClearItem(col, startRow, this.blockSize, direction));
+      }
+    }
+
+    this.blocks.push(...newBlocks);
+    this.items.push(...newItems);
+  }
+
+  /**
+   * 所有方块和道具下移一行
+   */
+  moveAllDown() {
+      for (const block of this.blocks) {
+        block.moveDown();
+      }
+      for (const item of this.items) {
+        item.moveDown();
+      }
+      // 移除已到达底部的道具
+      this.items = this.items.filter(item => item.gridY < this.rows);
+    }
+
+
+  /**
+   * 检查是否有方块触底(gridY >= rows)
+   * @returns {boolean}
+   */
+  checkGameOver() {
+    return this.blocks.some(block => block.gridY >= this.rows);
+  }
+
+  /**
+   * 移除指定方块
+   * @param {Block} block
+   */
+  removeBlock(block) {
+    const idx = this.blocks.indexOf(block);
+    if (idx !== -1) {
+      this.blocks.splice(idx, 1);
+    }
+  }
+
+  /**
+   * 指定行的所有方块 count 减 1,归零则移除
+   * @param {number} gridY
+   * @returns {Block[]} 被消除的方块列表
+   */
+  clearRow(gridY) {
+    const destroyed = [];
+    for (const block of this.blocks) {
+      if (block.gridY === gridY) {
+        block.hit();
+        if (block.isDestroyed()) {
+          destroyed.push(block);
+        }
+      }
+    }
+    this.blocks = this.blocks.filter(b => !b.isDestroyed());
+    return destroyed;
+  }
+
+  /**
+   * 指定列的所有方块 count 减 1,归零则移除
+   * @param {number} gridX
+   * @returns {Block[]} 被消除的方块列表
+   */
+  clearColumn(gridX) {
+    const destroyed = [];
+    for (const block of this.blocks) {
+      if (block.gridX === gridX) {
+        block.hit();
+        if (block.isDestroyed()) {
+          destroyed.push(block);
+        }
+      }
+    }
+    this.blocks = this.blocks.filter(b => !b.isDestroyed());
+    return destroyed;
+  }
+
+  /**
+   * 获取所有方块
+   * @returns {Block[]}
+   */
+  getBlocks() {
+    return this.blocks;
+  }
+
+  /**
+   * 获取所有道具
+   * @returns {Item[]}
+   */
+  getItems() {
+    return this.items;
+  }
+}

+ 200 - 0
src/systems/Collision.js

@@ -0,0 +1,200 @@
+/**
+ * Collision - 共享碰撞几何模块
+ * GuideLine 和 Physics 都使用这套函数,确保碰撞行为一致
+ * 支持方块圆角(Minkowski sum: 平边膨胀 + 角落圆弧)
+ */
+
+import { BALL_RADIUS, BLOCK_CORNER_RADIUS } from '../constants.js';
+
+const EPS = 1e-6;
+
+/**
+ * 射线与 AABB 交点检测
+ */
+export function rayAABBIntersect(ox, oy, dx, dy, rect) {
+  let tMin = -Infinity;
+  let tMax = Infinity;
+  let entryAxis = '';
+
+  if (Math.abs(dx) < EPS) {
+    if (ox < rect.x || ox > rect.x + rect.width) return null;
+  } else {
+    let t1 = (rect.x - ox) / dx;
+    let t2 = (rect.x + rect.width - ox) / dx;
+    let s1 = 'left', s2 = 'right';
+    if (t1 > t2) { [t1, t2] = [t2, t1]; [s1, s2] = [s2, s1]; }
+    if (t1 > tMin) { tMin = t1; entryAxis = s1; }
+    if (t2 < tMax) tMax = t2;
+  }
+
+  if (Math.abs(dy) < EPS) {
+    if (oy < rect.y || oy > rect.y + rect.height) return null;
+  } else {
+    let t1 = (rect.y - oy) / dy;
+    let t2 = (rect.y + rect.height - oy) / dy;
+    let s1 = 'top', s2 = 'bottom';
+    if (t1 > t2) { [t1, t2] = [t2, t1]; [s1, s2] = [s2, s1]; }
+    if (t1 > tMin) { tMin = t1; entryAxis = s1; }
+    if (t2 < tMax) tMax = t2;
+  }
+
+  if (tMin > tMax || tMax < EPS) return null;
+  const t = tMin > EPS ? tMin : tMax;
+  if (t < EPS) return null;
+  return { t, side: entryAxis };
+}
+
+/**
+ * 射线与圆交点检测
+ */
+export function rayCircleIntersect(ox, oy, dx, dy, cx, cy, radius) {
+  const fx = ox - cx, fy = oy - cy;
+  const a = dx * dx + dy * dy;
+  const b = 2 * (fx * dx + fy * dy);
+  const c = fx * fx + fy * fy - radius * radius;
+  let disc = b * b - 4 * a * c;
+  if (disc < 0) return null;
+  disc = Math.sqrt(disc);
+  const t1 = (-b - disc) / (2 * a);
+  const t2 = (-b + disc) / (2 * a);
+  if (t1 > EPS) return { t: t1 };
+  if (t2 > EPS) return { t: t2 };
+  return null;
+}
+
+/**
+ * 射线与墙壁交点检测(考虑球半径偏移)
+ */
+export function rayWallIntersect(ox, oy, dx, dy, boardWidth, boardHeight, r) {
+  if (r == null) r = 0;
+  let bestT = Infinity, bestWall = null;
+
+  if (Math.abs(dx) > EPS) {
+    let t = (r - ox) / dx;
+    if (t > EPS && t < bestT) {
+      const hy = oy + t * dy;
+      if (hy >= 0 && hy <= boardHeight) { bestT = t; bestWall = 'left'; }
+    }
+    t = (boardWidth - r - ox) / dx;
+    if (t > EPS && t < bestT) {
+      const hy = oy + t * dy;
+      if (hy >= 0 && hy <= boardHeight) { bestT = t; bestWall = 'right'; }
+    }
+  }
+  if (Math.abs(dy) > EPS) {
+    const t = (r - oy) / dy;
+    if (t > EPS && t < bestT) {
+      const hx = ox + t * dx;
+      if (hx >= 0 && hx <= boardWidth) { bestT = t; bestWall = 'top'; }
+    }
+  }
+
+  return bestWall ? { t: bestT, wall: bestWall } : null;
+}
+
+/**
+ * 反射方向计算(支持圆角法线)
+ */
+export function reflectDirection(dx, dy, surface, cornerNormal) {
+  if (surface === 'corner' && cornerNormal) {
+    const { nx, ny } = cornerNormal;
+    const dot = dx * nx + dy * ny;
+    return { dx: dx - 2 * dot * nx, dy: dy - 2 * dot * ny };
+  }
+  if (surface === 'left' || surface === 'right') return { dx: -dx, dy };
+  return { dx, dy: -dy };
+}
+
+/**
+ * 沿射线找到最近碰撞(墙壁或圆角方块)
+ * 方块碰撞区域:4条平边(Minkowski膨胀)+ 4个圆角圆弧 + 方块体十字
+ *
+ * @param {number} ox 起点x
+ * @param {number} oy 起点y
+ * @param {number} dx 方向x(单位向量分量)
+ * @param {number} dy 方向y
+ * @param {Array} blocks 方块数组
+ * @param {number} boardWidth
+ * @param {number} boardHeight
+ * @param {number} [minT=0] 最小距离阈值
+ * @returns {{ x, y, surface, cornerNormal, isBlock }}
+ */
+export function findFirstHit(ox, oy, dx, dy, blocks, boardWidth, boardHeight, minT) {
+  if (minT == null) minT = 0;
+  let bestT = Infinity;
+  let bestSurface = null;
+  let bestCornerNormal = null;
+  let isBlock = false;
+
+  const r = BALL_RADIUS;
+  const cr = BLOCK_CORNER_RADIUS;
+
+  // Walls
+  const wh = rayWallIntersect(ox, oy, dx, dy, boardWidth, boardHeight, r);
+  if (wh && wh.t > minT && wh.t < bestT) {
+    bestT = wh.t; bestSurface = wh.wall; bestCornerNormal = null; isBlock = false;
+  }
+
+  for (const block of blocks) {
+    const rect = block.getRect();
+
+    // --- 4 corner arcs (circle radius = cr + r) ---
+    const corners = [
+      { cx: rect.x + cr, cy: rect.y + cr },
+      { cx: rect.x + rect.width - cr, cy: rect.y + cr },
+      { cx: rect.x + cr, cy: rect.y + rect.height - cr },
+      { cx: rect.x + rect.width - cr, cy: rect.y + rect.height - cr }
+    ];
+    for (const c of corners) {
+      const hit = rayCircleIntersect(ox, oy, dx, dy, c.cx, c.cy, cr + r);
+      if (hit && hit.t > minT && hit.t < bestT) {
+        const hx = ox + hit.t * dx, hy = oy + hit.t * dy;
+        // Verify hit is in the corner quadrant
+        if ((hx < rect.x + cr || hx > rect.x + rect.width - cr) &&
+            (hy < rect.y + cr || hy > rect.y + rect.height - cr)) {
+          bestT = hit.t;
+          const nx = hx - c.cx, ny = hy - c.cy;
+          const len = Math.sqrt(nx * nx + ny * ny);
+          bestSurface = 'corner';
+          bestCornerNormal = len > 0 ? { nx: nx / len, ny: ny / len } : { nx: 0, ny: -1 };
+          isBlock = true;
+        }
+      }
+    }
+
+    // --- 4 flat edges (only the straight portion between corners) ---
+    const edges = [
+      { r: { x: rect.x + cr, y: rect.y - r, width: rect.width - 2 * cr, height: r }, s: 'top' },
+      { r: { x: rect.x + cr, y: rect.y + rect.height, width: rect.width - 2 * cr, height: r }, s: 'bottom' },
+      { r: { x: rect.x - r, y: rect.y + cr, width: r, height: rect.height - 2 * cr }, s: 'left' },
+      { r: { x: rect.x + rect.width, y: rect.y + cr, width: r, height: rect.height - 2 * cr }, s: 'right' }
+    ];
+    for (const e of edges) {
+      const hit = rayAABBIntersect(ox, oy, dx, dy, e.r);
+      if (hit && hit.t > minT && hit.t < bestT) {
+        bestT = hit.t; bestSurface = e.s; bestCornerNormal = null; isBlock = true;
+      }
+    }
+
+    // --- Block body (cross shape excluding corners) ---
+    const bodyH = { x: rect.x, y: rect.y + cr, width: rect.width, height: rect.height - 2 * cr };
+    const bH = rayAABBIntersect(ox, oy, dx, dy, bodyH);
+    if (bH && bH.t > minT && bH.t < bestT) {
+      bestT = bH.t; bestSurface = bH.side; bestCornerNormal = null; isBlock = true;
+    }
+    const bodyV = { x: rect.x + cr, y: rect.y, width: rect.width - 2 * cr, height: rect.height };
+    const bV = rayAABBIntersect(ox, oy, dx, dy, bodyV);
+    if (bV && bV.t > minT && bV.t < bestT) {
+      bestT = bV.t; bestSurface = bV.side; bestCornerNormal = null; isBlock = true;
+    }
+  }
+
+  return {
+    x: ox + bestT * dx,
+    y: oy + bestT * dy,
+    t: bestT,
+    surface: bestSurface,
+    cornerNormal: bestCornerNormal,
+    isBlock
+  };
+}

+ 34 - 0
src/systems/GuideLine.js

@@ -0,0 +1,34 @@
+import { MIN_ANGLE, MAX_ANGLE } from '../constants.js';
+import { findFirstHit, reflectDirection } from './Collision.js';
+
+/**
+ * 角度钳制
+ */
+export function clampAngle(angle) {
+  if (angle < MIN_ANGLE || (angle > 270 && angle <= 360)) return MIN_ANGLE;
+  if (angle > MAX_ANGLE) return MAX_ANGLE;
+  return angle;
+}
+
+/**
+ * 计算参考线路径(含一次折射)
+ */
+export function calculate(startX, startY, angle, blocks, boardWidth, boardHeight) {
+  const clamped = clampAngle(angle);
+  const rad = clamped * Math.PI / 180;
+  const dx = -Math.cos(rad);
+  const dy = -Math.sin(rad);
+
+  const points = [{ x: startX, y: startY }];
+
+  const hit1 = findFirstHit(startX, startY, dx, dy, blocks, boardWidth, boardHeight);
+  points.push({ x: hit1.x, y: hit1.y });
+
+  if (hit1.surface) {
+    const ref = reflectDirection(dx, dy, hit1.surface, hit1.cornerNormal);
+    const hit2 = findFirstHit(hit1.x, hit1.y, ref.dx, ref.dy, blocks, boardWidth, boardHeight, 1);
+    points.push({ x: hit2.x, y: hit2.y });
+  }
+
+  return points;
+}

+ 206 - 0
src/systems/InputHandler.js

@@ -0,0 +1,206 @@
+/**
+ * InputHandler - 输入处理模块
+ * 同时绑定 touch 和 mouse 事件,统一回调接口
+ * 支持 enable/disable 控制
+ */
+export class InputHandler {
+  /**
+   * @param {HTMLCanvasElement} canvas - 游戏画布元素
+   */
+  constructor(canvas) {
+    this._canvas = canvas;
+    this._enabled = true;
+    this._mouseDown = false;
+
+    // 发射点坐标(球的初始位置)
+    this._launchX = 0;
+    this._launchY = 0;
+
+    // 触摸/鼠标起始位置(用于绘制用户滑动线)
+    this._startX = 0;
+    this._startY = 0;
+
+    // 回调函数
+    this._onAimStart = null;
+    this._onAimMove = null;
+    this._onAimEnd = null;
+
+    // 绑定事件处理器(保存引用以便移除)
+    this._handleTouchStart = this._onTouchStart.bind(this);
+    this._handleTouchMove = this._onTouchMove.bind(this);
+    this._handleTouchEnd = this._onTouchEnd.bind(this);
+    this._handleMouseDown = this._onMouseDown.bind(this);
+    this._handleMouseMove = this._onMouseMove.bind(this);
+    this._handleMouseUp = this._onMouseUp.bind(this);
+
+    // 绑定事件
+    canvas.addEventListener('touchstart', this._handleTouchStart, { passive: false });
+    canvas.addEventListener('touchmove', this._handleTouchMove, { passive: false });
+    canvas.addEventListener('touchend', this._handleTouchEnd, { passive: false });
+    canvas.addEventListener('mousedown', this._handleMouseDown);
+    canvas.addEventListener('mousemove', this._handleMouseMove);
+    canvas.addEventListener('mouseup', this._handleMouseUp);
+  }
+
+  /**
+   * 设置发射点坐标
+   * @param {number} x
+   * @param {number} y
+   */
+  setLaunchPoint(x, y) {
+    this._launchX = x;
+    this._launchY = y;
+  }
+
+  /**
+   * 注册瞄准开始回调
+   * @param {function({x: number, y: number, angle: number}): void} callback
+   */
+  onAimStart(callback) {
+    this._onAimStart = callback;
+  }
+
+  /**
+   * 注册瞄准移动回调
+   * @param {function({x: number, y: number, angle: number}): void} callback
+   */
+  onAimMove(callback) {
+    this._onAimMove = callback;
+  }
+
+  /**
+   * 注册瞄准结束回调
+   * @param {function({x: number, y: number, angle: number}): void} callback
+   */
+  onAimEnd(callback) {
+    this._onAimEnd = callback;
+  }
+
+  /** 启用输入 */
+  enable() {
+    this._enabled = true;
+  }
+
+  /** 禁用输入 */
+  disable() {
+    this._enabled = false;
+    this._mouseDown = false;
+  }
+
+  /**
+   * 根据滑动方向计算瞄准角度(度)
+   * 角度从当前触摸位置指向起始触摸位置(与滑动方向相反)
+   * 参考 game.js: atan2(start.y - to.y, start.x - to.x)
+   * @param {number} x - 当前画布内 x 坐标
+   * @param {number} y - 当前画布内 y 坐标
+   * @returns {number} 角度(度)
+   */
+  _calcAngle(x, y) {
+    const dx = this._startX - x;
+    const dy = this._startY - y;
+    // atan2 返回弧度,转换为度数
+    const rad = Math.atan2(dy, dx);
+    let deg = rad * (180 / Math.PI);
+    // 将负角度转为正角度 (0-360)
+    if (deg < 0) deg += 360;
+    return deg;
+  }
+
+  /**
+   * 从事件中获取画布内坐标
+   * @param {Touch|MouseEvent} source - 触摸点或鼠标事件
+   * @returns {{x: number, y: number}}
+   */
+  _getCanvasPos(source) {
+    const rect = this._canvas.getBoundingClientRect();
+    return {
+      x: source.clientX - rect.left,
+      y: source.clientY - rect.top
+    };
+  }
+
+  /**
+   * 构建回调数据
+   * @param {number} x
+   * @param {number} y
+   * @returns {{x: number, y: number, angle: number, startX: number, startY: number}}
+   */
+  _buildEventData(x, y) {
+    return { x, y, angle: this._calcAngle(x, y), startX: this._startX, startY: this._startY };
+  }
+
+  // --- Touch 事件处理 ---
+
+  _onTouchStart(e) {
+    e.preventDefault();
+    if (!this._enabled) return;
+    const pos = this._getCanvasPos(e.touches[0]);
+    this._startX = pos.x;
+    this._startY = pos.y;
+    if (this._onAimStart) {
+      this._onAimStart(this._buildEventData(pos.x, pos.y));
+    }
+  }
+
+  _onTouchMove(e) {
+    e.preventDefault();
+    if (!this._enabled) return;
+    const pos = this._getCanvasPos(e.touches[0]);
+    if (this._onAimMove) {
+      this._onAimMove(this._buildEventData(pos.x, pos.y));
+    }
+  }
+
+  _onTouchEnd(e) {
+    e.preventDefault();
+    if (!this._enabled) return;
+    // touchend 没有 touches,使用 changedTouches
+    const pos = this._getCanvasPos(e.changedTouches[0]);
+    if (this._onAimEnd) {
+      this._onAimEnd(this._buildEventData(pos.x, pos.y));
+    }
+  }
+
+  // --- Mouse 事件处理 ---
+
+  _onMouseDown(e) {
+    if (!this._enabled) return;
+    this._mouseDown = true;
+    const pos = this._getCanvasPos(e);
+    this._startX = pos.x;
+    this._startY = pos.y;
+    if (this._onAimStart) {
+      this._onAimStart(this._buildEventData(pos.x, pos.y));
+    }
+  }
+
+  _onMouseMove(e) {
+    if (!this._enabled || !this._mouseDown) return;
+    const pos = this._getCanvasPos(e);
+    if (this._onAimMove) {
+      this._onAimMove(this._buildEventData(pos.x, pos.y));
+    }
+  }
+
+  _onMouseUp(e) {
+    if (!this._enabled || !this._mouseDown) return;
+    this._mouseDown = false;
+    const pos = this._getCanvasPos(e);
+    if (this._onAimEnd) {
+      this._onAimEnd(this._buildEventData(pos.x, pos.y));
+    }
+  }
+
+  /**
+   * 销毁,移除所有事件监听
+   */
+  destroy() {
+    const c = this._canvas;
+    c.removeEventListener('touchstart', this._handleTouchStart);
+    c.removeEventListener('touchmove', this._handleTouchMove);
+    c.removeEventListener('touchend', this._handleTouchEnd);
+    c.removeEventListener('mousedown', this._handleMouseDown);
+    c.removeEventListener('mousemove', this._handleMouseMove);
+    c.removeEventListener('mouseup', this._handleMouseUp);
+  }
+}

+ 237 - 0
src/systems/Physics.js

@@ -0,0 +1,237 @@
+/**
+ * Physics - 物理/碰撞系统
+ * 处理球与墙壁、方块、道具的碰撞检测和反弹逻辑
+ * 支持方块圆角碰撞,与 GuideLine 使用一致的圆角几何
+ */
+
+import { BLOCK_CORNER_RADIUS, BALL_RADIUS } from '../constants.js';
+import { findFirstHit, reflectDirection } from './Collision.js';
+
+/**
+ * 检测球与墙壁碰撞
+ */
+export function checkBallWallCollision(ball, boardWidth, boardHeight) {
+  if (ball.x - ball.radius <= 0) return 'left';
+  if (ball.x + ball.radius >= boardWidth) return 'right';
+  if (ball.y - ball.radius <= 0) return 'top';
+  return null;
+}
+
+/**
+ * AABB 重叠检测
+ */
+function aabbOverlap(a, b) {
+  return (
+    a.x < b.x + b.width &&
+    a.x + a.width > b.x &&
+    a.y < b.y + b.height &&
+    a.y + a.height > b.y
+  );
+}
+
+/**
+ * 检测球心是否在方块角落区域,并返回碰撞信息
+ */
+function checkCornerCollision(bx, by, ballRadius, rect, cr) {
+  const corners = [
+    { cx: rect.x + cr, cy: rect.y + cr },
+    { cx: rect.x + rect.width - cr, cy: rect.y + cr },
+    { cx: rect.x + cr, cy: rect.y + rect.height - cr },
+    { cx: rect.x + rect.width - cr, cy: rect.y + rect.height - cr }
+  ];
+
+  for (const c of corners) {
+    const dx = bx - c.cx;
+    const dy = by - c.cy;
+    const distSq = dx * dx + dy * dy;
+    const threshold = cr + ballRadius;
+
+    if (distSq < threshold * threshold && distSq > 0) {
+      const inCornerX = (bx < rect.x + cr || bx > rect.x + rect.width - cr);
+      const inCornerY = (by < rect.y + cr || by > rect.y + rect.height - cr);
+      if (inCornerX && inCornerY) {
+        const dist = Math.sqrt(distSq);
+        return { cx: c.cx, cy: c.cy, dist };
+      }
+    }
+  }
+  return null;
+}
+
+/**
+ * 检测球心是否在方块角落的"缺角"区域
+ */
+function isInCornerGap(bx, by, rect, cr) {
+  const inCornerX = (bx < rect.x + cr || bx > rect.x + rect.width - cr);
+  const inCornerY = (by < rect.y + cr || by > rect.y + rect.height - cr);
+  if (!inCornerX || !inCornerY) return false;
+
+  const corners = [
+    { cx: rect.x + cr, cy: rect.y + cr },
+    { cx: rect.x + rect.width - cr, cy: rect.y + cr },
+    { cx: rect.x + cr, cy: rect.y + rect.height - cr },
+    { cx: rect.x + rect.width - cr, cy: rect.y + rect.height - cr }
+  ];
+
+  let minDist = Infinity;
+  for (const c of corners) {
+    const dx = bx - c.cx;
+    const dy = by - c.cy;
+    const dist = Math.sqrt(dx * dx + dy * dy);
+    if (dist < minDist) minDist = dist;
+  }
+
+  return minDist > cr;
+}
+
+/**
+ * 检测球与方块碰撞(支持圆角)
+ */
+export function checkBallBlockCollision(ball, block) {
+  const ballRect = ball.getRect();
+  const blockRect = block.getRect();
+
+  if (!aabbOverlap(ballRect, blockRect)) {
+    return { hit: false, side: null, corner: null };
+  }
+
+  const cr = BLOCK_CORNER_RADIUS;
+
+  if (isInCornerGap(ball.x, ball.y, blockRect, cr + ball.radius)) {
+    const cornerHit = checkCornerCollision(ball.x, ball.y, ball.radius, blockRect, cr);
+    if (cornerHit) {
+      return { hit: true, side: 'corner', corner: cornerHit };
+    }
+    return { hit: false, side: null, corner: null };
+  }
+
+  const cornerHit = checkCornerCollision(ball.x, ball.y, ball.radius, blockRect, cr);
+  if (cornerHit) {
+    return { hit: true, side: 'corner', corner: cornerHit };
+  }
+
+  const overlapLeft = (ballRect.x + ballRect.width) - blockRect.x;
+  const overlapRight = (blockRect.x + blockRect.width) - ballRect.x;
+  const overlapTop = (ballRect.y + ballRect.height) - blockRect.y;
+  const overlapBottom = (blockRect.y + blockRect.height) - ballRect.y;
+
+  const minOverlap = Math.min(overlapLeft, overlapRight, overlapTop, overlapBottom);
+
+  let side;
+  if (minOverlap === overlapTop) side = 'top';
+  else if (minOverlap === overlapBottom) side = 'bottom';
+  else if (minOverlap === overlapLeft) side = 'left';
+  else side = 'right';
+
+  return { hit: true, side, corner: null };
+}
+
+/**
+ * 检测球与道具碰撞
+ */
+export function checkBallItemCollision(ball, item) {
+  return aabbOverlap(ball.getRect(), item.getRect());
+}
+
+/**
+ * 处理球与方块碰撞后的反弹(支持圆角)
+ */
+export function resolveBallBlockCollision(ball, side, blockRect, corner) {
+  if (side === 'corner' && corner) {
+    const dx = ball.x - corner.cx;
+    const dy = ball.y - corner.cy;
+    const len = Math.sqrt(dx * dx + dy * dy);
+    if (len > 0) {
+      const nx = dx / len;
+      const ny = dy / len;
+      const dot = ball.vx * nx + ball.vy * ny;
+      ball.vx -= 2 * dot * nx;
+      ball.vy -= 2 * dot * ny;
+      ball.x = corner.cx + nx * (BLOCK_CORNER_RADIUS + ball.radius);
+      ball.y = corner.cy + ny * (BLOCK_CORNER_RADIUS + ball.radius);
+    }
+    return;
+  }
+
+  if (side === 'top' || side === 'bottom') {
+    ball.reflect('y');
+  } else if (side === 'left' || side === 'right') {
+    ball.reflect('x');
+  }
+
+  if (blockRect) {
+    if (side === 'top') ball.y = blockRect.y - ball.radius;
+    else if (side === 'bottom') ball.y = blockRect.y + blockRect.height + ball.radius;
+    else if (side === 'left') ball.x = blockRect.x - ball.radius;
+    else if (side === 'right') ball.x = blockRect.x + blockRect.width + ball.radius;
+  }
+}
+
+/**
+ * 墙壁反射
+ */
+export function reflectWall(velocity, wall) {
+  if (wall === 'left' || wall === 'right') {
+    return { vx: -velocity.vx, vy: velocity.vy };
+  }
+  if (wall === 'top') {
+    return { vx: velocity.vx, vy: -velocity.vy };
+  }
+  return { vx: velocity.vx, vy: velocity.vy };
+}
+
+/**
+ * 连续碰撞物理步进(与 GuideLine 使用相同的 findFirstHit)
+ */
+export function stepBallPhysics(ball, stepDist, blocks, boardWidth, boardHeight) {
+  if (!ball.active) return [];
+
+  const hitBlocks = [];
+  let remaining = stepDist;
+  const maxBounces = 10;
+
+  for (let bounce = 0; bounce < maxBounces && remaining > 1e-6; bounce++) {
+    const speed = Math.sqrt(ball.vx * ball.vx + ball.vy * ball.vy);
+    if (speed < 1e-6) break;
+
+    const dx = ball.vx / speed;
+    const dy = ball.vy / speed;
+
+    const hit = findFirstHit(ball.x, ball.y, dx, dy, blocks, boardWidth, boardHeight, 0);
+
+    if (!hit.surface || hit.t >= remaining) {
+      ball.x += dx * remaining;
+      ball.y += dy * remaining;
+      remaining = 0;
+      break;
+    }
+
+    ball.x = hit.x;
+    ball.y = hit.y;
+    remaining -= hit.t;
+
+    const ref = reflectDirection(dx, dy, hit.surface, hit.cornerNormal);
+    ball.vx = ref.dx * speed;
+    ball.vy = ref.dy * speed;
+
+    if (hit.isBlock) {
+      for (const block of blocks) {
+        if (block.destroyed) continue;
+        const r = block.getRect();
+        const margin = BALL_RADIUS + BLOCK_CORNER_RADIUS + 2;
+        if (ball.x >= r.x - margin && ball.x <= r.x + r.width + margin &&
+            ball.y >= r.y - margin && ball.y <= r.y + r.height + margin) {
+          hitBlocks.push({ block });
+          break;
+        }
+      }
+    }
+  }
+
+  if (ball.y + ball.radius >= boardHeight) {
+    ball.y = boardHeight - ball.radius;
+    ball.active = false;
+  }
+
+  return hitBlocks;
+}

+ 406 - 0
src/systems/Renderer.js

@@ -0,0 +1,406 @@
+import { BG_COLOR, BALL_COLOR, GRID_GAP, BLOCK_CORNER_RADIUS } from '../constants.js';
+
+/**
+ * Renderer - 渲染器
+ * 负责 Canvas 上所有图形元素的绘制和动画
+ */
+export class Renderer {
+  /**
+   * @param {HTMLCanvasElement} canvas
+   * @param {number} [dpr=window.devicePixelRatio || 1] - 设备像素比
+   */
+  constructor(canvas, dpr) {
+    this.canvas = canvas;
+    this.ctx = canvas.getContext('2d');
+    this.dpr = dpr != null ? dpr : (typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1);
+
+    // Active animations
+    this._animations = [];
+
+    this._initCanvas();
+  }
+
+  /**
+   * Initialize canvas size based on container, apply devicePixelRatio scaling
+   */
+  _initCanvas() {
+    const parent = this.canvas.parentElement;
+    const width = parent ? parent.clientWidth : this.canvas.width;
+    const height = parent ? parent.clientHeight : this.canvas.height;
+
+    this.canvas.width = width * this.dpr;
+    this.canvas.height = height * this.dpr;
+    this.canvas.style.width = width + 'px';
+    this.canvas.style.height = height + 'px';
+
+    this.ctx.scale(this.dpr, this.dpr);
+
+    this.width = width;
+    this.height = height;
+  }
+
+  /**
+   * Clear the entire canvas with background color
+   */
+  clear() {
+    this.ctx.save();
+    this.ctx.setTransform(1, 0, 0, 1, 0, 0);
+    this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
+    this.ctx.restore();
+
+    this.ctx.fillStyle = BG_COLOR;
+    this.ctx.fillRect(0, 0, this.width, this.height);
+  }
+
+  /**
+   * Draw a block with color gradient based on count, and centered count text.
+   * Supports animation state (scale/opacity) for destroy animations.
+   * @param {import('../entities/Block.js').Block} block
+   * @param {{ scale?: number, opacity?: number }} [animation]
+   * @param {number} [yOffset=0] - vertical offset for slide animation
+   */
+  drawBlock(block, animation, yOffset) {
+    if (block.destroyed && !animation) return;
+
+    const rect = block.getRect();
+    if (yOffset) {
+      rect.y += yOffset;
+    }
+    const color = block.getColor();
+    const ctx = this.ctx;
+
+    ctx.save();
+
+    if (animation) {
+      const scale = animation.scale != null ? animation.scale : 1;
+      const opacity = animation.opacity != null ? animation.opacity : 1;
+      ctx.globalAlpha = opacity;
+      const cx = rect.x + rect.width / 2;
+      const cy = rect.y + rect.height / 2;
+      ctx.translate(cx, cy);
+      ctx.scale(scale, scale);
+      ctx.translate(-cx, -cy);
+    } else if (block._hitTime) {
+      // Hit pulse: brief scale-up then back over 150ms
+      const elapsed = performance.now() - block._hitTime;
+      const hitDuration = 150;
+      if (elapsed < hitDuration) {
+        const ht = elapsed / hitDuration;
+        // Scale peaks at 1.12 at t=0.3, then eases back to 1.0
+        const scale = 1 + 0.12 * Math.sin(ht * Math.PI);
+        const cx = rect.x + rect.width / 2;
+        const cy = rect.y + rect.height / 2;
+        ctx.translate(cx, cy);
+        ctx.scale(scale, scale);
+        ctx.translate(-cx, -cy);
+      }
+    }
+
+    // Filled rounded rect with block color
+    ctx.fillStyle = color;
+    if (ctx.roundRect) {
+      ctx.beginPath();
+      ctx.roundRect(rect.x, rect.y, rect.width, rect.height, BLOCK_CORNER_RADIUS);
+      ctx.fill();
+    } else {
+      ctx.fillRect(rect.x, rect.y, rect.width, rect.height);
+    }
+
+    // Count text centered in block
+    const fontSize = Math.max(12, rect.width * 0.4);
+    ctx.fillStyle = '#fff';
+    ctx.font = `bold ${fontSize}px 'number', monospace`;
+    ctx.textAlign = 'center';
+    ctx.textBaseline = 'middle';
+    ctx.fillText(
+      String(block.count),
+      rect.x + rect.width / 2,
+      rect.y + rect.height / 2
+    );
+
+    ctx.restore();
+  }
+
+  /**
+   * Draw a ball as a filled circle
+   * @param {import('../entities/Ball.js').Ball} ball
+   */
+  drawBall(ball) {
+    const ctx = this.ctx;
+    ctx.beginPath();
+    ctx.arc(ball.x, ball.y, ball.radius, 0, Math.PI * 2);
+    ctx.fillStyle = ball.color || BALL_COLOR;
+    ctx.fill();
+  }
+
+  /**
+   * Draw an item (BallItem or LineClearItem)
+   * @param {import('../entities/Item.js').Item} item
+   * @param {number} [yOffset=0] - vertical offset for slide animation
+   */
+  drawItem(item, yOffset) {
+    if (item.collected && item.shouldRemove()) return;
+
+    const rect = item.getRect();
+    if (yOffset) {
+      rect.y += yOffset;
+    }
+    const ctx = this.ctx;
+    const cx = rect.x + rect.width / 2;
+    const cy = rect.y + rect.height / 2;
+    const r = rect.width / 2 - 2;
+
+    ctx.save();
+
+    if (item.type === 'ball') {
+      // BallItem: green circle with "+"
+      ctx.beginPath();
+      ctx.arc(cx, cy, r, 0, Math.PI * 2);
+      ctx.fillStyle = '#4CAF50';
+      ctx.fill();
+
+      ctx.fillStyle = '#fff';
+      ctx.font = `bold ${r * 1.2}px sans-serif`;
+      ctx.textAlign = 'center';
+      ctx.textBaseline = 'middle';
+      ctx.fillText('+', cx, cy);
+    } else if (item.type === 'lineClear') {
+      // LineClearItem: orange circle with direction line
+      ctx.beginPath();
+      ctx.arc(cx, cy, r, 0, Math.PI * 2);
+      ctx.fillStyle = '#FF9800';
+      ctx.fill();
+
+      // Draw direction indicator line
+      ctx.strokeStyle = '#fff';
+      ctx.lineWidth = 2.5;
+      ctx.lineCap = 'round';
+      const lineLen = r * 0.7;
+      ctx.beginPath();
+      if (item.direction === 'horizontal') {
+        // Horizontal line
+        ctx.moveTo(cx - lineLen, cy);
+        ctx.lineTo(cx + lineLen, cy);
+      } else {
+        // Vertical line
+        ctx.moveTo(cx, cy - lineLen);
+        ctx.lineTo(cx, cy + lineLen);
+      }
+      ctx.stroke();
+    }
+
+    ctx.restore();
+  }
+
+  /**
+   * Draw a solid line from user touch start to current touch position
+   * @param {number} startX
+   * @param {number} startY
+   * @param {number} endX
+   * @param {number} endY
+   */
+  drawUserLine(startX, startY, endX, endY) {
+    if (startX === endX && startY === endY) return;
+    const ctx = this.ctx;
+    ctx.save();
+    ctx.beginPath();
+    ctx.setLineDash([]);
+    ctx.lineWidth = 3;
+    ctx.strokeStyle = '#BABABA';
+    ctx.moveTo(startX, startY);
+    ctx.lineTo(endX, endY);
+    ctx.stroke();
+    ctx.setLineDash([]);
+    ctx.restore();
+  }
+
+  /**
+   * Draw a dashed guide line connecting path points
+   * @param {{ x: number, y: number }[]} path - array of path points
+   */
+  drawGuideLine(path) {
+    if (!path || path.length < 2) return;
+
+    const ctx = this.ctx;
+    ctx.save();
+    ctx.strokeStyle = 'rgba(255, 255, 255, 0.6)';
+    ctx.lineWidth = 2;
+    ctx.setLineDash([8, 6]);
+
+    ctx.beginPath();
+    ctx.moveTo(path[0].x, path[0].y);
+    for (let i = 1; i < path.length; i++) {
+      ctx.lineTo(path[i].x, path[i].y);
+    }
+    ctx.stroke();
+    ctx.setLineDash([]);
+    ctx.restore();
+  }
+
+  /**
+   * Draw HUD: ball count at bottom of canvas
+   * @param {number} ballCount - current ball count
+   */
+  drawHUD(ballCount) {
+    const ctx = this.ctx;
+    ctx.save();
+
+    // Ball count at bottom center
+    ctx.fillStyle = '#aaa';
+    ctx.font = "16px 'number', monospace";
+    ctx.textAlign = 'center';
+    ctx.textBaseline = 'bottom';
+    ctx.fillText('×' + ballCount, this.width / 2, this.height - 4);
+
+    ctx.restore();
+  }
+
+  /**
+   * Draw speed multiplier indicator in top-right corner
+   * @param {number} multiplier - current speed multiplier (2, 4, 8)
+   */
+  drawSpeedIndicator(multiplier) {
+    if (multiplier <= 1) return;
+    const ctx = this.ctx;
+    ctx.save();
+    ctx.fillStyle = '#f8b42b';
+    ctx.font = "bold 20px 'number', monospace";
+    ctx.textAlign = 'right';
+    ctx.textBaseline = 'top';
+    ctx.fillText('×' + multiplier, this.width - 10, 10);
+    ctx.restore();
+  }
+
+  /**
+   * Draw game over overlay on canvas
+   * @param {number} round - final round number
+   */
+  drawGameOver(round) {
+    const ctx = this.ctx;
+    ctx.save();
+
+    // Semi-transparent overlay
+    ctx.fillStyle = 'rgba(0, 0, 0, 0.6)';
+    ctx.fillRect(0, 0, this.width, this.height);
+
+    // Game over text
+    ctx.fillStyle = '#fff';
+    ctx.font = "bold 32px sans-serif";
+    ctx.textAlign = 'center';
+    ctx.textBaseline = 'middle';
+    ctx.fillText('游戏结束', this.width / 2, this.height / 2 - 30);
+
+    // Round score
+    ctx.fillStyle = '#f8b42b';
+    ctx.font = "bold 48px 'number', monospace";
+    ctx.fillText(String(round), this.width / 2, this.height / 2 + 30);
+
+    ctx.restore();
+  }
+
+  /**
+   * Start a block destroy animation (shrink + fade out)
+   * @param {import('../entities/Block.js').Block} block
+   */
+  playBlockDestroyAnimation(block) {
+    const rect = block.getRect();
+    const color = block.getColor();
+    this._animations.push({
+      type: 'destroy',
+      x: rect.x,
+      y: rect.y,
+      width: rect.width,
+      height: rect.height,
+      color: color,
+      count: block.count + 1, // count before destruction
+      startTime: performance.now(),
+      duration: 300
+    });
+  }
+
+  /**
+   * Start a line sweep animation (horizontal or vertical line flash)
+   * @param {'horizontal'|'vertical'} direction
+   * @param {number} gridIndex - gridY for horizontal, gridX for vertical
+   * @param {number} blockSize - block size in pixels
+   * @param {number} extent - boardWidth for horizontal, boardHeight for vertical
+   */
+  playLineSweepAnimation(direction, gridIndex, blockSize, extent) {
+    const pos = gridIndex * (blockSize + GRID_GAP) + GRID_GAP + blockSize / 2;
+    this._animations.push({
+      type: 'lineSweep',
+      direction,
+      pos,
+      extent,
+      startTime: performance.now(),
+      duration: 350
+    });
+  }
+
+  /**
+   * Update and render all active animations. Call once per frame.
+   */
+  updateAnimations() {
+    const now = performance.now();
+    const ctx = this.ctx;
+
+    this._animations = this._animations.filter(anim => {
+      const elapsed = now - anim.startTime;
+      const t = Math.min(elapsed / anim.duration, 1);
+
+      if (t >= 1) return false; // animation complete
+
+      ctx.save();
+
+      if (anim.type === 'destroy') {
+        // Shrink + fade out
+        const scale = 1 - t;
+        const alpha = 1 - t;
+        const cx = anim.x + anim.width / 2;
+        const cy = anim.y + anim.height / 2;
+        ctx.globalAlpha = alpha;
+        ctx.translate(cx, cy);
+        ctx.scale(scale, scale);
+        ctx.translate(-cx, -cy);
+        ctx.fillStyle = anim.color;
+        if (ctx.roundRect) {
+          ctx.beginPath();
+          ctx.roundRect(anim.x, anim.y, anim.width, anim.height, BLOCK_CORNER_RADIUS);
+          ctx.fill();
+        } else {
+          ctx.fillRect(anim.x, anim.y, anim.width, anim.height);
+        }
+
+        // Count text
+        const fontSize = Math.max(12, anim.width * 0.4);
+        ctx.fillStyle = '#fff';
+        ctx.font = `bold ${fontSize}px 'number', monospace`;
+        ctx.textAlign = 'center';
+        ctx.textBaseline = 'middle';
+        ctx.fillText(String(anim.count), cx, cy);
+      } else if (anim.type === 'lineSweep') {
+        // Line sweep: bright line that fades out
+        const alpha = 1 - t;
+        const lineWidth = 4 * (1 - t * 0.5);
+        ctx.globalAlpha = alpha;
+        ctx.strokeStyle = '#FF9800';
+        ctx.lineWidth = lineWidth;
+        ctx.shadowColor = '#FF9800';
+        ctx.shadowBlur = 8 * (1 - t);
+        ctx.beginPath();
+        if (anim.direction === 'horizontal') {
+          ctx.moveTo(0, anim.pos);
+          ctx.lineTo(anim.extent, anim.pos);
+        } else {
+          ctx.moveTo(anim.pos, 0);
+          ctx.lineTo(anim.pos, anim.extent);
+        }
+        ctx.stroke();
+        ctx.shadowBlur = 0;
+      }
+
+      ctx.restore();
+      return true; // keep animation
+    });
+  }
+}

+ 42 - 0
src/ui/ModeSelector.js

@@ -0,0 +1,42 @@
+/**
+ * ModeSelector - 模式选择界面
+ * 管理模式选择 overlay 的显示/隐藏和选择回调
+ */
+export class ModeSelector {
+  /**
+   * @param {HTMLElement} container - #mode-selector overlay 元素
+   */
+  constructor(container) {
+    this.container = container;
+    this._callback = null;
+
+    // 绑定模式按钮点击事件
+    const buttons = this.container.querySelectorAll('.mode-btn');
+    buttons.forEach(btn => {
+      btn.addEventListener('click', () => {
+        const mode = Number(btn.dataset.mode);
+        if (this._callback) {
+          this._callback(mode);
+        }
+      });
+    });
+  }
+
+  /** 显示模式选择界面 */
+  show() {
+    this.container.style.display = 'flex';
+  }
+
+  /** 隐藏模式选择界面 */
+  hide() {
+    this.container.style.display = 'none';
+  }
+
+  /**
+   * 注册模式选择回调
+   * @param {function(number): void} callback - 接收选中的模式编号 (1 或 2)
+   */
+  onSelect(callback) {
+    this._callback = callback;
+  }
+}

+ 30 - 0
src/utils/color.js

@@ -0,0 +1,30 @@
+import { COLOR_TABLE } from '../constants.js';
+
+/**
+ * 基于色指定表和线性插值计算方块颜色
+ * @param {number} count - 正整数,方块的数字
+ * @returns {string} 7字符十六进制颜色字符串,格式为 #RRGGBB
+ */
+export function getColor(count) {
+  const colorIndex = Math.floor((count % 90) / 5);
+  const nextIndex = (colorIndex + 1) % COLOR_TABLE.length;
+  const step = count % 5;
+
+  const base = COLOR_TABLE[colorIndex];
+  const next = COLOR_TABLE[nextIndex];
+
+  const r = clampByte(base[0] + Math.round((next[0] - base[0]) / 5) * step);
+  const g = clampByte(base[1] + Math.round((next[1] - base[1]) / 5) * step);
+  const b = clampByte(base[2] + Math.round((next[2] - base[2]) / 5) * step);
+
+  return '#' + toHex(r) + toHex(g) + toHex(b);
+}
+
+function clampByte(value) {
+  return Math.max(0, Math.min(255, value));
+}
+
+function toHex(value) {
+  const hex = value.toString(16);
+  return hex.length === 1 ? '0' + hex : hex;
+}

+ 122 - 0
style.css

@@ -0,0 +1,122 @@
+@font-face {
+    font-family: 'number';
+    src: url("DIN1451.ttf");
+}
+
+* {
+    margin: 0;
+    padding: 0;
+    touch-action: none;
+}
+
+#contain {
+    width: 100vw;
+    max-width: 500px;
+    height: 100vh;
+    background: #232323;
+    margin: 0 auto;
+}
+
+.header {
+    background: #2a2a2a;
+    width: 100%;
+    min-height: 80px;
+}
+
+.footer {
+    background: #2a2a2a;
+    width: 100%;
+    max-width: 500px;
+    min-height: 60px;
+    position: fixed;
+    bottom: 0;
+    left: 50%;
+    transform: translateX(-50%);
+    display: flex;
+    align-items: center;
+    justify-content: center;
+}
+
+#ball-count {
+    color: #aaa;
+    font-size: 1.2em;
+    font-family: 'number', monospace;
+}
+
+#level {
+    position: absolute;
+    top: 0;
+    left: 0;
+    color: #fff;
+    font-size: 3em;
+    width: 100%;
+    text-align: center;
+}
+
+body {
+    background: #232323;
+}
+
+/* 覆盖层通用样式 */
+.overlay {
+    position: fixed;
+    top: 0;
+    left: 0;
+    width: 100vw;
+    height: 100vh;
+    background: rgba(0, 0, 0, 0.75);
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    z-index: 100;
+}
+
+.overlay-content {
+    background: #2a2a2a;
+    border-radius: 12px;
+    padding: 32px 28px;
+    text-align: center;
+    min-width: 240px;
+    max-width: 90vw;
+}
+
+.overlay-title {
+    color: #fff;
+    font-size: 1.5em;
+    margin-bottom: 24px;
+    font-weight: normal;
+}
+
+.mode-btn {
+    display: block;
+    width: 100%;
+    padding: 14px 0;
+    margin-bottom: 12px;
+    border: none;
+    border-radius: 8px;
+    background: #f8b42b;
+    color: #000;
+    font-size: 1.1em;
+    font-weight: bold;
+    cursor: pointer;
+    transition: background 0.2s;
+}
+
+.mode-btn:last-child {
+    margin-bottom: 0;
+}
+
+.mode-btn:hover {
+    background: #e8a020;
+}
+
+.mode-btn:active {
+    background: #d09018;
+}
+
+.final-score {
+    color: #f8b42b;
+    font-size: 2em;
+    margin-bottom: 24px;
+    font-family: 'number', monospace;
+}

+ 284 - 0
tests/ball.test.js

@@ -0,0 +1,284 @@
+import * as fc from 'fast-check';
+import { describe, it, expect } from 'vitest';
+import { Ball } from '../src/entities/Ball.js';
+import { BALL_RADIUS, BALL_COLOR, BALL_SPEED } from '../src/constants.js';
+
+describe('Ball', () => {
+  // ---- Unit Tests ----
+
+  describe('constructor', () => {
+    it('initializes with given position, radius, and color', () => {
+      const ball = new Ball(100, 200, 10, '#ff0000');
+      expect(ball.x).toBe(100);
+      expect(ball.y).toBe(200);
+      expect(ball.radius).toBe(10);
+      expect(ball.color).toBe('#ff0000');
+      expect(ball.vx).toBe(0);
+      expect(ball.vy).toBe(0);
+      expect(ball.active).toBe(true);
+    });
+
+    it('uses default radius and color when not provided', () => {
+      const ball = new Ball(50, 60);
+      expect(ball.radius).toBe(BALL_RADIUS);
+      expect(ball.color).toBe(BALL_COLOR);
+    });
+  });
+
+  describe('reflect', () => {
+    it('negates vx when axis is x', () => {
+      const ball = new Ball(100, 100);
+      ball.vx = 5;
+      ball.vy = -3;
+      ball.reflect('x');
+      expect(ball.vx).toBe(-5);
+      expect(ball.vy).toBe(-3);
+    });
+
+    it('negates vy when axis is y', () => {
+      const ball = new Ball(100, 100);
+      ball.vx = 5;
+      ball.vy = -3;
+      ball.reflect('y');
+      expect(ball.vx).toBe(5);
+      expect(ball.vy).toBe(3);
+    });
+
+    it('does nothing for invalid axis', () => {
+      const ball = new Ball(100, 100);
+      ball.vx = 5;
+      ball.vy = -3;
+      ball.reflect('z');
+      expect(ball.vx).toBe(5);
+      expect(ball.vy).toBe(-3);
+    });
+  });
+
+  describe('isAtBottom', () => {
+    it('returns true when ball touches bottom', () => {
+      const ball = new Ball(100, 492, 8);
+      expect(ball.isAtBottom(500)).toBe(true);
+    });
+
+    it('returns true when ball is past bottom', () => {
+      const ball = new Ball(100, 510, 8);
+      expect(ball.isAtBottom(500)).toBe(true);
+    });
+
+    it('returns false when ball is above bottom', () => {
+      const ball = new Ball(100, 400, 8);
+      expect(ball.isAtBottom(500)).toBe(false);
+    });
+  });
+
+  describe('getRect', () => {
+    it('returns correct AABB rectangle', () => {
+      const ball = new Ball(100, 200, 10);
+      const rect = ball.getRect();
+      expect(rect).toEqual({ x: 90, y: 190, width: 20, height: 20 });
+    });
+  });
+
+  describe('update', () => {
+    it('moves ball by vx and vy each frame', () => {
+      const ball = new Ball(100, 100, 8);
+      ball.vx = 3;
+      ball.vy = 4;
+      ball.update(0, 400, 600);
+      expect(ball.x).toBe(103);
+      expect(ball.y).toBe(104);
+    });
+
+    it('does not move inactive ball', () => {
+      const ball = new Ball(100, 100, 8);
+      ball.vx = 3;
+      ball.vy = 4;
+      ball.active = false;
+      ball.update(0, 400, 600);
+      expect(ball.x).toBe(100);
+      expect(ball.y).toBe(100);
+    });
+
+    it('reflects off left wall', () => {
+      const ball = new Ball(5, 100, 8);
+      ball.vx = -10;
+      ball.vy = 4;
+      ball.update(0, 400, 600);
+      expect(ball.x).toBe(8); // clamped to radius
+      expect(ball.vx).toBe(10); // reflected
+    });
+
+    it('reflects off right wall', () => {
+      const ball = new Ball(395, 100, 8);
+      ball.vx = 10;
+      ball.vy = 4;
+      ball.update(0, 400, 600);
+      expect(ball.x).toBe(392); // clamped to boardWidth - radius
+      expect(ball.vx).toBe(-10); // reflected
+    });
+
+    it('reflects off top wall', () => {
+      const ball = new Ball(100, 5, 8);
+      ball.vx = 3;
+      ball.vy = -10;
+      ball.update(0, 400, 600);
+      expect(ball.y).toBe(8); // clamped to radius
+      expect(ball.vy).toBe(10); // reflected
+    });
+
+    it('stops at bottom and becomes inactive', () => {
+      const ball = new Ball(100, 590, 8);
+      ball.vx = 3;
+      ball.vy = 5;
+      ball.update(0, 400, 600);
+      expect(ball.active).toBe(false);
+      expect(ball.y).toBe(592); // boardHeight - radius
+    });
+  });
+
+  // ---- Property-Based Tests ----
+
+  describe('Property: reflect preserves speed magnitude', () => {
+    /**
+     * Feature: ball-block-breaker
+     * **Validates: Requirements 4.3, 4.4, 4.5**
+     *
+     * reflect('x') negates vx only, reflect('y') negates vy only.
+     * Speed magnitude is preserved.
+     */
+    it('reflect(x) negates vx and preserves vy and speed magnitude', () => {
+      fc.assert(
+        fc.property(
+          fc.float({ min: -100, max: 100, noNaN: true, noDefaultInfinity: true }),
+          fc.float({ min: -100, max: 100, noNaN: true, noDefaultInfinity: true }),
+          (vx, vy) => {
+            const ball = new Ball(200, 200);
+            ball.vx = vx;
+            ball.vy = vy;
+            const speedBefore = Math.sqrt(vx * vx + vy * vy);
+
+            ball.reflect('x');
+
+            expect(ball.vx).toBe(-vx);
+            expect(ball.vy).toBe(vy);
+            const speedAfter = Math.sqrt(ball.vx * ball.vx + ball.vy * ball.vy);
+            expect(Math.abs(speedBefore - speedAfter)).toBeLessThan(0.001);
+          }
+        ),
+        { numRuns: 100 }
+      );
+    });
+
+    it('reflect(y) negates vy and preserves vx and speed magnitude', () => {
+      fc.assert(
+        fc.property(
+          fc.float({ min: -100, max: 100, noNaN: true, noDefaultInfinity: true }),
+          fc.float({ min: -100, max: 100, noNaN: true, noDefaultInfinity: true }),
+          (vx, vy) => {
+            const ball = new Ball(200, 200);
+            ball.vx = vx;
+            ball.vy = vy;
+            const speedBefore = Math.sqrt(vx * vx + vy * vy);
+
+            ball.reflect('y');
+
+            expect(ball.vx).toBe(vx);
+            expect(ball.vy).toBe(-vy);
+            const speedAfter = Math.sqrt(ball.vx * ball.vx + ball.vy * ball.vy);
+            expect(Math.abs(speedBefore - speedAfter)).toBeLessThan(0.001);
+          }
+        ),
+        { numRuns: 100 }
+      );
+    });
+  });
+
+  describe('Property: isAtBottom correctness', () => {
+    /**
+     * Feature: ball-block-breaker
+     * **Validates: Requirements 3.3**
+     *
+     * isAtBottom returns true iff y + radius >= boardHeight
+     */
+    it('isAtBottom returns true iff y + radius >= boardHeight', () => {
+      fc.assert(
+        fc.property(
+          fc.float({ min: 0, max: 1000, noNaN: true, noDefaultInfinity: true }),
+          fc.float({ min: 1, max: 50, noNaN: true, noDefaultInfinity: true }),
+          fc.float({ min: 100, max: 1000, noNaN: true, noDefaultInfinity: true }),
+          (y, radius, boardHeight) => {
+            const ball = new Ball(100, y, radius);
+            const expected = y + radius >= boardHeight;
+            expect(ball.isAtBottom(boardHeight)).toBe(expected);
+          }
+        ),
+        { numRuns: 100 }
+      );
+    });
+  });
+
+  describe('Property: getRect consistency', () => {
+    /**
+     * Feature: ball-block-breaker
+     *
+     * getRect returns an AABB centered on (x, y) with side = 2 * radius
+     */
+    it('getRect returns AABB centered on ball position with correct dimensions', () => {
+      fc.assert(
+        fc.property(
+          fc.float({ min: 0, max: 1000, noNaN: true, noDefaultInfinity: true }),
+          fc.float({ min: 0, max: 1000, noNaN: true, noDefaultInfinity: true }),
+          fc.float({ min: 1, max: 50, noNaN: true, noDefaultInfinity: true }),
+          (x, y, radius) => {
+            const ball = new Ball(x, y, radius);
+            const rect = ball.getRect();
+            expect(rect.x).toBeCloseTo(x - radius, 5);
+            expect(rect.y).toBeCloseTo(y - radius, 5);
+            expect(rect.width).toBeCloseTo(radius * 2, 5);
+            expect(rect.height).toBeCloseTo(radius * 2, 5);
+          }
+        ),
+        { numRuns: 100 }
+      );
+    });
+  });
+
+  describe('Property 6: 球速恒定', () => {
+    /**
+     * Feature: ball-block-breaker, Property 6: 球速恒定
+     * **Validates: Requirements 3.5**
+     *
+     * 对于任意小球在任意帧更新后,其速度向量的大小(Math.sqrt(vx² + vy²))
+     * 应与初始速度大小相等(允许浮点误差 ε < 0.001)。
+     */
+    it('ball speed magnitude remains constant after multiple updates', () => {
+      fc.assert(
+        fc.property(
+          fc.float({ min: -50, max: 50, noNaN: true, noDefaultInfinity: true }),
+          fc.float({ min: -50, max: 50, noNaN: true, noDefaultInfinity: true }),
+          fc.integer({ min: 1, max: 200 }),
+          (vx, vy, numFrames) => {
+            const initialSpeed = Math.sqrt(vx * vx + vy * vy);
+            // Skip zero-speed cases
+            fc.pre(initialSpeed > 0.001);
+
+            // Place ball in center of a very large board so it won't reach the bottom
+            const boardWidth = 100000;
+            const boardHeight = 100000;
+            const ball = new Ball(boardWidth / 2, boardHeight / 2, BALL_RADIUS);
+            ball.vx = vx;
+            ball.vy = vy;
+
+            for (let i = 0; i < numFrames; i++) {
+              ball.update(0, boardWidth, boardHeight);
+              const currentSpeed = Math.sqrt(ball.vx * ball.vx + ball.vy * ball.vy);
+              expect(Math.abs(currentSpeed - initialSpeed)).toBeLessThan(0.001);
+            }
+          }
+        ),
+        { numRuns: 100 }
+      );
+    });
+  });
+});
+

+ 135 - 0
tests/block.test.js

@@ -0,0 +1,135 @@
+import { describe, it, expect } from 'vitest';
+import * as fc from 'fast-check';
+import { Block } from '../src/entities/Block.js';
+import { GRID_GAP } from '../src/constants.js';
+
+describe('Block', () => {
+  describe('constructor', () => {
+    it('initializes with correct properties', () => {
+      const block = new Block(3, 2, 5, 40);
+      expect(block.gridX).toBe(3);
+      expect(block.gridY).toBe(2);
+      expect(block.count).toBe(5);
+      expect(block.size).toBe(40);
+      expect(block.destroyed).toBe(false);
+    });
+  });
+
+  describe('hit()', () => {
+    it('decrements count by 1', () => {
+      const block = new Block(0, 0, 5, 40);
+      block.hit();
+      expect(block.count).toBe(4);
+    });
+
+    it('returns false when count > 0 after hit', () => {
+      const block = new Block(0, 0, 3, 40);
+      expect(block.hit()).toBe(false);
+    });
+
+    it('marks destroyed and returns true when count reaches 0', () => {
+      const block = new Block(0, 0, 1, 40);
+      expect(block.hit()).toBe(true);
+      expect(block.destroyed).toBe(true);
+      expect(block.count).toBe(0);
+    });
+
+    it('marks destroyed when count goes below 0', () => {
+      const block = new Block(0, 0, 0, 40);
+      expect(block.hit()).toBe(true);
+      expect(block.destroyed).toBe(true);
+      expect(block.count).toBe(-1);
+    });
+  });
+
+  describe('getRect()', () => {
+    it('converts grid coordinates to pixel coordinates', () => {
+      const size = 40;
+      const block = new Block(2, 3, 5, size);
+      const rect = block.getRect();
+      expect(rect.x).toBe(2 * (size + GRID_GAP) + GRID_GAP);
+      expect(rect.y).toBe(3 * (size + GRID_GAP) + GRID_GAP);
+      expect(rect.width).toBe(size);
+      expect(rect.height).toBe(size);
+    });
+
+    it('returns correct rect for origin block (0,0)', () => {
+      const size = 50;
+      const block = new Block(0, 0, 1, size);
+      const rect = block.getRect();
+      expect(rect.x).toBe(GRID_GAP);
+      expect(rect.y).toBe(GRID_GAP);
+      expect(rect.width).toBe(size);
+      expect(rect.height).toBe(size);
+    });
+  });
+
+  describe('getColor()', () => {
+    it('returns a valid hex color string', () => {
+      const block = new Block(0, 0, 10, 40);
+      const color = block.getColor();
+      expect(color).toMatch(/^#[0-9a-f]{6}$/);
+    });
+
+    it('returns different colors for different counts', () => {
+      const block1 = new Block(0, 0, 1, 40);
+      const block2 = new Block(0, 0, 50, 40);
+      expect(block1.getColor()).not.toBe(block2.getColor());
+    });
+  });
+
+  describe('isDestroyed()', () => {
+    it('returns false when count > 0', () => {
+      const block = new Block(0, 0, 5, 40);
+      expect(block.isDestroyed()).toBe(false);
+    });
+
+    it('returns true when count is 0', () => {
+      const block = new Block(0, 0, 0, 40);
+      expect(block.isDestroyed()).toBe(true);
+    });
+
+    it('returns true when count is negative', () => {
+      const block = new Block(0, 0, -1, 40);
+      expect(block.isDestroyed()).toBe(true);
+    });
+  });
+
+  describe('moveDown()', () => {
+    it('increments gridY by 1', () => {
+      const block = new Block(3, 2, 5, 40);
+      block.moveDown();
+      expect(block.gridY).toBe(3);
+    });
+
+    it('can be called multiple times', () => {
+      const block = new Block(0, 0, 5, 40);
+      block.moveDown();
+      block.moveDown();
+      block.moveDown();
+      expect(block.gridY).toBe(3);
+    });
+  });
+
+  // Feature: ball-block-breaker, Property 7: 碰撞方块数字减1
+  // **Validates: Requirements 4.1**
+  describe('Property 7: 碰撞方块数字减1', () => {
+    it('hit() decrements count by exactly 1 for any block with count > 0', () => {
+      fc.assert(
+        fc.property(
+          fc.integer({ min: 1, max: 10000 }),
+          fc.integer({ min: 0, max: 6 }),
+          fc.integer({ min: 0, max: 20 }),
+          fc.constantFrom(30, 40, 50),
+          (count, gridX, gridY, size) => {
+            const block = new Block(gridX, gridY, count, size);
+            const originalCount = block.count;
+            block.hit();
+            expect(block.count).toBe(originalCount - 1);
+          }
+        ),
+        { numRuns: 100 }
+      );
+    });
+  });
+});

+ 391 - 0
tests/boardmanager.test.js

@@ -0,0 +1,391 @@
+import { describe, it, expect, beforeEach } from 'vitest';
+import fc from 'fast-check';
+import { BoardManager } from '../src/systems/BoardManager.js';
+import { Block } from '../src/entities/Block.js';
+import { BallItem, LineClearItem } from '../src/entities/Item.js';
+import { GameMode, TOP_PADDING_ROWS } from '../src/constants.js';
+
+describe('BoardManager', () => {
+  const COLS = 7;
+  const ROWS = 10;
+  const BLOCK_SIZE = 40;
+  let bm;
+
+  beforeEach(() => {
+    bm = new BoardManager(COLS, ROWS, BLOCK_SIZE);
+  });
+
+  describe('constructor', () => {
+    it('initializes with empty blocks and items', () => {
+      expect(bm.getBlocks()).toEqual([]);
+      expect(bm.getItems()).toEqual([]);
+      expect(bm.cols).toBe(COLS);
+      expect(bm.rows).toBe(ROWS);
+      expect(bm.blockSize).toBe(BLOCK_SIZE);
+    });
+  });
+
+  describe('generateRow', () => {
+    it('generates at least one block per row', () => {
+      for (let i = 0; i < 50; i++) {
+        const bm2 = new BoardManager(COLS, ROWS, BLOCK_SIZE);
+        bm2.generateRow(1, GameMode.CLASSIC);
+        expect(bm2.getBlocks().length).toBeGreaterThanOrEqual(1);
+      }
+    });
+
+    it('all new blocks have gridY = TOP_PADDING_ROWS', () => {
+      bm.generateRow(5, GameMode.CLASSIC);
+      for (const block of bm.getBlocks()) {
+        expect(block.gridY).toBe(TOP_PADDING_ROWS);
+      }
+    });
+
+    it('all new items have gridY = TOP_PADDING_ROWS', () => {
+      // Run multiple times to increase chance of item generation
+      for (let i = 0; i < 30; i++) {
+        const bm2 = new BoardManager(COLS, ROWS, BLOCK_SIZE);
+        bm2.generateRow(2, GameMode.CLASSIC); // round > 1 to generate BallItem
+        for (const item of bm2.getItems()) {
+          expect(item.gridY).toBe(TOP_PADDING_ROWS);
+        }
+      }
+    });
+
+    it('block count equals round or round*2', () => {
+      const round = 7;
+      for (let i = 0; i < 30; i++) {
+        const bm2 = new BoardManager(COLS, ROWS, BLOCK_SIZE);
+        bm2.generateRow(round, GameMode.CLASSIC);
+        for (const block of bm2.getBlocks()) {
+          expect([round, round * 2]).toContain(block.count);
+        }
+      }
+    });
+
+    it('classic mode only generates BallItem, no LineClearItem', () => {
+      for (let i = 0; i < 50; i++) {
+        const bm2 = new BoardManager(COLS, ROWS, BLOCK_SIZE);
+        bm2.generateRow(1, GameMode.CLASSIC);
+        for (const item of bm2.getItems()) {
+          expect(item).toBeInstanceOf(BallItem);
+        }
+      }
+    });
+
+    it('block gridX is within valid range', () => {
+      bm.generateRow(3, GameMode.CLASSIC);
+      for (const block of bm.getBlocks()) {
+        expect(block.gridX).toBeGreaterThanOrEqual(0);
+        expect(block.gridX).toBeLessThan(COLS);
+      }
+    });
+
+    it('items are not placed in columns with blocks', () => {
+      for (let i = 0; i < 50; i++) {
+        const bm2 = new BoardManager(COLS, ROWS, BLOCK_SIZE);
+        bm2.generateRow(1, GameMode.ENHANCED);
+        const blockCols = new Set(bm2.getBlocks().map(b => b.gridX));
+        for (const item of bm2.getItems()) {
+          expect(blockCols.has(item.gridX)).toBe(false);
+        }
+      }
+    });
+  });
+
+  describe('moveAllDown', () => {
+    it('moves all blocks down by one row', () => {
+      bm.blocks = [
+        new Block(0, 0, 1, BLOCK_SIZE),
+        new Block(3, 2, 5, BLOCK_SIZE),
+      ];
+      bm.moveAllDown();
+      expect(bm.blocks[0].gridY).toBe(1);
+      expect(bm.blocks[1].gridY).toBe(3);
+    });
+
+    it('moves all items down by one row', () => {
+      bm.items = [
+        new BallItem(1, 0, BLOCK_SIZE),
+        new LineClearItem(4, 3, BLOCK_SIZE),
+      ];
+      bm.moveAllDown();
+      expect(bm.items[0].gridY).toBe(1);
+      expect(bm.items[1].gridY).toBe(4);
+    });
+  });
+
+  describe('checkGameOver', () => {
+    it('returns false when no blocks touch bottom', () => {
+      bm.blocks = [
+        new Block(0, 0, 1, BLOCK_SIZE),
+        new Block(3, ROWS - 1, 5, BLOCK_SIZE),
+      ];
+      expect(bm.checkGameOver()).toBe(false);
+    });
+
+    it('returns true when a block reaches rows', () => {
+      bm.blocks = [
+        new Block(0, 0, 1, BLOCK_SIZE),
+        new Block(3, ROWS, 5, BLOCK_SIZE),
+      ];
+      expect(bm.checkGameOver()).toBe(true);
+    });
+
+    it('returns true when a block exceeds rows', () => {
+      bm.blocks = [new Block(0, ROWS + 2, 1, BLOCK_SIZE)];
+      expect(bm.checkGameOver()).toBe(true);
+    });
+
+    it('returns false with empty board', () => {
+      expect(bm.checkGameOver()).toBe(false);
+    });
+  });
+
+  describe('removeBlock', () => {
+    it('removes the specified block', () => {
+      const b1 = new Block(0, 0, 1, BLOCK_SIZE);
+      const b2 = new Block(1, 0, 2, BLOCK_SIZE);
+      bm.blocks = [b1, b2];
+      bm.removeBlock(b1);
+      expect(bm.getBlocks()).toEqual([b2]);
+    });
+
+    it('does nothing if block not found', () => {
+      const b1 = new Block(0, 0, 1, BLOCK_SIZE);
+      bm.blocks = [b1];
+      bm.removeBlock(new Block(2, 2, 3, BLOCK_SIZE));
+      expect(bm.getBlocks()).toEqual([b1]);
+    });
+  });
+
+  describe('clearRow', () => {
+    it('decrements count of all blocks in the specified row, removes destroyed ones, returns destroyed', () => {
+      bm.blocks = [
+        new Block(0, 2, 1, BLOCK_SIZE),  // count=1, will be destroyed
+        new Block(3, 2, 5, BLOCK_SIZE),  // count=5, becomes 4
+        new Block(5, 4, 3, BLOCK_SIZE),  // different row, untouched
+      ];
+      const destroyed = bm.clearRow(2);
+      expect(destroyed.length).toBe(1);
+      expect(destroyed[0].gridX).toBe(0);
+      expect(bm.getBlocks().length).toBe(2);
+      const row2Block = bm.getBlocks().find(b => b.gridY === 2);
+      expect(row2Block.count).toBe(4);
+      const row4Block = bm.getBlocks().find(b => b.gridY === 4);
+      expect(row4Block.count).toBe(3);
+    });
+  });
+
+  describe('clearColumn', () => {
+    it('decrements count of all blocks in the specified column, removes destroyed ones, returns destroyed', () => {
+      bm.blocks = [
+        new Block(3, 0, 1, BLOCK_SIZE),  // count=1, will be destroyed
+        new Block(3, 2, 5, BLOCK_SIZE),  // count=5, becomes 4
+        new Block(5, 4, 3, BLOCK_SIZE),  // different column, untouched
+      ];
+      const destroyed = bm.clearColumn(3);
+      expect(destroyed.length).toBe(1);
+      expect(destroyed[0].gridY).toBe(0);
+      expect(bm.getBlocks().length).toBe(2);
+      const col3Block = bm.getBlocks().find(b => b.gridX === 3);
+      expect(col3Block.count).toBe(4);
+      const col5Block = bm.getBlocks().find(b => b.gridX === 5);
+      expect(col5Block.count).toBe(3);
+    });
+  });
+
+  // Feature: ball-block-breaker, Property 10: 新行方块位置有效
+  // **Validates: Requirements 5.2**
+  describe('Property 10: generated row blocks have valid positions', () => {
+    it('all blocks have gridY === TOP_PADDING_ROWS, gridX in [0, cols-1], and at least 1 block', () => {
+      fc.assert(
+        fc.property(
+          fc.integer({ min: 1, max: 1000 }),
+          fc.constantFrom(GameMode.CLASSIC, GameMode.ENHANCED),
+          (round, mode) => {
+            const bm2 = new BoardManager(COLS, ROWS, BLOCK_SIZE);
+            bm2.generateRow(round, mode);
+
+            const blocks = bm2.getBlocks();
+
+            // At least one block generated
+            expect(blocks.length).toBeGreaterThanOrEqual(1);
+
+            for (const block of blocks) {
+              // All blocks at TOP_PADDING_ROWS
+              expect(block.gridY).toBe(TOP_PADDING_ROWS);
+              // gridX within valid range [0, cols-1]
+              expect(block.gridX).toBeGreaterThanOrEqual(0);
+              expect(block.gridX).toBeLessThanOrEqual(COLS - 1);
+            }
+          }
+        ),
+        { numRuns: 100 }
+      );
+    });
+  });
+
+  // Feature: ball-block-breaker, Property 11: 新方块数字有效
+  // **Validates: Requirements 5.3, 5.4**
+  describe('Property 11: new block count is round or round*2', () => {
+    it('every generated block count is either round or round*2', () => {
+      fc.assert(
+        fc.property(
+          fc.integer({ min: 1, max: 1000 }),
+          fc.constantFrom(GameMode.CLASSIC, GameMode.ENHANCED),
+          (round, mode) => {
+            const bm2 = new BoardManager(COLS, ROWS, BLOCK_SIZE);
+            bm2.generateRow(round, mode);
+
+            const blocks = bm2.getBlocks();
+            for (const block of blocks) {
+              expect([round, round * 2]).toContain(block.count);
+            }
+          }
+        ),
+        { numRuns: 100 }
+      );
+    });
+  });
+
+  // Feature: ball-block-breaker, Property 9: 方块下移一行
+  // **Validates: Requirements 5.1**
+  describe('Property 9: moveAllDown increments gridY by 1', () => {
+    it('every block gridY increases by exactly 1 after moveAllDown', () => {
+      fc.assert(
+        fc.property(
+          fc.array(
+            fc.record({
+              gridX: fc.integer({ min: 0, max: COLS - 1 }),
+              gridY: fc.integer({ min: 0, max: ROWS - 1 }),
+              count: fc.integer({ min: 1, max: 100 }),
+            }),
+            { minLength: 1, maxLength: 20 }
+          ),
+          (blockDefs) => {
+            const bm2 = new BoardManager(COLS, ROWS, BLOCK_SIZE);
+            bm2.blocks = blockDefs.map(
+              (b) => new Block(b.gridX, b.gridY, b.count, BLOCK_SIZE)
+            );
+
+            const originalYs = bm2.blocks.map((b) => b.gridY);
+
+            bm2.moveAllDown();
+
+            for (let i = 0; i < bm2.blocks.length; i++) {
+              expect(bm2.blocks[i].gridY).toBe(originalYs[i] + 1);
+            }
+          }
+        ),
+        { numRuns: 100 }
+      );
+    });
+  });
+
+  // Feature: ball-block-breaker, Property 12: 道具生成符合模式规则
+  // **Validates: Requirements 5.5, 5.6**
+  describe('Property 12: item generation follows mode rules', () => {
+    it('CLASSIC mode only generates BallItem, never LineClearItem', () => {
+      fc.assert(
+        fc.property(
+          fc.integer({ min: 1, max: 1000 }),
+          (round) => {
+            // Run multiple generateRow calls to increase item generation probability
+            for (let i = 0; i < 20; i++) {
+              const bm2 = new BoardManager(COLS, ROWS, BLOCK_SIZE);
+              bm2.generateRow(round, GameMode.CLASSIC);
+
+              for (const item of bm2.getItems()) {
+                expect(item).toBeInstanceOf(BallItem);
+                expect(item).not.toBeInstanceOf(LineClearItem);
+              }
+            }
+          }
+        ),
+        { numRuns: 100 }
+      );
+    });
+
+    it('ENHANCED mode only generates BallItem or LineClearItem', () => {
+      fc.assert(
+        fc.property(
+          fc.integer({ min: 1, max: 1000 }),
+          (round) => {
+            // Run multiple generateRow calls to increase item generation probability
+            for (let i = 0; i < 20; i++) {
+              const bm2 = new BoardManager(COLS, ROWS, BLOCK_SIZE);
+              bm2.generateRow(round, GameMode.ENHANCED);
+
+              for (const item of bm2.getItems()) {
+                const isBallItem = item instanceof BallItem;
+                const isLineClearItem = item instanceof LineClearItem;
+                expect(isBallItem || isLineClearItem).toBe(true);
+              }
+            }
+          }
+        ),
+        { numRuns: 100 }
+      );
+    });
+  });
+
+  // Feature: ball-block-breaker, Property 16: 方块触底判定
+  // **Validates: Requirements 7.1**
+  describe('Property 16: checkGameOver returns true iff any block gridY >= rows', () => {
+    it('checkGameOver is true iff at least one block has gridY >= ROWS', () => {
+      fc.assert(
+        fc.property(
+          fc.array(
+            fc.record({
+              gridX: fc.integer({ min: 0, max: COLS - 1 }),
+              gridY: fc.integer({ min: 0, max: ROWS + 5 }),
+              count: fc.integer({ min: 1, max: 100 }),
+            }),
+            { minLength: 0, maxLength: 30 }
+          ),
+          (blockDefs) => {
+            const bm2 = new BoardManager(COLS, ROWS, BLOCK_SIZE);
+            bm2.blocks = blockDefs.map(
+              (b) => new Block(b.gridX, b.gridY, b.count, BLOCK_SIZE)
+            );
+
+            const expected = blockDefs.some((b) => b.gridY >= ROWS);
+            expect(bm2.checkGameOver()).toBe(expected);
+          }
+        ),
+        { numRuns: 100 }
+      );
+    });
+  });
+
+  // Feature: ball-block-breaker, Property 16: 方块触底判定
+  // **Validates: Requirements 7.1**
+  describe('Property 16: checkGameOver returns true iff any block gridY >= rows', () => {
+    it('checkGameOver is true iff at least one block has gridY >= ROWS', () => {
+      fc.assert(
+        fc.property(
+          fc.array(
+            fc.record({
+              gridX: fc.integer({ min: 0, max: COLS - 1 }),
+              gridY: fc.integer({ min: 0, max: ROWS + 5 }),
+              count: fc.integer({ min: 1, max: 100 }),
+            }),
+            { minLength: 0, maxLength: 30 }
+          ),
+          (blockDefs) => {
+            const bm2 = new BoardManager(COLS, ROWS, BLOCK_SIZE);
+            bm2.blocks = blockDefs.map(
+              (b) => new Block(b.gridX, b.gridY, b.count, BLOCK_SIZE)
+            );
+
+            const expected = blockDefs.some((b) => b.gridY >= ROWS);
+            expect(bm2.checkGameOver()).toBe(expected);
+          }
+        ),
+        { numRuns: 100 }
+      );
+    });
+  });
+});
+

+ 34 - 0
tests/color.test.js

@@ -0,0 +1,34 @@
+import * as fc from 'fast-check';
+import { describe, it, expect } from 'vitest';
+import { getColor } from '../src/utils/color.js';
+
+/**
+ * Feature: ball-block-breaker, Property 17: 颜色计算有效性
+ * **Validates: Requirements 8.6**
+ *
+ * 对于任意正整数 count,getColor(count) 应返回一个有效的7字符十六进制颜色字符串(格式为 #RRGGBB)。
+ */
+describe('Color Utils', () => {
+  it('Property 17: getColor returns a valid 7-character hex color string for any positive integer', () => {
+    fc.assert(
+      fc.property(
+        fc.integer({ min: 1, max: 1_000_000 }),
+        (count) => {
+          const color = getColor(count);
+
+          // Must be a string of exactly 7 characters
+          expect(typeof color).toBe('string');
+          expect(color).toHaveLength(7);
+
+          // Must start with '#'
+          expect(color[0]).toBe('#');
+
+          // Remaining 6 characters must be valid hex digits [0-9a-f]
+          const hexPart = color.slice(1);
+          expect(hexPart).toMatch(/^[0-9a-f]{6}$/);
+        }
+      ),
+      { numRuns: 100 }
+    );
+  });
+});

+ 1143 - 0
tests/game.test.js

@@ -0,0 +1,1143 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import fc from 'fast-check';
+import { Game } from '../src/Game.js';
+import { Ball } from '../src/entities/Ball.js';
+import { GameState, GameMode, BALL_SPEED, BALL_RADIUS, LAUNCH_INTERVAL, GRID_COLS, GRID_GAP, TOP_PADDING_ROWS, FIXED_TIMESTEP } from '../src/constants.js';
+
+/**
+ * Create a minimal mock canvas for testing
+ */
+function createMockCanvas() {
+  const canvas = {
+    width: 375,
+    height: 667,
+    style: { width: '', height: '' },
+    parentElement: { clientWidth: 375, clientHeight: 667 },
+    getContext: () => ({
+      save: vi.fn(),
+      restore: vi.fn(),
+      scale: vi.fn(),
+      setTransform: vi.fn(),
+      clearRect: vi.fn(),
+      fillRect: vi.fn(),
+      fillText: vi.fn(),
+      beginPath: vi.fn(),
+      arc: vi.fn(),
+      fill: vi.fn(),
+      stroke: vi.fn(),
+      moveTo: vi.fn(),
+      lineTo: vi.fn(),
+      setLineDash: vi.fn(),
+      translate: vi.fn(),
+      fillStyle: '',
+      strokeStyle: '',
+      lineWidth: 1,
+      font: '',
+      textAlign: '',
+      textBaseline: '',
+      globalAlpha: 1,
+    }),
+    addEventListener: vi.fn(),
+    removeEventListener: vi.fn(),
+    getBoundingClientRect: () => ({ left: 0, top: 0, width: 375, height: 667 }),
+  };
+  return canvas;
+}
+
+describe('Game', () => {
+  let canvas;
+  let game;
+
+  beforeEach(() => {
+    // Mock window for devicePixelRatio
+    if (typeof globalThis.window === 'undefined') {
+      globalThis.window = { devicePixelRatio: 1 };
+    } else {
+      globalThis.window.devicePixelRatio = 1;
+    }
+    // Mock performance.now for animations
+    if (typeof globalThis.performance === 'undefined') {
+      globalThis.performance = { now: () => Date.now() };
+    }
+
+    canvas = createMockCanvas();
+    game = new Game(canvas, GameMode.CLASSIC);
+  });
+
+  describe('constructor', () => {
+    it('initializes with correct default state', () => {
+      expect(game.state).toBe(GameState.AIMING);
+      expect(game.round).toBe(1);
+      expect(game.ballCount).toBe(1);
+      expect(game.pendingBalls).toBe(0);
+      expect(game.balls).toEqual([]);
+      expect(game.mode).toBe(GameMode.CLASSIC);
+    });
+
+    it('calculates blockSize from canvas width', () => {
+      const expectedBlockSize = (375 - GRID_GAP * (GRID_COLS + 1)) / GRID_COLS;
+      expect(game.blockSize).toBeCloseTo(expectedBlockSize, 2);
+    });
+
+    it('sets board dimensions', () => {
+      expect(game.boardWidth).toBe(375);
+      expect(game.boardHeight).toBe(667);
+    });
+
+    it('creates subsystems', () => {
+      expect(game.renderer).toBeDefined();
+      expect(game.inputHandler).toBeDefined();
+      expect(game.boardManager).toBeDefined();
+    });
+  });
+
+  describe('start()', () => {
+    it('generates first row of blocks', () => {
+      game.start();
+      const blocks = game.boardManager.getBlocks();
+      expect(blocks.length).toBeGreaterThan(0);
+    });
+
+    it('enables input after start', () => {
+      game.inputHandler.disable();
+      game.start();
+      // InputHandler._enabled should be true after start
+      expect(game.inputHandler._enabled).toBe(true);
+    });
+  });
+
+  describe('update() - state machine', () => {
+    it('does nothing in AIMING state', () => {
+      game.state = GameState.AIMING;
+      game.update(16);
+      expect(game.state).toBe(GameState.AIMING);
+    });
+
+    it('does nothing in GAME_OVER state', () => {
+      game.state = GameState.GAME_OVER;
+      game.update(16);
+      expect(game.state).toBe(GameState.GAME_OVER);
+    });
+
+    it('calls nextRound in ROUND_END state', () => {
+      game.start();
+      game.state = GameState.ROUND_END;
+      const initialRound = game.round;
+      game.update(16);
+      // After nextRound, round should increment and state should change
+      expect(game.round).toBe(initialRound + 1);
+      // State should be AIMING, SLIDING_DOWN, or GAME_OVER
+      expect([GameState.AIMING, GameState.SLIDING_DOWN, GameState.GAME_OVER]).toContain(game.state);
+    });
+  });
+
+  describe('launch logic', () => {
+    it('transitions from LAUNCHING to RUNNING after all balls launched', () => {
+      game.start();
+      game.launchAngle = 90;
+      game.state = GameState.LAUNCHING;
+      game.launchIndex = 0;
+      game.launchTimer = 0;
+      game.ballCount = 1;
+      game.balls = [];
+
+      // Need enough fixed-timestep ticks for launchTimer to exceed LAUNCH_INTERVAL
+      const ticksNeeded = Math.ceil(LAUNCH_INTERVAL / FIXED_TIMESTEP) + 1;
+      game.update(ticksNeeded * FIXED_TIMESTEP);
+
+      // After launching 1 ball, should transition to RUNNING
+      expect(game.state).toBe(GameState.RUNNING);
+      expect(game.balls.length).toBe(1);
+    });
+
+    it('launches balls at correct intervals', () => {
+      game.start();
+      game.launchAngle = 90;
+      game.state = GameState.LAUNCHING;
+      game.launchIndex = 0;
+      game.launchTimer = 0;
+      game.ballCount = 3;
+      game.balls = [];
+
+      // Enough ticks for first ball launch
+      const ticksForOneLaunch = Math.ceil(LAUNCH_INTERVAL / FIXED_TIMESTEP) + 1;
+      game.update(ticksForOneLaunch * FIXED_TIMESTEP);
+      expect(game.balls.length).toBe(1);
+
+      // Another interval for second ball
+      game.update(ticksForOneLaunch * FIXED_TIMESTEP);
+      expect(game.balls.length).toBe(2);
+    });
+
+    it('sets correct velocity on launched balls', () => {
+      game.start();
+      game.launchAngle = 90; // straight up
+      game.state = GameState.LAUNCHING;
+      game.launchIndex = 0;
+      game.launchTimer = 0;
+      game.ballCount = 1;
+      game.balls = [];
+
+      const ticksNeeded = Math.ceil(LAUNCH_INTERVAL / FIXED_TIMESTEP) + 1;
+      game.update(ticksNeeded * FIXED_TIMESTEP);
+
+      const ball = game.balls[0];
+      const rad = 90 * Math.PI / 180;
+      expect(ball.vx).toBeCloseTo(BALL_SPEED * Math.cos(rad), 5);
+      expect(ball.vy).toBeCloseTo(-BALL_SPEED * Math.sin(rad), 5);
+    });
+  });
+
+  describe('running state - round end detection', () => {
+    it('transitions to ROUND_END when all balls are inactive', () => {
+      game.start();
+      game.state = GameState.RUNNING;
+
+      // Create a ball that is already inactive
+      const { Ball } = require('../src/entities/Ball.js');
+      const ball = new Ball(100, 600, BALL_RADIUS);
+      ball.active = false;
+      game.balls = [ball];
+
+      // Need at least one fixed-timestep tick
+      game.update(FIXED_TIMESTEP);
+      expect(game.state).toBe(GameState.ROUND_END);
+    });
+
+    it('stays in RUNNING when some balls are still active', () => {
+      game.start();
+      game.state = GameState.RUNNING;
+
+      const { Ball } = require('../src/entities/Ball.js');
+      const ball1 = new Ball(100, 300, BALL_RADIUS);
+      ball1.vx = 0;
+      ball1.vy = 5;
+      ball1.active = true;
+      const ball2 = new Ball(100, 600, BALL_RADIUS);
+      ball2.active = false;
+      game.balls = [ball1, ball2];
+
+      game.update(16);
+      expect(game.state).toBe(GameState.RUNNING);
+    });
+  });
+
+  describe('nextRound()', () => {
+    it('adds pending balls to ball count', () => {
+      game.start();
+      game.pendingBalls = 3;
+      game.ballCount = 2;
+      game.nextRound();
+      expect(game.ballCount).toBe(5);
+      expect(game.pendingBalls).toBe(0);
+    });
+
+    it('increments round number', () => {
+      game.start();
+      const initialRound = game.round;
+      game.nextRound();
+      expect(game.round).toBe(initialRound + 1);
+    });
+
+    it('transitions to AIMING state', () => {
+      game.start();
+      game.state = GameState.ROUND_END;
+      game.nextRound();
+      // nextRound now transitions to SLIDING_DOWN first
+      if (game.state === GameState.SLIDING_DOWN) {
+        // Force slide animation to complete
+        game._slideStartTime = performance.now() - game._slideDuration - 1;
+        game._updateSlideDown();
+      }
+      // Should be AIMING unless game over
+      if (game.state !== GameState.GAME_OVER) {
+        expect(game.state).toBe(GameState.AIMING);
+      }
+    });
+
+    it('enables input after round transition', () => {
+      game.start();
+      game.inputHandler.disable();
+      game.nextRound();
+      // Force slide animation to complete
+      if (game.state === GameState.SLIDING_DOWN) {
+        game._slideStartTime = performance.now() - game._slideDuration - 1;
+        game._updateSlideDown();
+      }
+      if (game.state !== GameState.GAME_OVER) {
+        expect(game.inputHandler._enabled).toBe(true);
+      }
+    });
+  });
+
+  describe('addPendingBall()', () => {
+    it('increments pending balls', () => {
+      expect(game.pendingBalls).toBe(0);
+      game.addPendingBall();
+      expect(game.pendingBalls).toBe(1);
+      game.addPendingBall();
+      expect(game.pendingBalls).toBe(2);
+    });
+  });
+
+  describe('clearRow() and clearColumn()', () => {
+    it('delegates clearRow to boardManager', () => {
+      game.start();
+      const spy = vi.spyOn(game.boardManager, 'clearRow').mockReturnValue([]);
+      game.clearRow(2);
+      expect(spy).toHaveBeenCalledWith(2);
+    });
+
+    it('delegates clearColumn to boardManager', () => {
+      game.start();
+      const spy = vi.spyOn(game.boardManager, 'clearColumn').mockReturnValue([]);
+      game.clearColumn(3);
+      expect(spy).toHaveBeenCalledWith(3);
+    });
+  });
+
+  describe('restart()', () => {
+    it('resets game state to initial values', () => {
+      game.start();
+      game.round = 5;
+      game.ballCount = 10;
+      game.pendingBalls = 3;
+      game.state = GameState.GAME_OVER;
+
+      game.restart();
+
+      expect(game.state).toBe(GameState.AIMING);
+      expect(game.round).toBe(1);
+      expect(game.ballCount).toBe(1);
+      expect(game.pendingBalls).toBe(0);
+      expect(game.balls).toEqual([]);
+    });
+
+    it('generates new blocks after restart', () => {
+      game.start();
+      game.restart();
+      const blocks = game.boardManager.getBlocks();
+      expect(blocks.length).toBeGreaterThan(0);
+    });
+  });
+
+  describe('gameOver()', () => {
+    it('sets state to GAME_OVER', () => {
+      game.gameOver();
+      expect(game.state).toBe(GameState.GAME_OVER);
+    });
+  });
+
+  describe('render()', () => {
+    it('does not throw when rendering in AIMING state', () => {
+      game.start();
+      game.state = GameState.AIMING;
+      expect(() => game.render()).not.toThrow();
+    });
+
+    it('does not throw when rendering in GAME_OVER state', () => {
+      game.start();
+      game.state = GameState.GAME_OVER;
+      expect(() => game.render()).not.toThrow();
+    });
+
+    it('does not throw when rendering with active balls', () => {
+      game.start();
+      game.state = GameState.RUNNING;
+      const { Ball } = require('../src/entities/Ball.js');
+      const ball = new Ball(100, 300, BALL_RADIUS);
+      ball.active = true;
+      game.balls = [ball];
+      expect(() => game.render()).not.toThrow();
+    });
+  });
+
+  describe('input disabled during RUNNING', () => {
+    it('disables input when launching starts', () => {
+      game.start();
+      game.inputHandler.enable();
+      // Simulate _startLaunch
+      game._startLaunch();
+      expect(game.inputHandler._enabled).toBe(false);
+    });
+
+    it('re-enables input after round ends', () => {
+      game.start();
+      game.inputHandler.disable();
+      game.nextRound();
+      // Force slide animation to complete
+      if (game.state === GameState.SLIDING_DOWN) {
+        game._slideStartTime = performance.now() - game._slideDuration - 1;
+        game._updateSlideDown();
+      }
+      if (game.state !== GameState.GAME_OVER) {
+        expect(game.inputHandler._enabled).toBe(true);
+      }
+    });
+  });
+
+  describe('collision handling', () => {
+    it('removes destroyed blocks during physics step', () => {
+      game.start();
+      // Add a block with count=1 that will be destroyed on hit
+      const { Block } = require('../src/entities/Block.js');
+      const block = new Block(3, 3, 1, game.blockSize);
+      game.boardManager.blocks = [block];
+
+      // Create a ball above the block, moving downward toward it
+      const { Ball } = require('../src/entities/Ball.js');
+      const blockRect = block.getRect();
+      const ball = new Ball(
+        blockRect.x + blockRect.width / 2,
+        blockRect.y - BALL_RADIUS - 2,
+        BALL_RADIUS
+      );
+      ball.vx = 0;
+      ball.vy = BALL_SPEED;
+      ball.active = true;
+      game.balls = [ball];
+
+      game.state = GameState.RUNNING;
+      game._stepPhysics();
+
+      // Block should be destroyed and removed
+      expect(game.boardManager.getBlocks().length).toBe(0);
+    });
+  });
+
+  /**
+   * Feature: ball-block-breaker, Property 4: 球到底部停止
+   * **Validates: Requirements 3.3**
+   *
+   * 对于任意小球,当其 y 坐标到达或超过 Game_Board 底部时,
+   * 该球应被标记为非活跃状态(active = false)。
+   */
+  describe('Property 4: 球到底部停止', () => {
+    it('balls at or beyond the bottom become inactive after update', () => {
+      fc.assert(
+        fc.property(
+          // Generate a y position at or beyond the bottom of the board
+          fc.float({ min: 0, max: 200, noNaN: true, noDefaultInfinity: true }),
+          // Generate an x position within the board
+          fc.float({ min: BALL_RADIUS + 1, max: 375 - BALL_RADIUS - 1, noNaN: true, noDefaultInfinity: true }),
+          // Generate a downward vy (positive = moving down)
+          fc.float({ min: 1, max: BALL_SPEED, noNaN: true, noDefaultInfinity: true }),
+          (yOffset, x, vy) => {
+            const boardHeight = game.boardHeight;
+            // Place ball at or past the bottom boundary
+            const y = boardHeight - BALL_RADIUS + yOffset;
+
+            const ball = new Ball(x, y, BALL_RADIUS);
+            ball.vx = 0;
+            ball.vy = vy;
+            ball.active = true;
+
+            game.state = GameState.RUNNING;
+            game.balls = [ball];
+
+            // Need at least one fixed-timestep tick
+            game.update(FIXED_TIMESTEP);
+
+            // Ball that was at or beyond the bottom should now be inactive
+            expect(ball.active).toBe(false);
+          }
+        ),
+        { numRuns: 100 }
+      );
+    });
+  });
+});
+
+/**
+ * Feature: ball-block-breaker, Property 5: 所有球到底部则回合结束
+ * **Validates: Requirements 3.4**
+ *
+ * 对于任意球列表,当且仅当所有球的 active 状态均为 false 时,
+ * 回合应结束(状态转为 ROUND_END)。
+ */
+describe('Property 5: 所有球到底部则回合结束', () => {
+  let canvas;
+  let game;
+
+  beforeEach(() => {
+    if (typeof globalThis.window === 'undefined') {
+      globalThis.window = { devicePixelRatio: 1 };
+    } else {
+      globalThis.window.devicePixelRatio = 1;
+    }
+    if (typeof globalThis.performance === 'undefined') {
+      globalThis.performance = { now: () => Date.now() };
+    }
+    canvas = createMockCanvas();
+    game = new Game(canvas, GameMode.CLASSIC);
+    game.start();
+  });
+
+  it('transitions to ROUND_END when all balls are inactive', () => {
+    fc.assert(
+      fc.property(
+        fc.integer({ min: 1, max: 20 }),
+        (numBalls) => {
+          game.state = GameState.RUNNING;
+
+          // Create numBalls balls, all inactive
+          game.balls = Array.from({ length: numBalls }, (_, i) => {
+            const ball = new Ball(50 + i * 10, game.boardHeight - BALL_RADIUS, BALL_RADIUS);
+            ball.vx = 0;
+            ball.vy = 0;
+            ball.active = false;
+            return ball;
+          });
+
+          game._updateRunning(16);
+
+          expect(game.state).toBe(GameState.ROUND_END);
+        }
+      ),
+      { numRuns: 100 }
+    );
+  });
+
+  it('stays in RUNNING when at least one ball is still active', () => {
+    fc.assert(
+      fc.property(
+        fc.integer({ min: 1, max: 20 }),
+        fc.integer({ min: 0, max: 19 }),
+        (numBalls, activeIndexRaw) => {
+          const activeIndex = activeIndexRaw % numBalls;
+          game.state = GameState.RUNNING;
+
+          // Create numBalls balls, all inactive except one at safe position
+          game.balls = Array.from({ length: numBalls }, (_, i) => {
+            const ball = new Ball(50 + i * 10, 300, BALL_RADIUS);
+            ball.vx = 0;
+            ball.vy = 0;
+            ball.active = (i === activeIndex);
+            return ball;
+          });
+
+          game._updateRunning(16);
+
+          expect(game.state).toBe(GameState.RUNNING);
+        }
+      ),
+      { numRuns: 100 }
+    );
+  });
+});
+
+
+/**
+ * Feature: ball-block-breaker, Property 13: BallItem碰撞效果
+ * **Validates: Requirements 6.1, 6.3**
+ *
+ * 对于任意游戏状态,当小球触碰 BallItem 后,下一轮的球数应比当前多1,
+ * 且该 BallItem 应被标记为已收集(从面板移除)。
+ */
+describe('Property 13: BallItem碰撞效果', () => {
+  let canvas;
+  let game;
+
+  beforeEach(() => {
+    if (typeof globalThis.window === 'undefined') {
+      globalThis.window = { devicePixelRatio: 1 };
+    } else {
+      globalThis.window.devicePixelRatio = 1;
+    }
+    if (typeof globalThis.performance === 'undefined') {
+      globalThis.performance = { now: () => Date.now() };
+    }
+    canvas = createMockCanvas();
+    game = new Game(canvas, GameMode.CLASSIC);
+    game.start();
+  });
+
+  it('collecting a BallItem increases pendingBalls by 1 and removes the item', () => {
+    const { BallItem } = require('../src/entities/Item.js');
+
+    fc.assert(
+      fc.property(
+        // Random grid position for the BallItem
+        fc.integer({ min: 0, max: GRID_COLS - 1 }),
+        fc.integer({ min: 1, max: 5 }),
+        // Random initial pendingBalls count
+        fc.integer({ min: 0, max: 10 }),
+        (gridX, gridY, initialPending) => {
+          // Set up game state
+          game.state = GameState.RUNNING;
+          game.pendingBalls = initialPending;
+
+          // Create a BallItem at the given grid position
+          const ballItem = new BallItem(gridX, gridY, game.blockSize);
+
+          // Clear existing items and add only our BallItem
+          game.boardManager.blocks = [];
+          game.boardManager.items = [ballItem];
+
+          // Position a ball overlapping the BallItem (guaranteed collision)
+          const itemRect = ballItem.getRect();
+          const ball = new Ball(
+            itemRect.x + itemRect.width / 2,
+            itemRect.y + itemRect.height / 2,
+            BALL_RADIUS
+          );
+          ball.vx = 0;
+          ball.vy = -0.001;
+          ball.active = true;
+          game.balls = [ball];
+
+          // Run physics step (handles item collisions)
+          game._stepPhysics();
+
+          // pendingBalls should have increased by exactly 1
+          expect(game.pendingBalls).toBe(initialPending + 1);
+
+          // BallItem should be removed from items array
+          expect(game.boardManager.getItems().length).toBe(0);
+
+          // BallItem should be marked as collected
+          expect(ballItem.collected).toBe(true);
+        }
+      ),
+      { numRuns: 100 }
+    );
+  });
+});
+
+
+/**
+ * Feature: ball-block-breaker, Property 15: LineClearItem碰撞效果
+ * **Validates: Requirements 6.4, 6.5**
+ *
+ * 对于任意方块布局和 LineClearItem 位置,触发后该行或列的所有方块应被移除,
+ * 且 LineClearItem 本身不被移除。
+ */
+describe('Property 15: LineClearItem碰撞效果', () => {
+  let canvas;
+  let game;
+
+  beforeEach(() => {
+    if (typeof globalThis.window === 'undefined') {
+      globalThis.window = { devicePixelRatio: 1 };
+    } else {
+      globalThis.window.devicePixelRatio = 1;
+    }
+    if (typeof globalThis.performance === 'undefined') {
+      globalThis.performance = { now: () => Date.now() };
+    }
+    canvas = createMockCanvas();
+    game = new Game(canvas, GameMode.ENHANCED);
+    game.start();
+  });
+
+  it('collecting a LineClearItem decrements count of blocks in its row or column by 1', () => {
+    const { LineClearItem } = require('../src/entities/Item.js');
+    const { Block } = require('../src/entities/Block.js');
+
+    fc.assert(
+      fc.property(
+        // LineClearItem grid position
+        fc.integer({ min: 0, max: GRID_COLS - 1 }),
+        fc.integer({ min: 1, max: 5 }),
+        // Direction
+        fc.constantFrom('horizontal', 'vertical'),
+        // Generate blocks that won't overlap with the ball position
+        fc.array(
+          fc.record({
+            gridX: fc.integer({ min: 0, max: GRID_COLS - 1 }),
+            gridY: fc.integer({ min: 0, max: 7 }),
+            count: fc.integer({ min: 2, max: 20 })
+          }),
+          { minLength: 1, maxLength: 20 }
+        ),
+        (itemGridX, itemGridY, direction, blockDefs) => {
+          game.state = GameState.RUNNING;
+
+          // Create LineClearItem with explicit direction
+          const lineClearItem = new LineClearItem(itemGridX, itemGridY, game.blockSize, direction);
+
+          // Filter out blocks that would overlap with the item's grid cell
+          // or are adjacent (ball physics might hit nearby blocks)
+          const safeBlockDefs = blockDefs.filter(def =>
+            !(def.gridX === itemGridX && def.gridY === itemGridY) &&
+            Math.abs(def.gridY - itemGridY) > 1 &&
+            Math.abs(def.gridX - itemGridX) > 1
+          );
+          if (safeBlockDefs.length === 0) return; // skip if no safe blocks
+
+          // Create blocks from definitions
+          const blocks = safeBlockDefs.map(def =>
+            new Block(def.gridX, def.gridY, def.count, game.blockSize)
+          );
+          const originalCounts = blocks.map(b => b.count);
+
+          // Set up board
+          game.boardManager.blocks = [...blocks];
+          game.boardManager.items = [lineClearItem];
+
+          // Position a ball overlapping the LineClearItem for guaranteed collision
+          const itemRect = lineClearItem.getRect();
+          const ball = new Ball(
+            itemRect.x + itemRect.width / 2,
+            itemRect.y + itemRect.height / 2,
+            BALL_RADIUS
+          );
+          ball.vx = 0;
+          ball.vy = -0.001;
+          ball.active = true;
+          game.balls = [ball];
+
+          // Run physics step (handles item collisions)
+          game._stepPhysics();
+
+          // LineClearItem should be marked as collected
+          expect(lineClearItem.collected).toBe(true);
+
+          // LineClearItem should NOT be removed (stays on board)
+          expect(game.boardManager.getItems()).toContain(lineClearItem);
+
+          // Check that affected blocks had count decremented by 1
+          // (blocks not overlapping with ball, so only clearRow/clearColumn hit them)
+          for (let i = 0; i < blocks.length; i++) {
+            const block = blocks[i];
+            const wasAffected = direction === 'horizontal'
+              ? block.gridY === itemGridY
+              : block.gridX === itemGridX;
+
+            if (wasAffected && !block.isDestroyed()) {
+              expect(block.count).toBe(originalCounts[i] - 1);
+            } else if (!wasAffected) {
+              expect(block.count).toBe(originalCounts[i]);
+            }
+          }
+        }
+      ),
+      { numRuns: 100 }
+    );
+  });
+});
+
+
+/**
+ * Feature: ball-block-breaker, Property 18: 运行中忽略输入
+ * **Validates: Requirements 9.3**
+ *
+ * 对于任意游戏状态为 RUNNING 或 LAUNCHING 时,输入事件不应改变游戏的瞄准角度或触发新的发射。
+ */
+describe('Property 18: 运行中忽略输入', () => {
+  let canvas;
+  let game;
+
+  beforeEach(() => {
+    if (typeof globalThis.window === 'undefined') {
+      globalThis.window = { devicePixelRatio: 1 };
+    } else {
+      globalThis.window.devicePixelRatio = 1;
+    }
+    if (typeof globalThis.performance === 'undefined') {
+      globalThis.performance = { now: () => Date.now() };
+    }
+    canvas = createMockCanvas();
+    game = new Game(canvas, GameMode.CLASSIC);
+    game.start();
+  });
+
+  it('input callbacks do not change aimAngle or trigger launch in RUNNING/LAUNCHING state', () => {
+    fc.assert(
+      fc.property(
+        // Random state: RUNNING or LAUNCHING
+        fc.constantFrom(GameState.RUNNING, GameState.LAUNCHING),
+        // Random initial aimAngle
+        fc.float({ min: 15, max: 165, noNaN: true, noDefaultInfinity: true }),
+        // Random angle from input event
+        fc.float({ min: -360, max: 360, noNaN: true, noDefaultInfinity: true }),
+        (state, initialAimAngle, inputAngle) => {
+          // Set game to the target state
+          game.state = state;
+          game.aimAngle = initialAimAngle;
+
+          // Record state before input
+          const aimAngleBefore = game.aimAngle;
+          const stateBefore = game.state;
+          const ballsBefore = game.balls.length;
+
+          const eventData = { x: 100, y: 100, angle: inputAngle };
+
+          // Trigger all three input callbacks directly
+          game.inputHandler._onAimStart(eventData);
+          game.inputHandler._onAimMove(eventData);
+          game.inputHandler._onAimEnd(eventData);
+
+          // aimAngle must not change
+          expect(game.aimAngle).toBe(aimAngleBefore);
+
+          // State must not change (no launch triggered)
+          expect(game.state).toBe(stateBefore);
+
+          // No new balls should be created
+          expect(game.balls.length).toBe(ballsBefore);
+        }
+      ),
+      { numRuns: 100 }
+    );
+  });
+});
+
+
+/**
+ * Game Over UI Tests
+ * **Validates: Requirements 7.2, 7.3**
+ *
+ * Tests for the game over overlay: showing the overlay with round number,
+ * restart button functionality, and hiding the overlay on restart.
+ */
+describe('Game Over UI (Requirements 7.2, 7.3)', () => {
+  let canvas;
+  let game;
+  let overlay;
+  let scoreEl;
+  let restartBtn;
+
+  function createMockCanvasLocal() {
+    return {
+      width: 375,
+      height: 667,
+      style: { width: '', height: '' },
+      parentElement: { clientWidth: 375, clientHeight: 667 },
+      getContext: () => ({
+        save: vi.fn(),
+        restore: vi.fn(),
+        scale: vi.fn(),
+        setTransform: vi.fn(),
+        clearRect: vi.fn(),
+        fillRect: vi.fn(),
+        fillText: vi.fn(),
+        beginPath: vi.fn(),
+        arc: vi.fn(),
+        fill: vi.fn(),
+        stroke: vi.fn(),
+        moveTo: vi.fn(),
+        lineTo: vi.fn(),
+        setLineDash: vi.fn(),
+        translate: vi.fn(),
+        fillStyle: '',
+        strokeStyle: '',
+        lineWidth: 1,
+        font: '',
+        textAlign: '',
+        textBaseline: '',
+        globalAlpha: 1,
+      }),
+      addEventListener: vi.fn(),
+      removeEventListener: vi.fn(),
+      getBoundingClientRect: () => ({ left: 0, top: 0, width: 375, height: 667 }),
+    };
+  }
+
+  beforeEach(() => {
+    if (typeof globalThis.window === 'undefined') {
+      globalThis.window = { devicePixelRatio: 1 };
+    } else {
+      globalThis.window.devicePixelRatio = 1;
+    }
+    if (typeof globalThis.performance === 'undefined') {
+      globalThis.performance = { now: () => Date.now() };
+    }
+
+    canvas = createMockCanvasLocal();
+    game = new Game(canvas, GameMode.CLASSIC);
+
+    // Create mock DOM elements for game over UI
+    overlay = { style: { display: 'none' } };
+    scoreEl = { textContent: '' };
+    restartBtn = {
+      _listeners: [],
+      addEventListener(event, handler) {
+        this._listeners.push({ event, handler });
+      },
+    };
+
+    game.setGameOverUI(overlay, scoreEl, restartBtn);
+    game.start();
+  });
+
+  describe('setGameOverUI()', () => {
+    it('stores references to DOM elements', () => {
+      expect(game.gameOverOverlay).toBe(overlay);
+      expect(game.gameOverScoreEl).toBe(scoreEl);
+      expect(game.gameOverRestartBtn).toBe(restartBtn);
+    });
+
+    it('binds click handler to restart button', () => {
+      expect(restartBtn._listeners.length).toBe(1);
+      expect(restartBtn._listeners[0].event).toBe('click');
+    });
+  });
+
+  describe('gameOver()', () => {
+    it('sets state to GAME_OVER', () => {
+      game.round = 5;
+      game.gameOver();
+      expect(game.state).toBe(GameState.GAME_OVER);
+    });
+
+    it('displays the current round number in the score element', () => {
+      game.round = 7;
+      game.gameOver();
+      expect(scoreEl.textContent).toBe('第 7 轮');
+    });
+
+    it('shows the game over overlay', () => {
+      game.gameOver();
+      expect(overlay.style.display).toBe('flex');
+    });
+
+    it('works without UI elements set (no errors)', () => {
+      const game2 = new Game(createMockCanvasLocal(), GameMode.CLASSIC);
+      expect(() => game2.gameOver()).not.toThrow();
+      expect(game2.state).toBe(GameState.GAME_OVER);
+    });
+  });
+
+  describe('restart via button', () => {
+    it('clicking restart button resets game state', () => {
+      game.round = 10;
+      game.gameOver();
+
+      // Simulate clicking the restart button
+      const clickHandler = restartBtn._listeners.find(l => l.event === 'click').handler;
+      clickHandler();
+
+      expect(game.state).toBe(GameState.AIMING);
+      expect(game.round).toBe(1);
+      expect(game.ballCount).toBe(1);
+    });
+
+    it('clicking restart button hides the overlay', () => {
+      game.gameOver();
+      expect(overlay.style.display).toBe('flex');
+
+      const clickHandler = restartBtn._listeners.find(l => l.event === 'click').handler;
+      clickHandler();
+
+      expect(overlay.style.display).toBe('none');
+    });
+  });
+
+  describe('nextRound triggers gameOver with UI', () => {
+    it('shows overlay when blocks reach bottom during nextRound', () => {
+      // Force checkGameOver to return true
+      vi.spyOn(game.boardManager, 'checkGameOver').mockReturnValue(true);
+      game.round = 12;
+
+      game.nextRound();
+      // Force slide animation to complete
+      if (game.state === GameState.SLIDING_DOWN) {
+        game._slideStartTime = performance.now() - game._slideDuration - 1;
+        game._updateSlideDown();
+      }
+
+      // round increments before gameOver is called
+      expect(game.state).toBe(GameState.GAME_OVER);
+      expect(overlay.style.display).toBe('flex');
+      expect(scoreEl.textContent).toBe('第 13 轮');
+    });
+  });
+});
+
+
+/**
+ * 集成单元测试 - 任务 11.2
+ * 测试模式选择后游戏正确初始化,以及完整回合流程
+ * **Validates: Requirements 1.1-1.4, 3.4, 5.1, 5.2**
+ */
+describe('Integration: Mode selection and game initialization', () => {
+  beforeEach(() => {
+    if (typeof globalThis.window === 'undefined') {
+      globalThis.window = { devicePixelRatio: 1 };
+    } else {
+      globalThis.window.devicePixelRatio = 1;
+    }
+    if (typeof globalThis.performance === 'undefined') {
+      globalThis.performance = { now: () => Date.now() };
+    }
+  });
+
+  it('CLASSIC mode initializes correctly (state=AIMING, round=1, ballCount=1)', () => {
+    const canvas = createMockCanvas();
+    const game = new Game(canvas, GameMode.CLASSIC);
+
+    expect(game.mode).toBe(GameMode.CLASSIC);
+    expect(game.state).toBe(GameState.AIMING);
+    expect(game.round).toBe(1);
+    expect(game.ballCount).toBe(1);
+  });
+
+  it('ENHANCED mode initializes correctly (state=AIMING, round=1, ballCount=1)', () => {
+    const canvas = createMockCanvas();
+    const game = new Game(canvas, GameMode.ENHANCED);
+
+    expect(game.mode).toBe(GameMode.ENHANCED);
+    expect(game.state).toBe(GameState.AIMING);
+    expect(game.round).toBe(1);
+    expect(game.ballCount).toBe(1);
+  });
+
+  it('start() generates blocks on the board', () => {
+    const canvas = createMockCanvas();
+    const game = new Game(canvas, GameMode.CLASSIC);
+    game.start();
+
+    const blocks = game.boardManager.getBlocks();
+    expect(blocks.length).toBeGreaterThan(0);
+    // All initial blocks should be at TOP_PADDING_ROWS
+    for (const block of blocks) {
+      expect(block.gridY).toBe(TOP_PADDING_ROWS);
+    }
+  });
+
+  it('start() in ENHANCED mode generates blocks on the board', () => {
+    const canvas = createMockCanvas();
+    const game = new Game(canvas, GameMode.ENHANCED);
+    game.start();
+
+    const blocks = game.boardManager.getBlocks();
+    expect(blocks.length).toBeGreaterThan(0);
+  });
+});
+
+describe('Integration: Full round flow (launch → collision → round end → new row)', () => {
+  let canvas;
+  let game;
+
+  beforeEach(() => {
+    if (typeof globalThis.window === 'undefined') {
+      globalThis.window = { devicePixelRatio: 1 };
+    } else {
+      globalThis.window.devicePixelRatio = 1;
+    }
+    if (typeof globalThis.performance === 'undefined') {
+      globalThis.performance = { now: () => Date.now() };
+    }
+
+    canvas = createMockCanvas();
+    game = new Game(canvas, GameMode.CLASSIC);
+    game.start();
+  });
+
+  it('transitions AIMING → LAUNCHING → RUNNING when launching', () => {
+    expect(game.state).toBe(GameState.AIMING);
+
+    // Simulate launch
+    game.launchAngle = 90;
+    game.state = GameState.LAUNCHING;
+    game.launchIndex = 0;
+    game.launchTimer = 0;
+    game.balls = [];
+
+    // Provide enough fixed-timestep ticks to launch all balls (ballCount=1)
+    const ticksNeeded = Math.ceil(LAUNCH_INTERVAL / FIXED_TIMESTEP) + 1;
+    game.update(ticksNeeded * FIXED_TIMESTEP);
+
+    expect(game.state).toBe(GameState.RUNNING);
+    expect(game.balls.length).toBe(1);
+    expect(game.balls[0].active).toBe(true);
+  });
+
+  it('transitions RUNNING → ROUND_END when all balls become inactive', () => {
+    game.state = GameState.RUNNING;
+
+    // Create a ball that is already inactive (simulating it reached bottom)
+    const ball = new Ball(game.boardWidth / 2, game.boardHeight - BALL_RADIUS);
+    ball.active = false;
+    game.balls = [ball];
+
+    game.update(FIXED_TIMESTEP);
+
+    expect(game.state).toBe(GameState.ROUND_END);
+  });
+
+  it('ROUND_END triggers nextRound: round increments, new blocks generated, blocks moved down', () => {
+    // Record initial blocks
+    const initialBlocks = game.boardManager.getBlocks();
+    const initialBlockCount = initialBlocks.length;
+    const initialGridYs = initialBlocks.map(b => b.gridY);
+
+    expect(game.round).toBe(1);
+
+    // Trigger round end
+    game.state = GameState.ROUND_END;
+    game.update(16);
+
+    // Round should have incremented (nextRound increments after generating)
+    if (game.state !== GameState.GAME_OVER) {
+      expect(game.round).toBe(2);
+      // Complete slide animation if in SLIDING_DOWN
+      if (game.state === GameState.SLIDING_DOWN) {
+        game._slideStartTime = performance.now() - game._slideDuration - 1;
+        game._updateSlideDown();
+      }
+      expect(game.state).toBe(GameState.AIMING);
+
+      // Blocks should have been moved down and new ones generated
+      const newBlocks = game.boardManager.getBlocks();
+      expect(newBlocks.length).toBeGreaterThan(0);
+
+      // There should be blocks at TOP_PADDING_ROWS (newly generated)
+      const topRowBlocks = newBlocks.filter(b => b.gridY === TOP_PADDING_ROWS);
+      expect(topRowBlocks.length).toBeGreaterThan(0);
+
+      // Original blocks should have moved down by 1 row
+      const movedBlocks = newBlocks.filter(b => b.gridY > 0);
+      expect(movedBlocks.length).toBeGreaterThanOrEqual(initialBlockCount);
+    }
+  });
+
+  it('complete flow: launch → all balls inactive → round end → new round', () => {
+    const round1Blocks = [...game.boardManager.getBlocks()];
+    expect(game.round).toBe(1);
+
+    // Step 1: Start launching
+    game.launchAngle = 90;
+    game.state = GameState.LAUNCHING;
+    game.launchIndex = 0;
+    game.launchTimer = 0;
+    game.balls = [];
+
+    // Step 2: Launch all balls
+    const ticksNeeded = Math.ceil(LAUNCH_INTERVAL / FIXED_TIMESTEP) + 1;
+    game.update(ticksNeeded * FIXED_TIMESTEP);
+    expect(game.state).toBe(GameState.RUNNING);
+
+    // Step 3: Set all balls to inactive (simulate reaching bottom)
+    for (const ball of game.balls) {
+      ball.active = false;
+    }
+
+    // Step 4: Update triggers ROUND_END detection
+    game.update(FIXED_TIMESTEP);
+    expect(game.state).toBe(GameState.ROUND_END);
+
+    // Step 5: Update triggers nextRound
+    game.update(16);
+
+    if (game.state !== GameState.GAME_OVER) {
+      expect(game.round).toBe(2);
+      // Complete slide animation if in SLIDING_DOWN
+      if (game.state === GameState.SLIDING_DOWN) {
+        game._slideStartTime = performance.now() - game._slideDuration - 1;
+        game._updateSlideDown();
+      }
+      expect(game.state).toBe(GameState.AIMING);
+
+      // New blocks should exist at TOP_PADDING_ROWS
+      const blocks = game.boardManager.getBlocks();
+      const topRowBlocks = blocks.filter(b => b.gridY === TOP_PADDING_ROWS);
+      expect(topRowBlocks.length).toBeGreaterThan(0);
+
+      // Original round 1 blocks should now be at TOP_PADDING_ROWS + 1 (moved down)
+      const row1Blocks = blocks.filter(b => b.gridY === TOP_PADDING_ROWS + 1);
+      expect(row1Blocks.length).toBeGreaterThanOrEqual(round1Blocks.length);
+    }
+  });
+});

+ 192 - 0
tests/guideline.test.js

@@ -0,0 +1,192 @@
+import { describe, it, expect } from 'vitest';
+import * as fc from 'fast-check';
+import { clampAngle, calculate } from '../src/systems/GuideLine.js';
+import { Block } from '../src/entities/Block.js';
+import { BALL_RADIUS } from '../src/constants.js';
+
+describe('GuideLine', () => {
+  // ---- clampAngle ----
+
+  describe('clampAngle', () => {
+    it('returns 15 for angles below 15', () => {
+      expect(clampAngle(0)).toBe(15);
+      expect(clampAngle(10)).toBe(15);
+      expect(clampAngle(-50)).toBe(15);
+    });
+
+    it('returns 165 for angles above 165', () => {
+      expect(clampAngle(170)).toBe(165);
+      expect(clampAngle(180)).toBe(165);
+    });
+
+    it('returns 15 for angles in 270-360 range', () => {
+      expect(clampAngle(300)).toBe(15);
+      expect(clampAngle(360)).toBe(15);
+    });
+
+    it('returns the angle itself when within range', () => {
+      expect(clampAngle(15)).toBe(15);
+      expect(clampAngle(90)).toBe(90);
+      expect(clampAngle(165)).toBe(165);
+      expect(clampAngle(45)).toBe(45);
+    });
+  });
+
+  // Feature: ball-block-breaker, Property 2: 角度钳制
+  // **Validates: Requirements 2.5**
+  describe('Property 2: 角度钳制', () => {
+    it('clamped angle is always within [15, 165] for any input', () => {
+      fc.assert(
+        fc.property(
+          fc.double({ min: -1000, max: 1000, noNaN: true }),
+          (angle) => {
+            const result = clampAngle(angle);
+            expect(result).toBeGreaterThanOrEqual(15);
+            expect(result).toBeLessThanOrEqual(165);
+          }
+        ),
+        { numRuns: 100 }
+      );
+    });
+  });
+
+  // ---- calculate ----
+
+  describe('calculate', () => {
+    const boardWidth = 400;
+    const boardHeight = 600;
+
+    it('returns 3 points for straight up (90°) hitting top wall', () => {
+      const points = calculate(200, 600, 90, [], boardWidth, boardHeight);
+      // Start -> top wall -> reflected back down
+      expect(points.length).toBe(3);
+      expect(points[0]).toEqual({ x: 200, y: 600 });
+      // Should hit top wall at x=200, y=BALL_RADIUS (offset by ball radius)
+      expect(points[1].x).toBeCloseTo(200, 1);
+      expect(points[1].y).toBeCloseTo(BALL_RADIUS, 1);
+    });
+
+    it('returns 3 points when hitting left wall at angle < 90°', () => {
+      // Angle 45° = up-left direction (reference convention: <90° goes left)
+      const points = calculate(200, 600, 45, [], boardWidth, boardHeight);
+      expect(points.length).toBe(3);
+      expect(points[0]).toEqual({ x: 200, y: 600 });
+      // Should hit left wall (x=BALL_RADIUS)
+      expect(points[1].x).toBeCloseTo(BALL_RADIUS, 0);
+    });
+
+    it('returns 3 points when hitting right wall at angle > 90°', () => {
+      // Angle 135° = up-right direction (reference convention: >90° goes right)
+      const points = calculate(200, 600, 135, [], boardWidth, boardHeight);
+      expect(points.length).toBe(3);
+      expect(points[0]).toEqual({ x: 200, y: 600 });
+      // Should hit right wall (x=boardWidth - BALL_RADIUS)
+      expect(points[1].x).toBeCloseTo(boardWidth - BALL_RADIUS, 0);
+    });
+
+    it('first point is always the start point', () => {
+      const points = calculate(100, 500, 90, [], boardWidth, boardHeight);
+      expect(points[0]).toEqual({ x: 100, y: 500 });
+    });
+
+    it('returns at most 3 points', () => {
+      const points = calculate(200, 600, 60, [], boardWidth, boardHeight);
+      expect(points.length).toBeLessThanOrEqual(3);
+      expect(points.length).toBeGreaterThanOrEqual(2);
+    });
+
+    it('clamps angle before calculating', () => {
+      // Angle 5° should be clamped to 15°
+      const pointsClamped = calculate(200, 600, 5, [], boardWidth, boardHeight);
+      const pointsAt15 = calculate(200, 600, 15, [], boardWidth, boardHeight);
+      expect(pointsClamped[1].x).toBeCloseTo(pointsAt15[1].x, 1);
+      expect(pointsClamped[1].y).toBeCloseTo(pointsAt15[1].y, 1);
+    });
+
+    it('handles block collision and reflects', () => {
+      // Place a block directly above the start point
+      const block = new Block(3, 5, 5, 50);
+      const blockRect = block.getRect();
+
+      // Start below the block, shoot straight up
+      const startX = blockRect.x + blockRect.width / 2;
+      const startY = blockRect.y + blockRect.height + 100;
+
+      const points = calculate(startX, startY, 90, [block], boardWidth, boardHeight);
+      expect(points.length).toBe(3);
+      // First hit should be near the block's bottom edge + ball radius
+      expect(points[1].y).toBeCloseTo(blockRect.y + blockRect.height + BALL_RADIUS, 1);
+    });
+
+    it('reflects off left wall and continues', () => {
+      // Shoot at 45° (up-left in reference convention) from center
+      const points = calculate(200, 600, 45, [], boardWidth, boardHeight);
+      expect(points.length).toBe(3);
+      // First hit: left wall (offset by ball radius)
+      expect(points[1].x).toBeCloseTo(BALL_RADIUS, 0);
+      // After reflection off left wall, dx flips, so second segment goes right
+      expect(points[2].x).toBeGreaterThan(BALL_RADIUS);
+    });
+
+    it('reflects off right wall and continues', () => {
+      // Shoot at 135° (up-right in reference convention) from center
+      const points = calculate(200, 600, 135, [], boardWidth, boardHeight);
+      expect(points.length).toBe(3);
+      // First hit: right wall (offset by ball radius)
+      expect(points[1].x).toBeCloseTo(boardWidth - BALL_RADIUS, 0);
+      // After reflection off right wall, dx flips, so second segment goes left
+      expect(points[2].x).toBeLessThan(boardWidth - BALL_RADIUS);
+    });
+
+    it('reflects off top wall and continues downward', () => {
+      // Shoot straight up
+      const points = calculate(200, 600, 90, [], boardWidth, boardHeight);
+      expect(points.length).toBe(3);
+      // First hit: top wall (offset by ball radius)
+      expect(points[1].y).toBeCloseTo(BALL_RADIUS, 1);
+      // After reflection off top, dy flips, so second segment goes down
+      expect(points[2].y).toBeGreaterThan(BALL_RADIUS);
+    });
+  });
+});
+
+// Feature: ball-block-breaker, Property 1: 参考线最多一次折射
+// **Validates: Requirements 2.2, 2.3, 2.4**
+describe('Property 1: 参考线最多一次折射', () => {
+  const boardWidth = 400;
+  const boardHeight = 600;
+  const blockSize = 50;
+
+  const blockArb = fc.record({
+    gridX: fc.integer({ min: 0, max: 6 }),
+    gridY: fc.integer({ min: 0, max: 8 }),
+    count: fc.integer({ min: 1, max: 100 }),
+  });
+
+  const blocksArb = fc.array(blockArb, { minLength: 0, maxLength: 5 });
+
+  it('path has at most 3 points and at least 2 points for any angle and block layout', () => {
+    fc.assert(
+      fc.property(
+        fc.double({ min: -1000, max: 1000, noNaN: true, noDefaultInfinity: true }),
+        fc.double({ min: 1, max: boardWidth - 1, noNaN: true, noDefaultInfinity: true }),
+        fc.double({ min: 1, max: boardHeight - 1, noNaN: true, noDefaultInfinity: true }),
+        blocksArb,
+        (angle, startX, startY, blockDefs) => {
+          const blocks = blockDefs.map(b => new Block(b.gridX, b.gridY, b.count, blockSize));
+          const points = calculate(startX, startY, angle, blocks, boardWidth, boardHeight);
+
+          // Path must have 2-3 points (at most one refraction)
+          expect(points.length).toBeGreaterThanOrEqual(2);
+          expect(points.length).toBeLessThanOrEqual(3);
+
+          // First point must equal start position
+          expect(points[0].x).toBeCloseTo(startX, 5);
+          expect(points[0].y).toBeCloseTo(startY, 5);
+        }
+      ),
+      { numRuns: 100 }
+    );
+  });
+});
+

+ 141 - 0
tests/item.test.js

@@ -0,0 +1,141 @@
+import { describe, it, expect, vi } from 'vitest';
+import { Item, BallItem, LineClearItem } from '../src/entities/Item.js';
+import { GRID_GAP, ITEM_SIZE_RATIO } from '../src/constants.js';
+
+describe('Item (base class)', () => {
+  describe('constructor', () => {
+    it('initializes with correct properties', () => {
+      const item = new Item(3, 2, 'ball', 40);
+      expect(item.gridX).toBe(3);
+      expect(item.gridY).toBe(2);
+      expect(item.type).toBe('ball');
+      expect(item.size).toBe(40);
+      expect(item.collected).toBe(false);
+    });
+  });
+
+  describe('getRect()', () => {
+    it('converts grid coordinates to pixel coordinates with reduced size', () => {
+      const size = 40;
+      const item = new Item(2, 3, 'ball', size);
+      const rect = item.getRect();
+      const itemSize = size * ITEM_SIZE_RATIO;
+      const offset = (size - itemSize) / 2;
+      expect(rect.x).toBe(2 * (size + GRID_GAP) + GRID_GAP + offset);
+      expect(rect.y).toBe(3 * (size + GRID_GAP) + GRID_GAP + offset);
+      expect(rect.width).toBe(itemSize);
+      expect(rect.height).toBe(itemSize);
+    });
+
+    it('returns correct rect for origin (0,0)', () => {
+      const size = 50;
+      const item = new Item(0, 0, 'ball', size);
+      const rect = item.getRect();
+      const itemSize = size * ITEM_SIZE_RATIO;
+      const offset = (size - itemSize) / 2;
+      expect(rect.x).toBe(GRID_GAP + offset);
+      expect(rect.y).toBe(GRID_GAP + offset);
+      expect(rect.width).toBe(itemSize);
+      expect(rect.height).toBe(itemSize);
+    });
+  });
+
+  describe('shouldRemove()', () => {
+    it('returns false by default', () => {
+      const item = new Item(0, 0, 'ball', 40);
+      expect(item.shouldRemove()).toBe(false);
+    });
+  });
+
+  describe('moveDown()', () => {
+    it('increments gridY by 1', () => {
+      const item = new Item(3, 2, 'ball', 40);
+      item.moveDown();
+      expect(item.gridY).toBe(3);
+    });
+  });
+});
+
+describe('BallItem', () => {
+  describe('constructor', () => {
+    it('initializes with type "ball"', () => {
+      const item = new BallItem(1, 2, 40);
+      expect(item.type).toBe('ball');
+      expect(item.gridX).toBe(1);
+      expect(item.gridY).toBe(2);
+      expect(item.collected).toBe(false);
+    });
+  });
+
+  describe('onCollect()', () => {
+    it('calls game.addPendingBall() and marks collected', () => {
+      const game = { addPendingBall: vi.fn() };
+      const item = new BallItem(0, 0, 40);
+      item.onCollect(game);
+      expect(game.addPendingBall).toHaveBeenCalledOnce();
+      expect(item.collected).toBe(true);
+    });
+  });
+
+  describe('shouldRemove()', () => {
+    it('returns true (ball items are removed on collect)', () => {
+      const item = new BallItem(0, 0, 40);
+      expect(item.shouldRemove()).toBe(true);
+    });
+  });
+});
+
+describe('LineClearItem', () => {
+  describe('constructor', () => {
+    it('initializes with type "lineClear"', () => {
+      const item = new LineClearItem(4, 5, 40, 'horizontal');
+      expect(item.type).toBe('lineClear');
+      expect(item.gridX).toBe(4);
+      expect(item.gridY).toBe(5);
+      expect(item.collected).toBe(false);
+      expect(item.direction).toBe('horizontal');
+    });
+  });
+
+  describe('onCollect()', () => {
+    it('marks collected and calls clearRow for horizontal direction', () => {
+      const game = { clearRow: vi.fn(), clearColumn: vi.fn() };
+      const item = new LineClearItem(3, 7, 40, 'horizontal');
+      item.onCollect(game);
+      expect(item.collected).toBe(true);
+      expect(game.clearRow).toHaveBeenCalledWith(7);
+      expect(game.clearColumn).not.toHaveBeenCalled();
+    });
+
+    it('marks collected and calls clearColumn for vertical direction', () => {
+      const game = { clearRow: vi.fn(), clearColumn: vi.fn() };
+      const item = new LineClearItem(5, 2, 40, 'vertical');
+      item.onCollect(game);
+      expect(item.collected).toBe(true);
+      expect(game.clearColumn).toHaveBeenCalledWith(5);
+      expect(game.clearRow).not.toHaveBeenCalled();
+    });
+
+    it('triggers clear each time onCollect is called', () => {
+      const game = { clearRow: vi.fn(), clearColumn: vi.fn() };
+      const item = new LineClearItem(3, 2, 40, 'horizontal');
+      item.onCollect(game);
+      item.onCollect(game);
+      expect(game.clearRow).toHaveBeenCalledTimes(2);
+    });
+  });
+
+  describe('shouldRemove()', () => {
+    it('returns false before collection', () => {
+      const item = new LineClearItem(0, 0, 40, 'horizontal');
+      expect(item.shouldRemove()).toBe(false);
+    });
+
+    it('returns false after collection (stays on board)', () => {
+      const game = { clearRow: vi.fn(), clearColumn: vi.fn() };
+      const item = new LineClearItem(0, 0, 40, 'horizontal');
+      item.onCollect(game);
+      expect(item.shouldRemove()).toBe(false);
+    });
+  });
+});

+ 301 - 0
tests/physics.test.js

@@ -0,0 +1,301 @@
+import { describe, it, expect } from 'vitest';
+import fc from 'fast-check';
+import {
+  checkBallWallCollision,
+  checkBallBlockCollision,
+  checkBallItemCollision,
+  resolveBallBlockCollision,
+  reflectWall
+} from '../src/systems/Physics.js';
+import { Ball } from '../src/entities/Ball.js';
+import { Block } from '../src/entities/Block.js';
+import { BallItem, LineClearItem } from '../src/entities/Item.js';
+
+describe('Physics', () => {
+  // ---- checkBallWallCollision ----
+
+  describe('checkBallWallCollision', () => {
+    it('returns "left" when ball touches left wall', () => {
+      const ball = { x: 8, y: 100, radius: 8 };
+      expect(checkBallWallCollision(ball, 400, 600)).toBe('left');
+    });
+
+    it('returns "right" when ball touches right wall', () => {
+      const ball = { x: 392, y: 100, radius: 8 };
+      expect(checkBallWallCollision(ball, 400, 600)).toBe('right');
+    });
+
+    it('returns "top" when ball touches top wall', () => {
+      const ball = { x: 200, y: 8, radius: 8 };
+      expect(checkBallWallCollision(ball, 400, 600)).toBe('top');
+    });
+
+    it('returns null when ball is in the middle', () => {
+      const ball = { x: 200, y: 300, radius: 8 };
+      expect(checkBallWallCollision(ball, 400, 600)).toBeNull();
+    });
+
+    it('returns "left" when ball is past left wall', () => {
+      const ball = { x: 3, y: 100, radius: 8 };
+      expect(checkBallWallCollision(ball, 400, 600)).toBe('left');
+    });
+
+    it('prioritizes left over top when in corner', () => {
+      const ball = { x: 5, y: 5, radius: 8 };
+      expect(checkBallWallCollision(ball, 400, 600)).toBe('left');
+    });
+  });
+
+  // ---- checkBallBlockCollision ----
+
+  describe('checkBallBlockCollision', () => {
+    it('returns hit:false when no overlap', () => {
+      const ball = new Ball(50, 50, 8);
+      const block = new Block(3, 3, 5, 40); // far away
+      const result = checkBallBlockCollision(ball, block);
+      expect(result.hit).toBe(false);
+      expect(result.side).toBeNull();
+    });
+
+    it('detects top collision', () => {
+      // Place ball just above the block, overlapping slightly from top
+      const block = new Block(0, 1, 5, 40);
+      const blockRect = block.getRect();
+      // Ball center just above block top, overlapping by 2px
+      const ball = new Ball(blockRect.x + 20, blockRect.y - 6, 8);
+      const result = checkBallBlockCollision(ball, block);
+      expect(result.hit).toBe(true);
+      expect(result.side).toBe('top');
+    });
+
+    it('detects bottom collision', () => {
+      const block = new Block(0, 1, 5, 40);
+      const blockRect = block.getRect();
+      // Ball center just below block bottom, overlapping by 2px
+      const ball = new Ball(blockRect.x + 20, blockRect.y + blockRect.height + 6, 8);
+      const result = checkBallBlockCollision(ball, block);
+      expect(result.hit).toBe(true);
+      expect(result.side).toBe('bottom');
+    });
+
+    it('detects left collision', () => {
+      const block = new Block(2, 2, 5, 40);
+      const blockRect = block.getRect();
+      // Ball center just left of block, overlapping by 2px
+      const ball = new Ball(blockRect.x - 6, blockRect.y + 20, 8);
+      const result = checkBallBlockCollision(ball, block);
+      expect(result.hit).toBe(true);
+      expect(result.side).toBe('left');
+    });
+
+    it('detects right collision', () => {
+      const block = new Block(2, 2, 5, 40);
+      const blockRect = block.getRect();
+      // Ball center just right of block, overlapping by 2px
+      const ball = new Ball(blockRect.x + blockRect.width + 6, blockRect.y + 20, 8);
+      const result = checkBallBlockCollision(ball, block);
+      expect(result.hit).toBe(true);
+      expect(result.side).toBe('right');
+    });
+  });
+
+  // ---- checkBallItemCollision ----
+
+  describe('checkBallItemCollision', () => {
+    it('returns true when ball overlaps item', () => {
+      const ball = new Ball(10, 10, 8);
+      const item = new BallItem(0, 0, 40);
+      expect(checkBallItemCollision(ball, item)).toBe(true);
+    });
+
+    it('returns false when ball does not overlap item', () => {
+      const ball = new Ball(200, 200, 8);
+      const item = new BallItem(0, 0, 40);
+      expect(checkBallItemCollision(ball, item)).toBe(false);
+    });
+
+    // Feature: ball-block-breaker, Property 14: 道具碰撞不改变球方向
+    // **Validates: Requirements 6.2**
+    it('item collision does not change ball velocity', () => {
+      fc.assert(
+        fc.property(
+          fc.float({ min: -1000, max: 1000, noNaN: true, noDefaultInfinity: true }),
+          fc.float({ min: -1000, max: 1000, noNaN: true, noDefaultInfinity: true }),
+          fc.constantFrom('ball', 'lineClear'),
+          (vx, vy, itemType) => {
+            // Place ball at item center so it overlaps
+            const item = itemType === 'ball'
+              ? new BallItem(0, 0, 40)
+              : new LineClearItem(0, 0, 40);
+            const itemRect = item.getRect();
+            const ball = new Ball(
+              itemRect.x + itemRect.width / 2,
+              itemRect.y + itemRect.height / 2,
+              8
+            );
+            ball.vx = vx;
+            ball.vy = vy;
+
+            // Perform collision detection
+            const collided = checkBallItemCollision(ball, item);
+
+            // Collision should be detected
+            expect(collided).toBe(true);
+
+            // Ball velocity must remain unchanged
+            expect(ball.vx).toBe(vx);
+            expect(ball.vy).toBe(vy);
+          }
+        ),
+        { numRuns: 100 }
+      );
+    });
+  });
+
+  // ---- resolveBallBlockCollision ----
+
+  describe('resolveBallBlockCollision', () => {
+    it('reflects y for top collision', () => {
+      const ball = new Ball(100, 100, 8);
+      ball.vx = 5;
+      ball.vy = 3;
+      resolveBallBlockCollision(ball, 'top');
+      expect(ball.vx).toBe(5);
+      expect(ball.vy).toBe(-3);
+    });
+
+    it('reflects y for bottom collision', () => {
+      const ball = new Ball(100, 100, 8);
+      ball.vx = 5;
+      ball.vy = -3;
+      resolveBallBlockCollision(ball, 'bottom');
+      expect(ball.vx).toBe(5);
+      expect(ball.vy).toBe(3);
+    });
+
+    it('reflects x for left collision', () => {
+      const ball = new Ball(100, 100, 8);
+      ball.vx = 5;
+      ball.vy = 3;
+      resolveBallBlockCollision(ball, 'left');
+      expect(ball.vx).toBe(-5);
+      expect(ball.vy).toBe(3);
+    });
+
+    it('reflects x for right collision', () => {
+      const ball = new Ball(100, 100, 8);
+      ball.vx = -5;
+      ball.vy = 3;
+      resolveBallBlockCollision(ball, 'right');
+      expect(ball.vx).toBe(5);
+      expect(ball.vy).toBe(3);
+    });
+
+    // Feature: ball-block-breaker, Property 8: 方块碰撞反弹正确性
+    // **Validates: Requirements 4.3, 4.4, 4.5**
+    it('block collision reflects correct axis and preserves speed magnitude', () => {
+      fc.assert(
+        fc.property(
+          fc.float({ min: -1000, max: 1000, noNaN: true, noDefaultInfinity: true }).filter(v => v !== 0),
+          fc.float({ min: -1000, max: 1000, noNaN: true, noDefaultInfinity: true }).filter(v => v !== 0),
+          fc.constantFrom('top', 'bottom', 'left', 'right'),
+          (vx, vy, side) => {
+            const ball = new Ball(100, 100, 8);
+            ball.vx = vx;
+            ball.vy = vy;
+
+            const originalSpeed = Math.sqrt(vx * vx + vy * vy);
+
+            resolveBallBlockCollision(ball, side);
+
+            if (side === 'top' || side === 'bottom') {
+              // vy should be negated, vx unchanged
+              expect(ball.vy).toBe(-vy);
+              expect(ball.vx).toBe(vx);
+            } else {
+              // left or right: vx should be negated, vy unchanged
+              expect(ball.vx).toBe(-vx);
+              expect(ball.vy).toBe(vy);
+            }
+
+            // Speed magnitude preserved
+            const newSpeed = Math.sqrt(ball.vx ** 2 + ball.vy ** 2);
+            expect(Math.abs(originalSpeed - newSpeed)).toBeLessThan(0.001);
+          }
+        ),
+        { numRuns: 100 }
+      );
+    });
+  });
+
+  // ---- reflectWall ----
+
+  describe('reflectWall', () => {
+    it('negates vx for left wall', () => {
+      const result = reflectWall({ vx: 5, vy: 3 }, 'left');
+      expect(result).toEqual({ vx: -5, vy: 3 });
+    });
+
+    it('negates vx for right wall', () => {
+      const result = reflectWall({ vx: -5, vy: 3 }, 'right');
+      expect(result).toEqual({ vx: 5, vy: 3 });
+    });
+
+    it('negates vy for top wall', () => {
+      const result = reflectWall({ vx: 5, vy: -3 }, 'top');
+      expect(result).toEqual({ vx: 5, vy: 3 });
+    });
+
+    it('returns unchanged velocity for unknown wall', () => {
+      const result = reflectWall({ vx: 5, vy: 3 }, 'bottom');
+      expect(result).toEqual({ vx: 5, vy: 3 });
+    });
+
+    it('is a pure function - does not mutate input', () => {
+      const velocity = { vx: 5, vy: 3 };
+      reflectWall(velocity, 'left');
+      expect(velocity).toEqual({ vx: 5, vy: 3 });
+    });
+
+    // Feature: ball-block-breaker, Property 3: 墙壁反射正确性
+    // **Validates: Requirements 3.2**
+    it('wall reflection preserves speed magnitude', () => {
+      fc.assert(
+        fc.property(
+          fc.float({ min: -1000, max: 1000, noNaN: true, noDefaultInfinity: true }),
+          fc.float({ min: -1000, max: 1000, noNaN: true, noDefaultInfinity: true }),
+          fc.constantFrom('left', 'right', 'top'),
+          (vx, vy, wall) => {
+            const reflected = reflectWall({ vx, vy }, wall);
+            const originalSpeed = Math.sqrt(vx * vx + vy * vy);
+            const newSpeed = Math.sqrt(reflected.vx ** 2 + reflected.vy ** 2);
+            expect(Math.abs(originalSpeed - newSpeed)).toBeLessThan(0.001);
+          }
+        ),
+        { numRuns: 100 }
+      );
+    });
+
+    it('wall reflection negates correct component and preserves the other', () => {
+      fc.assert(
+        fc.property(
+          fc.float({ min: -1000, max: 1000, noNaN: true, noDefaultInfinity: true }),
+          fc.float({ min: -1000, max: 1000, noNaN: true, noDefaultInfinity: true }),
+          fc.constantFrom('left', 'right', 'top'),
+          (vx, vy, wall) => {
+            const reflected = reflectWall({ vx, vy }, wall);
+            if (wall === 'left' || wall === 'right') {
+              expect(reflected.vx).toBe(-vx);
+              expect(reflected.vy).toBe(vy);
+            } else {
+              // wall === 'top'
+              expect(reflected.vx).toBe(vx);
+              expect(reflected.vy).toBe(-vy);
+            }
+          }
+        ),
+        { numRuns: 100 }
+      );
+    });
+  });
+});

+ 8 - 0
vitest.config.js

@@ -0,0 +1,8 @@
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+  test: {
+    environment: 'jsdom',
+    include: ['tests/**/*.test.js'],
+  }
+});