独立游戏的叙事设计与分支对话 那篇用散文谈「高潮处要给够信息、铺垫处要留白」这类经验,靠的是修改稿子的手感,代码不可跑;本节把「信息给没给对位置」变成一条可检出的判据:把密度序列与张力序列并排,找出两者不同向的位置并计数。7.1 量了节奏,7.2 控住了密度,本节查的是密度与张力的错配。
7.3 节奏冲突的检测
密度和张力是两条独立的曲线:张力来自作者标注的 beats[].tension,密度来自路径上的信息事件。它们本可以各自都「正常」,却互相错位——最典型的是张力冲到峰值的那一步,信息反而极稀,玩家正被吊起情绪却什么都没拿到;反过来,张力还在谷底,信息却密集地塞过来,玩家还没进入状态就被灌了一堆设定。这种错位就叫节奏冲突。本节给它两条可判定的判据,并在采样路径上检出、计数。
两条判据:绝对错配与方向错配
冲突必须用写死的规则判定,不能凭感觉。本节定义两条:
| 规则 | 判据 | 说明 |
|---|---|---|
| A 绝对错配 | 张力 ≥ 4 且密度 < 250;或张力 ≤ 2 且密度 > 750 | 峰处极疏 / 谷处极密,撞到 7.2 的可读区间边界 |
| B 方向错配 | 张力 ≥ 4 且密度 < 本路均值;或张力 ≤ 2 且密度 > 本路均值 | 峰处比本路平均还稀 / 谷处比本路平均还密 |
两条规则的关系是绝对 vs 相对。A 规则要求峰处的密度低到越过可读区间下界,是强判据;B 规则只要求峰处的密度低于这条路径自己的平均值,是弱判据。把两条都算出来,才能分清「结构性错位」与「只是撞了下边界」——这正是 6.3 那条口径的延续:同一个数,口径不同,读法就不同。
张力与密度的锚点
要把两条曲线并排,先得让它们对齐到同一组锚点。本节取节拍作为锚点:路径按首次进入场景的顺序命中一串节拍,每个节拍带着它的 tension,而它所在节点处的窗口密度就是这一拍的密度。于是每条路径得到一串 (张力, 密度) 对,冲突就在这些对上判。张力阈值取峰值 ≥ 4、谷值 ≤ 2,与 5.1 的节拍张力口径一致;密度可读区间沿用 7.2 的 [250, 750]。
S 层:在 500 条路径上检出并计数冲突
脚本 sim/nm_07_3_rhythm_conflict_detection.py 采样 500 条路径,对每条路径构建节拍锚点上的 (张力, 密度) 对,按两条规则计数冲突,并统计峰值 / 谷值节拍处的平均密度与全路径基线。
#!/usr/bin/env python3
# file: sim/nm_07_3_rhythm_conflict_detection.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/nm_07_3_rhythm_conflict_detection.py
"""卷三 7.3 节奏冲突的检测(S 层):密度与张力曲线的错配,检出并计数。
7.1 量了节奏的快慢,7.2 把密度维持在可读区间;这一节查**两者与张力的错配**。
可判定口径(写死在代码里):
张力 = 命中节拍的 beats[].tension(1~5 整数);
局部密度 = 该节拍**首次进入的场景**所在节点起、W=5 节点的滑窗密度(千分),
事件口径同 7.2(新场景 + 新 flag + 新物品 + 有意义选择)。
规则 A(绝对错配):张力峰值 tension>=4 而密度 < DENS_LO(峰处极疏);
张力谷 tension<=2 而密度 > DENS_HI(谷处极密)。
规则 B(方向错配,相对本路径基线):峰值处密度 < 本路均值;谷处密度 > 本路均值。
用 Lehmer MINSTD(rng = rng * 48271 % 2147483647)采样 500 条路径。
冲突计数是 **proxy**:它只说明「结构上张力的峰谷与信息疏密不同向」,不评价叙事好坏,
也不预测玩家是否感到「该紧不紧」。产出 out/nm_07_3_conflict.csv + .json。
"""
from __future__ import annotations
import csv
import itertools
import json
import os
import sys
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
PEAK = 4 # 张力峰值阈值
TROUGH = 2 # 张力谷阈值
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}
beat_by_scene = {}
for b in spec["beats"]:
beat_by_scene.setdefault(b["scene"], []).append(b)
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 = []
peak_dens, trough_dens, base_dens = [], [], []
for p in range(N_PATHS):
s = init_state()
seen_scenes = {scene_of_node[start]}
path = [start]
events = []
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}
path.append(s["node"])
# 每个节点位置上的滑窗密度(起点窗口从 0 开始)
wins = [sum(events[i:i + W]) * 1000 // (4 * W) for i in range(max(1, len(events) - W + 1))]
base = sum(wins) // len(wins) if wins else 0
base_dens.append(base)
# 节拍命中:按首次进入场景的顺序,取该节拍的张力与该处的窗口密度
first_pos, seen = {}, set()
for i, nid in enumerate(path):
sc = scene_of_node[nid]
if sc not in seen:
seen.add(sc)
first_pos[sc] = i
# 第 i 个节点对应的事件下标是 i-1(起点无事件);窗口密度取该处
conf_a, conf_b, pairs = 0, 0, []
for sc, i in first_pos.items():
if sc not in beat_by_scene:
continue
t = beat_by_scene[sc][0]["tension"]
wi = max(0, i - 1)
d = wins[wi] if wi < len(wins) else wins[-1]
pairs.append((t, d))
if t >= PEAK:
peak_dens.append(d)
if d < DENS_LO:
conf_a += 1
if d < base:
conf_b += 1
elif t <= TROUGH:
trough_dens.append(d)
if d > DENS_HI:
conf_a += 1
if d > base:
conf_b += 1
rows.append({
"path": p, "nodes": len(path), "beats": len(pairs),
"base_density": base, "conflict_a": conf_a, "conflict_b": conf_b,
"peak_beats": sum(1 for t, _ in pairs if t >= PEAK),
"trough_beats": sum(1 for t, _ in pairs if t <= TROUGH),
})
n = len(rows)
tot_beats = sum(r["beats"] for r in rows)
tot_a = sum(r["conflict_a"] for r in rows)
tot_b = sum(r["conflict_b"] for r in rows)
tot_peaks = sum(r["peak_beats"] for r in rows)
tot_troughs = sum(r["trough_beats"] for r in rows)
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "nm_07_3_conflict.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],
"peak_threshold": PEAK, "trough_threshold": TROUGH,
"beat_hits_total": tot_beats,
"peak_beats": tot_peaks, "trough_beats": tot_troughs,
"conflict_a_total": tot_a,
"conflict_b_total": tot_b,
"conflict_a_per_mille": tot_a * 1000 // tot_beats,
"conflict_b_per_mille": tot_b * 1000 // tot_beats,
"paths_with_conflict_a": sum(1 for r in rows if r["conflict_a"]),
"paths_with_conflict_b": sum(1 for r in rows if r["conflict_b"]),
"max_conflict_a_per_path": max(r["conflict_a"] for r in rows),
"max_conflict_b_per_path": max(r["conflict_b"] for r in rows),
"peak_beat_share_permille": tot_peaks * 1000 // tot_beats,
"trough_beat_share_permille": tot_troughs * 1000 // tot_beats,
"peak_density_mean": sum(peak_dens) // len(peak_dens) if peak_dens else 0,
"trough_density_mean": sum(trough_dens) // len(trough_dens) if trough_dens else 0,
"base_density_mean": sum(base_dens) // len(base_dens) if base_dens else 0,
}
with open(os.path.join(OUT, "nm_07_3_conflict.json"), "w", encoding="utf-8") as fh:
json.dump(summary, fh, ensure_ascii=False, indent=1)
fh.write("\n")
print(f"采样 {n} 条路径(seed={SEED}),节拍命中 {tot_beats} 次")
print(f"张力阈值:峰值 >={PEAK} 谷值 <={TROUGH};密度可读区间 [{DENS_LO}, {DENS_HI}] 千分")
print(f"峰值节拍 {tot_peaks} 次 = {summary['peak_beat_share_permille']} 千分"
f" 谷值节拍 {tot_troughs} 次 = {summary['trough_beat_share_permille']} 千分")
print(f"规则 A 绝对错配(峰处极疏 / 谷处极密):{tot_a} 次 = {summary['conflict_a_per_mille']} 千分")
print(f"规则 B 方向错配(峰处低于本路均值 / 谷处高于本路均值):{tot_b} 次 = "
f"{summary['conflict_b_per_mille']} 千分")
print(f"含 A 冲突的路径 {summary['paths_with_conflict_a']} / {n}"
f" 含 B 冲突的路径 {summary['paths_with_conflict_b']} / {n}")
print(f"单条路径最多 A 冲突 {summary['max_conflict_a_per_path']}"
f" 最多 B 冲突 {summary['max_conflict_b_per_path']}")
print(f"平均密度:峰值节拍处 {summary['peak_density_mean']} 千分"
f" 谷值节拍处 {summary['trough_density_mean']} 千分"
f" 全路径基线 {summary['base_density_mean']} 千分")
print("wrote out/nm_07_3_conflict.csv / .json")
if tot_beats == 0:
print("FAIL 采样未命中任何节拍")
return 1
print("OK nm_07_3_rhythm_conflict_detection:密度-张力错配已检出并计数(软告警)")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑 /opt/homebrew/bin/python3 sim/nm_07_3_rhythm_conflict_detection.py:
采样 500 条路径(seed=20261016),节拍命中 4310 次
张力阈值:峰值 >=4 谷值 <=2;密度可读区间 [250, 750] 千分
峰值节拍 1928 次 = 447 千分 谷值节拍 1500 次 = 348 千分
规则 A 绝对错配(峰处极疏 / 谷处极密):3 次 = 0 千分
规则 B 方向错配(峰处低于本路均值 / 谷处高于本路均值):2810 次 = 651 千分
含 A 冲突的路径 3 / 500 含 B 冲突的路径 500 / 500
单条路径最多 A 冲突 1 最多 B 冲突 8
平均密度:峰值节拍处 373 千分 谷值节拍处 502 千分 全路径基线 406 千分
wrote out/nm_07_3_conflict.csv / .json
OK nm_07_3_rhythm_conflict_detection:密度-张力错配已检出并计数(软告警)
判读:绝对错配几乎没有,方向错配却是系统性的
第一,绝对错配只有 3 次(0 千分)。 4310 次节拍命中里,只有 3 次张力峰值处的密度跌破了可读区间下界 250——而且这 3 次分散在 3 条不同路径上,单条最多 1 次。这说明这张图没有「峰处极疏」的结构性缺陷:张力冲到 4 或 5 的地方,信息量仍稳在可读区间内。规则 A 的价值正在于此——它是强判据,触发了就是真问题;实测几乎不触发,就是一个阴性结论。
第二,方向错配 2810 次(651 千分),500 条路径无一幸免。 这是本节真正的发现。峰值节拍处的平均密度是 373 千分,谷值节拍处是 502 千分,而全路径基线是 406 千分——峰处比谷处还稀 129 千分。方向是系统性反向的:张力越高、信息反而越疏。原因在结构:张力峰值集中在第二、三夜(drowned_bell 的 5、breakwater 的 5、fog_gate 的 5),而那时新场景、新 flag 大多已在第一夜解锁完毕,可新增的信息本就少;谷值节拍反而落在开场(pier 的 1、tavern 的 2),那里全是「新」的东西。于是张力曲线与密度曲线天然反相。
第三,两条规则给出的是相反的量级,而这正是要点。 同一个「节奏冲突」,用绝对判据只有 3 次,用相对判据有 2810 次。差别不在现象,在口径——绝对判据问「有没有撞破可读边界」,相对判据问「峰谷有没有同向」。6.3 定的纪律在这里兑现:报一个冲突数时必须带上它是哪条口径算的,否则 3 与 2810 会让人得出完全相反的结论。
| 检查项 | 实测 | 合格标准 |
|---|---|---|
| 节拍命中 | 4310 次 | 与采样规模对账 |
| 峰值 / 谷值节拍 | 1928 / 1500 次 | 447 / 348 千分 |
| 规则 A 绝对错配 | 3 次 = 0 千分 | 软告警,不硬失败 |
| 规则 B 方向错配 | 2810 次 = 651 千分 | 同上 |
| 峰 / 谷 / 基线密度 | 373 / 502 / 406 千分 | 峰低于谷 = 反相 |
| 口径声明 | 两条判据分列 | 不混用绝对与相对 |
本章到此收口:7.1 把节奏量成一个整数比值,7.2 把密度序列压在可读区间,7.3 把密度与张力的错配检出并计数。三步连起来,回答的是同一个问题——这条路径推进得快不快、密不密、密与紧对不对得上。全部走整数千分位,全部显式声明是 proxy,只做相对比较,不声称任何绝对体验。下一章把镜头从「节奏」转到「关系」,看好感度如何沿路径长成一条曲线。
阅读导航:上一节:7.2 叙事密度的控制 · 下一节:8.1 好感度随路径的变化 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。