10.1 纯 xml.etree 出曲线

把前几章算出的张力曲线用纯 xml.etree 手写成 SVG:ET.Element / ET.SubElement 建树、ET.tostring 落盘,再用 ET.fromstring 回读校验合法性并断言元素计数。16 个节拍里一条确定性路径命中 11 个、跳过 5 个,全程整数、无浮点、不依赖任何第三方绘图库。

《游戏设计》卷二《策略涌现与体验度量》 也画热力图与曲线,但它画的是机制数值空间——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 而不是先画后导的原因之一。

元素期望数构成
polyline116 个节拍的目标曲线
circle1816 个节拍标记 + 2 个图例圆点
line85 网格 + 2 轴 + 1 图例线
rect1背景
text255 纵轴刻度 + 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 个节拍逐个摊开,命中与否一目了然:

节拍场景张力该路径
b01pier1命中
b02tavern2命中
b03chapel2跳过
b04counting_house3命中
b05watch_post2命中
b06lighthouse_base3命中
b07tide_cave4命中
b08market_row3跳过
b09harbor_office4命中
b10drowned_bell5跳过
b11lighthouse_base3命中
b12lighthouse_top4命中
b13breakwater5跳过
b14fog_gate5跳过
b15dawn_quay4命中
b16dawn_quay2命中

判读:图只画结构量

这条路径在 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 分支图的可视化 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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