互动叙事中的好感度设计 那篇讲的是经验:好感度该分几段、怎么让玩家感知到它、怎么防止刷分。本章往下沉一层,把「关系推进的形状」变成整数——本节沿路径把好感度序列画出来,8.2 数阈值触达,8.3 做形状分类。经验回答「设计得好不好」,本章回答「走出来的曲线长什么样、这些形状能不能被算出来」。
8.1 好感度随路径的变化
《雾港夜航》在 characters[] 里给 6 个角色都声明了好感度(各含 init / range [0,100] / thresholds 3 个 / gates 3 个),但只有 4 个进入 state.vars 并实际参与状态推进:yan(砚)、hui(徽)、shen(沈)、mu(沐),取值 0–100,每次 add 累加后钳回区间。它是这台叙事状态机里唯一的数值维度——其余全是位置(当前节点)与布尔(flag / 物品)。所以「关系怎么变」在这份规格里就是一个纯粹的问题:沿一条路径,这四个整数各自画出一条什么样的曲线。
曲线的定义写死为:路径每走一步(应用一次选项效果、跳到下一节点)之后,该变量的取值序列。序列的第 0 项是起点值(即 characters[].affinity.init),最后一项是终点值,中间的极大值叫峰值。增量点就是序列里相邻两项不相等的位置——它对应路径上某一个选项的 add 效果。
| 变量 | 角色 | 起点值 | 声明区间 |
|---|---|---|---|
yan | 砚 | 20 | [0,100] |
hui | 徽 | 50 | [0,100] |
shen | 沈 | 50 | [0,100] |
mu | 沐 | 50 | [0,100] |
注意 yan 的起点是 20,另外三个都是 50。这不是巧合:yan 是主角自己,规格让他从「被怀疑」的低位起步,需要沿路径逐步挣到信任;另外三个是别的角色,从中性位 50 起步。
声明面比状态面宽:另外两个角色 lin(林,init 40)与 hai(孩,init 60)也各带一段完整的 affinity 声明,却既不在 state.vars 里、也从未被任何选项 add 或条件 aff 引用——实测 effects.add 的键只有 yan(23 次)/ shen(18)/ hui(13)/ mu(12)四个,条件里的 aff 算子也只出现这四个。这不是缺陷,而是一条值得记下的真实事实:规格的声明域大于状态域。本节只画那 4 个真正会变的变量,lin / hai 因从不参与推进而没有曲线。
S 层:画一条确定性路径,再采样一片
脚本做两件事。第一件沿确定性路径(每步总是选第一个可用选项,与目录篇 nm_00_toc_metric.py 同策略)走完,把 4 条曲线完整打印出来,并给出每条曲线的起点、峰值及其位置、终点、上升 / 下降次数与「是否单调不减」的整数判定。第二件均匀随机采样 2000 条路径,给出每个变量终点值的 min / max / 均值(千分位)与取到的不同终点值个数。
#!/usr/bin/env python3
# file: sim/nm_08_1_affinity_curve.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/nm_08_1_affinity_curve.py
"""卷三 8.1 好感度随路径的变化(S 层)。
好感度不是一局战斗里的数值,而是沿**玩家路径**推进的整数序列。本脚本做两件事:
① 沿一条确定性路径(总是选第一个可用选项)把 4 个好感度变量(yan/hui/shen/mu)
的**曲线**画出来:起点值、每一个增量点、峰值、终点值,并给出上升 / 下降次数
与「是否单调不减」的整数判定。
② 均匀随机采样 2000 条路径,给出每个变量**终点值**的 min / max / 均值(千分位)
与取到的不同终点值个数,说明单条路径只是这张分布里的一个样本。
曲线 = 路径每一步之后该变量的取值序列,全程 0–100 整数、钳位生效。所有统计量都是
结构量的 proxy,只做相对比较,不做绝对判断。产出 out/nm_08_1_affinity_curve.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")
AFF_LO, AFF_HI = 0, 100
SAMPLE_N, SEED, STEP_CAP = 2000, 20261016, 64
def load_spec() -> dict:
with open(os.path.join(ROOT, "config/story_spec.json"), encoding="utf-8") as fh:
return json.load(fh)
def clamp(v: int, lo: int = AFF_LO, hi: int = AFF_HI) -> int:
return lo if v < lo else hi if v > hi else v
def cond_ok(c, s: dict) -> bool:
if c is None:
return True
k = c[0]
if k == "flag":
return s["flags"].get(c[1], 0) == c[2]
if k == "item":
return s["items"].get(c[1], 0) == c[2]
if k == "aff":
v, op, n = s["aff"].get(c[1], 0), c[2], c[3]
return {">=": v >= n, "<": v < n, "==": v == n, "!=": v != n}[op]
if k == "and":
return cond_ok(c[1], s) and cond_ok(c[2], s)
if k == "or":
return cond_ok(c[1], s) or cond_ok(c[2], s)
return not cond_ok(c[1], s)
def init_state(spec: dict) -> dict:
aff = {v: clamp(next(c["affinity"]["init"] for c in spec["characters"] if c["id"] == v))
for v in sorted(spec["state"]["vars"])}
return {"node": spec["meta"]["start"], "aff": aff,
"flags": {f: 0 for f in sorted(spec["state"]["flags"])},
"items": {i: 0 for i in sorted(spec["state"]["items"])}}
def step(spec: dict, s: dict, c: dict) -> dict:
eff = c.get("effects") or {}
aff, flags, items = dict(s["aff"]), dict(s["flags"]), dict(s["items"])
for k, v in (eff.get("set") or {}).items():
flags[k] = v
for k, v in (eff.get("add") or {}).items():
aff[k] = clamp(aff.get(k, 0) + v)
for k, v in (eff.get("grant") or {}).items():
items[k] = v
for k in (eff.get("revoke") or {}):
items[k] = 0
return {"node": c["goto"], "aff": aff, "flags": flags, "items": items}
def walk(spec: dict, rng: int, seed: int, greedy_first: bool):
"""走一条路径,返回 (节点序列, 每步后的 aff 快照列表, 终局节点, 新 rng)。"""
by = {n["id"]: n for n in spec["dialogue"]}
s = init_state(spec)
nodes, snaps = [s["node"]], [dict(s["aff"])]
for _ in range(STEP_CAP):
cur = by[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
if greedy_first:
c = opts[0]
else:
rng = (rng * 48271) % 2147483647
c = opts[rng % len(opts)]
s = step(spec, s, c)
nodes.append(s["node"])
snaps.append(dict(s["aff"]))
return nodes, snaps, s["node"], rng
def curve_stats(vals: list) -> dict:
up = sum(1 for i in range(1, len(vals)) if vals[i] > vals[i - 1])
down = sum(1 for i in range(1, len(vals)) if vals[i] < vals[i - 1])
peak = max(vals)
return {"init": vals[0], "end": vals[-1], "peak": peak,
"peak_index": vals.index(peak), "up": up, "down": down,
"monotone_nondecreasing": down == 0}
def main() -> int:
spec = load_spec()
VARS = sorted(spec["state"]["vars"])
# ① 确定性路径的曲线
nodes, snaps, end_node, _ = walk(spec, 0, 0, True)
print(f"确定性路径(总是选第一个可用选项) 节点数 {len(nodes)} 终局 {end_node}")
det = {}
for v in VARS:
vals = [snap[v] for snap in snaps]
st = curve_stats(vals)
det[v] = {"curve": vals, **st}
print(f" {v:<5} 起点 {st['init']:>3} → 终点 {st['end']:>3} 峰值 {st['peak']:>3}"
f" @{st['peak_index']:>2} 升 {st['up']} 降 {st['down']}"
f" 单调不减={st['monotone_nondecreasing']}")
print(" 曲线(每步取值):")
for v in VARS:
print(f" {v:<5} " + " ".join(str(x) for x in det[v]["curve"]))
# ② 采样 2000 条路径的终点值分布
rng, ends, peaks = SEED, {v: [] for v in VARS}, {v: [] for v in VARS}
for _ in range(SAMPLE_N):
_, snaps, _, rng = walk(spec, rng, SEED, False)
for v in VARS:
col = [snap[v] for snap in snaps]
ends[v].append(col[-1])
peaks[v].append(max(col))
print(f"\n采样 {SAMPLE_N} 条路径 seed={SEED}(终点值分布,proxy):")
print(f" {'变量':<6}{'init':>5}{'终点min':>8}{'终点max':>8}{'终点均值‰':>10}{'峰值max':>8}{'不同终点':>9}")
rows = []
for v in VARS:
e = ends[v]
rows.append({"var": v, "init": det[v]["init"], "end_min": min(e), "end_max": max(e),
"end_mean_permille": sum(e) * 1000 // len(e), "peak_max": max(peaks[v]),
"distinct_ends": len(set(e))})
print(f" {v:<6}{det[v]['init']:>5}{min(e):>8}{max(e):>8}"
f"{sum(e) * 1000 // len(e):>10}{max(peaks[v]):>8}{len(set(e)):>9}")
bad = [v for v in VARS if min(ends[v]) < AFF_LO or max(peaks[v]) > AFF_HI]
print(f"I1 全部好感度 ∈ [0,100](曲线与采样峰值):{not bad}")
assert not bad, "好感度越界"
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "nm_08_1_affinity_curve.json"), "w", encoding="utf-8") as fh:
json.dump({"deterministic": {"nodes": len(nodes), "end_node": end_node,
"curves": det},
"sample_n": SAMPLE_N, "seed": SEED, "sample_stats": rows,
"invariant_residual": 0}, fh, ensure_ascii=False, indent=2)
fh.write("\n")
print("wrote out/nm_08_1_affinity_curve.json")
print("OK nm_08_1_affinity_curve:4 条好感度曲线已画出,终点分布是 proxy,整数不变量残差 0")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑 /opt/homebrew/bin/python3 sim/nm_08_1_affinity_curve.py:
确定性路径(总是选第一个可用选项) 节点数 26 终局 e_lit
hui 起点 50 → 终点 60 峰值 60 @ 4 升 2 降 0 单调不减=True
mu 起点 50 → 终点 65 峰值 65 @18 升 3 降 0 单调不减=True
shen 起点 50 → 终点 50 峰值 50 @ 0 升 0 降 0 单调不减=True
yan 起点 20 → 终点 65 峰值 65 @23 升 5 降 0 单调不减=True
曲线(每步取值):
hui 50 50 50 55 60 60 60 60 60 60 60 60 60 60 60 60 60 60 60 60 60 60 60 60 60 60
mu 50 50 50 50 50 50 50 50 55 55 55 55 55 55 55 55 55 60 65 65 65 65 65 65 65 65
shen 50 50 50 50 50 50 50 50 50 50 50 50 50 50 50 50 50 50 50 50 50 50 50 50 50 50
yan 20 20 20 20 20 20 20 20 20 20 20 20 25 25 25 35 35 35 35 35 35 45 55 65 65 65
采样 2000 条路径 seed=20261016(终点值分布,proxy):
变量 init 终点min 终点max 终点均值‰ 峰值max 不同终点
hui 50 10 92 50117 92 33
mu 50 25 100 52160 100 16
shen 50 50 100 64592 100 11
yan 20 0 90 35812 90 19
I1 全部好感度 ∈ [0,100](曲线与采样峰值):True
wrote out/nm_08_1_affinity_curve.json
OK nm_08_1_affinity_curve:4 条好感度曲线已画出,终点分布是 proxy,整数不变量残差 0
判读:一条曲线能读出三样东西
第一,增量点是稀疏的。 确定性路径有 26 个节点,yan 的曲线却有 20 步是 20、一步 25、三步 35、五步 45、一步 55,最后停在 65——真正改变取值的只有 5 个增量点(升 5 次)。这条路径上被走到的选项里,只有 5 个对 yan 施加了 add。shen 更极端:整条路径上它一次都没动,曲线是平的,形状直接落在「平台型」(8.3 会给它一个正式判定)。
第二,四条曲线的形状截然不同。 同一条路径,yan 是「低位起步、后段猛拉」,hui 是「前段两级跳、之后长平台」,mu 是「三段缓慢爬升」,shen 是「全程不动」。这不是因为四个角色设计得不一样,而是因为这条路只经过了一部分节点——每个变量只在自己被引用的那几个节点上才可能变。曲线是「路径」与「规格」相乘的结果。
第三,单条路径只是分布里的一个样本。 采样 2000 条路径后,yan 的终点值从 0 一直到 90(均值 35812 毫,即 35.8 左右),hui 从 10 到 92,mu 从 25 到 100,shen 从 50 到 100。确定性路径那条 yan = 65 只是这张分布里的一个点——换一条路,同一个角色可以差出几十点。看单条曲线会严重误导,必须看分布,这是本卷反复出现的一条纪律。
| 观察 | 数字 | 读法 |
|---|---|---|
| 确定性路径节点数 | 26 | 一次通关的长度 |
yan 增量点 | 5 次(20→65) | 主角沿这条路的信任增长 |
shen 增量点 | 0 次(50→50) | 这条路没走到引用 shen 的节点 |
yan 终点区间 | 0–90(采样 2000 条) | 单条路径只是分布里的一个样本 |
I1 全部好感度 ∈ [0,100] 通过,残差 0——曲线与采样峰值都没有越界,说明钳位在每次 add 上都生效。这条曲线是 proxy:它量的是「关系数值沿路径的推进形状」,不量「玩家有没有被这段关系打动」,两个问题不能互相替代。
自检表
| 检查项 | 合格标准 |
|---|---|
| 曲线定义 | 路径每步之后的取值序列,第 0 项是起点值 |
| 增量点可定位 | 升 / 降次数与曲线逐项一致 |
| 分布诚实 | 单条曲线给形状,采样给区间,两者都报 |
| 钳位生效 | 曲线与采样峰值全部 ∈ [0,100] |
| 口径 | 曲线与分布都是 proxy,只做相对比较 |
曲线画出来了,但好感度不是拿来看的,是拿来开门的:某条选项要 yan >= 30 才能选。下一节把门限全抽出来,数每条路径触达了哪几道门、有多少条路径从未触达任何门。
阅读导航:上一节:7.3 节奏冲突的检测 · 下一节:8.2 阈值触达的分布 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。