接着上一篇。这篇看 narrative 和 battle 两个模块,主要是数据格式和运行时怎么约束它们。
对话
数据分三层,都在 datapack 里:
data/<namespace>/
├── story_states/<path>.json
├── dialogue_profiles/<path>.json
└── dialogues/<locale>/<path>.json
文件路径就是资源 ID。dialogues/zh_cn/flowey/chatter.json 的包 ID 是 minetale:flowey/chatter,locale 不参与 ID。三层由 NarrativeCompiler.compile()(NarrativeCompiler.java:67)按顺序解析。
story_states
状态变量要先声明才能用:
{
"scope": "player",
"type": "int",
"default": 0,
"min": 0
}
scope 是 player 或 world,前者存进玩家 attachment,后者存主世界的 SavedData。type 支持 boolean、int、id。id 必须给非空的 allowed namespaced ID 数组,存不了没声明的值。
dialogue_profiles
决定什么条件下聊哪一段:
{
"rules": [
{
"when": [
{"state": "minetale:met_flowey", "op": "eq", "value": false}
],
"run": "minetale:flowey/first_meeting"
},
{
"when": [],
"run": "minetale:flowey/chatter"
}
]
}
when 不是按数组顺序取第一条匹配的。编译器算每条规则条件集合的逻辑具体度,选最具体的那个;永远不成立的规则、无法判定唯一结果的重叠规则,都在编译期拒绝。空 when 是 fallback。
条件分两类。state 读剧情状态,read 读世界事实,白名单只有八项:
interactor.biome
interactor.sneaking
target.biome
target.entity_type
level.dimension
level.time_of_day
level.raining
level.thundering
dialogues
一个包是包级配置加一棵执行节点。每个节点要有包内唯一的 id 和显式 type。
| type | 作用 |
|---|---|
| sequence | 按顺序执行 children |
| page | 显示 lines[].literal |
| choice | 显示 options,每个 option 自带 results 规则 |
| set | 设置已声明状态 |
| add | 给 int 状态加值 |
| run | 执行另一个包,完成后返回 |
| branch | 用规则选一个包 |
| random | 等概率选一个,允许连续重复 |
| shuffle_cycle | 随机,当前循环内不重复 |
| start_battle | 经 BattleStartCoordinator 启动战斗 |
shuffle_cycle 的实现是每个节点挂一个剩余队列,空了才重新洗牌:
Deque<ResourceLocation> remaining = playerHistory.computeIfAbsent(key, ignored -> new ArrayDeque<>());
if (remaining.isEmpty()) {
List<ResourceLocation> shuffled = new ArrayList<>(shuffle.packages());
Collections.shuffle(shuffled, session.random);
remaining.addAll(shuffled);
}
frame.selectedPackage = remaining.removeFirst();
(DialogueSessionManager.java:373)历史按玩家、包 ID、节点 ID 隔离,只在内存里,玩家离线或重启即清空。
解释器
DialogueSessionManager 用显式 Frame 栈,每 tick 有步数上限:
private static final int STEP_BUDGET_PER_TICK = 256;
run()(DialogueSessionManager.java:273)的循环带 remaining-- > 0,嵌套深的对话不会一次跑完。page 和 choice 阻塞等客户端动作,另有 10 分钟无进展自动中断。
受伤、死亡、离线、换维度、资源重载、服务器停止、战斗启动都会中断会话。中断时把整个栈存进玩家附件(key 是 pending_dialogues),下次跟同一个 Profile 说话时优先恢复。正常结束和出错会清掉。
多语言
每个逻辑包必须有 zh_cn,缺失时回退到它。所有语言必须同构,只允许三项不同:
lines[].literal- choice option 的
literal - auto page 的
auto_delay_seconds
节点 ID、结构、条件、状态操作、portrait、声音、包引用都要一致,否则整份 Narrative 快照拒绝发布。
战斗
战斗逻辑跑在客户端,服务端不跑仿真。服务端只发定义 ID、definition hash、schema version、起始 tick、seed 和参与者。每个客户端自己在本地 catalog 里找同 ID 定义,校验 hash 和 schema version 通过了才建 BattleInstance,失败发 BattleStartFailedPayload。
握手
- 确认定义存在、发起者不在别的 Battle 里
- 跳过断线或已 busy 的非发起者,生成 battleId 和 seed
- 发
BattleInitialPayload,等 ack - 全员确认就确定参与者;10 秒截止时发起者已确认就用已确认且仍在线的子集
- 给最终参与者各发
BattleStartPayload,同时中断其活动对话
发起者没在截止前确认,握手直接取消。Battle 内只同步玩家 Soul 的位置和低频状态补丁,服务端校验 Battle 归属和发送者身份,不校验仿真结果。结果按 DEFEAT > ESCAPED > VICTORY 汇总。
BattleScript
顶层有 variables、imports、budgets、actors、rulesets、patterns、outcomes、phaseGraph,其中 schemaVersion 和 phaseGraph 必填。
预算写在定义里,不是运行时猜的:
每 tick Actor 生成 256、Signal 派发 512、循环 1024、递归深度 64、
规则栈 64、单 Stack Action 256、Battle tick 总 Action 1024、渲染请求 64
可以逐项覆盖,但所有值必须是 32 位正整数。repeat 这类带计数的循环节点超限在编译期就报错。
导入的定义参与 hash 计算:
private static String compiledHash(ParsedDefinition parsed, ImportedDefinitions imported) {
JsonObject input = parsed.root().deepCopy();
JsonArray importHashes = new JsonArray();
imported.importHashes().stream().sorted().forEach(importHashes::add);
input.add("_resolvedImportHashes", importHashes);
return BattleDefinitionHasher.sha256(input);
}
(BattleScriptCompiler.java:363)被导入的内容变了,编译产物的 hash 就变,客户端加载时校验不过直接拒绝,不会拿新版规则跑老版流程。
加载用宽松模式,一个定义坏了不影响别的:
Map<ResourceLocation, RuntimeException> rejected = new LinkedHashMap<>();
(BattleScriptCompiler.java:32)compileLenient() 把无效定义收进 rejected,其余合法的照常发布到 catalog。compileAll() 才会因为某一个坏定义整体失败。
普通随机和声音随机是两个独立 Random,加音效不影响玩法随机序列。Actor 类里不存脚本执行器,行为由 prefab、状态缓存、组件和事件 Action 组成,当前组件是 velocity_movement、bounce_on_battle_box、spin、lifetime、damage_on_touch、heal_on_touch、destroy_on_touch、emit_signal_on_touch 这几个。
对话和战斗的接口
start_battle 节点是唯一的桥:
BattleStartCoordinator.StartResult result = BattleStartCoordinator.start(
session.player,
startBattle.battle(),
candidates
);
if (result.started()) {
complete(session);
(DialogueSessionManager.java:392)战斗启动成功,当前对话会话直接结束,不等。participants.scope 可以是 self 或 nearby,后者要带 range。反方向,战斗启动会中断活动对话。
相关文件
narrative/docs/dialogue-data-pack-format.md:数据格式全文narrative/CONTEXT.md:状态所有权、中断条件、客户端边界battle/docs/battlescript-runtime.md:Action 分类、默认预算battle/docs/battle-lifecycle.md:握手与结果汇总battle/CONTEXT.md:客户端仿真这条边界的由来contentpack/CONTEXT.md:Content Pack 验证失败时不替换已发布快照
代码片段摘自 MINETALE-FAULT 项目源码(Apache-2.0),JSON 摘自其 datapack 格式文档。

Comments NOTHING