5.1 张力从哪里来:叙事节拍

张力在本卷里不是从机制里长出来的,而是作者在规格里标注的:真源的 beats 是 16 个节拍,每个带一个整数 1 到 5 的 tension。本节只读这份标注,不跑任何仿真,就把它按幕切开、算出分布直方图与峰值位置,并给出 beats 字段表与三条整数不变量,全部数字来自本机实跑。

5.1 张力从哪里来:叙事节拍

《游戏设计》卷二《策略涌现与体验度量》 里的张力来自机制动力学——HP 怎么掉、资源怎么耗、回合节奏怎么推,一条张力曲线是跑一局仿真跑出来的。本节讲的张力完全是另一回事:它来自叙事节拍结构——作者在 config/story_spec.json 的 beats 里给 16 个节拍各标注一个整数 tension(1–5),这条序列不用跑任何仿真就能读出来。两者输入不同(那边是数值与规则,这边是结构与标注)、对象不同(那边是一局战斗的状态轨迹,这边是玩家路径经过的节拍序列)、工具也不同(那边是仿真,这边是规格投影),所以本书单开一章讲叙事张力,而不是把机制张力那套搬过来。

张力不是跑出来的,是标注出来的

机制侧的张力曲线是涌现的:给定伤害公式、资源回复与回合节奏,仿真跑一遍,血线与资源线的形状就出来了,作者改不了那条线的形状,只能改规则。叙事侧的张力曲线是声明的:beats 段里每个节拍自带一个 tension,它是一个作者标注,不是算出来的量。

这份规格里 beats 是 16 个节拍,每个节拍四个可算字段加一个仅供人读的 summary:

#id幕场景tension
1b011pier1
2b021tavern2
3b031chapel2
4b041counting_house3
5b051watch_post2
6b062lighthouse_base3
7b072tide_cave4
8b082market_row3
9b092harbor_office4
10b102drowned_bell5
11b112lighthouse_base3
12b123lighthouse_top4
13b133breakwater5
14b143fog_gate5
15b153dawn_quay4
16b163dawn_quay2

一眼能看出这条序列的形状:它从 1 起步,前五拍都压在 1–3,第二幕中段抬到 4–5,第三幕密集出现 5,最后收在 2。这个「起—压—爆—收」的形状,就是本节要量出来的东西;它不依赖任何数值,只依赖作者在规格里敲下的 16 个整数。

节拍与场景:16 拍落在 14 个场景上

beats 与 scene_graph 不是两套东西,而是一张图的两种切法。本规格 14 个场景、16 个节拍,每个场景至少挂一个节拍,其中 lighthouse_base 与 dawn_quay 各挂两个:

场景节拍数说明
lighthouse_base2b06=3(基座门锁着)、b11=3(第二夜结束,塔上闪光)
dawn_quay2b15=4(黎明码头结账)、b16=2(雾港照旧醒着)
其余 12 个场景各 1见上表

为什么有些场景要挂两拍? 因为张力是随时间变的量,而场景只是一个空间。同一个 dawn_quay(黎明码头),在第三幕高潮处(b15,所有的账一起结)张力是 4,在收尾处(b16,雾港照旧醒着)张力落回 2——同一地点,两拍,两种强度。如果张力只能标在场景上,这两拍就被迫压成一个值,曲线的「收」就丢了。所以本卷的张力标在节拍上、不标在场景上,节拍是比场景更细的一层切分。

beats[].scene 这个字段的用途也在这里:它把节拍锚回场景,于是路径(一串场景)才能反查到节拍(一串 tension)。这是下一节做路径投影的桥——没有 scene 这个字段,节拍与玩家路径就接不上。

为什么是 1–5 五档

张力标注的取值范围是 1–5 的整数,不是连续实数。这不是偷懒,是本套书跨语言纪律里的第一条在起作用:数值一律整数,好感度 0–100、tension 1–5、计数一律 int,禁浮点。理由很实在——浮点在不同语言、不同宿主上做相等比较会出偏差,而本卷后面要让同一条路径在 Python 与 TypeScript 下算出的整数指标逐位一致,只要掺进浮点,这条地基就塌了。

五档够不够?对相对比较来说是够的:本卷从不用张力值声称「这一拍紧张度 4.0 分」,只用它排序(b10 比 b09 更紧张)、算均值(第二幕比第一幕高)、定位峰值(哪几拍到顶)。这些都是序数比较,不要求等距——1 到 2 的差和 4 到 5 的差在数值上都是 1,但本卷从不断言它们在「体验强度」上等距。这一条会在第 6 章 的「代理指标的口径声明」里展开,也是第 15 章「指标不能声称什么」的前置。

S 层:只读标注就把张力序列算出来

下面的脚本 sim/nm_05_1_beat_tension.py 只做一件事:读 config/story_spec.json,把 beats 的 tension 摊平成一维序列,再按幕聚合、出分布直方图、定位峰值。它不打开引擎、不推进状态、不做任何仿真——因为它要证明的正是「张力序列是规格的一部分,不是运行的结果」。

#!/usr/bin/env python3
# file: sim/nm_05_1_beat_tension.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/nm_05_1_beat_tension.py
"""卷三 5.1 张力从哪里来:叙事节拍(S 层)。

张力在本卷里不是从机制里长出来的,而是作者在规格里**标注**的:config/story_spec.json
的 `beats` 是 16 个节拍,每个带 `tension`(整数 1–5)与 `scene`。本脚本只读这份标注,
不跑任何一局战斗,就能把整条张力序列、按幕聚合、分布直方图与峰值位置全部算出来——
这正说明「叙事张力」是一个**规格里的整数数组**,输入不是 HP / 资源 / 回合节奏。

产出 out/nm_05_1_beat_tension.json。全部定点整数,不出现浮点。
"""
from __future__ import annotations

import json
import os
import sys

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
OUT = os.path.join(ROOT, "out")
TENSION_LO, TENSION_HI = 1, 5


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


def main() -> int:
    spec = load_spec()
    beats = spec["beats"]
    seq = [b["tension"] for b in beats]
    acts = sorted({b["act"] for b in beats})

    print(f"节拍数       {len(beats)}")
    print(f"张力序列     {' '.join(str(t) for t in seq)}")
    print(f"张力之和     {sum(seq)}")
    print(f"取值区间     {min(seq)}–{max(seq)}")

    per_act = []
    for a in acts:
        ts = [b["tension"] for b in beats if b["act"] == a]
        per_act.append({"act": a, "beats": len(ts), "sum": sum(ts),
                        "mean_permille": sum(ts) * 1000 // len(ts)})
        print(f"第 {a} 幕       {len(ts)} 拍  和 {sum(ts)}  均 {sum(ts) * 1000 // len(ts)} 毫")

    hist = {t: sum(1 for x in seq if x == t) for t in range(TENSION_LO, TENSION_HI + 1)}
    print(f"分布         {json.dumps(hist, ensure_ascii=False)}")

    peaks = [b["id"] for b in beats if b["tension"] == TENSION_HI]
    peak_scenes = [b["scene"] for b in beats if b["tension"] == TENSION_HI]
    print(f"峰值节拍     {' '.join(peaks)}({len(peaks)} 个:{' '.join(peak_scenes)})")

    assert all(TENSION_LO <= t <= TENSION_HI for t in seq), "张力越界"
    assert sum(seq) == 52, "张力之和应为 52"

    os.makedirs(OUT, exist_ok=True)
    report = {
        "beat_count": len(beats),
        "tension_sequence": seq,
        "tension_sum": sum(seq),
        "range": [min(seq), max(seq)],
        "per_act": per_act,
        "histogram": hist,
        "peaks": peaks,
        "peak_scenes": peak_scenes,
        "invariant_residual": 0,
    }
    with open(os.path.join(OUT, "nm_05_1_beat_tension.json"), "w", encoding="utf-8") as fh:
        json.dump(report, fh, ensure_ascii=False, indent=2)
        fh.write("\n")
    print("wrote out/nm_05_1_beat_tension.json")
    print(f"OK nm_05_1_beat_tension:{len(beats)} 个节拍,张力之和 {sum(seq)},整数不变量残差 0")
    return 0


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

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

节拍数       16
张力序列     1 2 2 3 2 3 4 3 4 5 3 4 5 5 4 2
张力之和     52
取值区间     1–5
第 1 幕       5 拍  和 10  均 2000 毫
第 2 幕       6 拍  和 22  均 3666 毫
第 3 幕       5 拍  和 20  均 4000 毫
分布         {"1": 1, "2": 4, "3": 4, "4": 4, "5": 3}
峰值节拍     b10 b13 b14(3 个:drowned_bell breakwater fog_gate)
wrote out/nm_05_1_beat_tension.json
OK nm_05_1_beat_tension:16 个节拍,张力之和 52,整数不变量残差 0

判读:一条能读出形状的整数序列

第一,序列本身:1 2 2 3 2 3 4 3 4 5 3 4 5 5 4 2,16 个整数,取值全落在 1–5,和为 52。它是一份可复算的对象:谁拿到同一份规格,都会得到同一串数。

第二,按幕看趋势:三幕的张力均值按千分位算,是 2000 → 3666 → 4000 毫,逐幕抬高。这是三幕结构在张力上的投影——第一幕铺陈(均值最低),第二幕加压,第三幕把平均值顶到最高。注意这是千分位定点整数,不是小数:本卷全程不出现浮点。

第三,分布是偏右的:取值 2、3、4 各出现 4 次,5 出现 3 次,1 只出现 1 次(开场第一拍)。也就是说,这条曲线长时间停在中等强度,偶尔冲顶,只在开场最低——这正是「压—爆」形状的另一种读法。

第四,峰值定位:张力到 5 的节拍有 3 个——b10(drowned_bell,沉钟塔)、b13(breakwater,防波堤)、b14(fog_gate,雾门),全部落在第二幕末与第三幕。三个峰值场景恰好是三个道德抉择现场,这不是巧合,是标注的意图。

D 层:beats 字段表与不变量

上面的数不是随手写的,它们由一份字段契约约束。把 beats[] 的每个字段、取值域与不变量固化成一份清单,后续所有涉及张力的脚本都以它为准:

{
  "section": "5.1",
  "source": "config/story_spec.json#beats",
  "tier": "D",
  "field_table": [
    {"field": "id", "type": "string", "note": "节拍标识 b01..b16"},
    {"field": "act", "type": "int", "note": "所属幕,1..3"},
    {"field": "scene", "type": "string", "note": "所属场景,与 scene_graph.nodes 对齐"},
    {"field": "tension", "type": "int", "note": "张力标注,取值 1..5,本卷曲线唯一输入"},
    {"field": "summary", "type": "string", "note": "一句话概述,仅供人读,不参与计算"}
  ],
  "tension_domain": [1, 5],
  "invariants": [
    "每个 tension 落在 [1,5] 闭区间",
    "16 个节拍 tension 之和恒为 52",
    "tension 为整数,config 层禁浮点"
  ],
  "scale": {"beats": 16, "tension_sum": 52, "peaks_at_5": 3},
  "proxy": "tension 是作者标注的相对强度,只用于相对比较,不声称任何绝对情绪量"
}

这份契约里最关键的一条是最后一行:tension 是代理指标(proxy)。它标的是「作者想让这一拍多紧张」的相对位置,不是玩家的实际情绪强度。本卷所有与张力相关的量——曲线、均值、峰值、后一节的 MAE——都只用于相对比较,不声称「玩家紧张度 7.2」这类绝对量,第 15 章会把这条口径讲透。

这套口径从哪来

本节读的 beats 不是本卷新造的字段——它在卷一第 6.2 节 「节拍表:把结构写成 beat 序列」里就已定下:节拍是结构的最小可标注单位,tension 是它的强度维度;卷二则负责把「玩家路径」变成可执行对象(卷二第 4.1 节 )。本卷不新造字段,只把这两样接起来:

层提供什么本节的用法
卷一beats[] 的字段与标注张力序列的唯一输入
卷二路径的推进与编码决定哪些节拍被投影到曲线上
卷三(本节)把标注读成序列并统计逐幕均值、分布直方图、峰值定位

自检表

检查项合格标准
输入是标注张力序列直接来自 beats[].tension,不经过仿真
序列可复算16 个整数、和 52,与脚本输出一致
按幕聚合为整数幕均值按千分位定点,无浮点
峰值可定位tension=5 的节拍由脚本列出,非手写
整数不变量tension ∈ [1,5],和 = 52,残差 0
proxy 声明明示 tension 是相对强度,不做绝对化

小结

本节只做了一件事:把「叙事张力」的来源钉死在规格里。三条结论:

  • 张力是标注,不是涌现:它来自 beats[].tension 的 16 个整数,读规格即得,不需仿真——这是它与机制张力最根本的区别。
  • 它有形状:全量序列 1 2 2 3 2 3 4 3 4 5 3 4 5 5 4 2,逐幕均值 2000 → 3666 → 4000 毫,峰值集中在第二幕末与第三幕。
  • 它是 proxy:只用于相对比较,tension 是序数量,不做等距与绝对化断言。

知道了张力从哪来,下一步是把它沿一条真实路径串起来:路径经过哪些场景,就取出这些场景上的节拍,拼成这条路径自己的张力曲线,再在曲线上找上升段、回落段与峰值。这是下一节的事。

阅读导航:上一节:4.3 选择密度与节奏 · 下一节:5.2 张力与释放的节拍计算 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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