角色在散文里是被「塑造」出来的:一段台词、一次沉默、一个不肯回答的问题。站内 独立游戏的叙事设计与分支对话
用一千多行讲了这件事——Twine 里怎么让角色可信、分支里怎么让选项像「那个角色会做的事」,那是经验与清单,不能跑。本节换一种做法:把角色写成字段。characters[].goals 是目标,fears 是恐惧,secret 是秘密,每个角色各 2 + 2 + 1 条,6 个角色合计 30 条;结论先给——goals 与 fears 是自由文本数组,secret 是没有任何 flag 绑定的字符串(对照 world.lore[].reveal_flag,设定条目是绑定的),三个字段都不能被直接检查。要检查,就必须先补一层锚点表:把每条文本锚到规格里已存在的实体(场景、flag、条件),再让脚本回规格验证这层锚站不站得住。
4.1 角色的字段:目标、恐惧、秘密
三个字段在规格里长什么样
| 字段 | 规格里的类型 | 谁能读 | 能否直接检查 |
|---|---|---|---|
goals | string[],每角色 2 条 | 只有人 | 否——自由文本,无标识符 |
fears | string[],每角色 2 条 | 只有人 | 否——自由文本,无标识符 |
secret | string,每角色 1 条 | 只有人 | 否——没有 reveal_flag 字段 |
对照:world.lore[].reveal_flag | string | 脚本 | 是——已绑定到 18 个 flag 之一 |
30 个字段全部落在「只有人能读」这一列。这不是规格写得不好,而是自由文本本来就不承载标识符:"让灯塔再亮一次" 与 "saw_lighthouse_light" 之间没有任何字符串关系,机器无法从一个推出另一个。
可检查性来自锚定,而不是来自字段本身
字段本身不可检查,但字段所指的行为在规格里是可检查的。三类字段各有一个自然的锚定形式:
- 目标 → 场景 + flag:目标要被推进就得有场景,推进要在状态上留痕就得有 flag。可检查形式是「在场景 X 里,flag Y 被某个选项
set过」。 - 恐惧 → 条件里的实体:恐惧要能影响流程,就必须在某处表现为条件——
line_when、选项的when、场景的enter_when、地点的unlock_when、结局守卫。可检查形式是「锚定实体出现在至少一处条件里」。 - 秘密 →
reveal_flag:秘密要能被发现,就得有「知道」这个状态,而且这个状态要有用。可检查形式是「reveal_flag既被set过、又门控了至少一处条件」。
这三条口径把「角色写得对不对」换成了「字段有没有落到结构上」,可判定、可回归。锚点表由人写,它同时充当规格文本改动时的同步哨兵:脚本会逐条比对锚点表的文本与 characters[] 的原文。
{
"note": "锚点表:把 characters[] 的自由文本字段锚到 story_spec 的实体。goals = [角色, 目标文本, 场景, flag];fears = [角色, 恐惧文本, 实体类, 实体 id];secrets = [角色, 秘密文本, reveal_flag]。",
"goals": [
["yan", "让灯塔再亮一次", "lighthouse_top", "saw_lighthouse_light"],
["yan", "把十七个名字刻完", "tide_cave", "cave_explored"],
["hui", "把船队带出雾", "breakwater", "hui_trusts"],
["hui", "让船帮不再仰仗教会", "tavern", "met_hui"],
["shen", "证明灯塔是伪灯", "chapel", "met_shen"],
["shen", "安抚信众", "drowned_bell", "shen_trusts"],
["mu", "把账做平", "counting_house", "met_mu"],
["mu", "离开雾港", "harbor_office", "mu_trusts"],
["lin", "守住工会的规矩", "watch_post", "met_lin"],
["lin", "看着最后一个守望者上路", "watch_post", "has_lantern"],
["hai", "攒够船费", "pier", "met_hai"],
["hai", "找到姐姐", "market_row", "promised_hai"]
],
"fears": [
["yan", "雾再也不散", "flag", "bell_rung"],
["yan", "名字被遗忘", "flag", "found_yan_journal"],
["hui", "船沉", "item", "salt_letter"],
["hui", "被商会吞并", "flag", "met_mu"],
["shen", "教会分裂", "flag", "shen_trusts"],
["shen", "自己信了不该信的", "flag", "saw_lighthouse_light"],
["mu", "被查账", "item", "ledger_page"],
["mu", "账本被烧", "flag", "found_yan_journal"],
["lin", "工会散伙", "flag", "met_lin"],
["lin", "自己成为最后一个", "flag", "has_lantern"],
["hai", "被留下", "flag", "promised_hai"],
["hai", "忘掉姐姐的脸", "flag", "cave_explored"]
],
"secrets": [
["yan", "他是第十八个名字;灯灭那夜他本可以走,却选择留下。", "cave_explored"],
["hui", "她三夜前拒了砚的渡船请求。", "hui_trusts"],
["shen", "他私下修好了沉钟的机括,却不敢鸣。", "shen_trusts"],
["mu", "十七年的账里,有九笔支出进了同一个无名的户头。", "mu_trusts"],
["lin", "他知道砚在塔顶,也默许了。", "met_lin"],
["hai", "他姐姐是第十六个名字。", "promised_hai"]
]
}
S 层:五类交叉引用检查
#!/usr/bin/env python3
# file: sim/na_04_1_character_fields.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/na_04_1_character_fields.py
"""4.1 角色字段(目标 / 恐惧 / 秘密)与规格实体的交叉引用检查。
characters[].goals / fears 是自由文本,secret 是没有 flag 绑定的字符串(对比
world.lore[].reveal_flag),三者都不能被机器直接检查。本脚本读入锚点表
config/na_04_1_anchors.json,把每个字段锚到规格实体,再回规格做五类校验:
锚点与规格同步 / 实体存在 / goal 在锚定场景内可设置 / fear 出现在条件里 /
secret 的 reveal_flag 可设置且门控了条件。产出 out/na_04_1_character_fields.*。
"""
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 = 0, 100
LBL = {"goal": "目标", "fear": "恐惧", "secret": "秘密"}
COLS = ["目标 1", "目标 2", "恐惧 1", "恐惧 2", "秘密"]
def load(rel):
with open(os.path.join(ROOT, rel), encoding="utf-8") as fh:
return json.load(fh)
def walk(c, out):
"""收集条件里引用的实体 (kind, id)。"""
if c is None:
return
if c[0] in ("flag", "item", "aff"):
out.append((c[0], c[1]))
elif c[0] in ("and", "or"):
walk(c[1], out)
walk(c[2], out)
elif c[0] == "not":
walk(c[1], out)
def sites(spec):
"""规格里所有条件的出现位置。"""
out = [n["line_when"] for n in spec["dialogue"] if n.get("line_when")]
out += [c["when"] for n in spec["dialogue"] for c in (n.get("choices") or []) if c.get("when")]
out += [e["when"] for e in spec["scene_graph"]["edges"] if e.get("when")]
out += [nd["enter_when"] for nd in spec["scene_graph"]["nodes"] if nd.get("enter_when")]
out += [l["unlock_when"] for l in spec["world"]["locations"] if l.get("unlock_when")]
out += [e["when"] for e in spec["endings"] if e.get("when")]
return out
def settable(spec):
"""flag / item -> 设置它的 (scene, choice_id) 列表。"""
flags, items = {}, {}
for n in spec["dialogue"]:
for c in (n.get("choices") or []):
eff = c.get("effects") or {}
for f in sorted(eff.get("set") or {}):
flags.setdefault(f, []).append((n["scene"], c["id"]))
for it in sorted(eff.get("grant") or {}):
items.setdefault(it, []).append((n["scene"], c["id"]))
return flags, items
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 grid_svg(ids, grid, path):
cw, chh, lw, top = 132, 42, 92, 76
W, H = lw + cw * len(COLS) + 26, top + chh * len(ids) + 62
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, "绿 = 字段锚到规格实体且可检查;红 = 锚点悬空(字段无法被机器检查)", 11, f="#64748b")
for j, name in enumerate(COLS):
tx(s, lw + cw * j + cw // 2, top - 10, name, 12, "middle", "#475569")
for i, cid in enumerate(ids):
y = top + chh * i
tx(s, lw - 12, y + 26, cid, 12, "end", "#0f172a")
for j in range(len(COLS)):
c = grid.get((i, j))
s.append(el("rect", x=lw + cw * j + 2, y=y + 3, width=cw - 4, height=chh - 6, rx=5,
fill="#f1f5f9" if c is None else ("#dcfce7" if c["ok"] else "#fee2e2"),
stroke="#cbd5e1", **{"stroke-width": 1}))
if c:
tx(s, lw + cw * j + cw // 2, y + 26, c["label"], 10, "middle",
"#166534" if c["ok"] else "#991b1b")
tx(s, 18, H - 30, f"可检查 {sum(1 for c in grid.values() if c['ok'])}/{len(grid)} 个字段锚点",
12, f="#0f172a")
tx(s, 18, H - 12, "锚点表:config/na_04_1_anchors.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, anch = load("config/story_spec.json"), load("config/na_04_1_anchors.json")
chars = spec["characters"]
cids = [c["id"] for c in chars]
by_id = {c["id"]: c for c in chars}
idx = {cid: i for i, cid in enumerate(cids)}
flags, items = set(spec["state"]["flags"]), set(spec["state"]["items"])
scenes = {n["id"] for n in spec["scene_graph"]["nodes"]}
set_flags, _set_items = settable(spec)
refs = []
for c in sites(spec):
walk(c, refs)
cond_flags = {i for k, i in refs if k == "flag"}
cond_items = {i for k, i in refs if k == "item"}
rows, warns, grid = [], [], {}
got = {"goal": {}, "fear": {}, "secret": {}}
for cid, text, scene, flag in anch["goals"]:
hit = [s for s in set_flags.get(flag, []) if s[0] == scene]
ok = scene in scenes and flag in flags and bool(hit)
rows.append({"char": cid, "field": "goal", "text": text, "anchor": f"{scene}·{flag}", "ok": ok,
"detail": f"场景{'有' if scene in scenes else '无'} / flag{'有' if flag in flags else '无'} / "
+ (f"场景内可设置 ×{len(hit)}" if hit else "场景内无设置点")})
k = len(got["goal"].setdefault(cid, []))
got["goal"][cid].append(text)
grid[(idx[cid], k)] = {"ok": ok, "label": f"{scene}·{flag}"}
for cid, text, kind, aid in anch["fears"]:
n = sum(1 for k, i in refs if k == kind and i == aid)
exists = aid in (flags if kind == "flag" else items)
ok = exists and n > 0
rows.append({"char": cid, "field": "fear", "text": text, "anchor": f"{kind}·{aid}", "ok": ok,
"detail": ("存在" if exists else "缺失") + " / "
+ (f"出现在条件里 ×{n}" if n else "未出现在任何条件里")})
k = len(got["fear"].setdefault(cid, []))
got["fear"][cid].append(text)
grid[(idx[cid], 2 + k)] = {"ok": ok, "label": f"{kind}·{aid}"}
for cid, text, rf in anch["secrets"]:
ok = rf in flags and rf in set_flags and rf in cond_flags
rows.append({"char": cid, "field": "secret", "text": text, "anchor": f"reveal_flag·{rf}", "ok": ok,
"detail": f"flag{'有' if rf in flags else '无'} / "
+ (f"可设置 ×{len(set_flags.get(rf, []))}" if rf in set_flags else "不可设置")
+ " / " + ("门控了条件" if rf in cond_flags else "未门控任何条件")})
got["secret"].setdefault(cid, []).append(text)
grid[(idx[cid], 4)] = {"ok": ok, "label": rf}
for cid in cids:
ch = by_id[cid]
for key, fld in (("goal", "goals"), ("fear", "fears")):
if sorted(got[key].get(cid, [])) != sorted(ch[fld]):
warns.append(f"{cid}.{fld} 锚点与规格不同步")
if got["secret"].get(cid, [None])[0] != ch["secret"]:
warns.append(f"{cid}.secret 锚点文本与规格不同步")
inert = sorted(f for f in flags if f in set_flags and f not in cond_flags)
never_set = sorted(f for f in flags if f not in set_flags)
cond_only = sorted(f for f in cond_flags if f not in set_flags)
for c in chars:
assert LO <= c["affinity"]["init"] <= HI and c["affinity"]["range"] == [LO, HI]
out = os.path.join(ROOT, "out")
os.makedirs(out, exist_ok=True)
rep = {"characters": cids, "fields_total": len(rows), "fields_ok": sum(1 for r in rows if r["ok"]),
"by_kind": {k: [sum(1 for r in rows if r["field"] == k and r["ok"]),
sum(1 for r in rows if r["field"] == k)] for k in LBL},
"flags_total": len(flags), "flags_condition_referenced": len(cond_flags),
"flags_inert": inert, "flags_never_set": never_set, "flags_condition_only": cond_only,
"rows": rows, "warnings": warns}
with open(os.path.join(out, "na_04_1_character_fields.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_1_character_fields.csv"), "w", encoding="utf-8", newline="") as fh:
w = csv.writer(fh)
w.writerow(["char", "field", "text", "anchor", "ok", "detail"])
for r in rows:
w.writerow([r["char"], r["field"], r["text"], r["anchor"], int(r["ok"]), r["detail"]])
size = grid_svg(cids, grid, os.path.join(out, "na_04_1_character_fields.svg"))
rects = sum(1 for e in ET.parse(os.path.join(out, "na_04_1_character_fields.svg")).getroot().iter()
if isinstance(e.tag, str) and e.tag.endswith("rect"))
assert rects >= len(grid) + 1
print(f"角色 {len(cids)}:{cids}")
print(f"字段锚点 {len(rows)} 个:" + " · ".join(f"{LBL[k]} {v[0]}/{v[1]}" for k, v in rep["by_kind"].items()))
for r in rows:
if not r["ok"]:
print(f" WARN {r['char']:<4} {LBL[r['field']]} {r['text'][:16]:<18} {r['anchor']:<28} {r['detail']}")
print(f"flag {len(flags)} 个:出现在条件里 {len(cond_flags)} · 惰性(可设置但无门控){len(inert)}:{inert}")
print(f"从未被设置 {len(never_set)}:{never_set} · 只在条件里出现 {len(cond_only)}:{cond_only}")
for wmsg in warns:
print(f"WARN {wmsg}")
print(f"wrote out/na_04_1_character_fields.json / .csv / .svg({size} bytes,rect {rects})")
print(f"OK na_04_1_character_fields:整数不变量通过,字段锚点 {rep['fields_ok']}/{len(rows)} 可检查")
return 0
if __name__ == "__main__":
sys.exit(main())
实跑输出
脚本只打印悬空(WARN)行,通过行见 out/na_04_1_character_fields.csv。
角色 6:['yan', 'hui', 'shen', 'mu', 'lin', 'hai']
字段锚点 30 个:目标 12/12 · 恐惧 7/12 · 秘密 2/6
WARN hui 恐惧 被商会吞并 flag·met_mu 存在 / 未出现在任何条件里
WARN shen 恐惧 教会分裂 flag·shen_trusts 存在 / 未出现在任何条件里
WARN lin 恐惧 工会散伙 flag·met_lin 存在 / 未出现在任何条件里
WARN lin 恐惧 自己成为最后一个 flag·has_lantern 存在 / 未出现在任何条件里
WARN hai 恐惧 被留下 flag·promised_hai 存在 / 未出现在任何条件里
WARN hui 秘密 她三夜前拒了砚的渡船请求。 reveal_flag·hui_trusts flag有 / 可设置 ×3 / 未门控任何条件
WARN shen 秘密 他私下修好了沉钟的机括,却不敢鸣 reveal_flag·shen_trusts flag有 / 可设置 ×5 / 未门控任何条件
WARN lin 秘密 他知道砚在塔顶,也默许了。 reveal_flag·met_lin flag有 / 可设置 ×3 / 未门控任何条件
WARN hai 秘密 他姐姐是第十六个名字。 reveal_flag·promised_hai flag有 / 可设置 ×3 / 未门控任何条件
flag 18 个:出现在条件里 11 · 惰性(可设置但无门控)7:['has_lantern', 'hui_trusts', 'met_hai', 'met_lin', 'met_mu', 'promised_hai', 'shen_trusts']
从未被设置 0:[] · 只在条件里出现 0:[]
wrote out/na_04_1_character_fields.json / .csv / .svg(9157 bytes,rect 31)
OK na_04_1_character_fields:整数不变量通过,字段锚点 21/30 可检查
实测:目标全通过,恐惧过半,秘密只有两个有门控
| 字段类 | 可检查 | 总数 | 悬空的原因 |
|---|---|---|---|
| 目标 | 12 | 12 | —— |
| 恐惧 | 7 | 12 | 锚定实体从未出现在任何条件里 |
| 秘密 | 2 | 6 | 揭示无 reveal_flag 门控 |
| 合计 | 21 | 30 |
目标 12/12 全通过,原因是写规格时就自然形成了对应:每个目标都在某个场景里被至少一个选项推进过——「把十七个名字刻完」对应 tide_cave 里的 cave_explored(两个选项 set 它),「把船队带出雾」对应 breakwater 里的 hui_trusts。目标与场景、flag 之间本来就有结构关系,锚点表只是把它写下来。
恐惧 7/12:悬空的 5 条锚到了 met_mu、shen_trusts、met_lin、has_lantern、promised_hai——这些 flag 在选项效果里被 set,但全篇没有任何条件读它们。一个从不被读的状态,不可能是恐惧的结构形式。
秘密 2/6:只有砚(cave_explored)与沐(mu_trusts)的秘密有真正的门。另外 4 条在正文里是无条件说出来的——沈在 d_chapel_2 说「钟的机括我修好了。可我不知道该不该鸣。」,这一行没有 line_when;徽在 d_tavern_2 说「三夜前他来找过我,要一条船。我没给。」,同样无条件。玩家不可能错过它们,秘密作为「可发现的」结构就失效了。这不是文笔问题,是可检查性问题:如果秘密不该被错过,那是设计选择;如果该被错过,规格里现在缺一个字段来承载它——characters[].secret_reveal_flag。
惰性 flag:18 个里 7 个从不参与条件
has_lantern、hui_trusts、met_hai、met_lin、met_mu、promised_hai、shen_trusts 被 set 过、却从未出现在任何条件里;三个 *_trusts 里只有 mu_trusts 真正当了门(被 2 处条件引用)。这是结构量而非好坏判断:flag 不参与条件,只说明它对流程走向没有影响,可能是有意为之(纯记录),也可能是漏了——而这两种情况在文本层面看起来一样,只有这个计数能把它们分开。脚本同时报告「从未被设置」与「只在条件里出现」两个反向计数,本案例都是 0:18 个 flag 既没有死状态,也没有只读不写的条件。
本节不评价目标写得是否动人、秘密是否精彩——那是文本创作的事,划界到小说创作一侧;本节只处理一件事:字段能不能被交叉引用检查。锚点表是人写的一层,它给出的不是「角色写得好不好」,而是「规格里的哪些实体正在承担这个字段的功能」,以及哪些字段目前没有实体在承担。
阅读导航:上一节:3.3 设定的暴露节奏 · 下一节:4.2 关系矩阵 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。