站内的独立游戏的叙事设计与分支对话 谈的是叙事节奏的设计经验——什么时候该让玩家出手、什么时候该让他读,配的是清单与示意图,代码不可跑。本节把「出手的疏密」变成一条可算的序列:一条路径上每隔多少节点出现一次有分量的选择、这个间隔怎么起伏。4.1 量单次后果的距离,4.2 判有没有后果,本节量后果在一条路上的疏密——它接的是第 7 章的节奏度量,只是这里只盯选择这一种事件。
4.3 选择密度与节奏
先把口径讲死:密度与间隔都是代理指标(proxy),量的是结构量——选择点在路径上的分布,不是玩家的主观节奏感。玩家觉得某段「拖沓」或「一直在选」,需真实业务环境、命令行不可复现,本套不做。这些数只用于同一份规格内部的相对比较:比两种采样策略谁的密度更稳、比规格演化前后间隔怎么变。
定义:选择点、有分量选择、间隔
三样东西,全部写死在代码里:
| 记号 | 含义 | 规则 |
|---|---|---|
| 选择点 | 路径上除终局节点外的每个对话节点 | 本规格下全部非终局节点都至少有一个选项,故节点级选择密度恒约为 1000‰ |
| 有分量选择 | 该步所选选项不是状态级假选择(判据同 4.2:推进结果与同节点另一可用选项不同) | 真正有信息量的是这一档 |
| 选择间隔 | 相邻两个有分量选择之间隔了几个节点 | 1 = 连续两步都是有分量选择 |
因为每个非终局节点都逼着玩家出手,「每隔多少节点出现一次选择」在原始口径下恒为 1——这条数没有信息量。所以真正要量的是有分量那一档:一条路上有多少步是「选了等于没选」(4.1 的写而不读、4.2 的假选择都落在这里),以及它们把有分量的选择拉开多远。
S 层:采样 500 条路径
脚本 sim/nm_04_3_choice_density_rhythm.py 用 Lehmer MINSTD(rng = rng * 48271 % 2147483647,seed=20261016)在可用选项里均匀随机采 500 条路径,逐条算密度、有分量占比与间隔序列,落 out/nm_04_3_density.csv 与 .json:
#!/usr/bin/env python3
# file: sim/nm_04_3_choice_density_rhythm.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/nm_04_3_choice_density_rhythm.py
"""卷三 4.3 选择密度与节奏(S 层):一条路径上每隔多少节点出现一次「有分量的选择」。
可判定口径(写死在代码里,不按结果调参):
1) 选择点 = 路径上除终局节点外的每个对话节点(本规格下全部非终局节点都至少有一个选项,
故**节点级选择密度恒约为 1000‰**——真正有信息量的是「有分量」那部分)。
2) 有分量选择 = 该步所选选项**不是状态级假选择**:其推进结果(节点 + 好感度 + flag +
物品 逐字段)与同节点另一个在该状态下可选的选项**不同**。判据同 4.2。
3) 选择间隔 = 相邻两个有分量选择之间隔了几个节点(1 = 连续两步都是有分量选择)。
4) 节奏 = 有分量选择在**三幕**上的分布占比,以及间隔序列的均值 / 极差。
用 Lehmer MINSTD(rng = rng * 48271 % 2147483647)在可用选项里均匀随机采样 500 条路径。
这是一个 **proxy**:密度与间隔是**结构量**,只用于不同规格 / 不同采样策略之间的**相对比较**,
不预测玩家的主观节奏感,也不声称「间隔短 = 体验好」。
产出 out/nm_04_3_density.csv(每条路径一行)与 out/nm_04_3_density.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__))
LO, HI = 0, 100
SEED = 20261016
N_PATHS = 500
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"])
act_of_scene = {n["id"]: n.get("act") for n in spec["scene_graph"]["nodes"]}
def cond_ok(c, s) -> bool:
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 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}}
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 is_fake(c, opts, s) -> bool:
k = advance_key(c, s)
return any(o is not c and advance_key(o, s) == k for o in opts)
rng = SEED
rows = []
for p in range(N_PATHS):
s = init_state()
steps = 0
scenes = []
act_cnt = {1: 0, 2: 0, 3: 0}
marked = [] # 1 = 有分量选择
while True:
cur = by[s["node"]]
if cur.get("ending"):
scenes.append(cur["scene"])
break
scenes.append(cur["scene"])
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)]
steps += 1
a = act_of_scene.get(cur["scene"])
if a in act_cnt:
act_cnt[a] += 1
marked.append(0 if is_fake(c, opts, s) else 1)
aff, fl, it = apply_eff(c.get("effects") or {}, s)
s = {"node": c["goto"], "aff": aff, "flags": fl, "items": it}
pos = [i for i, m in enumerate(marked) if m]
gaps = [b - a for a, b in zip(pos, pos[1:])]
rows.append({
"path": p,
"nodes": len(scenes),
"choices": steps,
"scenes": len(set(scenes)),
"meaningful": len(pos),
"density_permille": steps * 1000 // len(scenes),
"meaningful_permille": len(pos) * 1000 // steps if steps else 0,
"act1": act_cnt[1], "act2": act_cnt[2], "act3": act_cnt[3],
"max_gap": max(gaps) if gaps else 0,
})
n = len(rows)
tot_nodes = sum(r["nodes"] for r in rows)
tot_choices = sum(r["choices"] for r in rows)
tot_mean = sum(r["meaningful"] for r in rows)
# 全局间隔分布:再采一遍只收间隔(同种子同序列,代价可忽略)
gap_hist = Counter()
rng = SEED
for p in range(N_PATHS):
s = init_state()
marked = []
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)]
marked.append(0 if is_fake(c, opts, s) else 1)
aff, fl, it = apply_eff(c.get("effects") or {}, s)
s = {"node": c["goto"], "aff": aff, "flags": fl, "items": it}
pos = [i for i, m in enumerate(marked) if m]
for a, b in zip(pos, pos[1:]):
gap_hist[b - a] += 1
mean_gap_milli = (sum(k * v for k, v in gap_hist.items()) * 1000 //
sum(gap_hist.values())) if gap_hist else 0
max_gap = max(gap_hist) if gap_hist else 0
act_tot = {1: sum(r["act1"] for r in rows), 2: sum(r["act2"] for r in rows),
3: sum(r["act3"] for r in rows)}
act_all = sum(act_tot.values())
out_dir = os.path.join(HERE, "../out")
os.makedirs(out_dir, exist_ok=True)
with open(os.path.join(out_dir, "nm_04_3_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,
"nodes_mean": tot_nodes // n,
"nodes_min": min(r["nodes"] for r in rows),
"nodes_max": max(r["nodes"] for r in rows),
"choices_total": tot_choices,
"density_permille": tot_choices * 1000 // tot_nodes,
"meaningful_total": tot_mean,
"meaningful_permille": tot_mean * 1000 // tot_choices,
"gap_mean_milli": mean_gap_milli,
"gap_max": max_gap,
"gap_hist": {str(k): v for k, v in sorted(gap_hist.items())},
"act_share_permille": {str(k): act_tot[k] * 1000 // act_all for k in (1, 2, 3)},
"dry_stretch_paths": sum(1 for r in rows if r["max_gap"] >= 2),
}
with open(os.path.join(out_dir, "nm_04_3_density.json"), "w", encoding="utf-8") as fh:
json.dump(summary, fh, ensure_ascii=False, indent=1)
ne = sum(1 for nd in nodes if not nd.get("ending"))
print(f"结构密度:非终局节点 {ne} 个,选项 115 条,平均每节点 {115 * 1000 // ne} 毫个选项")
print(f"采样 {n} 条路径(seed={SEED}):节点数 {summary['nodes_min']}/"
f"{summary['nodes_mean']}/{summary['nodes_max']}(min/mean/max)")
print(f"选择密度 {summary['density_permille']} 千分(选择点 {tot_choices} / 节点 {tot_nodes})")
print(f"有分量选择 {tot_mean} 次,占有分量比例 {summary['meaningful_permille']} 千分")
print(f"有分量选择的间隔:均值 {mean_gap_milli} 毫({mean_gap_milli / 1000:.3f} 步) 最大 {max_gap} 步 "
f"分布 {dict(sorted(gap_hist.items()))}")
print(f"三幕有分量选择占比 {summary['act_share_permille']}(第一夜/第二夜/第三夜)")
print(f"存在 >=2 步空档的路径 {summary['dry_stretch_paths']} / {n}")
print(f"wrote out/nm_04_3_density.csv rows={len(rows)} / .json")
if tot_choices == 0:
print("FAIL 采样未走出任何选择")
return 1
print("OK nm_04_3_choice_density_rhythm:选择密度与节奏已量出")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑 /opt/homebrew/bin/python3 sim/nm_04_3_choice_density_rhythm.py:
结构密度:非终局节点 43 个,选项 115 条,平均每节点 2674 毫个选项
采样 500 条路径(seed=20261016):节点数 23/26/32(min/mean/max)
选择密度 962 千分(选择点 12680 / 节点 13180)
有分量选择 11629 次,占有分量比例 917 千分
有分量选择的间隔:均值 1094 毫(1.094 步) 最大 3 步 分布 {1: 10083, 2: 1041, 3: 5}
三幕有分量选择占比 {'1': 433, '2': 296, '3': 270}(第一夜/第二夜/第三夜)
存在 >=2 步空档的路径 486 / 500
wrote out/nm_04_3_density.csv rows=500 / .json
OK nm_04_3_choice_density_rhythm:选择密度与节奏已量出
判读:密度饱和,间隔见真章
| 观察 | 数字 | 读法 |
|---|---|---|
| 结构密度 | 2674 毫 | 43 个非终局节点摊 115 个选项,平均每节点 2.674 个 |
| 路径长度 | 23 / 26 / 32 | 500 条路径的 min / mean / max 节点数 |
| 选择密度 | 962 千分 | 选择点 12680 / 节点 13180——几乎每个节点都要出手 |
| 有分量选择占比 | 917 千分 | 11629 / 12680 步是「选了会分岔」 |
| 间隔均值 | 1094 毫 | 相邻有分量选择平均隔 1.094 步 |
| 间隔分布 | 1:10083 · 2:1041 · 3:5 | 绝大多数相邻,偶尔隔一两步 |
| 三幕占比 | 433 / 296 / 270 | 有分量选择在第一夜最密,逐幕递减 |
第一层读法:密度是饱和的。 选择密度 962‰ 离 1000‰ 只差 38 个千分点——那点缺口正好是 500 条路径各有一个终局节点不计选择点(500 / 13180 = 37.9‰)。这说明本规格的设计取向是**「每一步都让玩家出手」,密度这个指标在它身上几乎没有区分度;有区分度的是有分量**那一档。
第二层读法:间隔把「假选择」摊开了。 有分量占比 917‰ 意味着 83‰ 的步是「选了等于没选」(4.1 的 16 条无差量 + 28 条写而不读,加 4.2 的状态级假,在路径上汇成这一档)。间隔分布 {1: 10083, 2: 1041, 3: 5} 说明这种「空步」很少连续出现:1041 次隔 1 步、5 次隔 2 步,最长空档 3 步。486 / 500 条路径至少出现过一次 ≥2 步的空档——几乎每条路都会遇到一次「连续两下没分岔」。
第三层读法:三幕的密度是有形状的。 有分量选择在第一夜占 433‰、第二夜 296‰、第三夜 270‰——递减。这不是缺陷,是结构:第一夜是「抵港、铺线索、认识人」,选项最密;第三夜收敛到结局,出手变少。这条三幕占比序列就是本章交给第 7 章的东西——第 7 章把同一套口径推广到所有叙事事件(节拍、台词、场景切换),量的是完整的节奏曲线;本节只截取了选择这一个截面。
还要提醒一个反直觉的点:间隔小不等于节奏好。 1.094 步的均值说明玩家几乎一直在做选择,这对「选择驱动」的叙事是合适的,但对「阅读驱动」的叙事就是打断。密度是中性的结构量,判断「合不合适」要看这份规格自己的设计意图,本节的数不替它下结论。
自检表
| 检查项 | 合格标准 |
|---|---|
| 真源唯一 | 只读 config/story_spec.json,判据同 4.2 |
| 随机可复现 | Lehmer MINSTD 自实现,seed 写死,禁内置 random |
| 整数不变量 | 密度 / 占比走千分位整数地板除,无浮点 |
| 口径分层 | 原始密度(饱和)与有分量密度分开报 |
| proxy 口径 | 密度与间隔只做相对比较,不声称玩家节奏感 |
三节到此收束:4.1 量后果离得多远,4.2 判有没有后果,4.3 量后果在一条路上有多密。 下一章换一个完全不同的量——把路径上的张力读成一条曲线。
阅读导航:上一节:4.2 假选择的检测 · 下一节:5.1 张力从哪里来:叙事节拍 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。