起点。 DBVO 自身不附带任何 SKSE 插件——它完全由 Papyrus、ConsoleUtil 以及一个经过编辑的对话菜单构建而成。你的配音台词将通过 ConsoleUtil 播放
Player.SpeakSound "DBVO/<file>.fuz",用于启动声音且无返回值——没有“此行播放完毕”的回调可供等待。因此,NPC的回复由dialoguemenu.swf内的一个普通计时器控制,该计时器在你选择话题时启动:
// DBVO的回复时机,在dialoguemenu.swf中
words = topicText.split(" (")[0].split(" ").length; // 字数统计,减去“(劝说)”等。
delay = round(words * 200) + 1400; // 约每分钟300词,外加1400毫秒的固定缓冲
setTimeout("topicClicked", delay); // → NPC在`delay`后回复
回复的时间是根据字数估算的。它无法知道你语音包的实际速度,所以误差是双向的:固定增加1400毫秒,以及每词200毫秒——而快速AI语音包很容易超过这个速度。修复方案需要知道音频何时
实际上 结束——这意味着触及声音本身,也就是一个DLL。
钩子。 这个模组是一个小型SKSE插件(基于CommonLibSSE-NG)。加载时,它使用MinHook来劫持引擎的说话音效函数——即ConsoleUtil的Player.SpeakSound所调用的同一个函数。该函数仅通过地址库ID(SE 36541 / AE 37542)寻址,这就是为什么一个DLL能同时支持SE和AE:正确地址在运行时解析。劫持函数会先调用原始函数,因此引擎会正常构建并启动音效句柄(口型同步不受影响),随后检查调用内容。仅当说话者为玩家角色时
和 路径以“DBVO/”开头,是否生效——游戏中的其他所有声音都会直接穿过,不受影响。对于匹配的台词,它会做三件事:将台词音量调整到MCM滑块所设的水平,保存一份音效句柄的副本以便后续截断,并启动回复监听器。
(句柄副本可以安全保留:BSSoundHandle是一个以声音ID为键的小值,每次操作都会通过音频管理器按该ID重新解析实时声音——因此,在台词开始时创建的副本,片刻之后仍能控制它。每句新台词都会覆盖该槽位,因此只有当前台词可以被切断,这正是最恰当的适用范围。)
在行尾回复。 一个后台线程在游戏加载时启动,并在整个会话期间持续运行。它每 tick 休眠约 30 毫秒——空闲时几乎不执行任何操作,绝不会占用整个核心。当一行文本被激活时,它会轮询保留的句柄:仍在播放,还是已停止?该检查在主线程之外运行(音频管理器的调用在那里是安全的),并且一个闩锁确保线程确实已经
已见 在允许检测结束之前播放的那一行——因此,从准备到音频开始之间的短暂窗口不会被误读为“已经完成”。一旦它检测到播放→停止,就精确地声明事件一次,并将单个任务交给主线程。最后一步至关重要:Scaleform(Flash UI 层)不是线程安全的,因此只能对主线程上的 swf 进行操作。该任务调用了一个新的 swf 函数,
dbvoOnPlayerLineEnded,这将取消字数计时器,并在您设定的间隔后重新安排回复。最终效果:每行回复均由音频实际结束时间加上您选择的间隔来驱动。
(为什么用独立线程而不是引擎的任务队列?早期版本每帧都在主线程的任务队列里重新调度自身,结果导致游戏完全卡死:SKSE每帧都会把队列清空,所以一个不断重新入队的任务会让帧永远循环下去。在自己的线程上轮询并真正休眠才是解决办法。)后挡板。 SWF仍然保留着字数计时器——但故意设置得很长,仅仅作为备用方案:
// swf 后备机制——仅当 DLL 从未报告结束时触发
delay = round(words * 300) + 2000; // 故意设置宽裕值:必须比任何实际台词持续时间更长
当DLL存在且正常工作时,
dbvoOnPlayerLineEnded 它在到期前很久就自动清除了,所以你永远看不到它。它的存在是为了让模组能够优雅降级:如果没有DLL——或者在某种无法检测到行尾的情况下——对话仍会推进,只是依据旧的(现在非常宽松的)猜测,而不会永远卡住。
那道缝隙。 dbvoOnPlayerLineEnded会在触发回复前等待MCM的“间隔”值(0–1000毫秒,默认250),因此你可以将节奏从即时调整到台词间的清晰停顿。
玩家语音音量。 同一个挂钩,无需额外机制:它只是将线条的手柄缩放到滑块上。数值通过一个小型Papyrus原生函数从MCM向下传递,并在挂钩中读取;在100%时,挂钩完全直通,因此保持默认设置不会改变任何东西。它只影响你自己的DBVO线条——游戏中的其他所有声音均保持各自的音量。
跳过与打断——干脆利落。 SWF在两种情况下触发模组事件,而DLL会将其分别转换为一次干净的停止:
- 你跳过自己的台词 (E/左键点击)。菜单立即推进,DLL将保留的玩家句柄在几毫秒内淡出,而非硬性停止——波形中途硬停会产生可闻的咔嗒声;短暂淡出则不会。同时它也使回复监听器进入待命状态,因此淡出自身的“播放→停止”转换不会触发第二次回复。(短暂的防抖处理可防止用于选择话题的同一点击操作瞬间跳过该话题。)
- 你在NPC回复中途选择了新话题。 这个比较复杂:NPC的话题语音并不在你预期的地方,而且引擎的“暂停对话”只会暂停——多段回复仍会继续播放。因此DLL做了两件事:它会将当前正在播放的段落淡出至静音 和 告诉引擎停止推进到下一段。切断音频还会使NPC的嘴保持张开的中间词状态,因此将面部恢复为中性表情——在引擎唇形同步写入器使用的同一把锁下清除口型同步关键帧,这样重置才能真正生效,而不会被下一帧覆盖。(这与PhotoMode等其他模组使用的全脸恢复是相同的机制。)
收拾一下。 当对话菜单关闭时,监听器会被解除,因此一句未说完的话不会把它的回复泄漏进你的下一次对话中。而且,向已关闭的菜单发送回复本身就是空操作——调用找不到菜单,也就什么都不做。