独立游戏的叙事设计与分支对话 那篇用散文谈「信息给得太快读者跟不上、给得太慢读者会走神」这条经验,靠的是修改稿子时的直觉,代码不可跑;本节把同一件事变成一条可算的密度曲线:沿路径每个窗口新增了多少信息,落在哪个区间。7.1 量的是整条路径的平均节奏,本节把它拆成局部的密度序列,并给出把密度维持在可读区间的判据。
7.2 叙事密度的控制
节奏是平均值,密度是序列。一条路径平均每 2.6 步命中一个节拍,但可能前 5 步挤了 3 个节拍、后 5 步一个都没有。玩家感受到的从来不是平均值,而是这个起伏的序列。所以密度必须按滑窗来算:取一段固定长度的节点窗口,数窗口里「新增了多少信息」,再把它换算成千分位。
密度的口径:每个节点算四件事
密度这个词同样需要可判定。本节把「信息」拆成四个可以逐位判定的来源,每个节点最多贡献 4 分:
| 事件 | 判据 | 含义 |
|---|---|---|
| 新场景 | 该步进入的场景此前未到过 | 新地点 = 新信息 |
| 新 flag | 该步置位了一个此前为 0 的 flag | 新事实 |
| 新物品 | 该步取得了一件此前没有的物品 | 新线索 |
| 有意义选择 | 该步所选选项不是状态级假选择 | 有分量的推进 |
窗口密度 = 窗口内信息量之和 × 1000 // (4 × 窗口宽度)。取窗口宽度 W = 5 节点,于是每个窗口的理论最大值是 20 分,换算成 1000 千分。四个来源里,「有意义选择」的判据沿用 4.2 / 4.3:若同节点另一个在该状态下可选的选项,推进结果(节点 + 好感度 + flag + 物品逐字段)与所选选项完全相同,则这一步是假选择,不计分。
可读区间是设计参数,不是拟合出来的
把密度维持在可读区间,先要有一个区间。本节声明可读区间为 [250, 750] 千分——这是设计参数,写死在代码里,不按跑出来的结果反调。低于 250 判「偏疏」:连续几步没有新信息,读起来拖;高于 750 判「偏密」:连续塞信息,读起来赶。两个判据都只产出软告警,不硬失败——密度越界不是错误,是需要人看的信号。
S 层:500 条路径的密度序列与越界窗口
脚本 sim/nm_07_2_density_control.py 用 Lehmer MINSTD 采样 500 条路径,对每条路径算逐节点的信息量、再滑窗求密度,统计窗口密度的分布、越界窗口数与最长连续越界段。
#!/usr/bin/env python3
# file: sim/nm_07_2_density_control.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/nm_07_2_density_control.py
"""卷三 7.2 叙事密度的控制(S 层):把一条路径的信息 / 事件密度维持在可读区间。
密度 = 单位节点上「新增了多少信息」。可判定口径(写死在代码里):
每个节点的信息量(0~4):
+1 首次进入该场景(新地点 = 新信息)
+1 本步置位了一个此前为 0 的 flag(新事实)
+1 本步取得了一件此前没有的物品(新线索)
+1 本步所选选项**不是状态级假选择**(有意义的选择,判据同 4.2 / 4.3)
窗口密度(滑窗 W=5 节点)= 窗口内信息量之和 × 1000 // (4 × W),千分位。
可读区间 [LO, HI] 是**设计参数**(不是按结果调出来的):
密度 < LO 判「偏疏」(长时间没有新信息,读起来拖);
密度 > HI 判「偏密」(连续塞信息,读起来赶)。
用 Lehmer MINSTD(rng = rng * 48271 % 2147483647)采样 500 条路径。
密度是 **proxy**:结构量,只用于相对比较,不预测玩家的主观可读性。
产出 out/nm_07_2_density.csv(每条路径一行)与 out/nm_07_2_density.json(汇总)。
"""
from __future__ import annotations
import csv
import itertools
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
W = 5 # 滑窗宽度(节点)
DENS_LO = 250 # 可读区间下界(千分)——设计参数,非拟合
DENS_HI = 750 # 可读区间上界(千分)——设计参数,非拟合
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 advance_key(c, s):
a, f, i = apply_eff(c.get("effects") or {}, s)
return (c["goto"], tuple(sorted(a.items())), tuple(sorted(f.items())), tuple(sorted(i.items())))
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"])
scene_of_node = {n["id"]: n["scene"] for n in nodes}
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 = []
win_hist = Counter()
dry_runs, burst_runs = [], []
for p in range(N_PATHS):
s = init_state()
seen_scenes = set()
# 每个节点的信息量(0~4),起点节点不产生事件
events = []
seen_scenes.add(scene_of_node[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)]
k = advance_key(c, s)
fake = any(o is not c and advance_key(o, s) == k for o in opts)
aff, fl, it = apply_eff(c.get("effects") or {}, s)
sc = scene_of_node[c["goto"]]
ev = 0
if sc not in seen_scenes:
seen_scenes.add(sc)
ev += 1
if any(fl[f] == 1 and s["flags"].get(f, 0) == 0 for f in flags):
ev += 1
if any(it[i] == 1 and s["items"].get(i, 0) == 0 for i in items):
ev += 1
if not fake:
ev += 1
events.append(ev)
s = {"node": c["goto"], "aff": aff, "flags": fl, "items": it}
n = len(events)
win = [sum(events[i:i + W]) * 1000 // (4 * W) for i in range(max(1, n - W + 1))]
for v in win:
win_hist[v // 50 * 50] += 1
dry = max((len(list(g)) for kk, g in itertools.groupby(win) if kk < DENS_LO),
default=0)
burst = max((len(list(g)) for kk, g in itertools.groupby(win) if kk > DENS_HI),
default=0)
dry_runs.append(dry)
burst_runs.append(burst)
rows.append({
"path": p,
"nodes": n,
"events": sum(events),
"density_permille": sum(events) * 1000 // (4 * n) if n else 0,
"win_min": min(win), "win_max": max(win),
"win_mean": sum(win) // len(win),
"dry_win": sum(1 for v in win if v < DENS_LO),
"burst_win": sum(1 for v in win if v > DENS_HI),
"dry_run_max": dry, "burst_run_max": burst,
})
n = len(rows)
tot_nodes = sum(r["nodes"] for r in rows)
tot_events = sum(r["events"] for r in rows)
all_wins = sum(r["win_mean"] for r in rows)
out_lo = sum(r["dry_win"] for r in rows)
out_hi = sum(r["burst_win"] for r in rows)
tot_win = sum((r["nodes"] - W + 1) if r["nodes"] >= W else 1 for r in rows)
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "nm_07_2_density.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, "window": W,
"readable_band_permille": [DENS_LO, DENS_HI],
"nodes_mean": tot_nodes // n,
"events_total": tot_events,
"density_permille_global": tot_events * 1000 // (4 * tot_nodes),
"win_mean_permille": all_wins // n,
"win_min": min(r["win_min"] for r in rows),
"win_max": max(r["win_max"] for r in rows),
"windows_total": tot_win,
"dry_windows": out_lo, "burst_windows": out_hi,
"dry_share_permille": out_lo * 1000 // tot_win,
"burst_share_permille": out_hi * 1000 // tot_win,
"dry_run_max": max(dry_runs), "burst_run_max": max(burst_runs),
"paths_with_dry": sum(1 for r in rows if r["dry_win"]),
"paths_with_burst": sum(1 for r in rows if r["burst_win"]),
"win_hist": {str(k): v for k, v in sorted(win_hist.items())},
}
with open(os.path.join(OUT, "nm_07_2_density.json"), "w", encoding="utf-8") as fh:
json.dump(summary, fh, ensure_ascii=False, indent=1)
fh.write("\n")
print(f"滑窗 W={W} 节点,每节点信息量 0~4(新场景 + 新 flag + 新物品 + 有意义选择)")
print(f"可读区间 [{DENS_LO}, {DENS_HI}] 千分(设计参数,非按结果拟合)")
print(f"采样 {n} 条路径(seed={SEED}):平均 {summary['nodes_mean']} 节点,"
f"共 {tot_events} 个信息事件")
print(f"全局密度 {summary['density_permille_global']} 千分"
f"({tot_events} × 1000 // (4 × {tot_nodes}))")
print(f"窗口密度:均值 {summary['win_mean_permille']} 千分 区间 {summary['win_min']}~"
f"{summary['win_max']} 千分")
print(f"窗口总数 {tot_win} 偏疏(<{DENS_LO}) {out_lo} 个 = {summary['dry_share_permille']} 千分"
f" 偏密(>{DENS_HI}) {out_hi} 个 = {summary['burst_share_permille']} 千分")
print(f"最长偏疏连续段 {summary['dry_run_max']} 窗 最长偏密连续段 {summary['burst_run_max']} 窗")
print(f"出现偏疏窗口的路径 {summary['paths_with_dry']} / {n}"
f" 出现偏密窗口的路径 {summary['paths_with_burst']} / {n}")
print(f"窗口密度直方图(50 千分一档){summary['win_hist']}")
print("wrote out/nm_07_2_density.csv / .json")
if tot_nodes == 0:
print("FAIL 采样未走出任何节点")
return 1
assert 0 <= summary["density_permille_global"] <= 1000, "密度越界"
print("OK nm_07_2_density_control:密度序列已算,偏疏 / 偏密窗口已计数(软告警)")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑 /opt/homebrew/bin/python3 sim/nm_07_2_density_control.py:
滑窗 W=5 节点,每节点信息量 0~4(新场景 + 新 flag + 新物品 + 有意义选择)
可读区间 [250, 750] 千分(设计参数,非按结果拟合)
采样 500 条路径(seed=20261016):平均 25 节点,共 20768 个信息事件
全局密度 409 千分(20768 × 1000 // (4 × 12680))
窗口密度:均值 406 千分 区间 200~600 千分
窗口总数 10680 偏疏(<250) 62 个 = 5 千分 偏密(>750) 0 个 = 0 千分
最长偏疏连续段 1 窗 最长偏密连续段 0 窗
出现偏疏窗口的路径 62 / 500 出现偏密窗口的路径 0 / 500
窗口密度直方图(50 千分一档){'200': 62, '250': 260, '300': 1333, '350': 2629, '400': 2162, '450': 1728, '500': 1675, '550': 779, '600': 52}
wrote out/nm_07_2_density.csv / .json
OK nm_07_2_density_control:密度序列已算,偏疏 / 偏密窗口已计数(软告警)
判读:密度整体落在可读区间,只在一端擦到边界
第一,全局密度 409 千分,窗口密度均值 406 千分。 两者几乎相等,说明「整条路径的平均」与「逐窗口的平均」一致——密度在路径上是平稳的,没有哪一段系统性偏高或偏低。409 千分落在可读区间 [250, 750] 的正中间偏下,是一段舒服的疏密。
第二,窗口密度区间 200~600 千分,10680 个窗口里只有 62 个偏疏、0 个偏密。 偏疏占 5 千分,且最长偏疏连续段只有 1 个窗——也就是没有任何一条路径连续两个窗口都偏疏。这说明偏疏是孤立的偶发现象(通常是某几步连续选到没有新场景、没有新 flag 的分支),而不是结构性的拖沓。偏密一端完全没触发:实测最高只有 600 千分,离 750 的边界还有余量,这张图不密。
第三,直方图显示密度集中在 350~500 千分。 50 千分一档的直方图里,350 档 2629 个、400 档 2162 个、450 档 1728 个,三个档占了全部窗口的近六成;两端的 200 档(62 个)与 600 档(52 个)都很薄。这是一条中间厚、两头薄的钟形分布,正是「密度被控制在可读区间」的样子。
| 检查项 | 实测 | 合格标准 |
|---|---|---|
| 全局密度 | 409 千分 | 落在可读区间 [250,750] |
| 窗口区间 | 200~600 千分 | 越界窗口计数并告警 |
| 偏疏窗口 | 62 / 10680 = 5 千分 | 软告警,不硬失败 |
| 偏密窗口 | 0 / 10680 | 同上 |
| 最长连续越界 | 偏疏 1 窗 / 偏密 0 窗 | 无长段即非结构性 |
| 可读区间来源 | 设计参数 | 不按结果反调 |
密度维持住了,说明这条路径在信息疏密上是稳的。但「稳」不等于「对」——密度还可能与张力错位:张力冲到峰值的那一步,信息反而极稀。下一节把密度与张力摆在一起,检出并计数这种节奏冲突。
阅读导航:上一节:7.1 叙事节奏的度量 · 下一节:7.3 节奏冲突的检测 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。