LiveGame
LiveGame 的开始、存读档、撤销重做、历史记录、截图、全屏与快进方法
LiveGame 是代表游戏当前状态的主类
公共属性
game
Game 实例
story
当前 Story 实例
公共方法
getStorable
get storable
newGame
开始新游戏
return this
deserialize
加载保存的游戏
调用此方法后,当前游戏状态将丢失,舞台将触发强制重置
**注意:**即使只更改一行脚本,保存的游戏也可能与新版本不兼容
示例:
const savedGame = {
// ...保存的游戏数据
};
// 在组件中使用钩子
const game = useGame();
// 将保存的游戏数据传递给游戏实例
game.getLiveGame().deserialize(savedGame);savedGame: SavedGame- SavedGame
serialize
onCharacterPrompt
角色说话时调用
fc: (event: LiveGameEvent["event:character.prompt"]) => void- 参见 LiveGameEvent- 返回
LiveGameEventToken- 参见 LiveGameEventToken
const game = useGame();
const [texts, setTexts] = useState<string[]>([]);
useEffect(() => {
const token = game.getLiveGame().onCharacterPrompt((event) => {
setTexts((prevTexts) => [...prevTexts, event.text]);
});
return () => {
token.cancel();
};
}, []);
return (
<div>
{/* 你的文本日志 */}
</div>
);onMenuChoose
菜单完成时调用
fc: (event: LiveGameEvent["event:menu.choose"]) => void- 参见 LiveGameEvent- 返回
LiveGameEventToken- 参见 LiveGameEventToken
capturePng
捕获游戏截图,仅包含玩家元素
返回 PNG 图像的 Base64 编码数据 URL
const game = useGame();
function handleButtonClick() {
game.getLiveGame().capturePng().then((dataUrl) => {
// 对 dataUrl 进行操作
});
}- 返回
Promise<string>
captureJpeg
捕获游戏截图,仅包含玩家元素
返回压缩后的 JPEG 图像数据 URL
- 返回
Promise<string>
captureSvg
捕获游戏截图,仅包含玩家元素
返回 SVG 数据 URL
- 返回
Promise<string>
capturePngBlob
捕获游戏截图,仅包含玩家元素
返回 PNG 图像的 Blob
- 返回
Promise<Blob | null>
requestFullScreen
在 Chrome/Safari/Firefox/IE/Edge/Opera 上请求全屏,玩家元素将进入全屏模式
注意:此方法应在用户手势(例如,点击事件)的响应中调用
Safari iOS 和 Webview iOS 不支持,更多信息请参见 MDN-requestFullscreen
options?: FullscreenOptions | undefined- 返回
Promise<void> | void
exitFullScreen
退出全屏
- 返回
Promise<void> | void
onPlayerEvent
监听玩家元素的事件
const game = useGame();
useEffect(() => {
return game.getLiveGame().onPlayerEvent("click", (event) => {
// 执行操作
}).cancel;
}, []);type: K- 事件类型listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any- 事件监听器options?: boolean | AddEventListenerOptions- 返回
LiveGameEventToken
getHistory
获取游戏历史。这个方法用于创建回溯
回溯历史是玩家已读过的每一行,直到并包含游戏当前所在的那一行。自存档格式 v2 起它会被持久化,因此 deserialize 之后本方法会立即返回完整历史 —— 读档后的游戏不再从空回溯开始
在 undo 回退之后,播放头之外的那些行不在这里。它们是玩家可以再次踏入的未来,由 getFuture 返回 —— 把它们列进回溯,等于展示尚未发生的事
import { useLiveGame, GameHistory } from "narraleaf-react";const liveGame = useLiveGame();
const history = liveGame.getHistory();
function handleRestore(entry: GameHistory) {
liveGame.restoreToHistory(entry.token);
}
return (
<div>
<h3>回溯</h3>
{history.map((item) => (
<div
key={item.token}
onClick={() => handleRestore(item)}
>
{/* show the action text */}
{/* text is available when the action is "say" or "menu" */}
{item.element.text}
</div>
))}
</div>
);条目的 token 会跨存档与回退持续指向同一行,因此回溯界面可以先持有它、之后再用
- 返回
GameHistory[]- 游戏历史,参见 GameHistory
在 0.26.0 之前,每次重建回溯都会为所有条目重新生成令牌,因此读档或恢复某一行会静默地让调用方持有的所有令牌失效 —— 回溯界面的按钮在重新读取列表之前都会失灵,向同一行恢复两次时第二次必定失败。现在令牌会被写入存档,并在读回时保留。更早写出的存档不携带令牌,其条目仍照旧获得新令牌
getFuture
播放头之前方的那些行:已经读过、被 undo 回退过、并且可以用 redo 再次抵达
正常游玩期间为空,刚读档之后也为空 —— 在回退之后写出的存档只携带到那一行为止的回溯,之外的一概不带,因此读取它时开局便无处可向前。这正是「在过去存档」的含义
const liveGame = useLiveGame();
// 玩家回退越过的那些行,最早的在前。
const future = liveGame.getFuture();- 返回
GameHistory[]- 前方的那些行,参见 GameHistory
自 0.26.0 起可用
canUndo
当前这一行之前是否还有可以回退到的行
<button disabled={!liveGame.canUndo()} onClick={() => liveGame.undo()}>
上一步
</button>- 返回
boolean
自 0.26.0 起可用
canRedo
前方是否有被回退越过、正在等待的行
<button disabled={!liveGame.canRedo()} onClick={() => liveGame.redo()}>
下一步
</button>- 返回
boolean
自 0.26.0 起可用
undo
回退一行
liveGame.undo();- 返回
boolean- 若游戏发生了移动则为true;若这已经是第一行,或那一行没有快照,则为false
向后与向前是同一套机制:每一行在被抵达时都会记录一份自包含的游戏快照,向任一方向移动都是恢复目标行的那份快照
读档之后依然可以回退。 在 0.26.0 之前,undo 只会遍历一个保存在内存里的闭包栈。闭包无法写进文件,因此读档之后玩家面对的回溯是退不回去的 —— 按钮在那里,但什么也不会发生。只要活的闭包栈还够得着目标行,它仍然是首选路径,正因为它回退时不会惊动任何东西:音乐照常播放,进行中的过渡不受打扰,舞台也不会被重建。栈够不着的地方由快照接手,于是那道边界不复存在
再往前读会保留前方的内容。 被回退离开的那一行不会被丢弃。回退三行后再往前读,走的是同样的三行而不是覆盖它们,因此此前读过的其余部分仍然在前方。只有当故事走向别处时 —— 选择的另一侧 —— 未来才会被丢弃,因为那段未来已不再由当前所在之处推出
0.26.0 的签名变更。 undo() 不再接受动作 id;一行由它的令牌通过 restoreToHistory 指定。无处可去时它返回 false,而不再抛出异常
redo
向前一行,踏入此前被回退越过的那一行
liveGame.redo();- 返回
boolean- 若游戏发生了移动则为true;若前方没有内容、或它没有快照,则为false
它只能抵达玩家已经读过的行:重放的是记录下来的未来,而不是把故事继续跑下去。要越过未来的末端继续,让游戏照常前进即可
自 0.26.0 起可用
restoreToHistory
把游戏移动到某一条记录下来的行,由它的令牌指定
与 undo 和 redo 是同一套机制,并且两个方向都够得着:来自 getHistory 的令牌向后走,来自 getFuture 的令牌向前走。和它们一样,本方法在读档之后依然有效,并且不丢弃任何东西 —— 目标行之后的那些行仍然可以抵达
const history = liveGame.getHistory();
// 跳到某一行 —— 包括从已读档游戏中恢复出来的行。
liveGame.restoreToHistory(history[0].token);token: string- 要移动到的历史项令牌,参见 GameHistory- 返回
boolean- 若成功恢复该行则为true;若令牌未知或该条目没有恢复快照则为false
在 0.26.0 之前,本方法只能向后走,并且会把回溯裁剪到它移动到的那一行,之后的一切都会丢失。现在它移动的是播放头,而不是切断时间线
notify
创建一个通知
// 通知 3 秒
game.getLiveGame().notify("Save success", 3000);// 通知持续时间设置为 `null` 时,通知将一直显示
const token = game.getLiveGame().notify("Fast forward", null);
// 当玩家释放箭头右键时,取消通知
window.addEventListener("keyup", (event) => {
if (event.key === "ArrowRight") {
token.cancel();
}
});message: string- 通知消息duration?: number | null- 通知持续时间,默认是 3000ms。设置为null时,通知将一直显示- 返回
NotificationToken- 参见 NotificationToken
playSound
立即播放声音并返回对应的 SoundToken
const game = useGame();
game.getLiveGame()
.playSound("https://example.com/voice.mp3")
.then((token) => {
token.once("ended", () => {
console.log("语音播放完成");
});
});音频会以它所属 Sound 配置的音量开始播放——Sound.voice({src, volume: 0.4}) 从 0.4 开始,而不是满音量
以字符串或 URL 给出的音源会变成一个默认的 Sound,也就是满音量;要另行指定,请传入一个 Sound
在 0.22.0 之前,这里把「没说音量」当成了满音量,因此配置好的 volume 会被丢弃
现在调用过 setVolume 之后再次播放的音频,会回到它最后被设定的音量,而不是跳到满音量
这里没有淡入淡出:这个 Promise 解决时音量已经稳定,也没有留下正在进行的渐变,
因此之后在返回的 token 上调用 setVolume 或驱动淡入淡出,一定是最后的写入者
sound: Sound | string | URL- 声音实例、声音地址或 URL- 返回
Promise<SoundToken>- 关于声音实例的更多信息,参见@NarraLeaf/Sound
waitForRouterExit
等待路由退出
此方法可用于在创建新游戏时等待路由退出
const game = useGame();
const router = useRouter();
const liveGame = game.getLiveGame();
useEffect(() => {
router.clear().cleanHistory();
const token = liveGame
.newGame()
.waitForRouterExit()
token
.promise
.then(() => {
dispatchState({ isPlaying: true });
});
return () => {
token.cancel();
};
}, []);- 返回
{ promise: Promise<void>; cancel: VoidFunction; }
waitForPageMount
等待页面挂载
const game = useGame();
const router = useRouter();
const liveGame = game.getLiveGame();
useEffect(() => {
router.push("home");
const token = liveGame.waitForPageMount();
token.promise.then(() => {
// 执行操作
});
return () => {
token.cancel();
};
}, []);- 返回
{ promise: Promise<void>; cancel: VoidFunction; }
onWindowEvent
监听窗口事件
const game = useGame();
useEffect(() => {
return game.getLiveGame().onWindowEvent("resize", (event) => {
// 处理窗口大小调整
}).cancel;
}, []);type: K- 事件类型listener: (this: Window, ev: WindowEventMap[K]) => any- 事件监听器options?: boolean | AddEventListenerOptions- 返回
LiveGameEventToken
reset
重置游戏状态
**注意:**调用此方法将丢失当前游戏状态
const game = useGame();
const router = useRouter();
game.getLiveGame().reset();
router.clear().cleanHistory().push("home");skipDialog
跳过当前对话
game.getLiveGame().skipDialog();fastForward
将游戏快进到下一个菜单、故事结尾,或某个指定的动作
其间的每一行都会被真实执行,因此回溯历史及其恢复快照会像正常游玩一样累积 —— 只是更快且静音。快进期间音频被静音,由本次快进执行到的定时暂停(Control.sleep、自动前进)会立即解决。一旦有菜单在等待选择,它就会停下,因此选择本身始终留给玩家。由于历史全程累积,getHistory 和 restoreToHistory 会像正常游玩一样覆盖被快进的区间
跳过一行是向渲染层广播的一个请求,而不是同步的状态变更,因此该请求会被反复重发,直到该行结算为止。始终不响应的一行会以 "stalled" 结束本次快进,而不是把调用挂住 —— 这个方法在任何情况下都会结算
// 快进到下一个决策点。
await game.getLiveGame().fastForward();
// 或运行到故事结尾。
await game.getLiveGame().fastForward({ until: "end" });
// 或把播放头停在某个指定动作上,且不执行它。
const result = await game.getLiveGame().fastForward({ until: { actionId: "act-42" } });
if (result.reason === "action") {
// 已停在 act-42 上,尚未执行
} else if (result.reachedTarget === false) {
// 菜单挡住了去路、调用栈被清空、达到 maxSteps,或某一行卡住了
}options.until?: "menu" | "end" | { actionId: string }-"menu"(默认)在下一个菜单处停止;"end"运行到故事结束;{ actionId }一直前进到该动作成为下一个待执行项,并在执行它之前停下,从而把播放头停在那一行上。只扫描根执行栈 —— 埋在进行中的Control.all/Control.any或异步分支里的 id 不构成停止点。菜单同样会中止actionId形式的快进,因为在玩家做出选择之前目标无法抵达options.maxSteps?: number- 前进步数的安全上限(默认为maxStackModelLoop配置)options.stepTimeout?: number- 单个挂起的行在被判定为"stalled"之前允许结算的时长(毫秒)。默认10000。如果剧情会快进穿过较长的、无法跳过的媒体,请调高它- 返回
Promise<{ reason: "menu" | "end" | "maxSteps" | "action" | "stalled"; reachedTarget?: boolean }>- 停止的原因reason-"action"抵达了until.actionId;"menu"有菜单在等待选择;"end"调用栈已清空;"maxSteps"触到步数上限;"stalled"某一行在stepTimeout内拒绝结算reachedTarget- 仅在请求了until: { actionId }时出现,且只有在reason为"action"时才为true。"menu"/"end"形式仍保持原来的{ reason }形状
until: { actionId }、"action" 原因与 reachedTarget 自 0.16.0 起可用。options.stepTimeout 与 "stalled" 原因自 0.17.1 起可用
快进可能因为一个无法跳过的步骤而提前结束。 在快进开始时就已经在进行中的步骤、视频(allowSkipVideo 默认为 false),以及摄像机或图层过渡,都不会响应跳过请求。这类步骤只要能在 stepTimeout 内自行结算就仍会照常结算;只有超出该时限的步骤才会结束本次快进 —— 返回 "stalled",并在退出时恢复音量与快进标志
在 0.17.1 之前,快进会停在这种步骤上:返回的 Promise 两边都不结算,游戏保持静音并永久停留在快进模式。原本能正常工作的用法不会因此失效,但把"非 "menu" 即成功"的宿主现在应当把 "stalled" 区分出来;对 reason 做穷举 switch 的代码也需要补上这个分支才能通过编译
onCurrentActionChange
自 0.16.0 起可用
订阅"当前动作"事件流。每当一个动作开始执行,处理函数就会被调用一次,并收到该动作的 id 与类型——外部播放头(例如编辑器的时间轴)据此就能跟住一局正在跑的游戏
它对每一个被执行的动作都会触发,包括 Control.all / Control.any 之中的,以及 Control.doAsync / Control.allAsync 的异步分支里的。只关心顶层行的监听者应当按自己的 id 集合过滤
const liveGame = useLiveGame();
useEffect(() => {
return liveGame.onCurrentActionChange(({actionId, actionType}) => {
// 把播放头移到 actionId
}).cancel;
}, []);fc: (payload: {actionId: string | null, actionType: string | null}) => void- 收到刚开始执行的那个动作。剧情没有为该动作记录 id 或类型时,对应字段为null- 返回
LiveGameEventToken- 参见 LiveGameEventToken
实验性,只读。 这套接口是给"观察一局游戏怎么跑"的工具用的。不要用它驱动剧情逻辑,也不要把它报告的东西存下来——存档格式是 serialize
getCurrentActionId
自 0.16.0 起可用
最近一次执行的动作 id。它是 onCurrentActionChange 的拉取式对应物,供轮询而非订阅的宿主使用
const actionId = game.getLiveGame().getCurrentActionId();- 返回
string | null- 本局第一个动作执行之前为null;剧情没有为该动作记录 id 时也是null
getStackSnapshot
自 0.16.0 起可用
执行栈的自顶向下视图,用于调用栈面板或调试面板:根栈,加上 Control.doAsync / Control.allAsync 启动的每一条进行中的异步栈
const {root, async} = game.getLiveGame().getStackSnapshot();
// 当前正在执行的那一行
root.frames[0]?.actionId;
// 并发帧带着它的各条分支,每条分支本身就是一个完整快照
root.frames[0]?.branches?.[0]?.frames;
// Control.repeat 的帧带着它的迭代计数
root.frames[0]?.branches?.[0]?.loop?.counter;- 返回
{root: StackSnapshot, async: StackSnapshot[]}- 游戏开始之前 frames 为空
StackSnapshot 的形状是 {tag?: string, frames: StackFrameSnapshot[], loop?: {type: "count" | "condition", counter: number, limit?: number, broken: boolean}}。frames 自顶向下排列:frames[0] 就是此刻正在执行的帧
StackFrameSnapshot 的形状是 {actionId: string | null, actionType: string | null, branchWaitType?: string, branches?: StackSnapshot[]}。属于并发组(Control.all / Control.any)的帧会列出它的各条分支,而每条分支都是一个完整的 StackSnapshot——因此嵌套栈自己的 loop 计数与 tag 也一并带出
实验性,只读。 这个形状是为方便阅读而做的投影,不是稳定性契约,可能在没有大版本变更的情况下改变。永远不要序列化快照——存档请用 serialize