5.3 与机制张力的分工

本章的张力来自叙事节拍结构,不是机制动力学。本节把这条边界跑成可核对的数:递归展开真源,叙事侧词元命中 143 次,机制侧词元 hp、damage、resource、turn 等命中 0 次——机制张力在这份规格上根本无法计算。分工不是一句口号,而是输入清单的差集。

5.3 与机制张力的分工

《游戏设计》卷二《策略涌现与体验度量》 里有一整套张力与情绪曲线的度量,它和本章的张力最像,也最容易被混为一谈。区别只有一句话:那一套的张力来自机制动力学——HP 怎么掉、资源怎么耗、回合节奏怎么推,曲线是跑一局仿真跑出来的;本章的张力来自叙事节拍结构——作者在 beats[].tension 里标注的整数(1–5)沿玩家路径的序列,曲线是规格投影出来的。两者共享「整数化 + 确定性 + proxy 口径」的工程纪律,但不共享任何规格字段。本节不写新指标,只把这条边界跑成一条可核对的差集。

两种张力,两套输入

先看两边各自需要什么输入。机制侧的张力要跑起来,至少得有生命值、资源、回合或时间步这几类量——没有它们,仿真根本无从开始,曲线也就无从谈起。叙事侧的张力则只要三样东西:每个节拍的张力标注、一条目标曲线(用来比对)、一张场景图(用来把节拍投影到路径上)。这三样在 config/story_spec.json 里都有,而前一类量一样都没有。

这不是巧合,是真源的定位决定的:这份规格描述的是结构与条件——世界、角色、弧光、分支、阈值,它的字段里没有 HP、没有资源、没有回合。所以「用这份规格算机制张力」不是难,是无米之炊:输入不存在,公式再漂亮也落不了地。反过来,「用这份规格算叙事张力」完全可行,因为输入就写在里面。

把这条判断从「读一遍规格的直觉」升级成「可复算的差集」,是本节 S 层要做的事。

为什么必须写死这条边界

把边界写死,不是为了划地盘,而是因为混用两套输入会直接算错。三件具体的事:

  • 指标不可比:机制张力曲线的纵轴是「一局战斗内的动力学起伏」,本章曲线的纵轴是「一条路径上的节拍标注」。把两条线画在同一张图上比高低,等于拿体温计和海拔仪比读数——单位不同、对象不同。
  • 输入不共享:机制侧要 HP / 资源 / 回合,本章要 beats / 目标曲线 / 场景图。没有一样是共用的,所以不存在「两套各算一半再拼起来」的做法。
  • 口径不可搬:机制侧那套「守恒不变量」在叙事侧没有对应物(叙事状态里没有守恒量),而叙事侧的「覆盖率 / 节拍投影」在机制侧也没有对应物。

一句话:两者共享的是工程纪律(整数化、确定性、proxy 口径),不是规格字段。纪律可以搬,字段不能搬——这也是卷一第 2.1 节 把「真源是结构与条件」写在字段全表最前面的原因。

S 层:把边界跑成一条差集

下面的脚本 sim/nm_05_3_mechanic_division.py 递归展开 config/story_spec.json,收集所有字段名与字符串标量(本规格 2597 个),然后对两组词元做命中统计:叙事侧(tension / beat / scene / ending / affinity / flag)应当有命中,机制侧(hp / health / damage / resource / turn / round / cooldown / stamina / mana / attack / defense)应当一个都没有。跑出这条差集,「本章的张力来自叙事节拍、不来自机制动力学」就不再是修辞。

#!/usr/bin/env python3
# file: sim/nm_05_3_mechanic_division.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/nm_05_3_mechanic_division.py
"""卷三 5.3 与机制张力的分工(S 层):把「边界」跑成可核对的数。

分工不是一句口号,而是**输入清单的差集**。本脚本把 config/story_spec.json 递归展开,
对每个字段名与字符串标量做词元命中统计:

  叙事侧词元(tension / beat / scene / ending / affinity / flag)应 > 0——它们是本节曲线的输入;
  机制侧词元(hp / damage / resource / turn / round / cooldown / stamina / mana)应为 0——
  这份规格里根本没有它们,所以「机制张力」在这份真源上**无法计算**,只能来自另一套规格。

把这条差集跑出来,「本章的张力来自叙事节拍、不来自机制动力学」就不再是修辞。
产出 out/nm_05_3_mechanic_division.json。全部定点整数。
"""
from __future__ import annotations

import json
import os
import sys

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
OUT = os.path.join(ROOT, "out")

NARRATIVE = ["tension", "beat", "scene", "ending", "affinity", "flag"]
MECHANIC = ["hp", "health", "damage", "resource", "turn", "round", "cooldown", "stamina", "mana", "attack", "defense"]


def load_spec() -> dict:
    with open(os.path.join(ROOT, "config/story_spec.json"), encoding="utf-8") as fh:
        return json.load(fh)


def tokens(obj, acc):
    """递归收集所有 dict 键与字符串标量,小写后入 acc。"""
    if isinstance(obj, dict):
        for k, v in obj.items():
            acc.append(str(k).lower())
            tokens(v, acc)
    elif isinstance(obj, list):
        for v in obj:
            tokens(v, acc)
    elif isinstance(obj, str):
        acc.append(obj.lower())


def main() -> int:
    spec = load_spec()
    acc: list[str] = []
    tokens(spec, acc)

    def hits(word: str) -> int:
        return sum(1 for t in acc if word in t)

    nar = {w: hits(w) for w in NARRATIVE}
    mec = {w: hits(w) for w in MECHANIC}

    print(f"词元总数     {len(acc)}(字段名 + 字符串标量)")
    print("叙事侧词元:")
    for w in NARRATIVE:
        print(f"  {w:<10} {nar[w]}")
    print("机制侧词元:")
    for w in MECHANIC:
        print(f"  {w:<10} {mec[w]}")

    nar_total = sum(nar.values())
    mec_total = sum(mec.values())
    print(f"叙事侧命中   {nar_total}   机制侧命中 {mec_total}")

    # 本节的曲线只读三样东西,逐一确认存在
    inputs = {
        "beats[].tension": len(spec["beats"]),
        "metrics.target_tension_curve": len(spec["metrics"]["target_tension_curve"]),
        "scene_graph.nodes": len(spec["scene_graph"]["nodes"]),
    }
    for k, v in inputs.items():
        print(f"输入 {k:<32} {v}")

    assert nar_total > 0, "叙事侧词元应命中"
    assert mec_total == 0, "本规格不应出现机制侧词元"
    assert len(spec["beats"]) == len(spec["metrics"]["target_tension_curve"])

    os.makedirs(OUT, exist_ok=True)
    report = {
        "token_total": len(acc),
        "narrative_tokens": nar,
        "mechanic_tokens": mec,
        "narrative_hits": nar_total,
        "mechanic_hits": mec_total,
        "narrative_inputs": inputs,
        "division": "叙事张力输入来自 beats[].tension 沿路径的序列;机制张力需 HP/资源/回合节奏,本规格无此字段",
        "invariant_residual": 0,
    }
    with open(os.path.join(OUT, "nm_05_3_mechanic_division.json"), "w", encoding="utf-8") as fh:
        json.dump(report, fh, ensure_ascii=False, indent=2)
        fh.write("\n")
    print("wrote out/nm_05_3_mechanic_division.json")
    print(f"OK nm_05_3_mechanic_division:叙事侧 {nar_total} 命中,机制侧 {mec_total} 命中,边界成立")
    return 0


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

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

词元总数     2597(字段名 + 字符串标量)
叙事侧词元:
  tension    18
  beat       7
  scene      66
  ending     8
  affinity   8
  flag       36
机制侧词元:
  hp         0
  health     0
  damage     0
  resource   0
  turn       0
  round      0
  cooldown   0
  stamina    0
  mana       0
  attack     0
  defense    0
叙事侧命中   143   机制侧命中 0
输入 beats[].tension                  16
输入 metrics.target_tension_curve     16
输入 scene_graph.nodes                14
wrote out/nm_05_3_mechanic_division.json
OK nm_05_3_mechanic_division:叙事侧 143 命中,机制侧 0 命中,边界成立

判读:边界是一条「0 命中」的差集

第一,叙事侧词元全部命中。 scene 66 次、flag 36 次、tension 18 次、ending 8 次、affinity 8 次、beat 7 次,合计 143 次。这些都是本章曲线真正读的字段——它们在这份规格里密集存在,说明叙事张力有充足的输入。

第二,机制侧词元全部 0 命中。 11 个机制词元(hp / health / damage / resource / turn / round / cooldown / stamina / mana / attack / defense)在这份规格的 2597 个词元里一个都没出现。这不是「罕见」,是根本不存在。所以机制张力那条路,在这份真源上第一步就走不下去:拿不到 HP,就没有血线;拿不到回合,就没有节奏;公式无从代入。

第三,本章曲线的三样输入都在。 beats[].tension 16 项、metrics.target_tension_curve 16 项、scene_graph.nodes 14 项——分别对应上一节的张力序列、目标曲线、路径投影的场景图。三样齐备,且没有一样来自机制域。

第四,两侧都是 proxy,本节不比较谁更准。 机制张力与叙事张力各自在自己的真源上是代理指标,只做相对比较。本节只声明边界:它们量的是两回事,一个是「一局战斗的动力学起伏」,一个是「一条叙事路径上的节拍起伏」,不声称哪个更能代表玩家体验。

第五,这条差集是可回归的。 命中统计不是一次性的人工观察——把它写进脚本,规格一改就能重跑。如果哪天有人往 story_spec.json 里加了 HP 字段,机制侧命中会从 0 变成非 0,脚本的 assert 立刻失败,提醒「真源的定位变了,这套边界要重新审」。边界不是写一次就完,是被断言守住的。

D 层:分工表

把上面的边界固化成一张对照表,逐维度写死两侧的差异:

{
  "section": "5.3",
  "tier": "D",
  "division": [
    {"aspect": "张力来源", "mechanic": "机制动力学", "narrative": "叙事节拍结构"},
    {"aspect": "输入", "mechanic": "HP / 资源 / 回合节奏", "narrative": "beats[].tension 整数 1..5"},
    {"aspect": "消费对象", "mechanic": "一局战斗的状态轨迹", "narrative": "玩家路径经过的节拍序列"},
    {"aspect": "计算方式", "mechanic": "机制仿真", "narrative": "规格投影 + 分段定位"},
    {"aspect": "真源", "mechanic": "数值与规则", "narrative": "config/story_spec.json#beats"},
    {"aspect": "本规格是否可算", "mechanic": "否(无 hp / resource / turn 字段)", "narrative": "是"}
  ],
  "narrative_inputs": ["beats[].tension", "metrics.target_tension_curve", "scene_graph.nodes"],
  "mechanic_token_hits": 0,
  "proxy": "两侧都是 proxy 口径,本节只做边界声明,不比较两种张力谁更准"
}

这张表里最容易被忽略的是最后一行:分工声明不等于优劣判断。本章说「张力来自叙事节拍」,只是说在这份真源上可算的是叙事张力;它没有、也不该说叙事张力比机制张力更接近玩家体验。两套度量各自服务各自的规格,边界清楚,互不越界。

自检表

检查项合格标准
边界可复算命中统计由脚本递归展开规格得出,非手写
叙事侧有输入6 个叙事词元全部命中,合计 143 次
机制侧无输入11 个机制词元全部 0 命中
输入清单齐备三样输入逐一打印且存在
不比较优劣明写两侧都是 proxy,只声明边界
整数不变量统计全为整数,残差 0

小结

本节把「与机制张力的分工」从口号落成一条可复算的差集:

  • 叙事侧 6 个词元命中 143 次,说明本章曲线的输入在真源里充足存在;
  • 机制侧 11 个词元命中 0 次,说明机制张力在这份规格上无法计算,只能来自另一套真源;
  • 边界被断言守住:assert mec_total == 0,规格一改就重跑,边界不会悄悄漂移;
  • 不判优劣:两侧都是 proxy,本节只说「量的是两回事」,不说谁更能代表体验。

三节写完,本章把「叙事张力」这件事讲完了:它来自作者标注的节拍(5.1),能沿路径投影成一条可切段的曲线(5.2),且与机制张力分属两套输入、互不重叠(5.3)。下一章把这条曲线收进一组更一般的代理指标里,给它一套统一的口径声明。

阅读导航:上一节:5.2 张力与释放的节拍计算 · 下一节:6.1 情感曲线的构造 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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