6.1 情感曲线的构造

情感曲线不是凭感觉画的,它由一条路径唯一决定:路径经过的节点落到场景,场景命中作者标注的 beats[].tension(整数 1–5),序列即成曲线。本节把确定性路径的 26 个节点构造成 11 槽场景序列与 11 点张力曲线 1 2 3 2 3 4 4 3 4 4 2,全程整数、不出现浮点。

《游戏设计》卷一《玩法机制与可执行规格》 给出的是机制层的可执行规格与守恒不变量——它的真源是数值与规则,量的是伤害、资源、回合这套动力学;本章接住它的真源做叙事层的曲线度量:把同一条路径经过的叙事节拍读成一条张力序列,再与设计目标逐项比。两者共享「整数化 + 确定性 + proxy 口径」的工程纪律,但输入、对象、工具完全不同——那里的一条曲线由仿真跑出来,这里的一条曲线由作者标注沿路径读出。本节先立「曲线怎么从路径长出来」这一步。

6.1 情感曲线的构造

先说一句口径:本节构造出来的「情感曲线」是一个代理指标(proxy)。它是作者标注的节拍张力沿一条路径的序列,不是玩家实际感受到的情绪强度——玩家情绪无法在命令行里复现,本套不做、也不假装能做。这条曲线只用于同一份规格内部的相对比较:比两条路径谁更陡、比规格演化前后曲线怎么变。任何「这段更感人」的绝对结论都不在本节的射程内(6.3 会把这条边界写成可机器检查的声明)。

四步构造:从路径到曲线

曲线由路径唯一决定,中间没有第二个自由参数。构造分四步,每一步都只做整数映射:

步输入输出规则
1路径(节点序列)场景序列每个节点查 dialogue[].scene,得到场景槽序列
2场景序列去重场景集去连续重复,保留先后顺序
3去重场景集节拍序列取 beats 中 scene 命中该集合的节拍,按节拍真源顺序
4节拍序列张力序列每个节拍取 beats[].tension(整数 1–5)

关键在第 3 步的口径:曲线取的是节拍真源顺序(beats 数组的下标序),不是玩家走动的先后序。这样同一条路径无论怎么绕,只要命中同一批场景,算出的曲线就是同一条——它量的是「这条路覆盖了哪一段结构」,而不是「玩家先去了哪」。「先去了哪」的先后序是第 1 章路径编码的对象,本节不重复。

张力的唯一真源

beats 段有 16 个节拍,每个节拍带一个整数 tension(1–5),全书常量里它们之和 = 52。这个整数是作者标注——是策划写下的「这一幕有多紧」,不是任何机器算出来的量。本节的曲线、6.2 的目标曲线、第 5 章的张力与释放,全部读这同一个字段,没有第二份真源。

S 层:把一条路径构造成曲线

脚本走一条确定性路径(每步选第一个条件成立的选项,同 nm_00_toc_metric.py),把它的 26 个节点构造成场景序列、节拍序列与张力曲线,落 out/nm_06_1_curve.json:

#!/usr/bin/env python3
# file: sim/nm_06_1_emotion_curve_construction.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/nm_06_1_emotion_curve_construction.py
"""卷三 6.1 情感曲线的构造(S 层)。

一条路径 → 经过的节点 → 场景序列 → 节拍序列 → 张力序列(情感曲线)。
张力的唯一真源是 beats[].tension(整数 1–5),本脚本只做「路径 → 曲线」这一步
构造,不做任何文本处理,全程整数。

与 nm_00_toc_metric.py 分工:那个给一页摘要(曲线 + 覆盖率 + 熵三件套);本脚本
只把「曲线怎么从路径长出来」单独量清楚——场景序列、节拍序列、张力序列,以及曲线
的形状统计(和 / 长度 / 千分位均值 / 极值)。

与《游戏设计》卷二 gd-play-09-2 的分工:那条曲线由机制动力学(HP / 资源 / 回合
节奏)仿真算出;本条曲线由作者标注的叙事节拍沿玩家路径的序列读出。
"""
from __future__ import annotations

import json
import os
import sys

AFF_LO, AFF_HI = 0, 100
HERE = os.path.dirname(os.path.abspath(__file__))
OUT = os.path.join(HERE, "../out")


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)          # not


def node(spec, nid):
    return next(n for n in spec["dialogue"] if n["id"] == nid)


def init_state(spec):
    aff = {v: next(c for c in spec["characters"] if c["id"] == v)["affinity"]["init"]
           for v in sorted(spec["state"]["vars"])}
    return {"node": spec["meta"]["start"], "aff": aff,
            "flags": {f: 0 for f in sorted(spec["state"]["flags"])},
            "items": {i: 0 for i in sorted(spec["state"]["items"])},
            "path": [spec["meta"]["start"]]}


def step(spec, s, c):
    eff = c.get("effects") or {}
    aff, flags, items = dict(s["aff"]), dict(s["flags"]), dict(s["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(AFF_HI, max(AFF_LO, 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 {"node": c["goto"], "aff": aff, "flags": flags, "items": items,
            "path": s["path"] + [c["goto"]]}


def walk(spec):
    """确定性策略:每步选第一个条件成立的选项。同 nm_00_toc_metric.py。"""
    s = init_state(spec)
    for _ in range(64):
        cur = node(spec, 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
        s = step(spec, s, opts[0])
    return s


def collapse(seq):
    """去掉连续重复,保留「第一次踏入某场景」的先后顺序。"""
    out = []
    for x in seq:
        if not out or out[-1] != x:
            out.append(x)
    return out


def main() -> int:
    with open(os.path.join(HERE, "../config/story_spec.json"), encoding="utf-8") as fh:
        spec = json.load(fh)

    s = walk(spec)
    path = s["path"]

    raw_scenes = [node(spec, nid)["scene"] for nid in path]
    scene_seq = collapse(raw_scenes)
    visited = set(scene_seq)

    beats = spec["beats"]
    beat_seq = [b for b in beats if b["scene"] in visited]        # 节拍真源顺序
    curve = [b["tension"] for b in beat_seq]                      # 情感曲线 = 张力序列
    n = len(curve)
    total = sum(curve)
    mean_permille = total * 1000 // n if n else 0

    print(f"真源         {os.path.basename(spec['meta']['code'])}  {spec['meta']['title']}")
    print(f"起点         {spec['meta']['start']}   节点数 {len(path)}   终局 {s['node']}")
    print(f"节点→场景    {len(path)} 个节点落到 {len(raw_scenes)} 个场景槽(含连续重复)")
    print(f"覆盖场景     {len(visited)} / {len(spec['scene_graph']['nodes'])}(去重后)")
    print(f"场景序列     {len(scene_seq)} 槽(去连续重复):{' → '.join(scene_seq)}")
    print(f"节拍序列     {' '.join(b['id'] for b in beat_seq)}")
    print(f"张力序列     {' '.join(str(v) for v in curve)}")
    print(f"曲线长度     {n}  (覆盖节拍 {n} / {len(beats)})")
    print(f"张力之和     {total}")
    print(f"均值千分位   {mean_permille} 毫  ({total} × 1000 // {n})")
    print(f"极值         min={min(curve)}  max={max(curve)}  (真源 tension ∈ 1–5)")

    # 整数不变量:tension ∈ [1,5],曲线长度 = 命中节拍数
    assert all(1 <= v <= 5 for v in curve), "tension 越界"
    assert n == sum(1 for b in beats if b["scene"] in visited)

    os.makedirs(OUT, exist_ok=True)
    with open(os.path.join(OUT, "nm_06_1_curve.json"), "w", encoding="utf-8") as fh:
        json.dump({"nodes": len(path), "ending": node(spec, s["node"]).get("ending", ""),
                   "scenes": scene_seq, "scene_unique": len(visited),
                   "beats": [b["id"] for b in beat_seq],
                   "curve": curve, "sum": total, "mean_permille": mean_permille,
                   "min": min(curve), "max": max(curve)},
                  fh, ensure_ascii=False, indent=1)
        fh.write("\n")
    print("wrote out/nm_06_1_curve.json")
    print("OK nm_06_1_emotion_curve_construction:路径 → 场景 → 节拍 → 张力序列已构造")
    return 0


if __name__ == "__main__":
    sys.exit(main())

本机实跑 /opt/homebrew/bin/python3 sim/nm_06_1_emotion_curve_construction.py:

真源         mistport  雾港夜航
起点         d_pier_1   节点数 26   终局 e_lit
节点→场景    26 个节点落到 26 个场景槽(含连续重复)
覆盖场景     9 / 14(去重后)
场景序列     11 槽(去连续重复):pier → tavern → pier → counting_house → watch_post → lighthouse_base → tide_cave → harbor_office → pier → lighthouse_top → dawn_quay
节拍序列     b01 b02 b04 b05 b06 b07 b09 b11 b12 b15 b16
张力序列     1 2 3 2 3 4 4 3 4 4 2
曲线长度     11  (覆盖节拍 11 / 16)
张力之和     32
均值千分位   2909 毫  (32 × 1000 // 11)
极值         min=1  max=4  (真源 tension ∈ 1–5)
wrote out/nm_06_1_curve.json
OK nm_06_1_emotion_curve_construction:路径 → 场景 → 节拍 → 张力序列已构造

判读:曲线是序列,不是集合

这条路径走了 26 个节点,落到 11 个场景槽(去连续重复后),去重后覆盖 9 / 14 个场景。它命中 11 / 16 个节拍,所以曲线长度是 11——曲线是「命中的节拍序列」,长度等于命中节拍数,这是构造规则直接给出的不变量,脚本用 assert 钉死。

观察数字读法
节点数26一条确定性路径的长度
场景槽11去连续重复后的序列长度(回头会重复出现)
覆盖场景9 / 14去重后的真实场景数
曲线长度11= 命中节拍数,构造规则的不变量
张力之和3211 个节拍的 tension 相加(全 16 个是 52)
均值(千分位)2909 毫32 × 1000 // 11,整数地板除,无浮点

注意「均值千分位」这个写法:11 个整数求平均本来是 2.909…,本套不写小数,一律按千分位整数地板除记成 2909 毫。这不是显示格式的偏好,而是跨语言对照的前提——Python 与 TypeScript 对浮点的舍入、打印、精度处理并不一致,只有整数才谈得上「逐位一致」。6.2 的 MAE 沿用同一条纪律。

这里还要提醒一个反直觉的点:曲线的长度不是固定的。同一份规格,走不同的路径会命中不同的节拍集合,曲线长度随之变化——本节这条路是 11 点,换一条路可能是 9 点或 14 点。所以两条曲线要放在一起比时,必须先确认它们是不是同一口径:路径口径的 MAE 只在同一条路径上自比才有意义,跨路径比较要回到全量口径(6.2 会把两个口径都算出来)。这也解释了为什么本卷目录篇那条 11 点曲线和本节是同一条——它们跑的是同一策略、同一条确定性路径。

自检表

检查项合格标准
真源唯一张力只读 beats[].tension,无第二份
构造确定同一路径算出的曲线唯一,无随机项
整数不变量tension ∈ [1,5];曲线长度 = 命中节拍数(脚本 assert)
全程无浮点均值走千分位整数地板除
proxy 口径曲线是作者标注序列,不是玩家情绪

曲线构造出来了,接下来要回答:它跟设计目标差多少。下一节把这条曲线与 metrics.target_tension_curve 逐项比,按千分位整数地板除算 MAE,并解释为什么当前这个 0 是自洽的证据而不是指标失效。

阅读导航:上一节:5.3 与机制张力的分工 · 下一节:6.2 目标曲线与 MAE 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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