# 单文件像素游戏模板集

**五个能读、能玩、能改的小游戏。每个都是一个 HTML 文件，零依赖，双击就能打开。**

在线试玩：**<https://erling78.github.io/single-file-pixel-games/>**　·　每个游戏也能单独双击打开，不需要服务器。

```
games/breakout.html     打砖块     —— 碰撞检测与关卡数据
games/snake.html        贪吃蛇     —— 网格逻辑与转向缓冲
games/platformer.html   平台跳跃   —— 瓦片碰撞与手感参数
games/sokoban.html      推箱子     —— 不可变状态与撤销栈
games/shooter.html      弹幕射击   —— 实体系统与弹幕模式
```

没有构建步骤，没有 `npm install`，没有任何 CDN 引用 —— 断网也能跑。

---

## 这个项目要解决什么

网上有大量「教你写游戏」的教程，但它们的产物通常是三种之一：

- **跑不起来的片段** —— 讲了碰撞检测，但没讲主循环
- **能跑的 demo** —— 只有十几行，改一点就崩
- **完整的项目** —— 用上了引擎、构建工具、一堆依赖，新手根本看不懂哪部分是"游戏"

这个项目想填中间那块：**每一个都是完整可玩的成品，但小到能在一次阅读里看完。**

而且它们是被验证过的 —— 862 条自动化断言在跑。写这些断言的过程中找出的真实缺陷，都写在 [docs/PATTERNS.md](docs/PATTERNS.md) 里。

---

## 五个游戏

| 文件 | 玩法 | 它能教什么 | 代码量 |
|---|---|---|---|
| [breakout.html](games/breakout.html) | 挡板反弹球打砖块，5 关，3 种道具 | 圆与矩形的碰撞求解、角度映射反弹、关卡用字符画描述 | ~930 行 |
| [snake.html](games/snake.html) | 经典贪吃蛇，可插值平滑移动 | 把"推进一格"写成纯函数、转向队列、网格与插值的分离 | ~690 行 |
| [platformer.html](games/platformer.html) | 4 关，金币、尖刺、单向平台、移动平台 | 轴分离碰撞、土狼时间、跳跃缓冲、变高跳 | ~1060 行 |
| [sokoban.html](games/sokoban.html) | 6 关，被 BFS 求解器证明可解 | 不可变状态 → 撤销/求解/回放三件事一次到手 | ~740 行 |
| [shooter.html](games/shooter.html) | 波次弹幕射击，每 5 波一个首领 | 弹幕是数据不是代码、波次难度曲线、实体对象池 | ~850 行 |

每个文件内部结构一致，看懂一个就等于看懂全部五个。详见 [docs/ANATOMY.md](docs/ANATOMY.md)。

---

## 快速开始

### 玩

**在线玩** —— 直接打开，不用下载任何东西：

**https://erling78.github.io/single-file-pixel-games/**

或者下载下来玩。直接双击 `games/` 里的任意 HTML 文件即可 —— **单个游戏文件是自包含的，双击就能跑。**

要让五个游戏互相跳转（以及走 [index.html](index.html) 落地页），用任意静态服务器：

```bash
python -m http.server 8000
# 或
npx serve .
```

> 为什么落地页需要服务器：浏览器对 `file://` 下的相对路径跳转有限制。但**每个游戏本身没有任何跨文件依赖**，所以单独玩一个游戏完全不需要服务器。

### 验证

```bash
node tests/core.test.mjs      # 算法单元测试
node tests/smoke.test.mjs     # 端到端冒烟测试
node tests/systems.test.mjs   # 规则与平衡测试
node tests/dom-check.mjs      # 静态一致性检查

npm test                      # 一次跑完
```

**不需要 `npm install`** —— 测试只用 Node 内置模块（`node:fs`、`node:path`）加一个自己写的 mock DOM。

---

## 操作方式

五个游戏的操作刻意保持了一致，减少记忆负担。

| 游戏 | 移动 | 主要动作 | 其他 |
|---|---|---|---|
| 打砖块 | `←` `→` 或鼠标 | `空格` 发射 | — |
| 贪吃蛇 | `方向键` / `WASD` | — | `空格` 暂停，`R` 重开 |
| 平台跳跃 | `←` `→` / `A` `D` | `空格` 跳（长按更高） | `R` 重来本关 |
| 推箱子 | `方向键` / `WASD` | — | `Z` 撤销，`R` 重来 |
| 弹幕射击 | `←` `→` / `A` `D` | 按住 `空格` 开火 | — |

---

## 双语

界面支持中文和英文。**两种语言都装在同一份文件里** —— 不做两份 HTML，因为那是两份要各自维护的代码，改一个 bug 得改两遍，迟早不一致。

切换方式：

| 方式 | 做法 | 优先级 |
|---|---|---|
| URL 参数 | `breakout.html?lang=en` | 最高（方便分享指定语言的链接） |
| 手动切换 | 游戏里按 `L`，选择被记住 | 中（刷新后仍生效） |
| 浏览器语言 | 自动检测，非中文一律英文 | 最低（兜底） |

测试会断言两种语言的键完全对齐、占位符一一对应、切换语言不影响任何游戏状态 —— 漏译一条就会变红。

## 测试

四套测试分工明确，覆盖不同的失败模式：

| 测试 | 项数 | 测什么 |
|---|---|---|
| `core.test.mjs` | 196 | 把 `#region CORE` 抠出来在 Node 里求值，做性质断言 |
| `smoke.test.mjs` | 158 | 在 mock DOM 里真正执行游戏，驱动完整链路 |
| `systems.test.mjs` | 256 | 规则、手感参数、难度曲线，以及双语切换的完整行为 |
| `dom-check.mjs` | 252 | 静态一致性：id 引用、标签配平、零外部依赖 |
| **合计** | **862** | |

还有一个 `tests/readme.test.mjs`，它**不**计入上面的总数 —— 因为它检查的就是本文档本身：跑一遍那四套、把结果加起来，然后断言这里的每个数字都对得上；同时验证所有链接指向真实文件、代码量与标注相符、文档里写的按键在代码里真的被处理了。

### 几条值得一提的断言

**推箱子的六个关卡由 BFS 求解器证明可解。** 因为 `tryMove` 是纯函数，求解器不需要写任何"反向操作"，十几行就能穷举状态空间。手写关卡最常见的毛病就是根本无解，而人眼完全看不出来。

**平台跳跃的四个关卡由自动驾驶打通。** 它会用真实物理一路跑、跳坑、躲尖刺，最后必须碰到终点。同时验证了地形可通行、物理参数够用、终点能被触发。

**打砖块的球速有上限断言。** 连打 400 帧，球速不能超过「基础速度 × 上限倍率」。

**跳跃高度是量出来的。** 满跳必须落在设计值 61px 附近，短按必须明显低于长按 —— 后者正是抓出「截断每帧重复施加」那个 bug 的断言。

### 测试怎么组织的

```
tests/
├── lib/
│   ├── env.mjs        mock DOM + 断言器（手动排程 rAF/setTimeout）
│   └── load.mjs       从 HTML 里提取脚本、求值 CORE、静态检查工具
├── core.test.mjs
├── smoke.test.mjs
├── systems.test.mjs
└── dom-check.mjs
```

`env.mjs` 里的 mock 环境**真的把游戏跑起来**：主循环、事件、渲染调用都会发生，只是 canvas 调用被一笔笔记下来而不是画到屏幕上。

一个必须知道的限制：**mock 的 `getElementById` 会自动创建任何元素**。这让测试好写，但也意味着「JS 引用了 HTML 里不存在的元素」这类错误在运行时完全暴露不出来 —— 所以 `dom-check.mjs` 不可省。

---

## 每个游戏里的结构约定

```html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<style>  /* UI 层：HUD、覆盖层，纯 CSS 矢量 */ </style>
</head>
<body>
  <div id="stage">
    <canvas id="world" width="640" height="360"></canvas>
    <div id="ui"> ... </div>
  </div>
<script>
/* #region CORE
   算法区：不碰 DOM、不读全局状态、没有副作用
   这里的函数能单独抠出来在 Node 里跑，所以可以被单元测试直接断言 */
/* #endregion CORE */

/* 游戏层：实体、状态机、渲染、主循环 */

window.__GAME__ = { /* 调试接口 + 测试驱动入口 */ };
</script>
</body>
</html>
```

三条约定：

1. **`#region CORE` 里不允许出现 `document`、`canvas`、`ctx`**。这一条是整个测试体系能成立的前提。
2. **640×360 内部分辨率**，整数倍缩放。canvas 走位图放大（`pixelated`），UI 层走矢量缩放（`transform: scale`），所以中文小字仍然清晰。
3. **调试接口暴露 `state()` / 各种 getter / 确定性驱动方法**。测试靠它驱动，你也可以在控制台里调参。

---

## 关于这个项目的三个决定

### 一、为什么不做成引擎

引擎的价值在于抽象，而抽象的代价是**你需要先理解引擎才能理解游戏**。

新手看一个引擎写的游戏，看到的是一堆配置和回调，很难回答"球到底是怎么弹起来的"。而在这个项目里，球怎么弹起来就是 [`circleRect`](games/breakout.html) 那二十行。

代价是每个游戏都要自己写一遍主循环和碰撞。**这在这个规模上是划算的** —— 五个游戏加起来约 4300 行，其中重复的部分不到 200 行。

### 二、为什么代码里几乎没有注释，但文档写得很长

代码里的注释只写"为什么"，不写"是什么"：

```js
/* 生长不需要额外的计数器：吃到食物那一 tick 不弹尾巴，长度自然 +1。
   早先这里多写了一个 grow 累加器，结果是"每吃一次长两节" ——
   而且因为蛇确实变长了，肉眼很难发现。 */
```

"这里遍历身体数组"这种注释没有价值，读代码就知道。"这里曾经写错过，错法是这样的"才有价值。

解释性的内容放在 `docs/` 里，因为它们需要上下文和篇幅，塞进代码会打断阅读。

### 三、为什么关卡要用机器验证

这一条改变的是**关卡设计的自由度**，不只是测试覆盖率。

「渡河」关最初用 7~8 格的缺口，逼玩家必须等移动平台。玩法上说得通，但代价是：**自动驾驶没法"等平台飘过来"，于是"这关能不能通"就只能靠人肉试**。

缺口收窄到 5 格（可跳越）之后，自动驾驶能一次跑完四关，关卡设计从此有了机器保障。

在「必须这样才能过」和「能被自动化验证」之间，这个规模的项目应该优先后者。

---

## 文档

| 文档 | 内容 |
|---|---|
| [docs/ANATOMY.md](docs/ANATOMY.md) | 拆解文件结构：三层分离、算法区为什么必须不碰 DOM、阅读顺序 |
| [docs/MODDING.md](docs/MODDING.md) | 十种常见改造的具体做法：加关卡、换配色、改手感、加机制、适配触屏…… |
| [docs/PATTERNS.md](docs/PATTERNS.md) | 可复用模式与踩过的坑，按「症状 → 原因 → 做法」组织 |
| [docs/PATTERNS.en.md](docs/PATTERNS.en.md) | 同上，英文版 |

如果只读一篇，读 [docs/PATTERNS.md](docs/PATTERNS.md)。

---

## 已知限制

诚实列出来，比藏着更好：

- **没有音效开关**。浏览器要求用户先交互才能播放声音，目前只在第一次操作后自动启用，没有提供静音的界面按钮。
- **界面双语，但代码注释只有中文**。这对英文读者是个障碍。`docs/PATTERNS.en.md` 覆盖了所有设计决策和踩坑记录，所以"为什么这么写"是可读的，但"这一行在做什么"仍需要读中文。加英文注释意味着每处注释都要写两遍，收益和成本需要你自己权衡。
- **只有中英两种语言**。加第三种语言要改五个文件里的 `TEXTS`、`detectLang` 的分支、以及文档里的语言表。好消息是测试会立刻指出漏掉的键。
- **没有移动端虚拟按键**。`docs/MODDING.md` 里有做法说明，但没有实装 —— 竖屏下 640×360 会被缩得很小，认真做需要重新设计布局。
- **平台跳跃只有横向滚动**。相机纵向固定在 `OFFSET_Y`，关卡高度必须正好 20 行。想加高关卡需要改相机逻辑，做法写在 `docs/MODDING.md` 里。
- **推箱子的死局检测很粗糙**。只查"箱子卡在墙角"和"贴着一整面墙"两种情况。完整的死锁分析要复杂得多，这个简化版能覆盖大多数实际死局，但会漏报。
- **弹幕射击的敌人不会互相避让**，有时会重叠。
- **没有存档除了贪吃蛇的最高分**。推箱子的关卡进度、平台跳跃的金币收集都不保存。
- **测试环境不是真实浏览器**。mock DOM 不解析 `innerHTML`、不做布局、不跑合成器。所以需要静态检查兜底，也需要你在真浏览器里实际玩一遍。

---

## 许可

[MIT](LICENSE)。拿去改成你自己的游戏，不用署名。
