5.2 弧光与行为的对齐
独立游戏的叙事设计与分支对话 谈「关系变好之后人物就该有不同的表现」——那是散文式的对齐经验,把「表现该变」说成一种直觉。本节把这句话拆成一条可判定的判据:弧光的第 i 段,必须能对上好感度的第 i 个门限。5.1 已经声明了「砚在 b01 / b08 / b12 转折」,本节要问的是——这三个节拍上,砚的好感度真的够得到对应的门限吗。
门限:把「关系变好」写成三个整数
characters[].affinity 把「关系」压成四个整数:一个初值、一个区间、三个门限。门限与门限名成对出现,顺序固定为 cold → warm → trust:
| 角色 | 初值 | cold | warm | trust | 在 state.vars 里 |
|---|---|---|---|---|---|
| 砚 yan | 20 | 30 | 60 | 85 | 是 |
| 徽 hui | 50 | 30 | 60 | 85 | 是 |
| 沈 shen | 50 | 30 | 60 | 85 | 是 |
| 沐 mu | 50 | 30 | 60 | 85 | 是 |
| 林 lin | 40 | 30 | 60 | 85 | 否 |
| 孩 hai | 60 | 30 | 60 | 85 | 否 |
门限值六个角色完全一样(30 / 60 / 85),差异只在初值。初值一确定,就有一批门限「初始即满足」:林(40)已过 cold,孩(60)已过 cold 与 warm,而砚(20)连 cold 都还没到。这不是巧合——砚是失踪者,玩家抵达时与他的关系本就最生分,机制用最低的初值表达了这一点。
表里最后一列是本节最要紧的一列。state.vars 只声明了四个好感度变量(yan / hui / shen / mu),但六个角色都带 affinity 与 arc。林与孩不在变量表里——这意味着他们的好感度没有增减渠道。这一条会在下面变成三条「不可达」。
对齐判据:乐观最早跨越 ≤ 声明转折节拍
对齐判据只有一句:该门限能在声明的转折节拍之前被跨越。用整数写就是 earliest_cross(gate) <= declared_beat。
earliest_cross 怎么算?取该角色的所有正向好感度增量(add 为正的那些选项),把每个选项所在的场景折成节拍号,从 b01 起按节拍顺序累加,看累加到哪一拍时 初值 + 累计 >= 门限。这是一个乐观上界:它假设玩家把该角色的正向选项全选了一遍,一步不漏。
乐观上界的用法是单向的:
- 连上界都没能在声明节拍前越过门限 → 一定错位(必要条件的反例)。
- 上界能越过 → 只能说「有机会」,不能说「一定对齐」——真实路径未必选得到那些选项。
所以本节是一个廉价的筛子:它用一次静态扫描,把明显够不到的门限挑出来。真正「玩家走不走得到」的问题留给 5.3 用采样回答。
一个算例:砚的乐观累计
拿砚走一遍,earliest_cross 是怎么来的就一目了然了。砚的初值是 20,正向增量集中在 b05 / b06 / b07 / b08 / b12 / b13 六拍(同一拍内的多个正向选项全部计入,负向选项忽略——乐观上界只取最好情况):
| 节拍 | 该拍正向增量 | 累计好感度(封顶 100) | 这一拍跨过的门限 |
|---|---|---|---|
| 开局 | — | 20 | — |
| b05 | +20 | 40 | cold=30 |
| b06 | +15 | 55 | — |
| b07 | +35 | 90 | warm=60、trust=85 |
| b08 | +15 | 100(封顶) | — |
| b12 | +75 | 100(封顶) | — |
| b13 | +10 | 100(封顶) | — |
读这张表:cold(30)在 b05 越过,warm(60)与 trust(85)都在 b07 越过——因为 b07 一次给出 +35,把累计从 55 顶到 90,一口气跨过两道门。于是砚的三个 earliest_cross 是 [5, 7, 7],而声明的转折节拍是 [1, 8, 12]:只有阶段①的 5 > 1 构成落后,阶段②③都满足 earliest <= declared。封顶也在表里显形——b08 之后累计已经到 100,再多的增量也不再改变读数,这正是「整数不变量:好感度 ∈ [0,100]」在算法里的样子。
S 层:对齐校验
下面这个脚本对 18 条(6 角色 × 3 阶段)逐条算 earliest_cross 并判定。注意它把「错位」当分析结论而非规格错误:脚本照常 exit 0,只有 gates 与 thresholds 不一致这类结构问题才 FAIL。
#!/usr/bin/env python3
# file: sim/na_05_2_arc_alignment.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/na_05_2_arc_alignment.py
"""弧光阶段与好感度门限的对齐:乐观最早跨越节拍 vs 声明转折节拍。
乐观上界 = 玩家把该角色的正向好感度选项全选一遍。连上界都没能在声明节拍前越过
门限,就是错位。错位是分析结论,脚本仍 exit 0;只有结构性问题才 FAIL。
"""
from __future__ import annotations
import json
import os
import sys
GATE_ORDER = ["cold", "warm", "trust"]
def load_spec() -> dict:
here = os.path.dirname(os.path.abspath(__file__))
with open(os.path.join(here, "../config/story_spec.json"), encoding="utf-8") as fh:
return json.load(fh)
def scene_to_beat(spec: dict) -> dict:
m: dict[str, int] = {}
for i, b in enumerate(spec["beats"]):
m.setdefault(b["scene"], i + 1)
return m
def gains_by_beat(spec: dict, cid: str, s2b: dict) -> dict:
out: dict[int, int] = {}
for n in spec["dialogue"]:
for c in (n.get("choices") or []):
v = ((c.get("effects") or {}).get("add") or {}).get(cid)
if v and v > 0 and n["scene"] in s2b:
out[s2b[n["scene"]]] = out.get(s2b[n["scene"]], 0) + v
return out
def earliest_cross(init: int, thr: int, gains: dict, n: int):
if init >= thr:
return 0
cur = init
for b in range(1, n + 1):
cur += gains.get(b, 0)
if cur >= thr:
return b
return None
def main() -> int:
spec = load_spec()
n = len(spec["beats"])
vars_ = set(spec["state"]["vars"])
s2b = scene_to_beat(spec)
structural, rows = [], []
for ch in spec["characters"]:
cid = ch["id"]
gates, thr = ch["affinity"]["gates"], ch["affinity"]["thresholds"]
arc = ch.get("arc") or {}
stages, declared = list(arc.get("stages") or []), list(arc.get("beats") or [])
if len(stages) != 3 or len(declared) != 3:
structural.append(f"{cid} 的 arc 不是三阶段三节拍")
continue
if [gates[k] for k in GATE_ORDER] != list(thr):
structural.append(f"{cid} 的 gates 与 thresholds 不一致:{gates} vs {thr}")
gains = gains_by_beat(spec, cid, s2b) if cid in vars_ else {}
for i in range(3):
ec = earliest_cross(ch["affinity"]["init"], thr[i], gains, n)
if ec is None:
verdict = "不可达"
elif ec <= declared[i]:
verdict = "对齐"
else:
verdict = f"落后 {ec - declared[i]} 节拍"
rows.append({"id": cid, "stage_no": i + 1, "stage": stages[i], "gate": GATE_ORDER[i],
"threshold": thr[i], "declared_beat": declared[i], "earliest_cross": ec,
"verdict": verdict, "is_var": cid in vars_})
aligned = sum(1 for r in rows if r["verdict"] == "对齐")
lag = sum(1 for r in rows if r["verdict"].startswith("落后"))
unreach = sum(1 for r in rows if r["verdict"] == "不可达")
mis = [f"{r['id']}·阶段{r['stage_no']}" for r in rows if r["verdict"] != "对齐"]
non_var = sorted({r["id"] for r in rows if not r["is_var"]})
here = os.path.dirname(os.path.abspath(__file__))
out = os.path.join(here, "../out/arc_alignment.json")
os.makedirs(os.path.dirname(out), exist_ok=True)
with open(out, "w", encoding="utf-8") as fh:
json.dump({"rows": rows, "aligned": aligned, "lagging": lag, "unreachable": unreach,
"total": len(rows), "misaligned": mis, "non_var_characters": non_var,
"structural_problems": structural}, fh, ensure_ascii=False, indent=2)
fh.write("\n")
print("角色 阶段 | 门限 | 声明节拍 | 乐观最早跨越 | 判定")
for r in rows:
ec = "—" if r["earliest_cross"] is None else ("初始即满足" if r["earliest_cross"] == 0 else f"b{r['earliest_cross']:02d}")
print(f"{r['id']} 阶段{r['stage_no']} {r['stage']} | {r['gate']}={r['threshold']} | 声明 b{r['declared_beat']:02d} | 乐观最早 {ec} | {r['verdict']}")
print(f"共 {len(rows)} 条(6 角色 × 3 阶段):对齐 {aligned} · 落后 {lag} · 不可达 {unreach}")
print(f"错位清单:{mis}")
print(f"不在 state.vars 的角色(无好感度变量):{non_var}")
for p in structural:
print(f"FAIL {p}")
if structural:
return 1
print("OK na_05_2_arc_alignment:结构自洽,错位 4 条为分析结论")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑 /opt/homebrew/bin/python3 sim/na_05_2_arc_alignment.py:
角色 阶段 | 门限 | 声明节拍 | 乐观最早跨越 | 判定
yan 阶段1 失踪的传说 | cold=30 | 声明 b01 | 乐观最早 b05 | 落后 4 节拍
yan 阶段2 被怀疑的守夜人 | warm=60 | 声明 b08 | 乐观最早 b07 | 对齐
yan 阶段3 托付者 | trust=85 | 声明 b12 | 乐观最早 b07 | 对齐
hui 阶段1 交易者 | cold=30 | 声明 b02 | 乐观最早 初始即满足 | 对齐
hui 阶段2 动摇的盟友 | warm=60 | 声明 b06 | 乐观最早 b02 | 对齐
hui 阶段3 共同承担者 | trust=85 | 声明 b14 | 乐观最早 b13 | 对齐
shen 阶段1 教条者 | cold=30 | 声明 b03 | 乐观最早 初始即满足 | 对齐
shen 阶段2 怀疑者 | warm=60 | 声明 b09 | 乐观最早 b03 | 对齐
shen 阶段3 鸣钟者 | trust=85 | 声明 b15 | 乐观最早 b03 | 对齐
mu 阶段1 旁观者 | cold=30 | 声明 b04 | 乐观最早 初始即满足 | 对齐
mu 阶段2 账页的持有者 | warm=60 | 声明 b10 | 乐观最早 b04 | 对齐
mu 阶段3 证人 | trust=85 | 声明 b13 | 乐观最早 b09 | 对齐
lin 阶段1 规矩的看守 | cold=30 | 声明 b05 | 乐观最早 初始即满足 | 对齐
lin 阶段2 沉默的同谋 | warm=60 | 声明 b11 | 乐观最早 — | 不可达
lin 阶段3 交接者 | trust=85 | 声明 b16 | 乐观最早 — | 不可达
hai 阶段1 向导 | cold=30 | 声明 b07 | 乐观最早 初始即满足 | 对齐
hai 阶段2 被抛下的孩子 | warm=60 | 声明 b12 | 乐观最早 初始即满足 | 对齐
hai 阶段3 记得的人 | trust=85 | 声明 b16 | 乐观最早 — | 不可达
共 18 条(6 角色 × 3 阶段):对齐 14 · 落后 1 · 不可达 3
错位清单:['yan·阶段1', 'lin·阶段2', 'lin·阶段3', 'hai·阶段3']
不在 state.vars 的角色(无好感度变量):['hai', 'lin']
OK na_05_2_arc_alignment:结构自洽,错位 4 条为分析结论
从输出读到的三件事
第一,14/18 对齐。四个「正牌变量」角色(砚、徽、沈、沐)里,徽、沈、沐九条全对齐,只有砚的阶段①落后。绝大多数声明的转折节拍,都能被乐观路径在节拍之前够到——这是规格「大体自洽」的证据。
第二,一条「落后」:砚的阶段①落后 4 节拍。砚初值 20、cold 门限 30,而玩家能给砚加好感的最早节拍是 b05(守望者哨所)。也就是说,砚的「失踪的传说 → 被怀疑的守夜人」这一转,声明在 b01,但机制上最早要到 b05 才可能开始积累。这 4 拍的差不是笔误,而是一个结构事实:砚的第一段弧发生在玩家见到他之前(他还没出场),所以那一转注定不由好感度驱动,而由传闻与在场者的转述驱动。脚本把这件事标成「落后」,是提醒:这一段弧的触发条件写错了地方。
第三,三条「不可达」全部落在林与孩身上。林(warm、trust)与孩(trust)在乐观路径上都够不到,因为二者不在 state.vars 里——没有 add 渠道,好感度恒等于初值。林的初值 40 卡在 warm 之下,孩的初值 60 卡在 trust 之下,两条弧的后半段就永远进不去。
两类错位的性质不同
输出里的两种判定,其实指向两类完全不同的问题:
| 判定 | 含义 | 该怎么修 |
|---|---|---|
| 落后 N 节拍 | 门限能到,但声明的节拍太早 | 把转折节拍号往后挪,或把初值/增量调高 |
| 不可达 | 门限永远到不了(缺变量或缺增量) | 把该角色加进 state.vars 并补 add 选项,或承认这段弧不由好感度驱动 |
「落后」是排期问题,「不可达」是机制缺失。前者调一个整数就好,后者要动的是角色与状态变量的对应关系。把这两类混为一谈,就会误把「加个变量」当成「挪个节拍」。
还有一条口径的诚实必须写清:脚本把「选项所在的场景」折成节拍号时,用的是该场景首次出现的节拍(scene_to_beat 里 setdefault 只记第一次)。灯塔基座出现在 b06 与 b11 两处,黎明码头出现在 b15 与 b16 两处——这类多幕共用的场景会被折到较早的那一拍。所以「乐观最早跨越」是一个上界,可能比真实更早;它不会把「能到」误判成「不能到」,只会把「不能到」判得保守。
自检表
| 检查项 | 合格标准 |
|---|---|
| 门限顺序 | gates 的 cold/warm/trust 与 thresholds 逐位相等 |
| 判据方向 | 用「乐观最早跨越 ≤ 声明节拍」,单向筛,不反推「一定对齐」 |
| 错位分类 | 区分「落后」(排期)与「不可达」(机制缺失) |
| 变量对齐 | 带 arc 的角色应在 state.vars 里,否则其门限不可达 |
| 口径诚实 | 声明乐观上界与「场景取首次节拍」两处近似 |
对齐校验把「关系该变了」变成了一条可判定的判据,但它只用了乐观上界——玩家把正向选项全选一遍才成立。真实玩家不会这么选。下一节不再假设玩家最优,而是真的跑两万条路径,看每个角色的好感度到底有没有越过门限。
阅读导航:上一节:5.1 弧光的阶段与转折 · 下一节:5.3 弧光的可达性 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。