7.1 叙事节奏的度量

节奏是叙事推进的快慢:沿路径每单位节点推进了多少剧情量。本节给 16 个节拍量出三幕分布 5/6/5,用 500 条采样路径算出节奏 384 千分、首次进入场景的间隔均值 3.080 步、命中节拍的三幕占比 345/297/357,全程整数千分位并显式声明为 proxy。

独立游戏的叙事设计与分支对话 那篇(约 1076 行)用散文与清单谈叙事节奏的经验——什么时候该快、什么时候该慢,靠的是写作者的手感,代码不可跑;本章把同一批问题变成可算的整数:节奏不再是一种感觉,而是「沿路径每单位节点推进了多少剧情量」这个可复现的比值。第 6 章收在「代理指标的口径声明」,把张力曲线 MAE 的边界钉死;第 7 章从这里往下,量的是剧情推进的快慢,以及它与密度的关系。

7.1 叙事节奏的度量

节奏这个词在日常语言里很含糊,但在本卷的口径里它必须可判定。本节把它固定成一个比值:一条路径上命中的节拍数,除以这条路径的节点数。节拍是作者标注的叙事单元(beats[]),节点是玩家实际走过的一步;两者的比值就是「平均每走 1000 步,推进了多少个剧情单元」。这个数越小,说明路径在剧情上走得越慢;越大,说明越紧凑。

节奏的三个口径

节奏不能只报一个数,否则「快」与「慢」会被混着读。本节把节奏拆成三个必须分开看的口径:

口径定义回答的问题
节奏(千分)命中节拍数 / 路径节点数单位步长推进了多少剧情量
推进间隔相邻两个「首次进入的场景」之间隔了几步新场景是「开场即给」还是「逐步展开」
幕分布命中节拍在三幕上的数量剧情量在纵深上怎么摊

还有一个结构基线要单列:真源里 16 个节拍分布在 3 幕,第一夜 5 拍、第二夜 6 拍、第三夜 5 拍。这是作者设定的基线,任何一条路径的幕分布都要拿它做参照——路径命中的节拍在三幕上偏了,说明玩家这一趟在某一夜走得过深或过浅。

结构基线:16 拍分在三幕,且绝大多数场景只有 1 拍

先看清真源本身。16 个节拍落在 3 幕 = 5 / 6 / 5;再按场景看,14 个有节拍的场景里,12 个场景各挂 1 拍、2 个场景各挂 2 拍——这两个「双拍场景」正是第二夜的 lighthouse_base 与第三夜的 dawn_quay,它们各自承担一次「幕内转折」。这个分布决定了节奏的上界:一条路径最多命中 16 拍,而它的节点数在 23~32 之间,所以节奏的理论上界远低于 1000 千分。

S 层:采样 500 条路径,量出节奏、间隔与幕分布

脚本 sim/nm_07_1_rhythm_metric.py 把上面三个口径一次跑齐:用 Lehmer MINSTD 在可用选项里均匀随机采样 500 条路径,对每条路径算出命中节拍数、节奏、首次进入场景的间隔,以及命中节拍的三幕分布,再汇总。

#!/usr/bin/env python3
# file: sim/nm_07_1_rhythm_metric.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/nm_07_1_rhythm_metric.py
"""卷三 7.1 叙事节奏的度量(S 层):沿路径每单位节点推进了多少剧情量。

节奏 = 剧情推进的快慢。可判定口径(写死在代码里,不按结果调参):

  剧情量 = 路径**命中过的节拍数**(beats[].scene ∈ 路径覆盖的场景);
  节奏   = 命中节拍数 / 路径节点数,千分位整数地板除(‰)。
  推进间隔 = 相邻两个「首次进入的场景」之间隔了几个节点(一步 = 一次选择)。
  幕分布   = 命中节拍在**三幕**上的数量(真源 16 拍 = 5 / 6 / 5)。

用 Lehmer MINSTD(rng = rng * 48271 % 2147483647)在可用选项里均匀采样 500 条路径。
节奏是 **proxy**:它是**结构量**,只用于不同规格 / 不同采样策略之间的**相对比较**,
不预测玩家的主观节奏感,也不声称「节奏快 = 体验好」。

产出 out/nm_07_1_rhythm.csv(每条路径一行)与 out/nm_07_1_rhythm.json(汇总)。
"""
from __future__ import annotations

import csv
import json
import os
import sys
from collections import Counter

HERE = os.path.dirname(os.path.abspath(__file__))
OUT = os.path.join(HERE, "../out")
LO, HI = 0, 100
SEED = 20261016
N_PATHS = 500


def cond_ok(c, s):
    if c is None:
        return True
    k = c[0]
    if k == "flag":
        return s["flags"].get(c[1], 0) == c[2]
    if k == "item":
        return s["items"].get(c[1], 0) == c[2]
    if k == "aff":
        v, op, n = s["aff"].get(c[1], 0), c[2], c[3]
        return v >= n if op == ">=" else v < n if op == "<" else v == n if op == "==" else v != n
    if k == "and":
        return cond_ok(c[1], s) and cond_ok(c[2], s)
    if k == "or":
        return cond_ok(c[1], s) or cond_ok(c[2], s)
    return not cond_ok(c[1], s)


def apply_eff(eff, s):
    aff, fl, it = dict(s["aff"]), dict(s["flags"]), dict(s["items"])
    for k, v in (eff.get("set") or {}).items():
        fl[k] = v
    for k, v in (eff.get("add") or {}).items():
        aff[k] = LO if aff.get(k, 0) + v < LO else HI if aff.get(k, 0) + v > HI else aff.get(k, 0) + v
    for k, v in (eff.get("grant") or {}).items():
        it[k] = v
    for k in (eff.get("revoke") or {}):
        it[k] = 0
    return aff, fl, it


def main() -> int:
    with open(os.path.join(HERE, "../config/story_spec.json"), encoding="utf-8") as fh:
        spec = json.load(fh)
    nodes = spec["dialogue"]
    by = {n["id"]: n for n in nodes}
    start = spec["meta"]["start"]
    vars_ = sorted(spec["state"]["vars"])
    flags = sorted(spec["state"]["flags"])
    items = sorted(spec["state"]["items"])
    beats = spec["beats"]
    scene_of_node = {n["id"]: n["scene"] for n in nodes}

    # 结构基线:每幕的节拍数、每个场景的节拍数
    act_beats = Counter(b["act"] for b in beats)
    scene_beats = Counter(b["scene"] for b in beats)
    beats_per_scene = Counter(scene_beats.values())

    def init_state():
        aff = {v: next(c["affinity"]["init"] for c in spec["characters"] if c["id"] == v)
               for v in vars_}
        return {"node": start, "aff": aff,
                "flags": {f: 0 for f in flags}, "items": {i: 0 for i in items}}

    rng = SEED
    rows = []
    for p in range(N_PATHS):
        s = init_state()
        path = [start]
        while True:
            cur = by[s["node"]]
            if cur.get("ending"):
                break
            opts = [c for c in (cur.get("choices") or []) if cond_ok(c.get("when"), s)]
            if not opts:
                break
            rng = (rng * 48271) % 2147483647
            c = opts[rng % len(opts)]
            aff, fl, it = apply_eff(c.get("effects") or {}, s)
            s = {"node": c["goto"], "aff": aff, "flags": fl, "items": it}
            path.append(s["node"])

        scenes_seq = [scene_of_node[nid] for nid in path]
        visited = set(scenes_seq)
        hit = [b for b in beats if b["scene"] in visited]
        # 首次进入场景的位置(一步 = 一次选择)
        first_pos, seen = [], set()
        for i, sc in enumerate(scenes_seq):
            if sc not in seen:
                seen.add(sc)
                first_pos.append(i)
        gaps = [b - a for a, b in zip(first_pos, first_pos[1:])]
        act_hit = Counter(b["act"] for b in hit)
        rows.append({
            "path": p,
            "nodes": len(path),
            "scenes": len(visited),
            "beats": len(hit),
            "rhythm_permille": len(hit) * 1000 // len(path),
            "beat_gap_mean_milli": (sum(gaps) * 1000 // len(gaps)) if gaps else 0,
            "beat_gap_max": max(gaps) if gaps else 0,
            "act1": act_hit[1], "act2": act_hit[2], "act3": act_hit[3],
        })

    n = len(rows)
    tot_nodes = sum(r["nodes"] for r in rows)
    tot_beats = sum(r["beats"] for r in rows)
    rhythm_global = tot_beats * 1000 // tot_nodes
    act_tot = {a: sum(r[f"act{a}"] for r in rows) for a in (1, 2, 3)}
    act_all = sum(act_tot.values())
    all_gaps = [r["beat_gap_mean_milli"] for r in rows if r["beat_gap_mean_milli"]]

    os.makedirs(OUT, exist_ok=True)
    with open(os.path.join(OUT, "nm_07_1_rhythm.csv"), "w", encoding="utf-8", newline="") as fh:
        w = csv.DictWriter(fh, fieldnames=list(rows[0]))
        w.writeheader()
        w.writerows(rows)

    summary = {
        "paths": n, "seed": SEED,
        "act_beats_structural": {str(a): act_beats[a] for a in sorted(act_beats)},
        "beats_per_scene_hist": {str(k): v for k, v in sorted(beats_per_scene.items())},
        "nodes_mean": tot_nodes // n,
        "nodes_min": min(r["nodes"] for r in rows),
        "nodes_max": max(r["nodes"] for r in rows),
        "beats_mean_milli": tot_beats * 1000 // n,
        "rhythm_permille_global": rhythm_global,
        "rhythm_permille_min": min(r["rhythm_permille"] for r in rows),
        "rhythm_permille_max": max(r["rhythm_permille"] for r in rows),
        "beat_gap_mean_milli": sum(all_gaps) // len(all_gaps) if all_gaps else 0,
        "act_share_permille": {str(a): act_tot[a] * 1000 // act_all for a in (1, 2, 3)},
    }
    with open(os.path.join(OUT, "nm_07_1_rhythm.json"), "w", encoding="utf-8") as fh:
        json.dump(summary, fh, ensure_ascii=False, indent=1)
        fh.write("\n")

    print(f"结构基线:{len(beats)} 个节拍分布在 {len(act_beats)} 幕 "
          f"= {' / '.join(str(act_beats[a]) for a in sorted(act_beats))}"
          f"(第一夜 / 第二夜 / 第三夜)")
    print(f"每场景节拍数直方图 {dict(sorted(beats_per_scene.items()))}"
          f"({len(scene_beats)} 个有节拍的场景)")
    print(f"采样 {n} 条路径(seed={SEED}):节点数 {summary['nodes_min']}/"
          f"{summary['nodes_mean']}/{summary['nodes_max']}(min/mean/max)")
    print(f"平均命中节拍 {summary['beats_mean_milli']} 毫个({tot_beats * 1000 // n / 1000:.3f} 拍)")
    print(f"节奏 {rhythm_global} 千分(命中节拍 {tot_beats} / 节点 {tot_nodes})"
          f"  区间 {summary['rhythm_permille_min']}~{summary['rhythm_permille_max']} 千分")
    print(f"首次进入场景的间隔均值 {summary['beat_gap_mean_milli']} 毫"
          f"({summary['beat_gap_mean_milli'] / 1000:.3f} 步)")
    print(f"命中节拍的三幕占比 {summary['act_share_permille']}"
          f"(第一夜 / 第二夜 / 第三夜,千分)")
    print("wrote out/nm_07_1_rhythm.csv / .json")

    if tot_nodes == 0 or tot_beats == 0:
        print("FAIL 采样未走出任何节拍")
        return 1
    assert 0 <= rhythm_global <= 1000, "节奏越界"
    print("OK nm_07_1_rhythm_metric:节奏(命中节拍 / 节点)已量出,三幕占比已算")
    return 0


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

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

结构基线:16 个节拍分布在 3 幕 = 5 / 6 / 5(第一夜 / 第二夜 / 第三夜)
每场景节拍数直方图 {1: 12, 2: 2}(14 个有节拍的场景)
采样 500 条路径(seed=20261016):节点数 23/26/32(min/mean/max)
平均命中节拍 10124 毫个(10.124 拍)
节奏 384 千分(命中节拍 5062 / 节点 13180)  区间 344~423 千分
首次进入场景的间隔均值 3080 毫(3.080 步)
命中节拍的三幕占比 {'1': 345, '2': 297, '3': 357}(第一夜 / 第二夜 / 第三夜,千分)
wrote out/nm_07_1_rhythm.csv / .json
OK nm_07_1_rhythm_metric:节奏(命中节拍 / 节点)已量出,三幕占比已算

判读:节奏是克制的,且三幕摊得比结构基线更匀

第一,节奏 384 千分,即平均每走 1000 步推进约 0.384 个节拍。 换算过来是「平均每 2.6 步命中一个新节拍」。这个数不是评价,是结构事实:真源只有 16 拍,而一条路径平均走 26 个节点、命中 10.124 拍,比值自然落在 0.38 附近。单条路径的节奏在 344~423 千分之间波动——波动很窄,说明这张图在不同走法下的推进速度相当一致,没有哪条路径突然变得极快或极慢。

第二,首次进入场景的间隔均值 3.080 步。 这是「新地点」出现的平均步距:每约 3 步换一个新场景。间隔的稳定来自场景图的收束结构——三夜各有一批场景,玩家每夜依次解锁,节奏天然均匀。这个数也是 proxy:它只说明「场景切换的频率」,不说明玩家是否觉得「有意思」。

第三,命中节拍的三幕占比是 345 / 297 / 357 千分。 结构基线是 5 / 6 / 5,即 312.5 / 375 / 312.5 千分。实测里第二夜反而偏低、第一夜与第三夜偏高——原因是第二夜有 4 个场景带 enter_when 条件(lighthouse_base / tide_cave / harbor_office / drowned_bell),条件不满足就走不进去,命中的节拍被压下来。这不是缺陷,是条件门在节奏上的可见投影:条件越多的那一夜,实测节拍占比越低于基线。

检查项实测合格标准
结构基线16 拍 = 5 / 6 / 5与 beats[] 逐幕对账
节奏口径命中节拍 / 节点,千分位不用「场景数」冒充节拍数
节奏区间344~423 千分波动收窄即节奏稳定
幕分布345 / 297 / 357与基线 312.5 / 375 / 312.5 对照
整数不变量节奏 ∈ [0,1000]越界即非零退出

节奏量完了,知道这条路平均走多快、剧情量在三幕上怎么摊。但节奏是整条路径的平均值,它盖住了「某几步特别密、某几步特别空」的局部起伏。下一节把平均值拆开,沿路径算密度序列,并给出把密度维持在可读区间的判据。

阅读导航:上一节:6.3 代理指标的口径声明 · 下一节:7.2 叙事密度的控制 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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