7.2 叙事密度的控制

密度是单位节点上新增了多少信息:新场景、新 flag、新物品、有意义选择各算一件。本节用滑窗 W=5 把 500 条路径切成 10680 个窗口,实测全局密度 409 千分、窗口区间 200~600 千分、偏疏窗口 62 个(5 千分)、偏密 0 个,可读区间 [250,750] 是设计参数,全程整数并声明 proxy。

独立游戏的叙事设计与分支对话 那篇用散文谈「信息给得太快读者跟不上、给得太慢读者会走神」这条经验,靠的是修改稿子时的直觉,代码不可跑;本节把同一件事变成一条可算的密度曲线:沿路径每个窗口新增了多少信息,落在哪个区间。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 节奏冲突的检测 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「narrative」更多文章

  1. 15.3 规模告警
  2. 15.2 求值与遍历的开销
  3. 15.1 规格规模的增长