5.2 弧光与行为的对齐

弧光的每一段要对到一个好感度门限上。本节把《雾港夜航》六个角色按 cold/warm/trust 三门限做对齐,用「乐观最早跨越节拍」当上界:连把正向选项全选一遍都没能在声明转折节拍前越过门限的,就一定是错位。18 条里查出 4 条错位——砚的阶段①落后 4 拍,林与孩的 trust 门在乐观路径上都够不到。

5.2 弧光与行为的对齐

独立游戏的叙事设计与分支对话 谈「关系变好之后人物就该有不同的表现」——那是散文式的对齐经验,把「表现该变」说成一种直觉。本节把这句话拆成一条可判定的判据:弧光的第 i 段,必须能对上好感度的第 i 个门限。5.1 已经声明了「砚在 b01 / b08 / b12 转折」,本节要问的是——这三个节拍上,砚的好感度真的够得到对应的门限吗。

门限:把「关系变好」写成三个整数

characters[].affinity 把「关系」压成四个整数:一个初值、一个区间、三个门限。门限与门限名成对出现,顺序固定为 cold → warm → trust:

角色初值coldwarmtrust在 state.vars 里
砚 yan20306085是
徽 hui50306085是
沈 shen50306085是
沐 mu50306085是
林 lin40306085否
孩 hai60306085否

门限值六个角色完全一样(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+2040cold=30
b06+1555—
b07+3590warm=60、trust=85
b08+15100(封顶)—
b12+75100(封顶)—
b13+10100(封顶)—

读这张表: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 弧光的可达性 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「narrative」更多文章

  1. 15.3 规模告警
  2. 15.2 求值与遍历的开销
  3. 15.1 规格规模的增长