# MdStory 写作规范与 Hooks Best Practice
本文面向 MdStory 作者,说明一篇 `.md` 故事应如何组织,以及什么时候使用 `globals()`、`locals()`、`view()` 和生命周期 hooks。
## 基本原则
MdStory 文件首先是一篇 Markdown 文档,其次才是一段可执行脚本。优先把故事文本、选择和变量表达清楚,只在需要状态、分支或副作用时使用 `
```
## Hook 速查
| 层级 | Hook | 时机 | 推荐用途 |
| ------- | -------------------------------------- | --------------------------------- | ------------------------------------------ |
| Story | `globals()` | `play()` 开始时,`onStart()` 之前 | 返回动态初始全局变量 |
| Story | `onStart({ globals })` | 故事开始时 | 初始化外部状态、修正全局状态、记录启动事件 |
| Chapter | `locals({ globals })` | 每次进入章节时,`onEnter()` 之前 | 返回本次进入章节的局部变量 |
| Chapter | `onEnter({ globals, locals })` | 每次进入章节时 | 修改状态、记录进入章节 |
| Chapter | `onLeave({ globals, locals, target })` | 离开章节或故事结束时 | 结算章节状态、记录目标 |
| Scene | `onEnter({ globals, locals })` | 每次进入场景时,`view()` 之前 | 修改状态、发放物品、记录访问 |
| Scene | `view({ globals, locals })` | 场景渲染前 | 返回只用于本次渲染的临时值 |
| Scene | `onLeave({ globals, locals, target })` | 读者提交输入并离开场景后 | 根据当前状态或目标更新状态 |
所有 hook 都可以是同步函数或 `async` 函数。`globals()` 不接收参数。
## 状态分层
### globals
`globals` 是整个故事共享的持久状态。
适合放:
- 读者输入:姓名、职业、开关选项。
- 跨章节状态:金币、背包、线索、成就。
- 影响结局的长期选择。
不适合放:
- 只在一个章节里使用的展示变量。
- 只用于当前场景渲染的一次性计算值。
### locals
`locals` 属于当前章节。每次进入章节时会重置,然后执行 `locals({ globals })` 重新计算。
适合放:
- 章节内反复使用的派生值。
- 本次进入章节的计数、难度、临时开关。
- 从 `globals` 派生出的章节视角状态。
示例:
```markdown
## 地下城 {#dungeon}
```
### view
`view()` 返回场景本次渲染使用的临时值。它的返回值会覆盖同名 `globals` / `locals` 参与模板渲染,但不会写回状态。
适合放:
- 只在当前场景显示的派生值。
- 格式化后的文本。
- 根据当前状态计算出的按钮可见性、提示文案、数值展示。
示例:
```markdown
### 宝箱 {#chest}
{{#if alreadyOpened}}
宝箱是空的。
{{else}}
你找到了 {{coins}} 枚金币。
{{/if}}
```
## 推荐写法
### 初始值:静态放 frontmatter,动态放 globals()
静态初始值优先写 YAML frontmatter:
```yaml
---
globals:
name: 旅人
gold: 0
---
```
需要运行时计算时使用 `globals()`:
```html
```
`globals()` 应只负责返回初始值。需要修改最终全局状态或执行副作用时,用 `onStart({ globals })`。
### 副作用写在 onEnter / onLeave
如果进入场景会获得线索,应写在 `onEnter()`:
```html
```
如果离开场景后才结算状态,应写在 `onLeave()`:
```html
```
### view() 不要承担状态写入职责
`view()` 的主要职责是生成渲染模型。可以读取 `globals` 和 `locals`,但不建议在里面修改状态。
推荐:
```js
view({ globals }) {
return { hpText: `${globals.hp}/100` };
}
```
不推荐:
```js
view({ globals }) {
globals.visits = (globals.visits ?? 0) + 1;
return { visits: globals.visits };
}
```
上面的访问计数应放到 `onEnter()`。
### locals() 返回章节状态,不做章节副作用
`locals()` 适合返回章节局部变量。需要记录进入章节、修改全局状态或调用外部 API 时,用 `onEnter()`。
```js
export default {
locals({ globals }) {
return {
danger: globals.level > 3,
};
},
onEnter({ globals }) {
globals.visitedDungeon = true;
},
};
```
## 命名规范
推荐使用简短稳定的英文 id:
```markdown
## 森林 {#forest}
### 古井 {#well}
```
变量名可以使用中文或英文,但同一篇故事中应保持一致。面向代码维护时,英文变量更容易和 JavaScript 生态配合;面向作者写作时,中文变量可读性更好。
推荐:
```markdown
{{#if hasKey}}
你打开了门。
{{/if}}
```
或:
```markdown
{{#if 有钥匙}}
你打开了门。
{{/if}}
```
避免在同一篇故事中混用 `hasKey`、`有钥匙`、`key` 表示同一件事。
## 输入规范
`{{input}}` 用来在当前场景声明输入字段。它不会在出现的位置暂停故事,也不会逐个提交;读者离开当前场景时,当前场景里的所有输入会和选择的 `nav target` 一并提交。
输入默认写入当前章节的 `locals`。如果变量名前加 `$`,则写入 `globals`,并在写入时去掉 `$` 前缀。
```markdown
{{input "string" name="旅人"}} ← 写入 locals.name
{{input "number" age=18}} ← 写入 locals.age
{{input "boolean" brave=false}} ← 写入 locals.brave
{{input "string" $name="旅人"}} ← 写入 globals.name
```
推荐:
- 输入名使用稳定变量名。
- 默认值和类型匹配。
- 同一场景里避免多个输入写入同一个变量名。
- 需要跨章节或跨场景长期保留的输入使用 `$` 前缀。
- 只服务于当前章节流程的输入保持默认 locals。
一次场景提交的顺序是:
1. 读者填写当前场景里的所有 `input`。
2. 读者选择一个 `nav`,提交目标 `target`。
3. 运行时根据变量名前缀把输入写入 `locals` 或 `globals`。
4. 当前场景的 `onLeave({ globals, locals, target })` 执行。
5. 如果发生章节切换,当前章节的 `onLeave({ globals, locals, target })` 执行。
因此 `onLeave()` 可以直接从 `locals` 或 `globals` 读取最新输入:
```js
onLeave({ globals, locals }) {
if (locals.name) {
globals.nameConfirmed = true;
}
}
```
## 进阶功能
### 资源与多媒体
YAML metadata 中定义资源,模板中引用:
```yaml
assets:
map: "https://example.com/map.png"
bgm: { url: "https://example.com/audio.mp3", mime: "audio/mpeg" }
```
```markdown

{{asset "bgm"}} → 输出 URL
{{mime "bgm"}} → 输出 "audio/mpeg"
```
### 图片
```markdown

```
### 样式
```html
```
### 空行
```markdown
{{linebreak}} ← 一个空行
{{linebreak 3}} ← 三个空行
```
## 常见反模式
### 在 view() 里改 globals
这会让“渲染一次”变成“推进状态一次”,调试时很难判断状态为何变化。把状态变化移到 `onEnter()` 或 `onLeave()`。
### 把所有变量都放进 globals
`globals` 应保存跨故事流程需要的状态。章节内临时值用 `locals()`,场景渲染值用 `view()`。
### 依赖标题文字作为 id
如果不显式写 `{#id}`,标题文字会被用作 id。标题一改,导航就可能失效。长篇故事应显式写 id。
### 重复使用含义不同的变量名
例如 `state`、`flag`、`count` 在不同场景含义不同,后期很难维护。使用更具体的名字,如 `chestOpened`、`loopCount`、`clueCount`。
## 推荐模板
```markdown
---
title: 示例故事
globals:
name: 旅人
---
# 示例故事
## 第一章 {#chapter1}
### 开始 {#start}
你好,{{name}}。{{greeting}}
{{#nav null}}结束{{/nav}}
```