独立游戏的叙事设计与分支对话 里,人物关系是靠事件与台词让玩家自己感觉出来的——谁欠谁、谁在怕谁、谁嘴上不说。那篇给的是经验,不能跑。本节要做的是把关系写成矩阵:6 个角色两两之间的态度落成整数,于是「关系」变成一个可以校验形状、检查值域、还能跟派系立场对账的对象。
先说规格现状:story_spec.json 里没有角色之间的关系数据。它有的是 world.factions[].stance——4 个派系彼此之间的 −1 / 0 / +1 立场矩阵。角色关系必须由本章补一层,就像 4.1 补了锚点表一样。
4.2 关系矩阵
表示:行 = 观察者,列 = 对象
矩阵是 6×6,与 characters 的顺序一致;R[a][b] 表示 a 对 b 的态度,整数 0–100,对角线固定 0(自己对自己没有关系值)。取值口径与好感度同源:整数、无浮点、有界。
{
"note": "角色关系矩阵:行 = 观察者,列 = 对象,整数 0–100,对角线固定 0。story_spec 只给派系立场,角色级关系由本表给出。",
"ids": ["yan", "hui", "shen", "mu", "lin", "hai"],
"matrix": [
[0, 35, 40, 45, 70, 55],
[60, 0, 35, 45, 60, 70],
[55, 45, 0, 50, 45, 55],
[50, 55, 50, 0, 55, 45],
[80, 55, 40, 50, 0, 65],
[75, 65, 40, 45, 60, 0]
]
}
与 world.factions[].stance 的关系
派系立场给的是一个先验:同派系之间中性,互相敌视的派系之间低,结盟的派系之间高。把它线性映射成角色关系的先验值:
prior(a, b) = 50 + 10 × stance(faction(a), faction(b))
于是 stance 为 +1 的结对先验 60、0 的结对先验 50、−1 的结对先验 40。角色关系如果大幅偏离这个先验,说明剧情在派系立场之外额外给了这对关系——这是设计空间,不是错误。所以校验的判据是「偏离超过容差就列出来」,而不是「偏离就判错」。
S 层:四类校验
脚本做四件事:形状(6×6,ids 与 characters 顺序一致);值域(全部 int 且 ∈ [0,100],对角线恒 0);派系立场矩阵自身(4×4、对角 0、取值 ∈ {−1,0,1}、是否对称);与派系先验的一致性(逐格算 prior 与偏差,超过容差 20 的列出)。最后统计矩阵的静态不对称度。
#!/usr/bin/env python3
# file: sim/na_04_2_relationship_matrix.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/na_04_2_relationship_matrix.py
"""4.2 关系矩阵:6×6 角色关系矩阵的形状 / 值域 / 对角线校验,
以及与 world.factions[].stance 的一致性交叉检查。
story_spec 里没有角色之间的关系数据(只有派系立场 stance 4×4),角色关系矩阵
由 config/na_04_2_relations.json 给出:行 = 观察者,列 = 对象,整数 0–100,
对角线固定 0。stance 映射成先验 prior = 50 + 10 × stance,角色关系偏离 prior
超过容差 20 的格子逐条列出——它们不是错误,而是剧情在派系先验之外额外给出的
关系,正是 4.3 要接着讲的不对称。产出 out/na_04_2_relationship_matrix.*。
"""
from __future__ import annotations
import csv
import json
import os
import sys
import xml.etree.ElementTree as ET
NS = "http://www.w3.org/2000/svg"
ET.register_namespace("", NS)
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
LO, HI, TOL, NEUTRAL, STEP = 0, 100, 20, 50, 10
RAMP = [(0, 0, "#f1f5f9", "#94a3b8"), (1, 19, "#e0f2fe", "#075985"),
(20, 39, "#bae6fd", "#075985"), (40, 59, "#7dd3fc", "#0c4a6e"),
(60, 79, "#38bdf8", "#ffffff"), (80, 100, "#0284c7", "#ffffff")]
def load(rel):
with open(os.path.join(ROOT, rel), encoding="utf-8") as fh:
return json.load(fh)
def el(t, **a):
return ET.Element(f"{{{NS}}}{t}", {k: str(v) for k, v in a.items()})
def tx(p, x, y, s, n=12, an="start", f="#334155"):
t = el("text", x=x, y=y, **{"font-size": n, "font-family": "ui-sans-serif",
"text-anchor": an, "fill": f})
t.text = s
p.append(t)
def heat_svg(ids, mat, dev, path):
cw, chh, lw, top = 84, 44, 96, 86
W, H = lw + cw * len(ids) + 26, top + chh * len(ids) + 66
s = el("svg", width=W, height=H, viewBox=f"0 0 {W} {H}")
s.append(el("rect", x=0, y=0, width=W, height=H, fill="#ffffff"))
tx(s, 18, 26, "《雾港夜航》角色关系矩阵(行 = 观察者,列 = 对象)", 14, f="#0f172a")
tx(s, 18, 46, "整数 0–100,对角线固定 0;红框 = 与派系先验偏离 > 20 的格子", 11, f="#64748b")
tx(s, 18, 66, f"先验 prior = {NEUTRAL} + {STEP} × stance(faction(行), faction(列))", 11, f="#64748b")
for j, cid in enumerate(ids):
tx(s, lw + cw * j + cw // 2, top - 12, cid, 12, "middle", "#475569")
for i, cid in enumerate(ids):
y = top + chh * i
tx(s, lw - 12, y + 28, cid, 12, "end", "#0f172a")
for j in range(len(ids)):
v = mat[i][j]
fill, fg = next((f, g) for lo, hi, f, g in RAMP if lo <= v <= hi)
s.append(el("rect", x=lw + cw * j + 2, y=y + 3, width=cw - 4, height=chh - 6, rx=5,
fill=fill, stroke="#dc2626" if (i, j) in dev else "#cbd5e1",
**{"stroke-width": 2 if (i, j) in dev else 1}))
tx(s, lw + cw * j + cw // 2, y + 28, str(v), 13, "middle", fg)
tx(s, 18, H - 34, f"偏离先验的格子 {len(dev)} / {len(ids) * (len(ids) - 1)} 个非对角格", 12, f="#0f172a")
tx(s, 18, H - 14, "矩阵来源:config/na_04_2_relations.json", 11, f="#64748b")
t = ET.ElementTree(s)
ET.indent(t, space=" ")
t.write(path, encoding="utf-8", xml_declaration=True)
return os.path.getsize(path)
def main():
spec, rel = load("config/story_spec.json"), load("config/na_04_2_relations.json")
cids = [c["id"] for c in spec["characters"]]
faction_of = {c["id"]: c["faction"] for c in spec["characters"]}
fids = [f["id"] for f in spec["world"]["factions"]]
stance = {f["id"]: f["stance"] for f in spec["world"]["factions"]}
ids, mat, problems = rel["ids"], rel["matrix"], []
if ids != cids:
problems.append(f"关系矩阵 ids 与 characters 不一致:{ids} vs {cids}")
for i in range(len(ids)):
if len(mat[i]) != len(ids):
problems.append(f"第 {i} 行列数 {len(mat[i])} != {len(ids)}")
off = 0
for i in range(len(ids)):
for j in range(len(ids)):
v = mat[i][j]
if not isinstance(v, int) or isinstance(v, bool):
problems.append(f"R[{ids[i]}][{ids[j]}] 不是整数:{v!r}")
elif i == j:
if v != 0:
problems.append(f"对角线 R[{ids[i]}][{ids[i]}] 应为 0,实为 {v}")
else:
off += 1
if not (LO <= v <= HI):
problems.append(f"R[{ids[i]}][{ids[j]}] 越界:{v}")
cells = 0
for a in fids:
if sorted(stance[a]) != sorted(fids):
problems.append(f"stance[{a}] 的列集合与 factions 不一致")
continue
for b in fids:
v = stance[a][b]
cells += 1
if not isinstance(v, int) or isinstance(v, bool) or not (-1 <= v <= 1):
problems.append(f"stance[{a}][{b}] 越界:{v!r}")
elif a == b and v != 0:
problems.append(f"stance[{a}][{a}] 应为 0,实为 {v}")
s_asym = [(a, b) for i, a in enumerate(fids) for b in fids[i + 1:] if stance[a][b] != stance[b][a]]
pairs, dev = [], set()
for i, a in enumerate(ids):
for j, b in enumerate(ids):
if i == j:
continue
prior = NEUTRAL + STEP * stance[faction_of[a]][faction_of[b]]
ok = abs(mat[i][j] - prior) <= TOL
if not ok:
dev.add((i, j))
pairs.append({"from": a, "to": b, "fa": faction_of[a], "fb": faction_of[b],
"value": mat[i][j], "prior": prior, "dev": mat[i][j] - prior, "ok": ok})
asym = sorted(({"a": a, "b": b, "ab": mat[i][ids.index(b)], "ba": mat[ids.index(b)][i],
"gap": abs(mat[i][ids.index(b)] - mat[ids.index(b)][i])}
for i, a in enumerate(ids) for b in ids[i + 1:]),
key=lambda r: (-r["gap"], r["a"], r["b"]))
assert off == len(ids) * (len(ids) - 1) and all(LO <= r["value"] <= HI for r in pairs)
out = os.path.join(ROOT, "out")
os.makedirs(out, exist_ok=True)
rep = {"ids": ids, "shape": [len(ids), len(ids)], "off_diagonal_cells": off, "matrix": mat,
"tolerance": TOL, "factions": fids, "stance_cells": cells,
"stance_asymmetric_pairs": [f"{a}->{b}" for a, b in s_asym],
"consistent_pairs": sum(1 for r in pairs if r["ok"]),
"deviating_pairs": [{k: r[k] for k in ("from", "to", "value", "prior", "dev")}
for r in pairs if not r["ok"]],
"asymmetry_pairs": sum(1 for r in asym if r["gap"] > 0),
"asymmetry_total_pairs": len(asym), "problems": problems}
with open(os.path.join(out, "na_04_2_relationship_matrix.json"), "w", encoding="utf-8") as fh:
json.dump(rep, fh, ensure_ascii=False, indent=2)
fh.write("\n")
with open(os.path.join(out, "na_04_2_relationship_matrix.csv"), "w", encoding="utf-8", newline="") as fh:
w = csv.writer(fh)
w.writerow(["from", "to"] + ids)
for i, a in enumerate(ids):
w.writerow([a, "->"] + mat[i])
size = heat_svg(ids, mat, dev, os.path.join(out, "na_04_2_relationship_matrix.svg"))
rects = sum(1 for e in ET.parse(os.path.join(out, "na_04_2_relationship_matrix.svg")).getroot().iter()
if isinstance(e.tag, str) and e.tag.endswith("rect"))
assert rects == len(ids) * len(ids) + 1
print(f"角色关系矩阵 {len(ids)}×{len(ids)}:非对角格 {off},值域 [{LO},{HI}],对角线 0")
print(" " + "".join(f"{c:>6}" for c in ids))
for i, a in enumerate(ids):
print(f" {a:<5} " + "".join(f"{mat[i][j]:>6}" for j in range(len(ids))))
print(f"派系立场矩阵 {len(fids)}×{len(fids)}:格 {cells},非对称对 {len(s_asym)}"
f"(本案例 stance 对称,而角色关系有 {rep['asymmetry_pairs']}/{len(asym)} 对不对称)")
print(f"与派系先验一致 {rep['consistent_pairs']}/{len(pairs)}(容差 {TOL})")
for r in pairs:
if not r["ok"]:
print(f" WARN 偏离 {r['from']}->{r['to']}({r['fa']}→{r['fb']}):值 {r['value']}"
f" vs 先验 {r['prior']},偏差 {r['dev']:+d}")
print("不对称度 Top 5:" + " · ".join(
f"{r['a']}↔{r['b']} {r['ab']}/{r['ba']}(差 {r['gap']})" for r in asym[:5]))
for p in problems:
print(f"FAIL {p}")
if problems:
return 1
print(f"wrote out/na_04_2_relationship_matrix.json / .csv / .svg({size} bytes,rect {rects})")
print("OK na_04_2_relationship_matrix:形状 / 值域 / 对角线 / 整数不变量全部通过")
return 0
if __name__ == "__main__":
sys.exit(main())
实跑输出
角色关系矩阵 6×6:非对角格 30,值域 [0,100],对角线 0
yan hui shen mu lin hai
yan 0 35 40 45 70 55
hui 60 0 35 45 60 70
shen 55 45 0 50 45 55
mu 50 55 50 0 55 45
lin 80 55 40 50 0 65
hai 75 65 40 45 60 0
派系立场矩阵 4×4:格 16,非对称对 0(本案例 stance 对称,而角色关系有 13/15 对不对称)
与派系先验一致 28/30(容差 20)
WARN 偏离 yan->hui(watch→guild):值 35 vs 先验 60,偏差 -25
WARN 偏离 lin->yan(watch→watch):值 80 vs 先验 50,偏差 +30
不对称度 Top 5:yan↔hui 35/60(差 25) · yan↔hai 55/75(差 20) · shen↔hai 55/40(差 15) · yan↔shen 40/55(差 15) · hui↔mu 45/55(差 10)
wrote out/na_04_2_relationship_matrix.json / .csv / .svg(10247 bytes,rect 37)
OK na_04_2_relationship_matrix:形状 / 值域 / 对角线 / 整数不变量全部通过
实测:2 个格子偏离派系先验
30 个非对角格里有 2 个偏离先验超过 20:
| 结对 | 派系 | 值 | 先验 | 偏差 | 剧情上的原因 |
|---|---|---|---|---|---|
| 砚 → 徽 | watch → guild(+1) | 35 | 60 | −25 | 三夜前徽拒了砚的渡船请求 |
| 林 → 砚 | watch → watch(0) | 80 | 50 | +30 | 同属守望者工会,林是默许者也是交接者 |
这两个格子恰好是本章最有信息量的地方:派系立场只能给出先验,同派系内部的关系可以远高于先验,跨派系的合作关系也可以远低于先验。如果只看 stance,砚对徽应当是 60(守望者与船帮结盟),但剧情里这条关系是负向的——矩阵把「派系层面的结盟」与「个人层面的怨」分开了。
反过来,28/30 个格子落在容差内,说明派系立场对角色关系确实是一个有效但粗糙的先验:它解释了大部分格子,剩下两个正好是需要剧情补足的位置。
矩阵的两条不变量与一个观察
两条不变量是硬约束,脚本每次跑都断言:非对角格数恒等于 6×5 = 30,所有值恒在 [0,100] 内。对角线恒 0 则是矩阵自身的定义。
一个观察:派系立场矩阵在本案例里是对称的——4 个派系的 16 个格子里,stance[a][b] == stance[b][a] 对全部 6 个无序对成立(非对称对 0 个)。但角色关系矩阵有 13/15 对不对称:砚对徽 35、徽对砚 60;砚对孩 55、孩对砚 75。派系之间是「立场对立场」,天然可以对称;角色之间是「人对人」,同一个态度两侧不对称才是常态。这正好是下一节要展开的东西。
阅读导航:上一节:4.1 角色的字段:目标、恐惧、秘密 · 下一节:4.3 关系的不对称与变化 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。