1.2 从故事到结构:叙事规格的骨架

一份散文大纲要变成可执行的规格,中间隔着一层「骨架」:meta 头部加九段内容字段。本节给出散文语句到字段的逐句映射表,列出 world、characters、beats、scene_graph、dialogue、conditions、state、endings、metrics 九段各自承载什么,并用一份 S 层脚本按实际键序逐段打印规模摘要。

站内的独立游戏的叙事设计与分支对话 (1076 行)用散文写叙事:先讲一段故事梗概,再讲这段梗概怎么拆成分支,最后给一份人工维护的清单。本节接过它的最后一棒,回答它没有回答的问题:那段散文梗概,究竟要变成哪些字段,才能被程序读? 那篇是散文式叙事经验,本节是形式化的规格字段——同一段故事,前者的产物是给人读的段落,后者的产物是可以逐项对账、可以穷举判定的结构。

上一节把叙事拆成四个最小构成,这一节看它们在文件里怎么排布。答案是一份 config/story_spec.json:一个 meta 头部,加九段内容字段。为什么是一份而不是四份?因为四个构成之间互相引用——选项引用对话节点,对话节点引用场景,场景引用地点,效果引用角色。分成四份文件,引用就变成了跨文件跳转,一致性再也无法在单次解析里校验。

1.2 从故事到结构:叙事规格的骨架

先看散文。下面是《雾港夜航》的一段大纲,写法与任何一份叙事设计文档没有区别:

雾港被浓雾与潮汐围困,三夜一循环:潮涨雾锁,潮退雾薄。灯塔守望者砚在三夜前登塔,此后没人见过他,灯也再没亮过。船帮把头徽三天前拒了他要的一条船。教会执事沈私下修好了沉钟的机括,却不敢鸣。玩家扮演抵达雾港的信使,在三夜之间做选择:点灯、鸣钟、还是什么也不做。雾散之后,会分出五种结局。

这段话不可执行:它没有说「点灯」需要什么前置,也没有说「雾散之后」按什么判定分结局。把它形式化,就是给每句话找到它该去的字段。

散文到字段:逐句映射

散文里的那句话落到哪个字段变成了什么
「三夜一循环:潮涨雾锁,潮退雾薄」world.lore[lore_tide] + reveal_flag一条可被揭示的设定,揭示动作由 flag 记录
「砚在三夜前登塔,此后没人见过他」characters[yan].secret + beats[b01]角色的秘密字段 + 一个节拍
「徽三天前拒了他要的一条船」characters[hui].secret + dialogue[d_tavern_2]秘密落在角色上,事实落在某句台词里
「沈修好了机括,却不敢鸣」characters[shen].secret + endings[e_truth].when秘密 + 一条结局的判定条件
「玩家在三夜之间做选择」dialogue[].choices[]每个选项带 when / effects / goto
「点灯、鸣钟、还是什么也不做」dialogue[d_dq_1].choices[]三个并列选项,各自的 effects 不同
「雾散之后,会分出五种结局」endings[] + 5 个结局节点5 条判定条件 + 5 个无选项节点

这张表就是形式化的全部内容:散文里的每一句断言,要么变成一个标识符(yan / d_dq_1),要么变成一个结构量(beats[] 的一条、choices[] 的一项),要么变成一个条件(when)。凡是落不到这三者之一的句子,都是规格之外的散文——留着给人读,程序不看。

落不到规格的句子类型为什么该去哪
「这一段读起来很压抑」是对文本的评价,不是结构本套不做(见 1.3 的文本创作划界)
「玩家到这儿会觉得愧疚」是对体验的断言,需要真人验证卷三第 12 章,且标 X
「砚的嗓音应该是沙哑的」是演出参数,不是叙事结构美术与音频管线
「这三夜应该再紧一点」是相对判断,没有绝对基准卷三的节奏与密度度量

骨架:一个头部加九段

段类别承载什么散文里对应的说法
meta头部标题、工程名、起点节点、幕数与幕名、scale 规模声明「这是一个三夜的故事」
world内容段派系与立场矩阵、地点与解锁条件、设定条目、世界规则「雾港是个什么样的地方」
characters内容段目标、恐惧、秘密、好感度、弧光「谁在这里,他想要什么」
beats内容段节拍与 tension 标注「故事分几段,哪里最紧」
scene_graph内容段场景节点、边、入口条件「他先到码头,才能去酒馆」
dialogue内容段对话节点、台词、选项、跳转「他说了什么,你选了什么」
conditions内容段条件文法声明:算子与六种形式散文里没有对应
state内容段18 个 flag、4 个好感度变量、9 件线索物品「他记住了什么,跟谁熟了,拿了什么」
endings内容段结局标识与判定条件「有五种结局」
metrics内容段目标张力曲线与各项阈值「这一段的紧张感应该是 4」

两点值得单独指出。

其一,conditions 段在散文里找不到对应。 它是纯声明(grammar + 4 个算子 + 6 种形式),自己不承载任何内容,却规定了其它段里所有 when 字段的合法形状。这是形式化多出来的东西——散文不需要声明自己的文法,因为它本来就没有文法。

其二,meta 不是内容段,它是头部。 它唯一的实质内容是 scale:9 个声明出来的规模数字。它是整份规格里唯一被自动对账的声明(上一节的脚本正是拿它逐项比对实测值)。头部里放声明、内容段里放事实,这条分界让「规格说自己有多大」与「规格实际有多大」可以被程序分开。

S 层:按实际键序逐段扫描

sim/na_01_skeleton.py 做三件事:按 JSON 里的实际键序遍历顶层(骨架不靠人工记忆)、每段打印一行由字段长度直接得出的摘要、校验九段内容字段齐备且非空——缺段即 FAIL 并 exit 1。

#!/usr/bin/env python3
# file: sim/na_01_skeleton.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/na_01_skeleton.py
"""叙事规格的骨架扫描:按顶层字段顺序逐段打印规模摘要。

骨架 = `meta` 头部 + 九段内容字段。本脚本只做三件事:
  1) 按 JSON 里的**实际键序**遍历顶层,证明骨架不靠人工记忆;
  2) 每段给一组**由字段长度直接得出**的计数;
  3) 校验九段内容字段全部存在且非空——缺段即 FAIL(exit 1)。

不解析条件语义、不做可达性——那是 na_00 / 后续章节的事。
"""
from __future__ import annotations

import json
import os
import sys

# 九段内容字段。meta 不在其中:它是头部(标题 / 幕数 / 起点 / 规模声明)。
CONTENT_SEGMENTS = [
    "world", "characters", "beats", "scene_graph", "dialogue",
    "conditions", "state", "endings", "metrics",
]


def load_spec() -> dict:
    here = os.path.dirname(os.path.abspath(__file__))
    with open(os.path.join(here, "../config/story_spec.json"), encoding="utf-8") as fh:
        return json.load(fh)


def summarize(spec: dict) -> dict[str, str]:
    """每段一行摘要。全部是长度或直接读取的标量,不含任何推导量。"""
    meta = spec["meta"]
    world = spec["world"]
    chars = spec["characters"]
    beats = spec["beats"]
    graph = spec["scene_graph"]
    dlg = spec["dialogue"]
    conds = spec["conditions"]
    state = spec["state"]
    endings = spec["endings"]
    met = spec["metrics"]
    tension = [b["tension"] for b in beats]
    return {
        "meta": (
            f"HEADER title={meta['title']} acts={meta['acts']} start={meta['start']} "
            f"scale={len(meta['scale'])} 项"
        ),
        "world": (
            f"factions={len(world['factions'])} locations={len(world['locations'])} "
            f"lore={len(world['lore'])} rules={len(world['rules'])}"
        ),
        "characters": (
            f"count={len(chars)} goals={sum(len(c['goals']) for c in chars)} "
            f"fears={sum(len(c['fears']) for c in chars)} arcs={sum(len(c['arc']['stages']) for c in chars)}"
        ),
        "beats": (
            f"count={len(beats)} tension_sum={sum(tension)} "
            f"tension_min={min(tension)} tension_max={max(tension)}"
        ),
        "scene_graph": (
            f"nodes={len(graph['nodes'])} edges={len(graph['edges'])} "
            f"with_cond={sum(1 for e in graph['edges'] if e.get('when'))}"
        ),
        "dialogue": (
            f"nodes={len(dlg)} choices={sum(len(n.get('choices') or []) for n in dlg)} "
            f"ending_nodes={sum(1 for n in dlg if n.get('ending'))} "
            f"cond_lines={sum(1 for n in dlg if n.get('line_when'))}"
        ),
        "conditions": (
            f"grammar={conds['grammar']} ops={len(conds['ops'])} forms={len(conds['forms'])}"
        ),
        "state": (
            f"flags={len(state['flags'])} vars={len(state['vars'])} items={len(state['items'])}"
        ),
        "endings": (
            f"count={len(endings)} guarded={sum(1 for e in endings if e.get('when'))} "
            f"unguarded={sum(1 for e in endings if not e.get('when'))}"
        ),
        "metrics": (
            f"curve_len={len(met['target_tension_curve'])} "
            f"coverage_target={met['coverage_target_permille']} "
            f"entropy_min={met['ending_entropy_min_milli_bits']} "
            f"aff_mae_max={met['affinity_mae_max']} tension_mae_max={met['tension_mae_max']}"
        ),
    }


def main() -> int:
    spec = load_spec()
    rows = summarize(spec)

    print("段            类别    摘要")
    for i, key in enumerate(spec.keys()):
        kind = "HEADER" if key == "meta" else "SEG"
        print(f"{key:<13} {kind:<7} {rows[key]}")

    problems: list[str] = []
    # 键序断言:JSON 的顶层键序必须与文件里声明的骨架一致(meta 打头)。
    if list(spec.keys())[0] != "meta":
        problems.append("meta 不在顶层第一位")
    for seg in CONTENT_SEGMENTS:
        if seg not in spec:
            problems.append(f"缺内容段 {seg}")
        elif not spec[seg]:
            problems.append(f"内容段 {seg} 为空")
    extra = [k for k in spec if k != "meta" and k not in CONTENT_SEGMENTS]
    if extra:
        problems.append(f"骨架外的顶层键:{extra}")

    print(f"骨架:meta 头部 1 段 + 内容段 {len(CONTENT_SEGMENTS)} 段,顶层键共 {len(list(spec.keys()))}")
    for p in problems:
        print(f"FAIL {p}")
    if problems:
        return 1
    print("OK na_01_skeleton:九段内容字段齐备且非空")
    return 0


if __name__ == "__main__":
    sys.exit(main())

本机实跑 /opt/homebrew/bin/python3 sim/na_01_skeleton.py:

段            类别    摘要
meta          HEADER  HEADER title=雾港夜航 acts=3 start=d_pier_1 scale=9 项
world         SEG     factions=4 locations=14 lore=6 rules=4
characters    SEG     count=6 goals=12 fears=12 arcs=18
beats         SEG     count=16 tension_sum=52 tension_min=1 tension_max=5
scene_graph   SEG     nodes=14 edges=16 with_cond=5
dialogue      SEG     nodes=48 choices=115 ending_nodes=5 cond_lines=2
conditions    SEG     grammar=nested-array ops=4 forms=6
state         SEG     flags=18 vars=4 items=9
endings       SEG     count=5 guarded=4 unguarded=1
metrics       SEG     curve_len=16 coverage_target=850 entropy_min=1500 aff_mae_max=12 tension_mae_max=1
骨架:meta 头部 1 段 + 内容段 9 段,顶层键共 10
OK na_01_skeleton:九段内容字段齐备且非空

判读:骨架告诉了我们什么

十段里只有一段是声明,九段是事实。 meta.scale 的 9 个数字是对账用的;world 到 metrics 的九段是事实本身。这条分界带来一个直接的好处:任何一次「规格改小了但声明没改」都会被一行比对抓住。

骨架里没有一段是散文。 九段内容字段的每一段,要么是标识符表(world.locations、state.flags),要么是结构表(beats[]、scene_graph.nodes[]),要么是条件文法(conditions)。文本只出现在一个地方——dialogue[].line 与 choices[].text——而它们的地位是渲染产物,这正是下一节的主题。

conditions 段的自指性质值得留意。 它只有 6 种形式(flag / aff / item / and / or / not)与 4 个算子(>= / < / == / !=),却规定了五处字段的合法形状。改一处文法,五处同时受影响——这是第 9 章要展开的对象。

消费条件文法的字段槽位个数判定的是
world.locations[].unlock_when7这个地点能不能进
scene_graph.edges[].when5这条场景边能不能走
dialogue[].line_when2这句台词渲染哪一条
choices[].when34这个选项出不出现
endings[].when4这条结局成不成立

五处相加是 52 个条件槽位,它们共用同一份文法与同一个求值器。这也是为什么条件文法必须单独成段:它不是某一段的内部细节,而是跨五段的公共契约。

endings 段有 4 条带条件、1 条无条件。 无条件的 e_dark(离港)是兜底结局,它保证任何一条路径都能走到终点。这条「至少一个无条件结局」的约定会在第 8 章变成结局可达性的判据。

自检表

检查项合格标准
九段齐备且非空world … metrics 九段都在,且都不是空容器
头部唯一meta 在顶层第一位,且不被算进九段
无骨架外字段顶层键恰好 10 个,多一个即 FAIL
规模声明可对账meta.scale 的 9 项与实测逐项一致
映射可追散文里的每句断言都能指到具体字段(或显式标为「规格之外」)

骨架立起来之后,还剩一个位置没交代清楚:dialogue[].line 里的那些中文句子,在规格里到底算什么?下一节给出本套的核心主张——文本是规格的一种渲染产物,并演示同一条规格记录怎么渲染出两种台词。

阅读导航:上一节:1.1 世界、角色、事件、选择 · 下一节:1.3 文本是渲染:规格与文本的分工 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「narrative」更多文章

  1. 15.3 规模告警
  2. 15.2 求值与遍历的开销
  3. 15.1 规格规模的增长