3.3 设定的暴露节奏
游戏叙事设计概览
用散文提醒过「设定不要一次倒完」,那是经验之谈;本节把「什么时候倒」写成一个可统计的量:每条设定何时被玩家第一次知道,落在哪一夜,多少比例的路径根本不会知道。3.1 说了 lore 要靠 reveal_flag 挂到状态上,3.2 查了设定之间不打架,本节算的是同一批设定在时间轴上的分布。
揭示时机是一个状态,不是一个时间点
一个常见的写法是给设定标个「第几章揭示」。在这份规格里没有这种字段,取而代之的是 lore[].reveal_flag——每条 lore 挂一个 flag,这条设定何时被知道,等价于那个 flag 何时被置 1。这个转换带来三个好处:时机由玩家的选择决定而不是作者排期;同一份 reveal_flag 可以被多个选项设置,于是同一设定在不同路径上落在不同位置;「始终没揭示」也变成一个可数的结果(flag 没被置 1),而不是一个盲区。
沿路径统计首次揭示位置
用自实现的可播种 PRNG(Lehmer MINSTD,state = state * 48271 % 2147483647)采样 20000 条路径,每条路径从 meta.start 走到结局,记录每条 lore 首次揭示时的步号与所在幕,最后聚合成「第一夜 / 第二夜 / 第三夜 / 未揭示」四列的千分比。全程不用 random 模块,也不做浮点运算——分布是整数计数,曲线是整数平均。
#!/usr/bin/env python3
# file: sim/na_03_exposure_rhythm.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/na_03_exposure_rhythm.py
"""3.3 设定的暴露节奏(S 层):沿采样路径统计每条 lore 的首次揭示位置,并出一张 SVG。
设定不是一次倒完的。`lore[].reveal_flag` 把「揭示时机」写成**状态**:
某条 lore 何时被玩家知道,取决于它的 `reveal_flag` 在哪一步、哪一个场景被置 1。
本脚本用自实现的可播种 PRNG(Lehmer MINSTD)采样 20000 条路径,对每条 lore 记录
**首次揭示**的步号与幕号,聚合成一张「首次揭示位置分布」表,并用纯 `xml.etree`
画成 SVG(无 matplotlib)。
产物:
out/na03_exposure.json —— 每条 lore 的首次揭示分布(千分比)与平均步号
out/exposure_rhythm.svg —— 6 行(lore)× 4 列(第一夜/第二夜/第三夜/未揭示)的
分布网格 + 一条「平均已揭示条数」的累积曲线
随机数一律自实现、可播种;断言只对**整数**做(计数与千分比),不对文本做。
"""
from __future__ import annotations
import json
import os
import sys
import xml.etree.ElementTree as ET
SVG_NS = "http://www.w3.org/2000/svg"
ET.register_namespace("", SVG_NS)
SAMPLE_N = 20000
SEED = 20261016
MAX_STEP = 24
BUCKETS = [1, 2, 3, 0] # 幕 1/2/3,0 表示未揭示
BUCKET_LABEL = {1: "第一夜", 2: "第二夜", 3: "第三夜", 0: "未揭示"}
def load_spec() -> dict:
here = os.path.dirname(os.path.abspath(__file__))
with open(os.path.join(here, "../config/story_spec.json"), encoding="utf-8") as fh:
return json.load(fh)
def eval_cond(c, st: dict) -> bool:
if c is None:
return True
k = c[0]
if k == "flag":
return st["flags"].get(c[1], 0) == c[2]
if k == "aff":
v, op, n = st["aff"].get(c[1], 0), c[2], c[3]
return {">=": v >= n, "<": v < n, "==": v == n, "!=": v != n}[op]
if k == "item":
return st["items"].get(c[1], 0) == c[2]
if k == "and":
return eval_cond(c[1], st) and eval_cond(c[2], st)
if k == "or":
return eval_cond(c[1], st) or eval_cond(c[2], st)
if k == "not":
return not eval_cond(c[1], st)
raise ValueError(f"unknown cond op: {k}")
def apply_effects(eff: dict, st: dict) -> dict:
aff, flags, items = dict(st["aff"]), dict(st["flags"]), dict(st["items"])
for k, v in (eff.get("set") or {}).items():
flags[k] = v
for k, v in (eff.get("add") or {}).items():
aff[k] = min(100, max(0, aff.get(k, 0) + v))
for k, v in (eff.get("grant") or {}).items():
items[k] = v
for k in (eff.get("revoke") or {}):
items[k] = 0
return {"aff": aff, "flags": flags, "items": items}
def sample(spec: dict) -> tuple[dict, list[int]]:
by_id = {n["id"]: n for n in spec["dialogue"]}
scene_act = {n["id"]: n["act"] for n in spec["scene_graph"]["nodes"]}
node_act = {n["id"]: scene_act[n["scene"]] for n in spec["dialogue"]}
lore = spec["world"]["lore"]
flags0 = {f: 0 for f in sorted(spec["state"]["flags"])}
aff0 = {c["id"]: c["affinity"]["init"] for c in spec["characters"]}
init = {"aff": aff0, "flags": flags0, "items": {i: 0 for i in sorted(spec["state"]["items"])}}
rng = SEED
def rnd(mod: int) -> int:
nonlocal rng
rng = (rng * 48271) % 2147483647
return rng % mod
# first_act[lore_id][bucket] = 计数;cum[s] = 该步已揭示条数之和
first_act = {L["id"]: {b: 0 for b in BUCKETS} for L in lore}
cum = [0] * (MAX_STEP + 1)
steps_seen = [0] * (MAX_STEP + 1)
for _ in range(SAMPLE_N):
node, st, step = spec["meta"]["start"], init, 0
revealed: dict[str, int] = {}
cum[0] += 0
steps_seen[0] += 1
for _step in range(MAX_STEP):
cur = by_id[node]
if cur.get("ending"):
break
opts = [c for c in (cur.get("choices") or []) if eval_cond(c.get("when"), st)]
if not opts:
break
c = opts[rnd(len(opts))]
st = apply_effects(c.get("effects") or {}, st)
node = c["goto"]
step += 1
for L in lore:
if L["id"] not in revealed and st["flags"].get(L["reveal_flag"], 0) == 1:
revealed[L["id"]] = node_act[node]
cum[step] += len(revealed)
steps_seen[step] += 1
for s in range(step + 1, MAX_STEP + 1): # 已结束的路径保持终值
cum[s] += len(revealed)
steps_seen[s] += 1
for L in lore:
first_act[L["id"]][revealed.get(L["id"], 0)] += 1
curve = [cum[s] // max(1, steps_seen[s]) for s in range(MAX_STEP + 1)]
return first_act, curve
def el(tag: str, **attrs) -> ET.Element:
return ET.Element(f"{{{SVG_NS}}}{tag}", {k: str(v) for k, v in attrs.items()})
def txt(parent, x, y, s, size=12, anchor="start", fill="#334155"):
t = el("text", x=x, y=y, **{"font-size": size, "font-family": "ui-sans-serif, system-ui",
"text-anchor": anchor, "fill": fill})
t.text = s
parent.append(t)
def heat(p: int) -> str:
t = min(1.0, p / 600.0)
r, g, b = 239 - int(210 * t), 246 - int(168 * t), 255 - int(39 * t)
return f"#{r:02x}{g:02x}{b:02x}"
def build_svg(lore: list[dict], dist: dict, curve: list[int], path: str) -> int:
W, H = 820, 470
L, T = 150, 46
cw, ch = 120, 40
svg = el("svg", width=W, height=H, viewBox=f"0 0 {W} {H}")
svg.append(el("rect", x=0, y=0, width=W, height=H, fill="#ffffff"))
txt(svg, 24, 24, "《雾港夜航》设定暴露节奏:每条 lore 的首次揭示位置(采样 20000 条路径)", 13, "start", "#0f172a")
for j, b in enumerate(BUCKETS):
txt(svg, L + j * cw + cw // 2, T - 12, BUCKET_LABEL[b], 12, "middle", "#0f172a")
for i, Lr in enumerate(lore):
y = T + i * ch
txt(svg, L - 12, y + ch // 2 + 4, Lr["id"], 12, "end", "#334155")
for j, b in enumerate(BUCKETS):
p = dist[Lr["id"]][b]
x = L + j * cw
svg.append(el("rect", x=x + 2, y=y + 2, width=cw - 4, height=ch - 4, rx=4,
fill=heat(p), stroke="#e2e8f0", **{"stroke-width": 1}))
txt(svg, x + cw // 2, y + ch // 2 + 4, f"{p}", 11, "middle",
"#ffffff" if p > 330 else "#334155")
# 累积曲线:平均已揭示条数
base_y, curve_h, curve_w = 330, 90, 620
txt(svg, 24, base_y - 12, "平均已揭示条数(沿步号)", 12, "start", "#0f172a")
n = len(curve)
pts = []
for s, v in enumerate(curve):
px = 150 + (curve_w * s) // max(1, n - 1)
py = base_y + curve_h - (curve_h * v) // max(1, max(curve))
pts.append(f"{px},{py}")
svg.append(el("line", x1=150, y1=base_y + curve_h, x2=150 + curve_w, y2=base_y + curve_h,
stroke="#94a3b8", **{"stroke-width": 1.2}))
svg.append(el("polyline", points=" ".join(pts), fill="none", stroke="#0ea5e9",
**{"stroke-width": 2.5, "stroke-linejoin": "round"}))
txt(svg, 150, base_y + curve_h + 18, "步 0", 10, "middle", "#94a3b8")
txt(svg, 150 + curve_w, base_y + curve_h + 18, f"步 {n - 1}", 10, "middle", "#94a3b8")
txt(svg, 150 + curve_w + 8, base_y + 14, f"max {max(curve)}", 10, "start", "#0284c7")
tree = ET.ElementTree(svg)
ET.indent(tree, space=" ")
tree.write(path, encoding="utf-8", xml_declaration=True)
return os.path.getsize(path)
def readback(path: str) -> dict:
"""按**局部名**精确计数:带 xmlns 的 SVG 里 `iter("rect")` 一个都取不到,
但改用 `endswith` 又会让 `polyline` 被算进 `line`——所以先剥命名空间再比对。"""
root = ET.parse(path).getroot()
counts: dict[str, int] = {}
for e in root.iter():
if not isinstance(e.tag, str):
continue
local = e.tag.rsplit("}", 1)[-1]
if local in ("rect", "polyline", "text", "line"):
counts[local] = counts.get(local, 0) + 1
return counts
def main() -> int:
spec = load_spec()
lore = spec["world"]["lore"]
first_act, curve = sample(spec)
dist = {L["id"]: {b: (first_act[L["id"]][b] * 1000) // SAMPLE_N for b in BUCKETS} for L in lore}
print(f"采样 {SAMPLE_N} 条路径,seed={SEED},PRNG=Lehmer MINSTD")
print(f"{'lore':<12}{'reveal_flag':<22}{'第一夜':>8}{'第二夜':>8}{'第三夜':>8}{'未揭示':>8}")
for L in lore:
d = dist[L["id"]]
print(f"{L['id']:<12}{L['reveal_flag']:<22}{d[1]:>8}{d[2]:>8}{d[3]:>8}{d[0]:>8}")
never = sum(dist[L["id"]][0] for L in lore)
early = sum(dist[L["id"]][1] for L in lore)
print(f"千分比合计:第一夜 {early} 未揭示 {never}(每条 lore 一列,6 条合计上限 6000)")
print(f"平均已揭示条数曲线(步 0..{MAX_STEP}):{curve}")
print(f"首末:步 0 = {curve[0]} 条,步 {MAX_STEP} = {curve[-1]} 条")
here = os.path.dirname(os.path.abspath(__file__))
out = os.path.join(here, "../out")
os.makedirs(out, exist_ok=True)
p = os.path.join(out, "exposure_rhythm.svg")
size = build_svg(lore, dist, curve, p)
counts = readback(p)
print(f"wrote out/exposure_rhythm.svg {size} bytes {counts}")
assert counts.get("rect", 0) == len(lore) * len(BUCKETS) + 1, "矩形数应为 lore×桶 + 背景"
assert counts.get("polyline", 0) == 1, "应恰有一条累积曲线"
assert len(curve) == MAX_STEP + 1 and curve[0] == 0, "累积曲线应自 0 起步"
with open(os.path.join(out, "na03_exposure.json"), "w", encoding="utf-8") as fh:
json.dump({"sample_n": SAMPLE_N, "seed": SEED, "dist_permille": dist, "curve": curve},
fh, ensure_ascii=False, indent=2)
fh.write("\n")
for L in lore:
assert 0 <= dist[L["id"]][1] + dist[L["id"]][2] + dist[L["id"]][3] <= 1000, "分布越界"
print("OK na_03_exposure_rhythm:6 条 lore 的首次揭示分布已聚合,SVG 回读通过")
return 0
if __name__ == "__main__":
sys.exit(main())
实跑 /opt/homebrew/bin/python3 sim/na_03_exposure_rhythm.py:
采样 20000 条路径,seed=20261016,PRNG=Lehmer MINSTD
lore reveal_flag 第一夜 第二夜 第三夜 未揭示
lore_tide knows_tide_rule 500 135 0 363
lore_bell knows_bell_legend 499 377 0 123
lore_yan knows_yan_missing 501 0 0 498
lore_light saw_lighthouse_light 0 1000 0 0
lore_ledger found_yan_journal 0 252 0 747
lore_fog cave_explored 0 247 0 752
千分比合计:第一夜 1500 未揭示 2483(每条 lore 一列,6 条合计上限 6000)
平均已揭示条数曲线(步 0..24):[0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3]
首末:步 0 = 0 条,步 24 = 3 条
wrote out/exposure_rhythm.svg 8154 bytes {'rect': 25, 'text': 39, 'line': 1, 'polyline': 1}
OK na_03_exposure_rhythm:6 条 lore 的首次揭示分布已聚合,SVG 回读通过
读出来:三夜各揭一半,平均只揭三条
第三夜整列是 0。六条 lore 没有一条的首次揭示发生在第三夜——这不是「第三夜没有设定」,而是「所有设定在第二夜结束前都已经有机会被知道」。第三夜的任务是收束(点灯、鸣钟、结账),不是铺新信息。
第一夜揭三条、第二夜揭三条,分工清楚:knows_tide_rule(潮汐规律)、knows_bell_legend(沉钟传说)、knows_yan_missing(砚失踪)在抵港当晚就可能听到,属于环境类设定;saw_lighthouse_light、found_yan_journal、cave_explored 要等到第二夜,属于调查类设定——它们不是「听说」,而是「去过」。
lore_light 是 1000‰。第一夜结束时那个必经的二选一(去灯塔基座或先去鱼市街)两个分支都会设置 saw_lighthouse_light,所以这条设定对所有玩家都成立,落在第二夜是路径结构决定的,不是随机结果。这是保底设定:它可以被用作后续选项的前置,因为不存在「没拿到它」的玩家。
平均只揭三条。累积曲线从步 0 的 0 条爬到步 14 的 3 条,此后平在 3——采样路径平均只让玩家知道 6 条设定里的 3 条。lore_ledger(砚的日志)与 lore_fog(十七个名字)各有约 75% 的路径不会揭示,它们藏在 tide_cave 这条支线后面。暴露是分岔的:不同玩家看到的是不同的半张设定表,这正是「不是一次倒完」在数据上的样子。
SVG:网格 + 累积曲线
产物 out/exposure_rhythm.svg 由纯 xml.etree 生成(本机无 matplotlib)。上半部是 6 行 × 4 列的热力网格,每格填该 lore 落在该桶的千分比;下半部是累积曲线。回读时有个坑要绕两次:带 xmlns 的 SVG 里 root.iter("rect") 匹配不到任何元素,真实标签是 {http://www.w3.org/2000/svg}rect;但改用 tag.endswith("rect") 这类后缀匹配,又会把 polyline 同时算进 line。脚本的做法是先剥掉命名空间再按局部名精确比对,所以输出里 line: 1 与 polyline: 1 各归各。rect: 25 正好等于 6 × 4 + 1(背景),两条断言都通过。
自检表
| 检查项 | 合格标准 | 本案例 |
|---|---|---|
| 随机可播种 | 自实现 MINSTD,不用 random 模块 | seed 20261016 |
| 整数口径 | 分布是千分比计数,曲线是整数平均 | 无浮点输出 |
| 覆盖三夜 | 首次揭示按幕分桶,且单列「未揭示」 | 第三夜 0‰ 也是一个结论 |
| 保底可辨 | 1000‰ 的设定能被指认出来 | lore_light |
| 产物可回读 | ET.parse 通过,元素计数用 tag 后缀 | rect 25 / polyline 1 |
| 不越权归因 | 分布是采样结果,不说「玩家一定会」 | 结论限定在采样口径内 |
三节下来,world 段的每一类字段都被读成了可判定的量:矩阵可判对称与失衡、解锁可查硬软、规则待读法表、lore 可算分布。下一章离开「世界」,进入「人」——角色的目标、恐惧与秘密。
阅读导航:上一节:3.2 设定的一致性:冲突检测 · 下一节:4.1 角色的字段:目标、恐惧、秘密 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。