10.3 好感度曲线的形状
Godot 对话选择历史与后果日志 在引擎里画出的是「选择 → 后果」的事件链:谁在什么时候选了什么,于是什么被改变了。本节画的不是事件链,而是单条数值曲线——某个角色的好感度沿一条路径走出来的整数序列。事件链回答「发生了什么」,曲线回答「这个数是怎么走过去的」:是一路抬升,还是来回震荡。
10.2 已经算出每条门限被跨过多少次;本节换一个问法:好感度在一条路径上是什么形状。形状不涉及具体数值,只涉及升降结构,所以它可以用几类互斥的名字说完。
五类互斥的形状
把一条路径上某变量的整数序列按升降结构分类,只有五种可能:
| 形状 | 判据 |
|---|---|
| 单调上升 | 全程非降,且末步仍在升 |
| 先升后平 | 全程非降,但末步已持平 |
| 单调下降 | 全程非增,且末步仍在降 |
| 波动 | 升降都出现过 |
| 全程不变 | 全程既不升也不降 |
五类互斥且完备:任意一条序列必然落进且只落进其中一类,所以各类计数之和恒等于采样路径数。这条性质就是本节的不变量 I3。
S 层:形状分类与曲线 SVG
下面这段是 S 层脚本 sim/na_10_3_affinity_curve.py。它沿 2000 条采样路径为每个变量收集整数序列,分类计数,然后对波动占比最高的变量用纯 xml.etree 出一张曲线 SVG(叠加前 5 条路径与它的门限横线)。它复用 10.2 的 collect_gates 拿门限值,共享辅助也直接复用,不重复定义。
#!/usr/bin/env python3
# file: sim/na_10_3_affinity_curve.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/na_10_3_affinity_curve.py
"""卷一 10.3 好感度曲线的形状(S 层):沿采样路径画好感度随步数的变化。
一条路径上某变量走出一串整数序列(起点 init,每步由 add + clamp 推进)。本节按升降结构
把序列分成五类互斥形状并统计占比,再用纯 xml.etree 出一张曲线 SVG:叠加若干条采样路径与
该变量的门限横线。回读用 `tag.endswith("polyline")` / `endswith("rect")`(带 xmlns 的 SVG
用 root.iter("rect") 匹配不到)。产出 out/na_10_3_affinity_curve.svg 与 .csv,占比用千分位整数。
"""
from __future__ import annotations
import csv
import os
import sys
import xml.etree.ElementTree as ET
import na_10_2_threshold_gate as tg
# 共享辅助与 10.2 同名同义,直接复用(clamp / load_spec / cond_ok / init_state / apply_effects)
clamp, load_spec = tg.clamp, tg.load_spec
cond_ok, init_state, apply_effects = tg.cond_ok, tg.init_state, tg.apply_effects
SVG_NS = "http://www.w3.org/2000/svg"
ET.register_namespace("", SVG_NS)
AFF_LO, AFF_HI = 0, 100
SAMPLE_N, DRAW_K, SEED = 2000, 5, 20261016
CLASSES = ["单调上升", "先升后平", "单调下降", "波动", "全程不变"]
COLORS = ["#2563eb", "#dc2626", "#059669", "#d97706", "#7c3aed"]
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
OUT = os.path.join(ROOT, "out")
SVG_PATH = os.path.join(OUT, "na_10_3_affinity_curve.svg")
CSV_PATH = os.path.join(OUT, "na_10_3_affinity_curve.csv")
def classify(seq):
"""五类互斥:波动(升降都出现)/ 单调上升 / 先升后平 / 单调下降 / 全程不变。"""
up = any(seq[i + 1] > seq[i] for i in range(len(seq) - 1))
down = any(seq[i + 1] < seq[i] for i in range(len(seq) - 1))
if up and down:
return "波动"
if up:
return "单调上升" if seq[-1] > seq[-2] else "先升后平"
return "单调下降" if down else "全程不变"
def sample_curves(spec, n, seed):
"""返回 (各变量各形状计数, 前 DRAW_K 条路径的逐步 aff 快照)。"""
by_id = {x["id"]: x for x in spec["dialogue"]}
vars_ = sorted(spec["state"]["vars"])
counts = {v: {c: 0 for c in CLASSES} for v in vars_}
snaps, rng = [], seed
for i in range(n):
s = init_state(spec)
trail = [dict(s["aff"])]
for _ in range(200):
cur = by_id[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
rng = (rng * 48271) % 2147483647
c = opts[rng % len(opts)]
s = apply_effects(c.get("effects") or {}, s)
s["node"] = c["goto"]
trail.append(dict(s["aff"]))
for v in vars_:
counts[v][classify([row[v] for row in trail])] += 1
if i < DRAW_K:
snaps.append(trail)
return counts, snaps
def build_svg(snaps, thr, var, path):
W, H, L, R, T, B = 760, 340, 56, 20, 42, 56
pw, ph = W - L - R, H - T - B
maxlen = max(len(s) for s in snaps)
def el(tag, **a):
return ET.Element(f"{{{SVG_NS}}}{tag}", {k: str(v) for k, v in a.items()})
def text(parent, x, y, s, size=11, anchor="start", fill="#334155"):
t = el("text", x=x, y=y, **{"font-size": size, "font-family": "ui-sans-serif, system-ui",
"text-anchor": anchor, "fill": fill})
t.text = s
parent.append(t)
def py(val):
return T + ph - (ph * val) // AFF_HI
svg = el("svg", width=W, height=H, viewBox=f"0 0 {W} {H}")
svg.append(el("rect", x=0, y=0, width=W, height=H, fill="#ffffff"))
for lv in (0, 25, 50, 75, 100):
svg.append(el("line", x1=L, y1=py(lv), x2=L + pw, y2=py(lv), stroke="#e2e8f0", **{"stroke-width": 1}))
text(svg, L - 6, py(lv) + 4, str(lv), 9, "end", "#94a3b8")
for t in thr:
svg.append(el("line", x1=L, y1=py(t), x2=L + pw, y2=py(t), stroke="#94a3b8",
**{"stroke-width": 1, "stroke-dasharray": "3 3"}))
text(svg, L + pw - 2, py(t) - 3, f"门限 {t}", 9, "end", "#64748b")
for j, trail in enumerate(snaps):
pts = [f"{L + (pw * i) // max(1, maxlen - 1)},{py(row[var])}" for i, row in enumerate(trail)]
svg.append(el("polyline", points=" ".join(pts), fill="none",
stroke=COLORS[j % len(COLORS)], **{"stroke-width": 1.6}))
svg.append(el("line", x1=L, y1=T + ph, x2=L + pw, y2=T + ph, stroke="#94a3b8", **{"stroke-width": 1.2}))
text(svg, L, 20, f"《雾港夜航》好感度曲线:{var}(前 5 条采样路径;虚线=该变量的门限)", 12, "start", "#0f172a")
tree = ET.ElementTree(svg)
ET.indent(tree, space=" ")
tree.write(path, encoding="utf-8", xml_declaration=True)
return os.path.getsize(path)
def readback(path):
counts = {}
for e in ET.parse(path).getroot().iter():
for k in ("rect", "line", "text", "polyline"):
if isinstance(e.tag, str) and e.tag.endswith(k):
counts[k] = counts.get(k, 0) + 1
return counts
def main():
spec = load_spec()
vars_ = sorted(spec["state"]["vars"])
counts, snaps = sample_curves(spec, SAMPLE_N, SEED)
var = max(vars_, key=lambda v: counts[v]["波动"])
thr = sorted({g["n"] for g in tg.collect_gates(spec)[0] if g["var"] == var})
print(f"spec={spec['meta']['title']} 变量 {vars_} 采样 {SAMPLE_N} 条路径 seed={SEED}")
print(f"曲线长度:最短 {min(len(s) for s in snaps)} 步 最长(前 {len(snaps)} 条){max(len(s) for s in snaps)} 步")
print(f"形状分布(占比千分位,五类互斥):\n {'变量':<6}" + "".join(f"{c:>18}" for c in CLASSES))
rows = []
for v in vars_:
c = counts[v]
print(f" {v:<6}" + "".join(f"{c[cls]:>8}({c[cls] * 1000 // SAMPLE_N:>4}‰)" for cls in CLASSES))
rows.append({"var": v, "paths": SAMPLE_N, **{cls: c[cls] for cls in CLASSES},
**{cls + "_permille": c[cls] * 1000 // SAMPLE_N for cls in CLASSES}})
bad = [v for v in vars_ if sum(counts[v].values()) != SAMPLE_N]
print(f"I3 五形状计数之和 == {SAMPLE_N}:{not bad}")
if bad:
return 1
os.makedirs(OUT, exist_ok=True)
with open(CSV_PATH, "w", encoding="utf-8", newline="") as fh:
w = csv.DictWriter(fh, fieldnames=["var", "paths"] + CLASSES + [c + "_permille" for c in CLASSES])
w.writeheader()
w.writerows(rows)
size = build_svg(snaps, thr, var, SVG_PATH)
rb = readback(SVG_PATH)
print(f"绘图变量 {var}(波动占比最高);wrote out/na_10_3_affinity_curve.svg {size} bytes {rb}")
assert rb.get("polyline", 0) == len(snaps), "折线数应等于绘制路径数"
assert rb.get("rect", 0) == 1, "矩形数应为 1(背景)"
print(f"wrote out/na_10_3_affinity_curve.csv rows={len(rows)}")
print("OK na_10_3_affinity_curve:形状分类为划分,SVG 回读通过,I3 成立")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑 /opt/homebrew/bin/python3 sim/na_10_3_affinity_curve.py:
spec=雾港夜航 变量 ['hui', 'mu', 'shen', 'yan'] 采样 2000 条路径 seed=20261016
曲线长度:最短 23 步 最长(前 5 条)29 步
形状分布(占比千分位,五类互斥):
变量 单调上升 先升后平 单调下降 波动 全程不变
hui 0( 0‰) 643( 321‰) 183( 91‰) 1036( 518‰) 138( 69‰)
mu 0( 0‰) 660( 330‰) 296( 148‰) 666( 333‰) 378( 189‰)
shen 0( 0‰) 962( 481‰) 0( 0‰) 700( 350‰) 338( 169‰)
yan 0( 0‰) 581( 290‰) 115( 57‰) 1304( 652‰) 0( 0‰)
I3 五形状计数之和 == 2000:True
绘图变量 yan(波动占比最高);wrote out/na_10_3_affinity_curve.svg 3587 bytes {'rect': 1, 'line': 14, 'text': 9, 'polyline': 5}
wrote out/na_10_3_affinity_curve.csv rows=4
OK na_10_3_affinity_curve:形状分类为划分,SVG 回读通过,I3 成立
表里有两点值得单独看。第一,「单调上升」在四个变量上全是 0‰。这不是 bug:这一类要求「全程不降」与「末步仍在升」同时成立;而末步是走入结局的那一步,这些路径的最后一步都没有再把变量抬高,所以凡是「全程不降」的路径都落进了「先升后平」。第二,yan 的波动占比最高(652‰),同时它的「全程不变」是 0‰——它几乎每条路径都在动,而且动得最不规矩。SVG 因此选了 yan 来画。
SVG 回读得到 rect=1(背景)、line=14(5 条网格 + 该变量的门限虚线 + 1 条底轴)、polyline=5(5 条采样路径)、text=9,与断言一致。回读用 tag.endswith("polyline") 而不是 root.iter("polyline")——带 xmlns 的 SVG 用后者匹配不到任何东西。
B 层:扰动初始值,看门限的相变
曲线本身是描述性的。要问「它稳不稳」,就得扰动参数看结果怎么变。B 层脚本 sim/na_10_3_affinity_perturb.py 把 4 个变量的初始值整体上下扰动(Δ ∈ {-10, -5, 0, +5, +10},夹回 [0,100]),在同一采样口径下重算 10.2 的全部门限跨越率。
#!/usr/bin/env python3
# file: sim/na_10_3_affinity_perturb.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/na_10_3_affinity_perturb.py
"""卷一 10.3 好感度初始值的扰动(B 层,软告警不硬失败)。
把 4 个变量的初始值**整体**上下扰动(Δ ∈ {-10, -5, 0, +5, +10},夹回 [0,100]),复用
10.2 的门限机件(collect_gates / run_gates)在同一采样口径下重算门限跨越率,看曲线与门限
的相变:跨越率 = 全部门限的满足次数 / 判定次数(千分位整数)。扰动只改内存里的 init,
**不改 config/story_spec.json**;任何偏离只打印 WARN,exit 0。产出
out/na_10_3_affinity_perturb.json。
"""
from __future__ import annotations
import json
import os
import sys
import na_10_2_threshold_gate as tg
SAMPLE_N, SEED = 8000, 20261016
DELTAS = [-10, -5, 0, 5, 10]
WARN_PERMILLE = 50
JSON_PATH = os.path.join(tg.OUT, "na_10_3_affinity_perturb.json")
def main() -> int:
spec = tg.load_spec()
gates, at = tg.collect_gates(spec)
res = {d: tg.run_gates(spec, gates, at, SAMPLE_N, SEED, d) for d in DELTAS}
base = res[0]["rate_permille"]
print(f"spec={spec['meta']['title']} 门限 {len(gates)} 条 采样 {SAMPLE_N} 条/档 seed={SEED}")
print("init 扰动 → 门限跨越率(软告警口径,不硬失败)")
print(" Δinit 判定次数 满足次数 跨越率 相对基线 死门数")
for d in DELTAS:
r = res[d]
rel = "(基线)" if d == 0 else f"{r['rate_permille'] - base:+d}‰"
print(f" {d:>+4} {r['visits']:>9} {r['satisfied']:>10} {r['rate_permille']:>5}‰ {rel:>8} {r['dead']:>4}")
warns = []
for d in DELTAS:
if not d:
continue
rel = res[d]["rate_permille"] - base
if abs(rel) >= WARN_PERMILLE:
warns.append(f"Δinit={d:+d} 跨越率偏离基线 {rel:+d}‰(阈值 {WARN_PERMILLE}‰)")
if res[d]["dead"] > res[0]["dead"]:
warns.append(f"Δinit={d:+d} 新增死门 {res[d]['dead'] - res[0]['dead']} 条")
print(f"基线跨越率 {base}‰;相对偏离阈值 {WARN_PERMILLE}‰")
print("\n".join(f"WARN {w}" for w in warns) or "无软告警:±10 的 init 扰动未使跨越率越过告警阈值")
os.makedirs(tg.OUT, exist_ok=True)
with open(JSON_PATH, "w", encoding="utf-8") as fh:
json.dump({"sample_n": SAMPLE_N, "seed": SEED, "warn_permille": WARN_PERMILLE, "baseline": base,
"results": {str(k): v for k, v in res.items()}, "warnings": warns},
fh, ensure_ascii=False, indent=2)
fh.write("\n")
print(f"wrote out/na_10_3_affinity_perturb.json({len(DELTAS)} 档)")
print("OK na_10_3_affinity_perturb:扰动已跑完,软告警口径,exit 0")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑 /opt/homebrew/bin/python3 sim/na_10_3_affinity_perturb.py:
spec=雾港夜航 门限 15 条 采样 8000 条/档 seed=20261016
init 扰动 → 门限跨越率(软告警口径,不硬失败)
Δinit 判定次数 满足次数 跨越率 相对基线 死门数
-10 60095 3288 54‰ -280‰ 9
-5 60178 6306 104‰ -230‰ 8
+0 60263 20129 334‰ (基线) 0
+5 60367 38173 632‰ +298‰ 0
+10 60442 48226 797‰ +463‰ 0
基线跨越率 334‰;相对偏离阈值 50‰
WARN Δinit=-10 跨越率偏离基线 -280‰(阈值 50‰)
WARN Δinit=-10 新增死门 9 条
WARN Δinit=-5 跨越率偏离基线 -230‰(阈值 50‰)
WARN Δinit=-5 新增死门 8 条
WARN Δinit=+5 跨越率偏离基线 +298‰(阈值 50‰)
WARN Δinit=+10 跨越率偏离基线 +463‰(阈值 50‰)
wrote out/na_10_3_affinity_perturb.json(5 档)
OK na_10_3_affinity_perturb:扰动已跑完,软告警口径,exit 0
结果是一条相变曲线:初始值下调 5 到 10 个点,全体门限跨越率从 334‰ 掉到 104‰ 与 54‰,并且从「0 条死门」跳到 8 条、9 条死门;上调则单调抬到 632‰、797‰,死门保持 0。也就是说,这套阈值设计对初始值下调比对上调敏感得多——下调会立刻把一批门限锁死。
这里的关键是档位性质:B 层软告警,不硬失败。脚本把「偏离基线超过 50‰」与「新增死门」打印成 WARN,但 exit 0。原因是 B 层做的是参数扫描,不是验收:一次扰动让跨越率掉一半,是「值得看一眼」的信号,不是「代码错了」的结论。把软信号写成硬失败,只会让扫描跑不完。
自检表
| 检查项 | 合格标准 |
|---|---|
| 形状互斥完备 | 五类计数之和恒等于采样路径数(I3) |
| 门限复用 | 曲线上的门限值取自 10.2 的 collect_gates,不另行硬编码 |
| SVG 可回读 | 用 tag.endswith(...) 取元素,rect/polyline 计数与断言一致 |
| 扰动可复现 | 同种子重跑得到同一组跨越率 |
| 软告警 | B 层只打印 WARN,exit 0,不当成验收失败 |
到这里,好感度的三件事就说完了:从哪来(10.1 的起点与 add/clamp)、门在哪(10.2 的阈值门与跨越率)、走过去是什么形状(本节的五类形状与扰动相变)。它们合起来是同一套整数状态的三个侧面。下一章从「数值」转到「对话」本身:节点、选项与跳转怎么在规格里写清楚。
阅读导航:上一节:10.2 阈值门 · 下一节:11.1 对话节点、选项与跳转 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。