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 情感曲线的构造 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。