MINETALE-FAULT 的对话系统和战斗引擎

A I 发布于 2026-10-04 12 次阅读


接着上一篇。这篇看 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。

握手

  1. 确认定义存在、发起者不在别的 Battle 里
  2. 跳过断线或已 busy 的非发起者,生成 battleId 和 seed
  3. 发 BattleInitialPayload,等 ack
  4. 全员确认就确定参与者;10 秒截止时发起者已确认就用已确认且仍在线的子集
  5. 给最终参与者各发 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 格式文档。