# 《救一笔！》关卡格式与生成规则 · v1

本文可用于手工离线迭代，也可连同 Schema 直接发给 LLM。**当前引擎只接受数据，不接受 JavaScript、回调、表达式、HTML 组件或外部资源 URL。** 不需要也不接入在线模型服务。

- 机器可读格式：[level-v1.schema.json](../schemas/level-v1.schema.json)，JSON Schema Draft 2020-12。
- 权威运行时校验：[`js/level-format.js`](../js/level-format.js)，浏览器导入、内置目录加载和离线 CLI 共用。
- 物理判定：[`js/physics.js`](../js/physics.js)，与游戏逐帧试坐相同。
- 可直接导入的完整例子：[examples/level.json](examples/level.json)。配套样例：[examples/level.cases.json](examples/level.cases.json)。

> **格式合法 ≠ 可解，更不等于有趣或难度合适。** JSON Schema 只能检查结构；运行时还检查跨字段关系；只有合法笔画经过同一个物理引擎成功试坐，才能证明这个具体文件至少有一个解。模型给出的“验证通过”文字、`verified` 字段或答案描述都不是证据，未知字段会被拒绝。

## 1. 文件、版本和边界

单关文件是 UTF-8 JSON 对象，最多 **65,536 字节（64 KiB，按 UTF-8 字节而非字符数）**。允许文件头 BOM；不允许 Markdown 围栏、注释、尾逗号、`NaN`、`Infinity`、数字字符串或函数。未知字段在任何嵌套层级都会报错，不忽略它们。

必填 `format: "save-one-stroke-level"`、`version: 1`。将来出现新机制应升级格式并实现迁移，不能把新机制写进 v1 的未知字段里。本轮没有修改九个现有关卡的物理规则。

内置目录 `levels/manifest.json` 列出文件名，格式为 `save-one-stroke-manifest` / 版本 1。文件名为 `01-name.json` 这样的两到三位数字加小写字母/连字符；内置 id 必须按清单从 1 连续编号。

**外部单关不要求连续编号。** 其 id 可以是 1–999999；浏览器为每份导入内容另外分配本地键，所以即使 id 与内置关相同，也不会覆盖内置几何或成绩。同内容重复导入会复用已有存档；相同 id 但不同内容可以并存。

## 2. 坐标与作画规则

- 逻辑画布固定 **720 × 480**。原点在左上，x 向右、y 向下，允许小数。显示缩放和屏幕像素不影响世界坐标。
- 所有矩形以左上角 `{x,y}` 加宽高 `{w,h}` 表示，**不是中心点坐标**。绘画区 x 为 24–696，y 为 `chair[0].y - 4` 到 465。
- 地板可以延伸到画布下方 600，屏幕之外只是用于碰撞体厚度。通常地面顶面 y=400，座面顶面 y=251。
- 笔画的物理半径为 4.5。要接地板顶面，中心线通常停在 `地面 y - 4.5`；不要把中心线画进实体。
- 一笔是连续折线，可以拐弯、从地面开始、中途碰椅子再落地；抬笔后不能再补第二笔。每段都是实际有质量的碰撞体。
- 线与任一木椅矩形接触就焊成同一刚体；没有接上的是自由落体，不会静止钉在空气里。
- 原始轨迹长度计墨水，至少 10、不得超过 `ink`。游戏输入达到墨水上限会截断收笔；离线样例不自动截断，超长就不合法。
- 游戏有小于 10 单位的端点吸附，仍扣墨水；仅吸附木椅的水平表面、实心地面/台阶顶面，洞不会吸附。离线测试坐标是最终轨迹，不自动吸附。
- 笔画会做 1.2 单位几何简化以控制碰撞体数量。原始线段及简化后的线段都会检查禁区穿越，不能只让端点避开猫。

## 3. 顶层字段

除下表外没有其他 v1 顶层字段；`script`、`onWin`、`solution`、`goal`、`duration`、`verified`、`$schema` 等都不接受。不要为了给 JSON 加 Schema 提示而在关卡内部塞 `$schema`，应在编辑器或验证器外部关联 Schema 文件。

| 字段 | 必填 | 类型与合法范围 | 作用 |
| --- | --- | --- | --- |
| `format` | 是 | 固定字符串 `save-one-stroke-level` | 格式标记 |
| `version` | 是 | 整数 `1` | 版本 |
| `id` | 是 | 整数 1–999999 | 数据编号，不决定物理行为 |
| `chapter` | 是 | `入门` / `挑战` | 分类，不改变引擎规则 |
| `title` | 是 | 非空纯文本，≤40 字 | 标题 |
| `tag` | 是 | 非空纯文本，≤80 字 | 副标题/思路分类 |
| `brief` | 是 | 非空纯文本，≤240 字 | 情境简介 |
| `condition` | 是 | 非空纯文本，≤300 字 | 向玩家说明试坐条件；文字本身不执行 |
| `quip` | 是 | 非空纯文本，≤120 字 | 试坐前对白 |
| `success` / `failure` | 是 | 各 ≤240 字的非空纯文本 | 成败反馈 |
| `ink` | 是 | 有限数字 10–2000 | 允许的笔画总长度 |
| `gold` | 是 | 有限数字 10–2000 且 ≤`ink` | 三星最大用墨长度 |
| `chair` | 是 | 1–30 个木椅矩形 | 刚性座面与已有椅脚 |
| `terrain` | 是 | 1–30 个承重矩形 | 固定实心地板、台阶、小岛 |
| `load` | 是 | 载荷对象，见下文 | 假人及可选抱猫重量 |
| `hint` | 是 | 1–4 条非空文字，各 ≤300 字 | 顺序展开的提示 |
| `forbidden` | 否 | 一个禁区矩形 | 单猫区，兼容已有数据 |
| `forbiddenZones` | 否 | 1–10 个禁区矩形 | 多猫区；与 `forbidden` 二选一 |
| `gap` | 否 | `[左边界,右边界]`，各 0–720，左<右 | 仅空洞警示标记；承重仍由 terrain 决定 |
| `labels` | 否 | 0–12 个画板注释 | 纯视觉注释和箭头 |
| `forces` | 否 | 0–10 段定时外力 | 真正施加到结构的推力，见下文 |

纯文本可以含标点；程序通过 `textContent` 或 Canvas 绘字展示，不作为 HTML/代码执行。字数按 Unicode 码点计数，纯空白文字不合法。

### 矩形

公共字段均为有限数字：`x` 0–719、`y` 0–480、`w` 1–720、`h` 1–600。还必须满足 **x+w≤720、y+h≤600**。

- `chair[]` 额外必填 `kind: "seat" | "leg"`；**第一块必须是 seat**，其 y 用来定义座面基准及作画上界。所有木块参与物理，不仅是画出来的装饰。
- `terrain[]` 可选 `step: boolean`，只改变台阶绘制风格；即使没有 step，矩形仍会承重。
- `forbidden` 只接受四个公共字段。
- `forbiddenZones[]` 可额外带非空 `label`（≤40 字）。禁区是整个矩形，不是仅猫咪插画的轮廓；画线和试坐碰到任一禁区都会失败。
- 允许矩形相邻或重叠，但校验不保证初始几何合理；把座面埋进地面也可能格式合法而无法游玩，需要后续物理验证。

### 载荷 load

必填 `x`（25–695）、`mass`（0.1–200）。mass 是引擎质量单位，不是经标定的公斤。

可选 `cat: boolean`；当 `cat=true` 时必须给 `catX`（25–695）和 `catMass`（0.1–200）。当 cat 为 false/缺省时额外猫载荷不生效；若提供 catX/catMass，它们仍须在合法范围。

阿稳是**抱住椅子的刚性假人**，不是独立布娃娃。假人矩形中心 `(load.x, seat.y-37)`，宽40、高69；猫载荷中心 `(catX, seat.y-21)`，宽42、高35。物理重心还包含木椅与墨水自身质量，不等于 load.x。

### 注释 labels

每项只接受：`x`（0–720）、`y`（0–480）、`text`（非空≤100字）、`to:[x,y]`（分别0–720、0–480）。它们仅负责绘字与箭头，不产生碰撞或支撑。

### 定时外力 forces

每项只接受：

- `start`、`end`：毫秒，0–5000，且 start<end；生效区间是 **[start,end)**。
- `fx`：必填，有限数字 -0.1 到 +0.1；正值向右。
- `fy`：可选，-0.1 到 +0.1，缺省0；正值向下。
- `at:{x,y}`：必填，初始世界坐标 x=0–720、y=0–480；随后随木椅刚体旋转/平移，表示固定在结构上的施力点。
- `label`：非空≤100字，给玩家看的外力名称。

引擎每个固定物理步调用 `Body.applyForce`，**力的方向仍是世界坐标方向**；同一时间多段外力会叠加。上限是安全/数值边界，不是推荐强度。先参考 `levels/07-crosswind.json` 的 ±0.017，再谨慎调整。强力可能使关卡无解。

## 4. 引擎支持什么，以及固定胜负规则

支持的声明式机制：缺脚/无腿、偏心人猫载荷、多个不同高度的固定平台、空洞、多猫区、分时/反向外力、墨水限额及省墨评级。上述行为来自几何/载荷/forces，不按 id 触发。

不支持：会动的地板、铰链、绳索、弹簧、结构断裂、任意斜多边形 terrain、独立布娃娃、可编程目标、任意时间轴回调、图片/网络资源、新材质摩擦参数。不要生成引擎没有实现的字段，也不要只在 condition 里声称它们生效。

**v1 的存活目标是全局规则，而不是可自由改写的字段：**

1. 固定120 Hz模拟，试坐5000ms；切后台或打开玩法/导入窗口时暂停。
2. 任何时刻绝对倾角>24°，或载荷所在座面点比初始座面下降>65，或其 x 跑到40–680之外，或结构/松散笔画碰猫，判失败；以后翻回正面也不能复活。
3. 到5000ms时，还需绝对倾角<10°、连续稳定至少700ms（平动 speed<0.6），且存在真实地面接触才成功。
4. 成功用墨≤gold获三星；其余≤ink×0.91获两星，再其余为一星。建议 gold<ink×0.91，否则两星区间会缩小甚至不存在；这不影响可解性。

Matter.js 0.20.0 是本地 vendored 依赖，木椅与焊接笔画为复合刚体；补足平行轴转动惯量。木头和墨水本版不会折断；整把椅子仍会滑落、转动和倾覆。这些固定规则是格式版本的合同，不能只改 JSON 来绕过。

## 5. 完整可用 JSON 示例

以下代码块与 `docs/examples/level.json` 相同，可直接保存为 UTF-8 JSON 并从游戏导入，不需要 manifest。它使用既有无腿双支撑机制，已有同引擎解和失败例，并非待验证的占位例子。

```json
{
  "format": "save-one-stroke-level",
  "version": 1,
  "id": 101,
  "chapter": "挑战",
  "title": "单关文件示例",
  "tag": "离线迭代 · 两端落地",
  "brief": "两条旧腿都不在。一笔能不能同时接住两边？",
  "condition": "从地面起笔也可以，中途接触木椅就会焊牢；左右都要有落脚点。",
  "quip": "带上 JSON，也请带上我的椅腿。",
  "success": "文件可以搬家，阿稳不用搬去地板。",
  "failure": "只救一边，另一边还是会倒。",
  "ink": 345,
  "gold": 310,
  "chair": [{"x":234,"y":251,"w":184,"h":18,"kind":"seat"}],
  "terrain": [{"x":20,"y":400,"w":680,"h":90}],
  "load": {"x":344,"mass":16},
  "hint": ["一笔可以从地面起笔，经过椅面，再落到另一边。", "尖头朝上的帐篷形状可以同时造两只脚，比大方框省墨。"],
  "labels": [{"x":475,"y":292,"text":"两边都要接住","to":[412,351]}]
}
```

配套 `docs/examples/level.cases.json` 有两笔：

- 成功：`[[265,395.5],[344,265],[424,395.5]]`，两个地脚经座面连接。
- 失败：`[[344,265],[424,395.5]]`，合法且接上椅子，但只接住右侧，会翻。

答案样例**单独保存**，不放到游戏关卡 JSON；物理引擎不读预设答案来判题。

## 6. 离线迭代与验证

Node.js 18+；离线格式/物理验证无需 npm install、网络或浏览器。

```sh
# 只验格式，输出明确的 verifiedSolvable:false
npm run level:validate -- docs/examples/level.json --json

# 同一物理引擎验证合法解 + 合法失败例
npm run level:validate -- docs/examples/level.json --cases docs/examples/level.cases.json --json
```

退出码：0表示本次请求的检查通过；1表示文件/参数/格式错误、样例非法或预期成败不符。若只验格式，即使退出0也**不是可解证明**。`--json` 输出含 `formatValid`、`verifiedSolvable`、提示和各样例结果；npm 自己可能在 JSON 前打印脚本横幅，纯机器管道可用 `node scripts/validate-level.cjs ... --json`。

cases 是单独版本化 JSON：`format:"save-one-stroke-cases"`、`version:1`、`cases:[{name,expected,stroke}]`。expected 只能是 `success`/`failure`，stroke 是连续 `[x,y]` 点数组。必须2–20个样例、至少一个成功和一个失败；每笔2–2000个有限数字点，文件最多256KiB。**失败样例也必须是合法画法**；空笔画、超墨、穿猫这类输入错误不算“验证过关卡会失败”。

推荐迭代流程：

1. 复制一个机制接近的现有关卡，先画出两种草案，不先追求极限墨水。
2. 修改纯 JSON；先跑格式校验修正路径明确的错误。Schema 检查结构，运行时补充 gold/矩形边界/区间顺序等跨字段约束，两者都要满足。
3. 为当前文件写一条候选解和一条朴素错误画法的 cases，运行同引擎校验。候选解失败就继续调整，不写“已验证”。
4. 在游戏点击「导入 / 本地关卡」→「选择 JSON 并保存」，试画、试坐，看碰撞与失败原因。必要时换一条自由画法检查是否过于脆弱。
5. 用「保存关卡」导出。修改文件后重新导入：相同内容复用；修改后的版本会作为另一份本地关保存，旧版不被悄悄覆盖。
6. 若要纳入内置列表，给它正确的连续 id/文件名，再加入 manifest；只有导入试玩不需要改清单。

运行验证后再改几何、载荷、墨水或外力，旧结果就不能证明新文件。CLI 不是自动求解器，不穷举、不判断趣味性，也不保证所有浏览器/帧调度下绝对无数值偏差；最终仍需浏览器复核。

## 7. 浏览器保存语义与坏文件

- 导入文件先检查64KiB上限，再 JSON.parse，再共用运行时校验，**最后才原子写入本地库并切换关卡**。语法错误、未知字段、不支持版本、越界数字会在对话框友好报错，当前笔画/关卡和已有存档不变。
- 最多12份本地关；原始 JSON 保留全部合法字段，导出不混入本地元数据、样例或成绩。
- 本地库键：`save-one-stroke:imported:v1`。内置成绩仍用 `save-one-stroke:v1`，二者隔离。
- `#local=...` 只是这台浏览器的存档引用，不是可分享的完整关卡。跨设备请发导出的 JSON。导入的同 id 文件不污染内置 id 的成绩。
- 关卡和成绩刷新后仍保留。同一浏览器里不同端口/域名是不同 localStorage；例如4173与5178的本地库相互独立。
- 存储被拒绝、配额满或已有库损坏时，不假装保存成功；保留旧数据并报错。移除存档需再次点击确认；导出的磁盘文件不被删除。

## 8. 可直接给 LLM 的生成提示

把**本文和本地 Schema 的完整内容**一并提供，再复制下列提示。它既适用于离线模型，也适用于用户自行调用的在线模型；游戏本身不发送任何数据给模型。

```text
你为《救一笔！》生成一个中高难度单关。遵守我提供的 LEVEL_FORMAT.md v1 和 level-v1.schema.json；它们是完整能力边界。

目标：提供一个可解释的几何/重心/定时外力谜题，不只是旧关卡平移或减少墨水。保留玩家能观察和推理的条件，提示分两步，不直接画答案。

只能用：矩形木椅与固定 terrain、偏心人猫载荷、一个或多个矩形猫区、墨水上限、forces 定时恒定推力。不要创造回调、脚本、HTML、URL、运动地板、铰链、弹簧、goal/duration、编辑器字段或不存在的机制。

坐标720×480，y向下；矩形用左上角xywh。椅面必须是chair[0]，kind=seat。一笔物理半径4.5，地脚中心通常放在地面顶面y-4.5；线必须接上木椅，空气不承重。墨水按轨迹长度计费；碰猫区/穿实体非法。固定试坐5秒，成败阈值以文档为准。

输出两个独立文件的内容：
1. level.json：完整单关 JSON，format=save-one-stroke-level，version=1，id=101，chapter=挑战，包含所有必填文字。不要放答案、验证标记或任何未支持字段，文件≤64KiB。
2. level.cases.json：format=save-one-stroke-cases，version=1，cases中至少一条expected=success的候选解及一条expected=failure的朴素错误画法，每条含name和连续stroke坐标。失败例也必须合法，不能用超墨/空线/穿猫冒充。

你可以推算长度和落脚范围，但若没有实际运行同一物理引擎，必须称这些笔画为“候选样例，待验证”，不得声称已验证可解。先不输出更大的平台、编辑器、后台或在线调用代码。

我会保存两个文件并执行：
node scripts/validate-level.cjs level.json --cases level.cases.json --json
若失败，我会把机器错误或物理结果返回给你。收到失败反馈后只修正相关数据，不删除失败测试，不增加虚构引擎能力；重新输出完整文件并继续标明未验证，直到得到真实通过结果。
```

生成流程的最后一步不是模型回答“完成”，而是：**格式通过 → 合法解和失败例通过同引擎 → 浏览器导入试玩 → 导出可复用 JSON**。
