《游戏设计》卷二《策略涌现与体验度量》 也画热力图与曲线,但它画的是机制数值空间——HP、资源、回合节奏;本章画的是叙事节拍与玩家路径的形状。两者共享「整数化 + proxy 口径」的工程纪律,输入与对象却完全不同。这一章把前九章算出来的数画出来:曲线、分支图、路径热力图。
10.1 纯 xml.etree 出曲线
张力曲线在第 5、6 两章已经算完了:beats[].tension 是作者标注的 16 个整数(1–5),一条确定性路径命中其中一部分。本节只做一件事——把它画成一张 SVG。
画图这条路上有一个硬约束:本机没装 matplotlib。这不是可以绕过去的配置问题,而是本套书的环境事实。所以出图没有第二条路,只能手写 SVG:用 xml.etree.ElementTree 的 Element / SubElement 建一棵元素树,tostring 落盘,再用 fromstring 把文件读回来、数一遍元素——图也是产物,产物也要能回读校验。
S 层:手写 SVG 并回读校验
脚本读 config/story_spec.json,走一条「总是选第一个可用选项」的确定性路径,取 metrics.target_tension_curve 作为 16 个节拍的目标值,画一条 polyline;再逐个节拍判断这条路径是否命中,用实心 / 空心 circle 标出来。坐标轴、5 条水平网格线、16 个节拍刻度、图例全部手写。共用内核(条件求值、状态推进、确定性路径、sub / readback)在 sim/nm_10_common.py。
#!/usr/bin/env python3
# file: sim/nm_10_1_xml_etree_curve.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/nm_10_1_xml_etree_curve.py
"""卷三 10.1 纯 xml.etree 出曲线(S 层)。
把 5、6 两章算出的张力曲线画成一张 SVG,**全程只用 xml.etree**:ET.Element /
ET.SubElement 建树、ET.tostring 落盘,再用 ET.fromstring 回读校验合法性并断言元素
计数。本机没装 matplotlib,所以没有第二条路——这正是「图也是产物」的口径。
图里画什么:
- 一条 polyline:metrics.target_tension_curve 的 16 个节拍目标值(1–5 整数);
- 16 个 circle:逐个节拍标出**确定性首选项路径**是否命中(实心 = 命中,空心 = 跳过);
- 坐标轴、刻度、图例。
判读口径:这张图**只画结构量**,是 proxy;张力值来自作者标注的 beats[].tension,
不是任何「情绪强度」的绝对值。整数不变量:张力值 ∈ 1–5、命中数 + 跳过数 = 16。
共用内核在 sim/nm_10_common.py。
"""
from __future__ import annotations
import json
import os
import sys
import xml.etree.ElementTree as ET
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from nm_10_common import OUT, SVG_NS, first_path, load_spec, readback, sub # noqa: E402
T_LO, T_HI = 1, 5
W, H = 760, 360
L, R, T, B = 64, 24, 48, 56
SVG_PATH = os.path.join(OUT, "nm_10_1_curve.svg")
JSON_PATH = os.path.join(OUT, "nm_10_1_curve.json")
def build_svg(target, visited):
pw, ph = W - L - R, H - T - B
n = len(target)
def px(i):
return L + (pw * i) // max(1, n - 1)
def py(v):
return T + ph - (ph * (v - T_LO)) // (T_HI - T_LO)
svg = ET.Element(f"{{{SVG_NS}}}svg", {"width": str(W), "height": str(H),
"viewBox": f"0 0 {W} {H}"})
sub(svg, "rect", x=0, y=0, width=W, height=H, fill="#ffffff")
sub(svg, "text", x=L, y=26, **{"font-size": 13, "font-family": "ui-sans-serif, system-ui",
"fill": "#0f172a"}).text = "《雾港夜航》张力曲线:目标 vs 一条确定性路径的命中"
for v in range(T_LO, T_HI + 1):
sub(svg, "line", x1=L, y1=py(v), x2=L + pw, y2=py(v), stroke="#e2e8f0", **{"stroke-width": 1})
sub(svg, "text", x=L - 8, y=py(v) + 4, **{"font-size": 10, "text-anchor": "end",
"fill": "#94a3b8"}).text = str(v)
sub(svg, "line", x1=L, y1=T + ph, x2=L + pw, y2=T + ph, stroke="#94a3b8", **{"stroke-width": 1.2})
sub(svg, "line", x1=L, y1=T, x2=L, y2=T + ph, stroke="#94a3b8", **{"stroke-width": 1.2})
for i in range(n):
sub(svg, "text", x=px(i), y=T + ph + 18, **{"font-size": 9, "text-anchor": "middle",
"fill": "#64748b"}).text = f"b{i + 1:02d}"
pts = " ".join(f"{px(i)},{py(target[i])}" for i in range(n))
sub(svg, "polyline", points=pts, fill="none", stroke="#2563eb", **{"stroke-width": 1.8})
for i in range(n):
hit = i in visited
sub(svg, "circle", cx=px(i), cy=py(target[i]), r=4 if hit else 3,
fill="#2563eb" if hit else "#ffffff", stroke="#2563eb", **{"stroke-width": 1.4})
lx, ly = L + pw - 210, T + 6
sub(svg, "line", x1=lx, y1=ly, x2=lx + 22, y2=ly, stroke="#2563eb", **{"stroke-width": 1.8})
sub(svg, "text", x=lx + 28, y=ly + 4, **{"font-size": 10, "fill": "#334155"}).text = "目标曲线(16 节拍)"
sub(svg, "circle", cx=lx + 11, cy=ly + 18, r=4, fill="#2563eb", stroke="#2563eb")
sub(svg, "text", x=lx + 28, y=ly + 22, **{"font-size": 10, "fill": "#334155"}).text = f"路径命中 {len(visited)} 节拍"
sub(svg, "circle", cx=lx + 11, cy=ly + 34, r=3, fill="#ffffff", stroke="#2563eb", **{"stroke-width": 1.4})
sub(svg, "text", x=lx + 28, y=ly + 38, **{"font-size": 10, "fill": "#334155"}).text = f"路径跳过 {n - len(visited)} 节拍"
return svg
def main() -> int:
spec = load_spec()
by = {n["id"]: n for n in spec["dialogue"]}
path = first_path(spec, by)
visited_scenes = {by[n]["scene"] for n in path}
beats = spec["beats"]
target = spec["metrics"]["target_tension_curve"]
visited = {i for i, b in enumerate(beats) if b["scene"] in visited_scenes}
assert len(beats) == len(target) == 16, "节拍数与目标曲线长度应为 16"
assert all(T_LO <= v <= T_HI for v in target), "张力值应落在 1–5"
assert all(T_LO <= b["tension"] <= T_HI for b in beats), "节拍张力应落在 1–5"
assert len(visited) + (16 - len(visited)) == 16, "命中 + 跳过 = 16"
os.makedirs(OUT, exist_ok=True)
data = ET.tostring(build_svg(target, visited), encoding="utf-8", xml_declaration=True)
with open(SVG_PATH, "wb") as fh:
fh.write(data)
root, counts = readback(SVG_PATH)
assert root.tag == f"{{{SVG_NS}}}svg", "回读根节点应为 svg"
assert counts.get("polyline", 0) == 1, "折线数应为 1"
assert counts.get("circle", 0) == 16 + 2, "圆点数应为 16 个节拍 + 2 个图例"
assert counts.get("line", 0) == 5 + 2 + 1, "线数应为 5 网格 + 2 轴 + 1 图例"
with open(JSON_PATH, "w", encoding="utf-8") as fh:
json.dump({"beats": len(beats), "path_nodes": len(path), "visited_beats": len(visited),
"skipped_beats": 16 - len(visited), "target": target,
"svg_bytes": len(data), "elements": counts}, fh, ensure_ascii=False, indent=1)
fh.write("\n")
print(f"spec={spec['meta']['title']} 节拍 {len(beats)} 目标曲线 {' '.join(map(str, target))}")
print(f"确定性路径:{len(path)} 节点 命中节拍 {len(visited)} / 16 跳过 {16 - len(visited)}")
print(f"命中下标 {' '.join('b%02d' % (i + 1) for i in sorted(visited))}")
print(f"跳过下标 {' '.join('b%02d' % (i + 1) for i in range(16) if i not in visited)}")
print(f"SVG 元素 " + " ".join(f"{k}={counts[k]}" for k in sorted(counts)))
print(f"wrote out/nm_10_1_curve.svg {len(data)} bytes(ET.tostring 落盘,ET.fromstring 回读通过)")
print("OK nm_10_1_xml_etree_curve:曲线 SVG 由纯 xml.etree 生成并可回读,元素计数与断言一致")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑 /opt/homebrew/bin/python3 sim/nm_10_1_xml_etree_curve.py:
spec=雾港夜航 节拍 16 目标曲线 1 2 2 3 2 3 4 3 4 5 3 4 5 5 4 2
确定性路径:26 节点 命中节拍 11 / 16 跳过 5
命中下标 b01 b02 b04 b05 b06 b07 b09 b11 b12 b15 b16
跳过下标 b03 b08 b10 b13 b14
SVG 元素 circle=18 line=8 polyline=1 rect=1 svg=1 text=25
wrote out/nm_10_1_curve.svg 4615 bytes(ET.tostring 落盘,ET.fromstring 回读通过)
OK nm_10_1_xml_etree_curve:曲线 SVG 由纯 xml.etree 生成并可回读,元素计数与断言一致
元素计数就是断言
回读不是走过场。ET.fromstring 把落盘文件重新解析成一棵树,再按局部名数元素:这张图应当恰好有 1 条 polyline、18 个 circle(16 个节拍 + 2 个图例)、8 条 line(5 条网格 + 2 条轴 + 1 条图例线)、1 个背景 rect。任何一处画多画少,断言立刻失败。
这里有一个必须绕开的坑:带 xmlns 的 SVG,root.iter("rect") 匹配不到任何元素——真实标签是 {http://www.w3.org/2000/svg}rect。所以要先剥命名空间再比对。但不能用 e.tag.endswith("line") 做后缀匹配,因为 polyline 也以 line 结尾,会把折线算进线条数。正确写法是剥掉 {...} 前缀后精确相等:
# snippet: 回读统计(摘自 sim/nm_10_common.py)
name = e.tag.rsplit("}", 1)[-1] # {http://www.w3.org/2000/svg}line → line
counts[name] = counts.get(name, 0) + 1 # 精确相等,polyline 不会被算成 line
SVG 还有一个好处:它是纯文本,可以进版本库、可以逐行 diff。一张图改动一个颜色或一个坐标,diff 里看得见;换成一个二进制 PNG,就只能靠肉眼比对。这也是本套书坚持手写 SVG 而不是先画后导的原因之一。
| 元素 | 期望数 | 构成 |
|---|---|---|
| polyline | 1 | 16 个节拍的目标曲线 |
| circle | 18 | 16 个节拍标记 + 2 个图例圆点 |
| line | 8 | 5 网格 + 2 轴 + 1 图例线 |
| rect | 1 | 背景 |
| text | 25 | 5 纵轴刻度 + 16 节拍刻度 + 3 图例文字 + 1 标题 |
坐标也走整数
出图最容易引入浮点的地方是坐标映射。这里刻意不让它出现:横轴第 i 个节拍落在 L + (pw*i) // (n-1),纵轴张力 v 落在 T + ph - (ph*(v-1)) // 4——全是整数地板除。于是同一份规格跑两次,SVG 的每个属性值逐位相同,图本身也就可复现(这正是本卷「整数化 + 确定性」纪律在出图上的落点)。
# snippet: 坐标映射(摘自 sim/nm_10_1_xml_etree_curve.py,整数地板除,无浮点)
pw, ph = W - L - R, H - T - B # 绘图区宽高,全部整数
def px(i): # 第 i 个节拍的横坐标
return L + (pw * i) // max(1, n - 1)
def py(v): # 张力值 v(1–5)的纵坐标,v 越大越靠上
return T + ph - (ph * (v - T_LO)) // (T_HI - T_LO)
16 个节拍逐个摊开,命中与否一目了然:
| 节拍 | 场景 | 张力 | 该路径 |
|---|---|---|---|
| b01 | pier | 1 | 命中 |
| b02 | tavern | 2 | 命中 |
| b03 | chapel | 2 | 跳过 |
| b04 | counting_house | 3 | 命中 |
| b05 | watch_post | 2 | 命中 |
| b06 | lighthouse_base | 3 | 命中 |
| b07 | tide_cave | 4 | 命中 |
| b08 | market_row | 3 | 跳过 |
| b09 | harbor_office | 4 | 命中 |
| b10 | drowned_bell | 5 | 跳过 |
| b11 | lighthouse_base | 3 | 命中 |
| b12 | lighthouse_top | 4 | 命中 |
| b13 | breakwater | 5 | 跳过 |
| b14 | fog_gate | 5 | 跳过 |
| b15 | dawn_quay | 4 | 命中 |
| b16 | dawn_quay | 2 | 命中 |
判读:图只画结构量
这条路径在 16 个节拍里命中 11 个、跳过 5 个。跳过的是 b03(chapel)、b08(market_row)、b10(drowned_bell)、b13(breakwater)、b14(fog_gate)——场景没被走到,对应节拍自然不出现。曲线本身是 metrics.target_tension_curve 的原样,beats[].tension 与它当前逐项相同(第 6 章已量出 MAE 恒 0)。
要强调口径:这张 SVG 画的只是结构量。它把「16 个整数按顺序连起来」画成折线,不代表任何情绪强度的绝对值——它是 proxy,只用于相对比较。一条路径命中 11 个节拍说明的是覆盖,不是「体验更好」。
自检表
| 检查项 | 合格标准 |
|---|---|
| 纯 xml.etree | 只用 Element / SubElement / tostring / fromstring,无 matplotlib |
| 落盘可回读 | ET.fromstring 解析通过,根节点为 {...}svg |
| 元素计数 | polyline=1、circle=18、line=8、rect=1 全部断言通过 |
| 整数不变量 | 张力值 ∈ 1–5、命中 + 跳过 = 16 |
| 口径声明 | 曲线为 proxy,只做相对比较 |
曲线画完了,但它只讲了一条路径的经历。下一节把整张故事图画出来——48 个节点、56 条边,以及一条路径压亮了哪 25 条。
阅读导航:上一节:9.3 失调的告警阈值 · 下一节:10.2 分支图的可视化 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。