# 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 "map"}) {{asset "bgm"}} → 输出 URL {{mime "bgm"}} → 输出 "audio/mpeg" ``` ### 图片 ```markdown ![]({asset "map"}) ``` ### 样式 ```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}} ```