互动叙事中的好感度设计 那篇讲的是经验:一段关系该怎么推进、什么时候该有起伏。本章往下沉一层,把「关系推进的形状」变成整数——8.1 画出曲线,8.2 数阈值触达,本节把每条曲线分类成一个形状并统计占比。经验回答「这段关系写得好不好」,本节回答「走出来的曲线能不能被归成有限几种形状、各占多少」。
8.3 关系曲线的形状分类
分类只用整数规则:把一条曲线相邻两项的差取符号,得到一个 +1 / −1 的符号序列(差为 0 的项跳过),再按符号序列的形态归类。规则先匹配先返回,不重叠。派单里列了四类,实测把方向也分开后得到六类——「单调」和「先升后降」各自成一对(升 / 降),加上平台型与震荡型。
| 形状 | 整数规则 | 直觉 |
|---|---|---|
| 平台型 | 相邻差全为 0 | 该变量沿这条路径从未变化 |
| 单调上升 | down == 0 且 up >= 1 | 只涨不跌 |
| 单调下降 | up == 0 且 down >= 1 | 只跌不涨 |
| 先升后降 | 恰一个峰、峰后回落(单转折,+…+-…-) | 先热后冷 |
| 先降后升 | 恰一个谷、谷后回升(单转折,-…-+…+) | 先冷后热 |
| 震荡型 | 转折 >= 2 | 方向反复切换 |
S 层:把 20000 条曲线分类
脚本采样 5000 条路径,每条路径取 4 个变量的曲线,共 20000 条曲线,逐条分类后统计各形状的计数与占比(千分位);同时给出那条确定性路径的 4 个形状作为对照。
#!/usr/bin/env python3
# file: sim/nm_08_3_shape_classify.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/nm_08_3_shape_classify.py
"""卷三 8.3 关系曲线的形状分类(S 层)。
8.1 画出了曲线,8.2 数了门限触达。本节把每条「路径 × 变量」的曲线**分类**成一个
形状,并统计各形状占比。分类只用**整数规则**(看相邻差的符号序列),先匹配先返回:
平台型 相邻差全为 0(该变量沿这条路径从未变化);
单调上升 差只增不减(down == 0 且 up >= 1);
单调下降 差只减不增(up == 0 且 down >= 1);
先升后降 恰有一个峰、峰后回落(符号序列 +…+-…-,单转折);
先降后升 恰有一个谷、谷后回升(符号序列 -…-+…+,单转折);
震荡型 转折 >= 2(方向反复切换)。
采样 5000 条路径 × 4 个变量 = 20000 条曲线,给出各形状计数与占比(千分位)。形状
占比是结构量的 proxy,只做相对比较。产出 out/nm_08_3_shape_classify.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 = 5000, 20261016, 64
SHAPES = ["平台型", "单调上升", "单调下降", "先升后降", "先降后升", "震荡型"]
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 classify(vals: list) -> str:
"""整数规则:只看相邻差的符号序列。"""
signs = []
for i in range(1, len(vals)):
if vals[i] > vals[i - 1]:
signs.append(1)
elif vals[i] < vals[i - 1]:
signs.append(-1)
up, down = signs.count(1), signs.count(-1)
if up == 0 and down == 0:
return "平台型"
if down == 0:
return "单调上升"
if up == 0:
return "单调下降"
turns = sum(1 for i in range(1, len(signs)) if signs[i] != signs[i - 1])
if turns == 1:
return "先升后降" if signs[0] == 1 else "先降后升"
return "震荡型"
def sample_curves(spec: dict, n: int, seed: int) -> list:
by = {x["id"]: x for x in spec["dialogue"]}
VARS = sorted(spec["state"]["vars"])
rng = seed
out = []
for _ in range(n):
s = init_state(spec)
cols = {v: [s["aff"][v]] for v in VARS}
for _step 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
rng = (rng * 48271) % 2147483647
c = opts[rng % len(opts)]
s = step(spec, s, c)
for v in VARS:
cols[v].append(s["aff"][v])
out.append(cols)
return out
def main() -> int:
spec = load_spec()
VARS = sorted(spec["state"]["vars"])
curves = sample_curves(spec, SAMPLE_N, SEED)
total = SAMPLE_N * len(VARS)
# 确定性路径的 4 条曲线形状
by = {x["id"]: x for x in spec["dialogue"]}
s = init_state(spec)
det_cols = {v: [s["aff"][v]] for v in VARS}
while True:
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
s = step(spec, s, opts[0])
for v in VARS:
det_cols[v].append(s["aff"][v])
print("确定性路径(总是选第一个可用选项)的曲线形状:")
for v in VARS:
print(f" {v:<5} {classify(det_cols[v])}")
# 全量形状统计
overall = {sh: 0 for sh in SHAPES}
per_var = {v: {sh: 0 for sh in SHAPES} for v in VARS}
for cols in curves:
for v in VARS:
sh = classify(cols[v])
overall[sh] += 1
per_var[v][sh] += 1
print(f"\n形状占比({SAMPLE_N} 条路径 × {len(VARS)} 变量 = {total} 条曲线,千分位):")
print(f" {'形状':<8}{'计数':>8}{'占比‰':>8}")
for sh in SHAPES:
print(f" {sh:<8}{overall[sh]:>8}{overall[sh] * 1000 // total:>8}")
print(f" 合计 {sum(overall.values()):>8}{sum(overall.values()) * 1000 // total:>8}")
print("\n按变量看形状分布(计数):")
print(" " + f"{'变量':<6}" + "".join(f"{sh:>10}" for sh in SHAPES))
for v in VARS:
print(f" {v:<6}" + "".join(f"{per_var[v][sh]:>10}" for sh in SHAPES))
assert sum(overall.values()) == total, "形状计数与曲线总数不符"
assert sum(1 for sh in SHAPES if overall[sh] > 0) >= 1, "无任何形状被分类"
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "nm_08_3_shape_classify.json"), "w", encoding="utf-8") as fh:
json.dump({"sample_n": SAMPLE_N, "seed": SEED, "vars": VARS, "curves": total,
"shapes": SHAPES, "overall": overall,
"overall_permille": {sh: overall[sh] * 1000 // total for sh in SHAPES},
"per_var": per_var,
"deterministic": {v: classify(det_cols[v]) for v in VARS}},
fh, ensure_ascii=False, indent=2)
fh.write("\n")
print("wrote out/nm_08_3_shape_classify.json")
print("OK nm_08_3_shape_classify:曲线形状已按整数规则分类并统计占比")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑 /opt/homebrew/bin/python3 sim/nm_08_3_shape_classify.py:
确定性路径(总是选第一个可用选项)的曲线形状:
hui 单调上升
mu 单调上升
shen 平台型
yan 单调上升
形状占比(5000 条路径 × 4 变量 = 20000 条曲线,千分位):
形状 计数 占比‰
平台型 2137 106
单调上升 7197 359
单调下降 1486 74
先升后降 2613 130
先降后升 2931 146
震荡型 3636 181
合计 20000 1000
按变量看形状分布(计数):
变量 平台型 单调上升 单调下降 先升后降 先降后升 震荡型
hui 315 1653 474 1213 413 932
mu 919 1730 712 875 378 386
shen 903 2367 0 14 930 786
yan 0 1447 300 511 1210 1532
wrote out/nm_08_3_shape_classify.json
OK nm_08_3_shape_classify:曲线形状已按整数规则分类并统计占比
判读:六种形状与两处零值
整体分布:20000 条曲线里 单调上升占 359‰ 最多——多数「路径 × 变量」的关系是只涨不跌的,这与规格的设计一致:大多数 add 都是正增量,负增量只出现在少数几个「得罪人」的选项上。震荡型 181‰ 次之,说明有近五分之一的曲线方向反复切换;先降后升 146‰ 与先升后降 130‰ 各占一成多;平台型 106‰(该变量在这条路上从头到尾没动);单调下降只有 74‰ 最少。四类方向性形状(升 / 降 / 先升后降 / 先降后升)合起来占 709‰,平台与震荡合计 287‰。
两处零值各有成因。第一,shen 的单调下降是 0 条:shen 的 add 里唯一的负增量是 -5(d_db_1/c_db_1b),而引用 shen 的其它节点几乎全是正增量(+5 到 +20),所以「只跌不涨」的组合在采样里一条都没出现。第二,yan 的平台型是 0 条:yan 起点 20,几乎每条路径都会在很早的节点(d_wp_1)上给它一个 +5,于是 yan 在每条路上都至少动一次,从没有「全程不动」的曲线。零值不是缺陷,是规格结构的直接读数——它反过来证明了分类规则能区分出变量的引用模式。
按变量看,mu 的平台型最多(919 条):mu 只在商会 / 港口那几条支线上被引用,绕开它们的路径上 mu 一动不动。yan 的震荡型最多(1532 条):主角的好感度在最多节点上被引用,既有正也有负,方向最容易反复。这两条观察合起来说明一件事:一个变量被引用的节点越多、正负增量越混杂,它的曲线形状就越分散——形状分布本身成了「这个变量在规格里被用得多不多、用得多干净」的 proxy。
| 形状 | 计数 | 占比‰ | 读法 |
|---|---|---|---|
| 单调上升 | 7197 | 359 | 多数 add 为正,关系整体上行 |
| 震荡型 | 3636 | 181 | 方向反复切换 |
| 先降后升 | 2931 | 146 | 先冷后热 |
| 先升后降 | 2613 | 130 | 先热后冷 |
| 平台型 | 2137 | 106 | 该变量在整条路上未动 |
| 单调下降 | 1486 | 74 | 只跌不涨,最少见 |
自检表
| 检查项 | 合格标准 |
|---|---|
| 规则整数化 | 只看相邻差的符号序列,不出现浮点 / 阈值 |
| 互斥完备 | 六类先匹配先返回,计数合计 = 曲线总数 20000 |
| 零值有解释 | shen 单调下降 0、yan 平台型 0,各给出成因 |
| 双向对照 | 既给整体占比,也给按变量的分布 |
| 口径 | 形状占比是 proxy,只做相对比较 |
曲线画了(8.1)、门触达数了(8.2)、形状也分类了(8.3)——第 8 章把「关系推进的形状」变成了一组可算的整数。下一章换一个维度:把叙事一致性做成一个可回归的残差,看设定与行为之间有没有对不上的地方。
阅读导航:上一节:8.2 阈值触达的分布 · 下一节:9.1 一致性残差的度量 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。