16 KiB
对话与剧情(Dialogue)
脚本入口: eve.Dialogue()
视觉小说式对话舞台:角色注册、打字机台词、选项分支、口型(lip-sync)、表情与
动作转发。剧情继续用 Squirrel 写(函数 / generator),不发明第二套 DSL。
文案可走 i18n 翻译表。
基本用法
dlg <- eve.Dialogue();
dlg.registerCharacter("alice", "爱丽丝");
dlg.bindAvatar("alice", aliceAv); // Avatar 实例
dlg.show("alice", "left"); // 舞台槽位 left|center|right
dlg.say("alice", "你好,冒险者。");
dlg.update(dt); // 每帧驱动打字机
if (dlg.isWaitingAdvance()) dlg.advance();
目标导向指南
带选项的剧情分支
dlg.clearChoices();
dlg.addChoice("yes", "接受委托");
dlg.addChoice("no", "再想想");
dlg.presentChoices();
// 每帧:if (dlg.isWaitingChoice() && 按下1/2) dlg.selectChoice(index);
if (dlg.getSelectedChoiceId() == "yes") { /* 分支 A */ }
用 generator 写整段剧情
dlg.say(...) 后 yield "wait",选项阶段 yield "choice"——驱动循环消费
generator 的 yield 值。完整可运行示例见
examples/dialogue(含 i18n + 口型 + Avatar)。
API 快查
Dialogue(模块)
getName():模块名("Dialogue")。- 角色:
registerCharacter(id, displayName)、hasCharacter、getDisplayName、getCharacterCount、getCharacterId、bindAvatar(id, avatar)、getAvatar(id)。 - 舞台:
show(id, slot)、hide、isShown、getSlot、setSlotX/getSlotX、setExpression、setMotion、syncStage()。 - 台词:
say(speakerId, text)、narrate(text)、setTypeSpeed/getTypeSpeed、skipTyping、isTyping、isWaitingAdvance、isIdle、advance、getSpeakerId/getSpeakerName/getFullText/getVisibleText/getPhase。 - 口型:
setLipSyncEnabled/isLipSyncEnabled、setLipSyncParameter/getLipSyncParameter、setLipSyncAmplitude/getLipSyncAmplitude/getLipSyncValue。 - 选项:
clearChoices/addChoice(id, label)/presentChoices/isWaitingChoice、getChoiceCount/getChoiceId/getChoiceLabel/selectChoice/getSelectedChoiceId。 - 变量:
setVar/getVarType/getVarInt/getVarFloat/getVarBool/getVarString/hasVar/clearVar/clearVars。 - 条件:
registerCondition(name, fn)/unregisterCondition/evalCondition。 - 台词池:
loadPoolsFromTable(table)、loadPoolsFromDnut(src)、loadPoolsFromDnutFile(path)、clearPools、getPoolCount/getPoolId/hasPool、getLastPoolsError、setRandomSeed/getRandomSeed、pickLine/playLine/playPool、getCurrentLineId/getCurrentLineMeta/getCurrentLineTags。 - 驱动:
update(dt)、reset()。
DialogueUX(默认呈现辅助)
构建包含 dialogue 时,根表同时提供 dialogueUX。它不强制使用某一种 UI,
但为官方 DefaultDialogueUI 和自定义 UI 保存一致的呈现状态:
- 历史:
record、clearHistory、getHistoryCount、getHistoryLineId/getHistorySpeaker/getHistoryText。 - 富文本:
plainText去除[pause]、[speed]、[color]、[shake]控制标签;getTextActionCount返回控制动作数。 - 自动播放:
setAutoMode/isAutoMode、setAutoDelay/getAutoDelay、updateAuto(dt, voicePlaying)、resetAutoTimer。 - 跳过:
setSkipMode("off"|"read"|"all")、getSkipMode、markRead/isRead/shouldSkip。
make_default_dialogue_ui(dialogue, dialogueUX, ui) 创建可直接挂载的默认 UI;
调用返回对象的 mount()、每帧 update(dt) 和 render() 即可。项目可以完全替换
这个脚本视图,而继续复用 DialogueUX 的历史、已读和自动推进状态。
皮肤、显示位置与脚本绘制
DefaultDialogueUI 随引擎启动脚本一起打包,不需要游戏引用源码目录。
Dialogue 只拥有台词、打字进度和选项;视图仅保存可丢弃的显示配置。
因此切换视图时调用 setPresentation(options) 和 mount(),不调用 dlg.reset()。
local view = make_default_dialogue_ui(dialogue, dialogueUX, ui, "conversation");
// Keep the image decoder/provider alive while using image-backed UI skins.
local skinImages = eve.Image();
view.setPresentation({
skin = "skins/moonlight.9.png",
x = 40.0, y = 330.0, width = 864.0, height = 184.0,
hint = "Space: continue"
});
view.mount();
// Update after dialogue.update(dt). Consume clicks before rebuilding the view.
local selected = view.consumeChoice(); // -1 means no choice click
if (selected >= 0) {
if (dialogue.selectChoice(selected)) resumeStory();
} else if (view.update(dt, voicePlaying)) {
resumeStory(); // UX auto/skip already advanced Dialogue once.
}
view.render(); // Call once in the frame's rendering phase.
skin使用现有 UI 的原始 Android.9.png支持:一像素透明标记框, 上/左黑线标记拉伸区,下/右黑线标记内容区。每条边只支持一段连续标记, 不接受 Android 编译后的二进制 nine-patch。角不随面板拉伸,文字在内容边距内布局。 无效资源会抛出包含路径的错误,不替换已挂载的 UI 树;修正配置后重新mount()。- 省略
skin使用透明布局容器,不带窗口标题栏。x/y是 UI 屏幕像素位置;width控制文字换行,皮肤的height控制内容滚动区域。 默认按钮按实际选项数量生成,没有八项上限。 layout(frame)可逐帧返回{x, y},用于说话人气泡或跟随世界投影点。 投影、屏幕边缘约束由游戏决定。尺寸改变时重新挂载。drawBackground(frame)在标准 UI 渲染前同步执行,适合用gfx画动态边框、 波形、尾巴或入场效果;文字与可点击选项仍由 UI 负责。draw(frame)接管整个绘制过程,不调用默认 UI 渲染;此时可传uiInstance = null。 自定义绘制器负责字体、换行、选项绘制、命中测试及键盘/触屏输入。consumeChoice()在此模式返回 -1,游戏将自己的输入交给 Dialogue。
view.setPresentation({
draw = function(frame) {
paintSpeechBubble(frame.speakerId, frame.speaker, frame.text);
foreach (choice in frame.choices)
paintChoice(choice.index, choice.id, choice.label);
}
});
view.mount();
frame 是独立快照,字段为 speakerId/speaker/text/typing/waitingAdvance/idle/choices;
choices 包含 index/id/label。文字已通过 DialogueUX.plainText 去掉控制标签。
回调修改快照不会修改剧情;需要富文本效果的绘制器可直接查询 Dialogue/DialogueUX。
draw 优先于 drawBackground 与 layout。回调异常直接传播,不能静默吞掉。
所有视图操作及回调在游戏主线程同步执行,不持有引擎锁;回调不得递归调用视图生命周期方法。
游戏拥有 Dialogue、UX、UI、字体与纹理资源,它们必须覆盖视图使用期。
离开场景调用 unmount() 隐藏宿主并释放回调引用;皮肤缓存仍由 UI 拥有。
不要把视图、回调或 GPU 资源写入剧情存档:恢复后从配置重建视图。
不提供 UI 模块的构建可以只使用 draw,不引入 Dialogue 到 UI/Graphics 的原生依赖。
examples/dialogue 提供月夜圆角皮肤、信笺边框皮肤与脚本通讯面板;按 4/5/6
切换,保留当前台词和选项。选项支持鼠标按钮与原有数字键。
皮肤源码位于 examples/dialogue/skins/generate.py,可确定性重新生成。
DialogueVoice(语音与音频口型)
根表 dialogueVoice 管理按 lineId + locale 定位的语音。项目先用
sound.newSoundDataFromFile 与 audio.newSource 创建 Source,再调用
bindSource(lineId, locale, source);无音频设备的测试也可用
registerClip(lineId, locale, duration, envelopeCsv) 注册时长和振幅轨。
- 资产:
registerClip、bindSource、hasClip、clear。 - 播放:
play(lineId, locale)、stop、update(dt)、isPlaying、getTime/getDuration/getCurrentLineId。 - 自动推进:
shouldAutoAdvance()在语音达到结尾后返回 true。 - 口型:
getAmplitude()读取预计算振幅包络,采样率由setEnvelopeRate/getEnvelopeRate控制。可把返回值传给 Avatar 参数。
locale 精确匹配失败时回退到空 locale 的默认语音。Source 的生命周期仍由游戏持有,
切换台词或销毁 DialogueVoice 时会停止当前 Source。
DialogueFlow(参数化流程与内容工具)
dialogueFlow 将 .dnut 中的 conversation 块编译为带稳定 node ID 的资产,并用
可保存的显式 Runner 执行。台词池与 conversation 块可以共存于同一文件。
conversation common.greeting version=1 entry=decide params=speaker,listener,location
node decide branch
when score(speaker, listener, location) >= threshold(speaker.personality) -> friendly
else -> formal
node friendly line speaker=speaker pool=greeting.friendly i18n=dialogue.greeting.friendly next=end
node formal line speaker=speaker text="Good day, {listener.name}." next=end
node end end
endconversation
- 内容:
loadFromDnut/loadFromDnutFile、importYarn/importTwee、clear、getConversationCount()、getConversationId(index)、hasConversation;失败详情通过诊断查询 API 获取。 - 外部格式:Yarn Spinner 节点和 Twine Twee 3 passages 会转换为相同的稳定节点模型;
角色前缀、参数占位符、双向 Twine 链接、Yarn shortcut options、
jump/stop/wait/call/set命令以及#line:/#voice:标签都会保留,并进入统一的引用校验流程。Twee 的StoryTitle/StoryData元数据 passages 会自动忽略。 - 校验:
getDiagnosticCount、getDiagnosticSeverity/getDiagnosticPath/getDiagnosticLine/getDiagnosticMessage;编译检查 重复或缺失 ID、无效引用,并报告不可达节点。 - 工具链:相同 path、相同内容的
loadFromDnut会命中内存增量缓存,getLastLoadChanged()可判断是否重编译;removeSource(path)卸载该来源产生的资产;lintAll()批量检查全库并验证跨文件 call;renameConversation/renameNode自动改写引用, 同时提升资产版本以显式拒绝不兼容的旧执行游标。 - 热重载:
reloadFromDnut(source, path)在临时工作区编译并执行全库跨文件引用校验; 编译、引用校验或活动游标迁移任一失败时,会保留旧资产和旧执行位置。成功时则结合registerMigration恢复当前对话和全部调用栈。普通loadFromDnut仍允许按任意顺序初次 装入互相引用的文件,全部装入后用lintAll()做一次完整校验。 - 本地化:
exportLocalizationCsv()返回带 conversation/node 稳定 ID、i18n key、 speaker、源文和 voice key 的 RFC4180 CSV。 - 回导与配音:
importLocalizationCsv(csv, defaultLocale)接受i18n_key/locale/translation以及可选的voice/status/duration列;setLocale/getLocale控制当前语言,读取当前getText/getVoice时按精确 locale、语言代码、默认 locale 依次回退。exportMissingLocalizationCsv(locale)生成待翻译清单,exportVoiceRecordingCsv(locale)生成并可再次回导的录音清单,包含角色、源文、译文、 voice key、录制状态和时长;当前行可用getVoiceStatus/getVoiceDuration读取回导结果, 将时长直接交给dialogueVoice.registerVoice即可驱动语音结束自动推进。 - 内容包提交前可调用
validateLocalization(i18n, locale),逐节点验证i18n引用在指定 精确语言中存在;该检查故意不接受默认语言回退,并返回结构化 Result 和节点路径。 - 执行:产品路径使用
startChecked(id, bindings)、advanceChecked()和select(routeId)的 结构化 Result;start(id, bindings)/advance()仅作兼容投影;isActive/isBlocked、getActiveConversationId/getNodeId/getNodeKind。 - 当前节点:
getSpeaker/getText/getPool/getI18nKey/getVoice、getRouteCount/getRouteId。 - 复杂条件:
setExpressionEvaluator(fn)注册纯计算函数,fn 接收{ expression, bindings, locals }并返回 bool 或结构化值;clearExpressionEvaluator解除注册。表达式文本不会被编译器限制成简单比较式。 - 脚本存档:
captureStateJson()返回可直接交给项目存档系统的 JSON;restoreStateJson(json)恢复当前节点、参数、局部值和完整的子对话调用栈。 资产升级后用registerMigration(oldAssetId, oldVersion, currentAssetId, nodeMap)显式登记迁移,其中nodeMap是逗号分隔的old:new对;未改名的稳定节点无需列出。 迁移会事务式覆盖当前帧和全部调用帧,任何缺失规则或目标节点都会拒绝恢复;clearMigrations()清除规则。 - 参数化文本:本地化回退完成后,
getText()会以 locals 优先、bindings 次之的顺序 渲染{speaker.name}等路径;支持{path??fallback},以及|upper、|lower、|capitalize修饰。找不到且没有 fallback 的占位符会原样保留,便于内容 QA 发现缺参。 - 人物语气:
addToneRule(expression, prefix, suffix, find, replacement)添加有序规则; expression 复用setExpressionEvaluator,因此可以读取人物性格、关系、疲劳或剧情状态, 对渲染后的文本加前后缀或做词语替换。多个命中规则依次叠加,clearToneRules()可在场景或角色配置切换时清空。
Runner 在 line、choice、wait 和异步 command 边界暂停;C++ API 的
captureState/restoreState 保存资产版本、node ID、bindings、locals 和子对话调用栈。
ConversationDocument(可组合编辑数据)
引擎不内置固定的对话编辑器。dialogueFlow.newDocument(id) 创建 UI 无关的可变图文档,
getDocument(id) 返回运行资产的可编辑副本;项目可用任意 MVVM/UI 组件组合节点列表、
画布、Inspector、诊断面板与游戏内剧情工具。只有 applyDocument(document) 校验整个工作区
成功后才事务式替换运行资产。
local doc = dialogueFlow.newDocument("quest.intro");
doc.addNode("welcome", "line");
doc.setEntry("welcome");
doc.setField("welcome", "text", "Welcome, {player.name}.");
doc.setField("welcome", "next", "end");
// Inspector 不硬编码字段:按 node kind 的 schema 动态生成控件。
for (local i = 0; i < doc.getFieldCount("welcome"); ++i) {
local key = doc.getFieldName("welcome", i);
local hint = doc.getFieldKind("welcome", i); // string|multiline|node|asset|json
// viewModel.addEditor(key, hint, doc.getField("welcome", key));
}
if (doc.validate()) dialogueFlow.applyDocument(doc);
- 文档:
getId/setId、getVersion/setVersion、getEntry/setEntry、getParameterCount/getParameter/addParameter/removeParameter。 - 节点:
getNodeCount/getNodeId/hasNode/addNode/removeNode/renameNode、getNodeKind/setNodeKind。节点类型为line|branch|choice|call|command|wait|end。 - 反射式 Inspector:
getFieldCount/getFieldName/getFieldKind/getField/setField;arguments字段以 JSON object 编辑,其余字段为字符串或资源/节点引用。 - 连线:
getRouteCount/getRouteLabel/getRouteTarget/addRoute/setRoute/removeRoute; route 适用于 branch 与 choice,节点改名或删除会同步维护引用。 - 结构化诊断:
validate、getDiagnosticCount/getDiagnosticSeverity/getDiagnosticPath/getDiagnosticLine/getDiagnosticMessage。 - Registry 桥接:
DialogueFlow.newDocument/getDocument/applyDocument。文档只是数据类, 不依赖 editor 或 ui 模块,因此同一资产可被桌面编辑器、游戏内工具、自动化生成器和测试复用。
生命周期
- 角色注册幂等(重复注册覆盖显示名);
bindAvatar接受avatar.newImageAvatar()等任何AvatarInstance。 update(dt)推进打字机/口型/舞台同步;选项选择前先isWaitingChoice()判空。loadPoolsFromDnutFile解析.dnut(剧本数据文件),错误看getLastPoolsError。