历史记录
标准框架提供了完整的历史记录(Backlog)功能,允许玩家回顾之前的对话并跳转到任意历史位置。该功能基于引擎 scenario 插件的快照机制实现。
历史记录系统的数据流:
记录:命令处理 → gameState 写入引擎变量 → 引擎创建运行时快照 → backlog 记录池跳转:选择记录 → 引擎恢复快照(执行栈+变量)→ 恢复 gameState → 关闭 overlay引擎的 scenario 插件负责管理运行时快照的创建和恢复,最多保留 50 条记录。框架层通过 useBacklog Hook 封装了完整的历史记录交互。
打开历史记录
Section titled “打开历史记录”标准框架中有两种打开 backlog overlay 的方式:
- 文本框按钮:点击文本框上的 LOG 按钮
- 鼠标滚轮:在 Stage 页面向上滚动鼠标滚轮
// Stage 中的滚轮触发(参考实现)useEffect(() => { return addEventListener('wheel', (event: WheelEvent) => { if (event.deltaY <= 0) return; if (navigation.getCurrentPage() !== 'stage') return; if (navigation.getOverlayStack().length > 0) return; navigation.pushOverlay('backlog'); });}, [navigation]);标准框架在以下时机自动创建历史记录:
每次剧本引擎输出文本行时,自动记录说话人、语音文件名和对话内容。
标准框架支持在前导内容中用 | 同时指定说话人和语音,例如 [Alice|alice_001],此时 voice 字段会自动写入记录:
export const handleTextLine: TextLineHandler = (e, control) => { // Parse leading: "Alice|alice_001" → { speaker: 'Alice', voice: 'alice_001' } const { speaker, voice } = parseTextLeading(e.leading);
// If a voice file is specified, play it before printing if (voice) { handleVoice({ command: 'voice', src: `voice/${voice}.opus`, name: speaker, volume: 1 }, control); }
// ...处理文本显示...
recordBacklog(control, { kind: 'text', speaker, voice, // empty string when no voice text: e.text || '', });
control.hold();};选择项显示时,记录所有选项文本:
export const handleOptionShow: CommandHandler = (cmd, control) => { // ...显示选择...
recordBacklog(control, { kind: 'selection', options: gameState.selection.options.map((option) => option.text), });
control.unskippable(); control.hold();};recordBacklog 辅助函数
Section titled “recordBacklog 辅助函数”recordBacklog 在记录前会先将当前 gameState 写入引擎变量,确保快照包含完整的前端状态:
function recordBacklog( control: { record(meta: Record<string, any>): string }, meta: Record<string, any>,) { writeCurrentGameStateToScenario(); control.record(meta);}useBacklog Hook
Section titled “useBacklog Hook”import { useBacklog } from '../hooks/useBacklog';
function BacklogPage() { const { records, // BacklogRecord[] — 历史记录列表(按时间正序) scrollOffset, // SpringValue<number> — 当前滚动偏移的 spring 值 maxScroll, // number — 最大滚动距离 showScrollbar, // boolean — 当前是否需要显示滚动条 scrollbarHeight, // number — 滚动条高度 scrollbarOffset, // number | Interpolation<number, number> — 滚动条位置 scrollToRatio, // (ratio: number, immediate?: boolean) => void — 按滚动比例定位 handleWheel, // (event: WheelEvent) => void — 处理滚轮事件 jumpToRecord, // (recordId: string) => Promise<boolean> — 跳转到指定记录 close, // () => void — 关闭 backlog overlay } = useBacklog({ itemHeight: 110, // 每条记录的高度 viewportHeight: 584, // 可视区域高度 });}API 参考
Section titled “API 参考”| 方法 | 返回值 | 说明 |
|---|---|---|
records | BacklogRecord[] | 历史记录列表,按时间正序 |
scrollOffset | SpringValue<number> | 当前滚动偏移量的 spring 值,可直接用于 animated.* |
maxScroll | number | 最大可滚动距离 |
showScrollbar | boolean | 当前是否需要显示滚动条 |
scrollbarHeight | number | 当前滚动条的高度 |
scrollbarOffset | number | Interpolation<number, number> | 当前滚动条偏移 |
scrollToRatio(ratio, immediate?) | void | 按滚动比例定位,拖拽滚动条时使用 |
handleWheel(event) | void | 处理滚轮事件,自动计算滚动量 |
jumpToRecord(id) | Promise<boolean> | 跳转到指定记录,成功返回 true |
close() | void | 关闭 backlog overlay |
export type BacklogMeta = | { kind: 'text'; speaker: string; // 说话人名称 voice?: string; // 语音文件名(不含路径和扩展名) text: string; // 对话内容 } | { kind: 'selection'; options: string[]; // 选项列表 };
export interface BacklogRecord { id: string; // 唯一标识(格式:record-{timestamp}-{serial}) createdAt: number; // 创建时间戳(毫秒) meta: BacklogMeta; // 元数据}跳转流程详解
Section titled “跳转流程详解”jumpToRecord 内部执行以下步骤:
- 调用引擎的
jumpToRecord命令,恢复运行时快照(执行栈、变量) - 目标记录之后的历史记录被截断
- 调用
restoreGameStateFromScenario()从引擎变量恢复前端的gameState - 关闭 backlog overlay,游戏从该位置继续
const jumpToRecord = async (recordId: string) => { // Restore runtime snapshot (execution stack + variables) const success = await executePluginCommand('scenario', { subCommand: 'jumpToRecord', recordId, });
if (!success) { uiActions.notify('跳转失败'); return false; }
// Restore frontend game state (background, characters, etc.) await restoreGameStateFromScenario(); navigation.popOverlay(); return true;};标准框架已内置语音重播功能。当历史记录中存在 voice 字段时,Backlog 页面会在说话人名称前显示一个语音图标按钮,点击后可重新播放对应语音。实现示例:
async function replayBacklogVoice(speaker: string, voice: string) { const channelName = speaker ? `voice:backlog:${speaker}` : 'voice:backlog:default';
await executePluginCommand('audio', { subCommand: 'load', name: channelName, src: `voice/${voice}.opus`, settings: { autoPlay: false, volume: 1 }, });
await executePluginCommand('audio', { subCommand: 'play', name: channelName, fadeTime: 0, });}图标使用 ui/backlog_voice.png(28×28)。有语音的条目中,说话人文字会向右偏移,为图标留出空间:
function BacklogRow({ record, y, onJump }: BacklogRowProps) { const voice = record.meta.kind === 'text' ? (record.meta.voice || '') : ''; const titleX = voice ? 44 : 0;
return ( <container y={y}> {voice ? ( <Button fileNames={['ui/backlog_voice.png', 'ui/backlog_voice.png', 'ui/backlog_voice.png']} onClick={(event) => { event.stopPropagation(); void replayBacklogVoice(title, voice); }} /> ) : null} <text text={title} x={titleX} /> <text text={content} x={140} /> </container> );}自定义记录元数据
Section titled “自定义记录元数据”你可以进一步扩展 BacklogMeta 类型来记录更多自定义信息,方法与上面的 voice 字段完全相同:在 BacklogMeta 中添加可选字段,在 handleTextLine 的 recordBacklog 调用中写入,在 BacklogRow 中读取渲染即可。
自定义 Backlog 页面
Section titled “自定义 Backlog 页面”Backlog 页面在 src/pages/backlog.tsx 中实现,注册为 overlay 类型。你可以自由调整布局和样式。
当前标准实现的关键点:
records在 Hook 内部已经转为按时间正序展示,但页面首次打开时默认滚动到底部。- 列表滚动由
react-spring驱动,因此scrollOffset是 spring 值,需要通过animated.container和.to(...)使用。 - 右侧滚动条使用
ui/backlog_scrollbar.png,仅在showScrollbar为true时显示,并支持拖拽。 - 点击历史条目时,标准页面会先通过
uiActions.confirm(...)做二次确认,再调用jumpToRecord()。
参考实现:
export function Backlog() { const { records, scrollOffset, maxScroll, showScrollbar, scrollbarHeight, scrollbarOffset, handleWheel, scrollToRatio, jumpToRecord, close, } = useBacklog({ itemHeight: ROW_HEIGHT, viewportHeight: VIEWPORT_HEIGHT - VIEWPORT_PADDING_Y * 2, });
const handleJumpRequest = (record: BacklogRecord) => { uiActions.confirm('确定要跳转到这个位置吗?', () => { void jumpToRecord(record.id); }); };
useEffect(() => { const cleanups = [ addEventListener('wheel', handleWheel), addEventListener('mousemove', handleScrollbarDragMove), addEventListener('touchmove', handleScrollbarDragMove), addEventListener('mouseup', handleScrollbarDragEnd), addEventListener('touchend', handleScrollbarDragEnd), addEventListener('touchcancel', handleScrollbarDragEnd), ];
return () => { for (const cleanup of cleanups) cleanup(); }; }, [handleScrollbarDragEnd, handleScrollbarDragMove, handleWheel]);
return ( <container> <clip width={VIEWPORT_WIDTH} height={VIEWPORT_HEIGHT}> <animated.container y={scrollOffset.to((value) => VIEWPORT_PADDING_Y - value)}> {records.map((record, index) => ( <BacklogRow key={record.id} record={record} y={index * ROW_HEIGHT} onJump={() => handleJumpRequest(record)} /> ))} </animated.container> </clip>
{showScrollbar && ( <animated.sprite src="ui/backlog_scrollbar.png" mode="nineslice" bounds={SCROLLBAR_BOUNDS} targetWidth={SCROLLBAR_WIDTH} targetHeight={scrollbarHeight} y={ typeof scrollbarOffset === 'number' ? SCROLLBAR_Y + scrollbarOffset : scrollbarOffset.to((value) => SCROLLBAR_Y + value) } onMouseDown={handleScrollbarDragStart} onTouchStart={handleScrollbarDragStart} /> )} </container> );}