4.3 选择密度与节奏

选择密度量的是:一条路径上每隔多少节点出现一次有分量的选择。采样 500 条路径实测——选择密度 962 千分,有分量选择占 917 千分,相邻有分量选择的间隔均值 1.094 步、最大 3 步;三幕占比 433 / 296 / 270,第一夜最密。全程定点整数,与第 7 章的节奏度量共用同一套口径。

站内的独立游戏的叙事设计与分支对话 谈的是叙事节奏的设计经验——什么时候该让玩家出手、什么时候该让他读,配的是清单与示意图,代码不可跑。本节把「出手的疏密」变成一条可算的序列:一条路径上每隔多少节点出现一次有分量的选择、这个间隔怎么起伏。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 / 32500 条路径的 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 张力从哪里来:叙事节拍 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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