1.1 世界、角色、事件、选择

叙事可以被拆成四个最小构成:世界(派系、地点、设定、规则)、角色(目标、恐惧、秘密、弧光)、事件(幕、节拍、场景、对话节点)、选择(选项与跳转)。本节用《雾港夜航》的 story_spec.json 把四者逐项清点:4 个派系与 14 个地点、6 个角色、16 个节拍与 48 个对话节点、115 个选项,并跑通一份 S 层清点脚本。

站内的独立游戏的叙事设计与分支对话 (1076 行)是一篇散文式的叙事经验谈:讲分支对话怎么写、讲玩家的选择心理、讲一份靠人工维护的分支清单,代码是 Twine 片段、跑不起来。本节不重复那些经验,而是把叙事形式化成规格字段——让「这个世界有几个派系」「这个角色的秘密是什么」「这个选项在什么条件下才出现」都变成能从文件里读出来、能逐项对账、能穷举判定的事实。分工就在这里:那篇回答「怎么写得好」,本卷回答「怎么写成一个能被程序读的结构」。

这是全书的开篇,先把贯穿三卷的案例讲清楚。《雾港夜航》(工程名 inkwell)是一个互动叙事的最小完整案例:雾港被浓雾与潮汐围困,灯塔守望者「砚」在三夜前失踪;玩家扮演抵达雾港的信使,在三夜之间于 6 个角色、4 个派系之间做出选择,收集线索、推进关系,最终分岔到 5 个结局。它的全部结构写在只读真源 config/story_spec.json 里,S / P / W 三层读的是同一个文件。

1.1 世界、角色、事件、选择

任何一段叙事,只要它要被程序执行,就必然能被拆成四个最小构成。这不是分类学,而是可执行性的下限:四个构成里少一个,故事就推不动。没有世界的选择无从渲染,没有角色的世界无从承载,没有事件的角色无从变化,没有选择的故事则根本不需要被写成规格——它只是一段文本。

构成一句话在 config/story_spec.json 里长在哪
世界故事建立在哪些名词之上world.factions / world.locations / world.lore / world.rules
角色谁会因为事件而改变characters[](目标 / 恐惧 / 秘密 / 好感度 / 弧光)
事件什么在什么时间发生meta.acts + beats[] + scene_graph + dialogue[]
选择玩家在哪里改变方向dialogue[].choices[](when + effects + goto)

这四个构成在规格里不是四块平级的 JSON,而是互相引用的一张网:dialogue[].scene 指向 scene_graph.nodes[].id,scene_graph.nodes[].location 指向 world.locations[].id,dialogue[].speaker 指向 characters[].id,choices[].goto 指向另一个 dialogue[].id。本节只把四者各自有多大数清楚;这些引用是否闭合,留给第 7 章(场景图可达性)与第 13 章(结构校验)。

世界:能被引用的名词

世界是叙事里唯一不随时间变化的部分。它的作用不是「氛围」,而是提供一组稳定的标识符,让别处可以引用它们。

项数量说明
派系4船帮 guild / 教会 church / 商会 merchant / 守望者工会 watch
派系立场矩阵16 格每个派系对 4 个派系各持一个 −1 / 0 / +1 的立场
地点14按 act 分属三夜:第一夜 5 个、第二夜 5 个、第三夜 4 个
带解锁条件的地点7其余 7 个无条件可进
设定 lore / 规则 rules6 / 4lore 每条绑定一个 reveal_flag;rules 是潮禁、鸣钟、船帮、教会四条世界律

14 个地点里有 7 个带 unlock_when——这意味着一半的地点不是「走到就能进」,而是要满足某个 flag 或持有某件线索。地点因此不只是布景,它是条件系统的一个作用域:world.locations[].unlock_when 与 scene_graph.nodes[].enter_when 用的是同一套条件文法。

角色:目标、恐惧、秘密、弧光

角色是叙事里唯一会因为事件而改变的部分。规格给每个角色四样东西:他想干什么(goals)、他怕什么(fears)、他瞒着什么(secret)、他会怎么变(arc)。

项数量在规格里
角色6characters[]:砚 / 徽 / 沈 / 沐 / 林 / 孩
目标12每角色 2 条 goals
恐惧12每角色 2 条 fears
秘密6每角色 1 条 secret
弧光阶段18每角色 3 段 arc.stages
弧光锚点节拍18每角色 3 个 arc.beats,指向 beats[]
好感度阈值18每角色 3 档 thresholds(30 / 60 / 85)

这里有一处需要说清楚:6 个角色都有 affinity 声明,但只有 4 个进入 state.vars(yan / hui / shen / mu)。林与孩的好感度声明存在,却没有任何一个选项 add 过它们——声明域比状态域大。这不是错误,但它是第 13 章一致性校验会读到的一类残差,本节先把事实摆出来。

事件:幕、节拍、场景、对话节点

事件是「在什么时间发生了什么」。规格用四层粒度写同一根时间轴,粒度从粗到细各有各的用处。

层数量粒度与用处
幕 acts3meta.act_names:第一夜 抵港 / 第二夜 潮退 / 第三夜 灯灭
节拍 beats[]16作者标注的结构单位,每条带一个 1–5 的 tension,之和为 52
场景节点 scene_graph.nodes[]14可达性的单位:能不能走到这个场景
场景边 scene_graph.edges[]1611 条无条件 + 5 条带 when
对话节点 dialogue[]48执行的单位:引擎停在这里、渲染台词、给出选项

四层的关系是嵌套而非并列:3 幕切分 16 节拍,节拍落到 14 个场景,场景里展开 48 个对话节点。beats[] 的 tension 是作者标注的结构量,不是从别处算出来的——卷三算张力曲线时读的就是这一列整数。

选择:选项与跳转

选择是玩家改变方向的地方。规格把「选什么」与「选了会怎样」分开写:text 是给人看的标签,when 是能否出现的判定,effects 是状态推进,goto 是跳转目标。

项数量说明
选项总数11548 个节点上所有 choices[] 之和
带条件的选项34when 非 null,不满足时不出现在界面上
有选项的节点43玩家在这些节点上必须做决定
无选项的节点5全部是结局节点
单节点最宽5d_dq_2(黎明码头「看什么」)
跳转目标55 个结局节点,各被一个或多个选项指向

「无选项的节点恰好是 5 个结局节点」是一条结构恒等式,而不是巧合:在规格里,「没有选择」正是结局的定义方式。这条恒等式会在第 8 章变成「死路检测」的判据——一个既无选项又非结局的节点,就是卡死。

S 层:四构成的规模清点

sim/na_01_world_scale.py 只读真源,不做推断:所有计数都是字段长度或直接读取的标量。它同时承担一次骨架存在性校验——任何一段缺失即 KeyError 退出,脚本不可能在骨架有洞时打印出「OK」。

#!/usr/bin/env python3
# file: sim/na_01_world_scale.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/na_01_world_scale.py
"""《雾港夜航》四个最小构成的规模清点:世界 / 角色 / 事件 / 选择。

只读 config/story_spec.json,不做任何推断:四类计数全部直接来自字段长度。
任何一段缺失即 KeyError 退出(exit != 0),因此本脚本同时是一次「骨架存在性」校验。
"""
from __future__ import annotations

import json
import os
import sys

AFF_LO, AFF_HI = 0, 100
TENSION_LO, TENSION_HI = 1, 5


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 count_world(world: dict) -> dict:
    """世界:派系与它们的相互立场、地点、设定条目、世界规则。"""
    factions = world["factions"]
    return {
        "factions": len(factions),
        "stance_cells": sum(len(f["stance"]) for f in factions),
        "locations": len(world["locations"]),
        "locations_locked": sum(1 for loc in world["locations"] if loc.get("unlock_when")),
        "lore": len(world["lore"]),
        "rules": len(world["rules"]),
    }


def count_characters(chars: list[dict]) -> dict:
    """角色:目标 / 恐惧 / 秘密是三个平级列表,弧光另有两个列表。"""
    return {
        "characters": len(chars),
        "goals": sum(len(c["goals"]) for c in chars),
        "fears": sum(len(c["fears"]) for c in chars),
        "secrets": sum(1 for c in chars if c.get("secret")),
        "arc_stages": sum(len(c["arc"]["stages"]) for c in chars),
        "arc_beats": sum(len(c["arc"]["beats"]) for c in chars),
        "thresholds": sum(len(c["affinity"]["thresholds"]) for c in chars),
    }


def count_events(spec: dict) -> dict:
    """事件:幕 / 节拍 / 场景图 / 对话节点。四者是同一条时间轴的不同粒度。"""
    graph = spec["scene_graph"]
    edges = graph["edges"]
    nodes = spec["dialogue"]
    return {
        "acts": spec["meta"]["acts"],
        "beats": len(spec["beats"]),
        "scenes": len(graph["nodes"]),
        "edges": len(edges),
        "edges_unconditional": sum(1 for e in edges if not e.get("when")),
        "edges_conditional": sum(1 for e in edges if e.get("when")),
        "dialogue_nodes": len(nodes),
        "ending_nodes": sum(1 for n in nodes if n.get("ending")),
    }


def count_choices(nodes: list[dict]) -> dict:
    """选择:选项总数、条件选项、以及「有选项的节点 / 无选项的节点」。"""
    total = 0
    conditional = 0
    for n in nodes:
        for c in (n.get("choices") or []):
            total += 1
            if c.get("when"):
                conditional += 1
    with_choices = sum(1 for n in nodes if (n.get("choices") or []))
    widest = max(nodes, key=lambda n: len(n.get("choices") or []))
    return {
        "choices": total,
        "choices_conditional": conditional,
        "nodes_with_choices": with_choices,
        "nodes_without_choices": len(nodes) - with_choices,
        "widest_node": f"{widest['id']}({len(widest.get('choices') or [])})",
    }


def declared_but_unwritten(spec: dict, nodes: list[dict]) -> list[str]:
    """声明了好感度、但没有任何选项 `add` 过的角色——「声明域 > 状态域」。"""
    written: set[str] = set()
    for n in nodes:
        for c in (n.get("choices") or []):
            written |= set(((c.get("effects") or {}).get("add") or {}))
    return sorted({c["id"] for c in spec["characters"]} - written)


def invariants(spec: dict, nodes: list[dict]) -> list[str]:
    """整数不变量:残差恒为 0。返回违规清单(空即通过)。"""
    bad: list[str] = []
    ids = {n["id"] for n in nodes}
    for n in nodes:
        for c in (n.get("choices") or []):
            if c["goto"] not in ids:
                bad.append(f"{c['id']} 的 goto 指向不存在的节点 {c['goto']}")
    for b in spec["beats"]:
        if not (TENSION_LO <= b["tension"] <= TENSION_HI):
            bad.append(f"beat {b['id']} 的 tension 越界 {b['tension']}")
    for c in spec["characters"]:
        a = c["affinity"]
        if not (AFF_LO <= a["init"] <= AFF_HI):
            bad.append(f"角色 {c['id']} 的 init 越界 {a['init']}")
    for f in spec["world"]["factions"]:
        for peer, v in f["stance"].items():
            if v not in (-1, 0, 1):
                bad.append(f"派系 {f['id']} 对 {peer} 的立场越界 {v}")
    return bad


def main() -> int:
    spec = load_spec()
    nodes = spec["dialogue"]

    world = count_world(spec["world"])
    chars = count_characters(spec["characters"])
    events = count_events(spec)
    choices = count_choices(nodes)

    print("构成   计数项")
    for k, v in world.items():
        print(f"世界   {k:<24} {v}")
    for k, v in chars.items():
        print(f"角色   {k:<24} {v}")
    for k, v in events.items():
        print(f"事件   {k:<24} {v}")
    for k, v in choices.items():
        print(f"选择   {k:<24} {v}")

    # 与 meta.scale 对账:这是四个构成与「声明规模」之间的唯一接口。
    scale = spec["meta"]["scale"]
    actual = {
        "scenes": events["scenes"],
        "characters": chars["characters"],
        "dialogue_nodes": events["dialogue_nodes"],
        "choices": choices["choices"],
        "flags": len(spec["state"]["flags"]),
        "affinity_vars": len(spec["state"]["vars"]),
        "items": len(spec["state"]["items"]),
        "factions": world["factions"],
        "endings": len(spec["endings"]),
    }
    print(f"meta.scale 对账:{'一致' if actual == scale else '不一致'} ({actual})")

    bad = invariants(spec, nodes)
    for b in bad:
        print(f"FAIL {b}")
    if bad:
        return 1

    unwritten = declared_but_unwritten(spec, nodes)
    print(f"声明了好感度但无选项写入的角色 {len(unwritten)} 个:{unwritten}")
    print("OK na_01_world_scale:四个构成清点完成,整数不变量残差 0")
    return 0


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

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

构成   计数项
世界   factions                 4
世界   stance_cells             16
世界   locations                14
世界   locations_locked         7
世界   lore                     6
世界   rules                    4
角色   characters               6
角色   goals                    12
角色   fears                    12
角色   secrets                  6
角色   arc_stages               18
角色   arc_beats                18
角色   thresholds               18
事件   acts                     3
事件   beats                    16
事件   scenes                   14
事件   edges                    16
事件   edges_unconditional      11
事件   edges_conditional        5
事件   dialogue_nodes           48
事件   ending_nodes             5
选择   choices                  115
选择   choices_conditional      34
选择   nodes_with_choices       43
选择   nodes_without_choices    5
选择   widest_node              d_dq_2(5)
meta.scale 对账:一致 ({'scenes': 14, 'characters': 6, 'dialogue_nodes': 48, 'choices': 115, 'flags': 18, 'affinity_vars': 4, 'items': 9, 'factions': 4, 'endings': 5})
声明了好感度但无选项写入的角色 2 个:['hai', 'lin']
OK na_01_world_scale:四个构成清点完成,整数不变量残差 0

判读:四张表各自暴露了什么

世界的 14 个地点里有 7 个带解锁条件,比例正好一半。这提示了一件事:地点在规格里不是「地图上的坐标」,而是条件系统的作用域——一个地点的 unlock_when 与一条边的 when 可以指向同一个 flag。第 3 章会把 world 整段拆开讲。

角色的表里有一处不对称:6 个角色都有好感度声明,但状态域只登记了 4 个变量。林与孩的好感度声明存在、无人写入。规格允许这种「声明域大于状态域」的存在,但它必须被显式记录,否则读者会以为它们真的在变。这是第 13 章一致性校验的第一类输入。

事件的四层粒度里,只有 beats[].tension 是人工标注量,其余三层都是结构量(长度、边数、节点数)。这一区分很重要:卷三算张力曲线时读的是标注量,算覆盖率时读的是结构量,两者的可信度口径不同。

选择的两行值得并排看:115 个选项里 34 个带条件(约 30%),而 5 个无选项的节点全部是结局。前者说明分支并不全靠条件筛选(七成选项无条件可见),后者说明「无选项」在规格里不是遗漏而是语义标记。第 8 章会从这里展开「假选择与真后果」。

D 档:四构成的字段归属表

构成spec 字段是否随时间变化主要消费者
世界world.factions[].stance否第 3 章:派系立场矩阵
世界world.locations[].unlock_when否(条件在读时求值)第 7 章:场景可达性
世界world.lore[].reveal_flag否第 3 章:设定的暴露节奏
角色characters[].goals / fears / secret否第 4 章:角色字段与关系
角色characters[].affinity是(add 改写)第 10 章:好感度与阈值门
角色characters[].arc否(阶段由节拍锚定)第 5 章:弧光的可达性
事件meta.acts / beats[].tension否第 6 章:节拍表与比例检验
事件scene_graph否第 7 章:节点、边、入口条件
事件dialogue[]否第 11 章:对话树的数据结构
选择choices[].when否(求值时读状态)第 9 章:条件系统
选择choices[].effects是(写状态)第 10 章:好感度状态机
选择choices[].goto否第 11 章:跳转与遍历校验

自检表

检查项合格标准
四构成齐全世界 / 角色 / 事件 / 选择各有一张可核对的表
计数从规格长出每个数字都是字段长度或直接读取的标量,不手写
与声明规模对账meta.scale 的 9 项与实测逐项一致
整数不变量好感度 ∈ [0,100]、tension ∈ 1–5、派系立场 ∈ {−1,0,1}、goto 目标存在,残差 0
说法诚实声明域大于状态域的角色(林 / 孩)显式标出,不假装它们在变

四个构成数清楚之后,下一个问题是:这四张表在文件里是怎么排布的?为什么是一份 JSON 而不是四份?下一节把 story_spec.json 的顶层骨架拆开,看它如何用十段字段承载这四个构成,以及一份散文大纲要经过哪些映射才能落到这些字段上。

阅读导航:上一节:目录 · 下一节:1.2 从故事到结构:叙事规格的骨架 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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