独立游戏的叙事设计与分支对话 那篇(约 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 叙事密度的控制 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。