Extension 模块,是观察”真实扩展长什么样”的最好材料。这一页挑出其中几个有代表性的场景,配上精简版代码。
照着改通常比从零写更快。
1. 跟随对话显隐的常驻工具栏
问题:做一个底部工具栏,对话出现时滑入、对话隐藏时滑出。整个游戏期间常驻,不需要剧本调用。 关键点:autonomous: true—— 启动期自动加载static onRegister里订阅dialogue:changed事件- 总开关 + 子开关用
.enabledWhen()联动置灰
@extension({
id: "toolbar",
label: "工具栏",
autonomous: true,
})
export class Toolbar extends Extension {
static settings = settings((s: SettingsBuilder) => ({
// 总开关 —— 关掉后下面所有按钮一并不显示
showToolbar: s.boolean("显示对话工具栏").default(true),
// 子开关 —— 用 .enabledWhen 挂到总开关上
// 总开关关闭时,设置面板里这些选项变灰不可点(值保持不变)
showSkip: s.boolean("显示跳过按钮").default(true).enabledWhen("showToolbar"),
showAuto: s.boolean("显示自动按钮").default(true).enabledWhen("showToolbar"),
showSave: s.boolean("显示存档按钮").default(true).enabledWhen("showToolbar"),
// ...
}));
static onRegister(ctx: ExtensionContext): void {
let visible = false;
const sync = () => {
const enabled = ctx.settings.get<boolean>("showToolbar") ?? true;
const inDialogue = ctx.dialogue.line() !== null;
const shouldShow = enabled && inDialogue;
if (shouldShow && !visible) {
visible = true;
void ctx.ui.show("toolbar");
} else if (!shouldShow && visible) {
visible = false;
ctx.ui.hide("toolbar");
}
};
// 对话变化时同步
ctx.subscribe("dialogue:changed", sync);
// 玩家在设置面板里切总开关时也立即同步,不用等下次对话
ctx.settings.subscribe<boolean>("showToolbar", sync);
// 启动时同步一次(可能进入时已经在对话里了)
sync();
}
render() {
return { component: ToolbarComponent, props: {} };
}
}
2. F5 / F9 全局快存快读
问题:注册 F5 快速存档、F9 快速读档,玩家可在 Studio「输入按键」面板重新映射。 关键点:ctx.input.registerAction声明语义动作(玩家可重映射),不是bindShortcut(硬绑物理键)- action id 必须以扩展 id 为前缀
ctx.input.onAction订阅触发
@extension({
id: "save-screen",
label: "存档画面",
autonomous: true,
supportsSlot: [INTERNAL_SYSTEM_SLOT.Save, INTERNAL_SYSTEM_SLOT.Load],
})
export class SaveScreen extends Extension {
static onRegister(ctx: ExtensionContext): void {
// 注册语义动作 —— 出现在 Studio 输入按键面板,玩家可改键
ctx.input.registerAction({
id: "save-screen.quick-save",
label: "快速存档",
defaultKeys: ["F5"],
});
ctx.input.registerAction({
id: "save-screen.quick-load",
label: "快速读档",
defaultKeys: ["F9"],
});
// 订阅触发
ctx.input.onAction("save-screen.quick-save", () => {
ctx.archive
.quickSave()
.then(() => showToast("已快速存档", { type: "success" }))
.catch((err) => {
console.error(err);
showToast("快速存档失败", { type: "error" });
});
});
ctx.input.onAction("save-screen.quick-load", async () => {
const loaded = await ctx.archive.quickLoad();
if (!loaded) showToast("没有可读取的快速存档", { type: "warn" });
});
}
render() {
return { component: SaveScreenComponent, props: {} };
}
}
3. 存档作用域:跨存档 vs 跟随存档
问题:CG 鉴赏室——有的项目希望解锁的 CG 全局共享(任意周目通用),有的希望只在当前存档可见。让创作者一个设置项切换两种模式。 关键点:persistence是声明式静态字段,无法运行时切换- 解决:同时声明两个存档作用域(一个
"shared"一个"slot"),界面里取并集;设置项只决定新解锁写到哪个作用域 - 切换设置不会丢已解锁数据
type GallerySaveMap = {
unlockedShared: readonly GalleryEntry[];
unlockedSlot: readonly GalleryEntry[];
};
@extension({ id: "gallery-screen", label: "鉴赏画面" })
export class GalleryScreen extends Extension {
static saveSchema = defineSave({
unlockedShared: {
type: "list",
persistence: "shared", // 跨所有存档
default: [] as GalleryEntry[],
},
unlockedSlot: {
type: "list",
persistence: "slot", // 跟随当前存档槽位
default: [] as GalleryEntry[],
},
});
static settings = settings((s: SettingsBuilder) => ({
unlockScope: s
.enum("解锁记录范围", ["shared", "slot"] as const)
.labels({ shared: "全局共享(跨存档)", slot: "跟随存档" })
.default("shared")
.describe("已解锁条目不受切换影响"),
}));
static addToGallery = method({
title: "加入鉴赏",
schema: {
scene: { type: "scene", label: "场景", required: true },
},
run(ctx, params) {
const save = this.save as unknown as SaveAPI<GallerySaveMap>;
const scope = ctx.settings.get<"shared" | "slot">("unlockScope") ?? "shared";
const entry: GalleryEntry = buildEntry(params.scene);
// 并集去重:任一作用域已存在就跳过
const existing = [...save.get("unlockedShared"), ...save.get("unlockedSlot")];
if (existing.some((e) => e.entryId === entry.entryId)) return;
// 写入对应的作用域
if (scope === "slot") {
save.set("unlockedSlot", [...save.get("unlockedSlot"), entry]);
} else {
save.set("unlockedShared", [...save.get("unlockedShared"), entry]);
}
},
});
render() {
return { component: GalleryScreenComponent, props: {} };
}
}
function useGalleryEntries(ctx: ExtensionContext): GalleryEntry[] {
// 直接订阅变量,解锁发生时界面自动刷新
const [shared] = ctx.variables.useValue<GalleryEntry[]>(
"gallery-screen.unlockedShared",
);
const [slot] = ctx.variables.useValue<GalleryEntry[]>(
"gallery-screen.unlockedSlot",
);
// 合并 + 按 entryId 去重
const seen = new Set<string>();
const merged: GalleryEntry[] = [];
for (const e of [...(shared ?? []), ...(slot ?? [])]) {
if (seen.has(e.entryId)) continue;
seen.add(e.entryId);
merged.push(e);
}
return merged;
}
4. 替换标题画面
问题:做一个自定义标题画面替换内置的 Default Shell 标题。 关键点:- 用
supportsSlot声明实现哪个系统槽位 - 菜单文案给创作者在设置里可定制
- 通过
ctx.system.invoke()触发其他系统槽位(存档、设置等)
@extension({
id: "title-screen",
label: "标题画面",
supportsSlot: INTERNAL_SYSTEM_SLOT.Title,
})
export class MyTitleScreen extends Extension {
static settings = settings((s: SettingsBuilder) => ({
startLabel: s.string("开始按钮文案").default("开始游戏"),
loadLabel: s.string("读档按钮文案").default("读取存档"),
settingsLabel: s.string("设置按钮文案").default("设置"),
exitLabel: s.string("退出按钮文案").default("退出"),
showGallery: s.boolean("显示鉴赏入口").default(true),
galleryLabel: s
.string("鉴赏按钮文案")
.default("鉴赏")
.enabledWhen("showGallery"),
}));
render() {
return { component: TitleScreenComponent, props: {} };
}
}
const TitleScreenComponent: React.FC = () => {
const ctx = useExtensionContext();
const [startLabel] = ctx.settings.useValue<string>("startLabel");
const [loadLabel] = ctx.settings.useValue<string>("loadLabel");
const [showGallery] = ctx.settings.useValue<boolean>("showGallery");
const onStart = () => ctx.ui.hide("title-screen");
const onLoad = () =>
ctx.system.invoke(INTERNAL_SYSTEM_SLOT.Load, { source: "title" });
const onSettings = () => ctx.system.invoke(INTERNAL_SYSTEM_SLOT.Settings);
const onGallery = () => ctx.ui.show("gallery-screen");
const onExit = () => ctx.game.exit();
return (
<div>
<h1>{ctx.game.title()}</h1>
<button onClick={onStart}>{startLabel}</button>
<button onClick={onLoad}>{loadLabel}</button>
{showGallery && <button onClick={onGallery}>鉴赏</button>}
<button onClick={onSettings}>设置</button>
<button onClick={onExit}>退出</button>
</div>
);
};
5. UI 进入前截图(避免被自己遮挡)
问题:存档画面要在槽位里显示”当前游戏画面”缩略图。但 SaveScreen 一打开就已经盖住游戏画面了——截到的是 SaveScreen 自己。 关键点:onInit()实例钩子在 React 渲染之前触发,这时画面上还没 SaveScreen 自身覆盖ctx.archive.cacheGameSnapshot()截一张并缓存onClose()清缓存
@extension({
id: "save-screen",
label: "存档画面",
supportsSlot: [INTERNAL_SYSTEM_SLOT.Save, INTERNAL_SYSTEM_SLOT.Load],
})
export class SaveScreen extends Extension {
/**
* onInit 在 React 把 SaveScreen 渲染进 DOM 之前触发。
* 此时画面上还没 SaveScreen 覆盖,截图正好是"打开存档前"的纯游戏画面。
* 缓存到 ArchiveSystem,后续点保存时复用,不会闪。
*/
onInit() {
void this.context.archive.cacheGameSnapshot();
}
/** 关闭时清缓存,避免下次打开还用上一次的旧画面。 */
onClose() {
this.context.archive.clearGameSnapshot();
}
render() {
return { component: SaveScreenComponent, props: {} };
}
}
6. 跨存档 vs 跟随存档的对比表
帮你判断每条字段该写"slot" 还是 "shared":
| 字段 | persistence | 理由 |
|---|---|---|
| 玩家昵称 | "shared" | 玩家身份,跨所有存档不变 |
| 总游玩时间 | "shared" | 跨存档累积 |
| 成就解锁 | "shared" | 跨所有周目共享 |
| 已解锁 CG | "shared"(也可 "slot") | 看作者倾向;范例 3 给了两种作用域并存的方案 |
| 当前关卡进度 | "slot" | 不同存档之间应该独立 |
| 背包内容 | "slot" | 跟当前周目走 |
| 角色好感度 | "slot" | 跟当前周目走 |
| 提示阅读历史 | "shared" | 同一段提示玩家看一次就够了 |
7. 总开关 + 子开关联动模式
工具栏那种”总开关 + 一堆子开关”是个常用模式。要点:- 总开关用普通
s.boolean()声明 - 子开关挂
.enabledWhen("总开关字段名")—— 总开关关闭时子开关在设置面板里变灰不可点 - 子开关的值不受影响——重开总开关后所有子开关原样恢复
- 运行时自己再读一遍总开关:
.enabledWhen只控制 UI 编辑态,不影响实际值
static settings = settings((s: SettingsBuilder) => ({
showToolbar: s.boolean("显示工具栏").default(true),
// 这些子开关默认值 = true,即使总开关关掉它们的值还是 true
showSkip: s.boolean("跳过按钮").default(true).enabledWhen("showToolbar"),
showAuto: s.boolean("自动按钮").default(true).enabledWhen("showToolbar"),
}));
// 运行时:要自己再读一遍总开关
function ToolbarComponent() {
const ctx = useExtensionContext();
const [showToolbar] = ctx.settings.useValue<boolean>("showToolbar");
const [showSkip] = ctx.settings.useValue<boolean>("showSkip");
// 总开关优先 —— .enabledWhen 不会自动让子开关返回 false
if (!showToolbar) return null;
return (
<div>
{showSkip && <button>跳过</button>}
</div>
);
}
8. 当前任务提示 HUD
问题:剧本里推进任务时,在画面右上角小角落显示「当前任务:xxx」,不打扰玩家看对话。 关键点:- 跟工具栏一样
autonomous: true,但只显示一条文字,没有按钮 - 用 method 在剧本里设置当前任务(不需要总览面板,避免和好感度的”按 H 打开总览”重复)
- 数据存
slot——任务跟随当前周目走
type Task = { id: string; title: string; done: boolean };
type QuestSaveMap = { tasks: readonly Task[] };
import manifest from "../extension.json";
const EXT_ID = "quest-hud";
const VAR_TASKS = `${manifest.id}.tasks`;
@extension({ id: EXT_ID, label: "任务提示", autonomous: true })
export class QuestHud extends Extension {
static saveSchema = defineSave({
tasks: { type: "list", persistence: "slot", default: [] as Task[] },
});
static onRegister(ctx: ExtensionContext): void {
// 启动期就把 HUD 挂上,自己常驻
void ctx.ui.show(EXT_ID, {}, {
size: "(auto, auto)",
position: "(right, top)",
interactable: false, // 不接管点击,玩家照常点对话推进
});
}
static addTask = method({
title: "添加任务",
schema: {
taskId: { type: "string", label: "任务 ID", required: true },
title: { type: "string", label: "任务标题", required: true },
},
run(_ctx, params) {
const save = this.save as unknown as SaveAPI<QuestSaveMap>;
const list = save.get("tasks");
if (list.some((t) => t.id === params.taskId)) return;
save.set("tasks", [...list, { id: params.taskId, title: params.title, done: false }]);
},
});
static completeTask = method({
title: "完成任务",
schema: { taskId: { type: "string", label: "任务 ID", required: true } },
run(_ctx, params) {
const save = this.save as unknown as SaveAPI<QuestSaveMap>;
save.set(
"tasks",
save.get("tasks").map((t) => t.id === params.taskId ? { ...t, done: true } : t),
);
},
});
render() {
return { component: QuestHudComponent, props: {} };
}
}
const QuestHudComponent: React.FC = () => {
const ctx = useExtensionContext();
const [raw] = ctx.variables.useValue(VAR_TASKS);
const tasks = (raw as unknown as readonly Task[] | undefined) ?? [];
const current = tasks.find((t) => !t.done); // 最早一个未完成
if (!current) return null;
return (
<div style={{
position: "absolute", top: 16, right: 16,
padding: "8px 14px", background: "rgba(0,0,0,0.6)", color: "white",
borderRadius: 4, fontSize: 14, pointerEvents: "none",
}}>
📋 {current.title}
</div>
);
};
ctx.ui.show 的第三个参数 UIShowOptions 支持 position: "(right, top)" 等字符串格式,interactable: false 让 HUD 不挡点击。
9. 回忆录:用 ctx.flow.unsafe_goToFragment 重放剧情
问题:在剧本关键节点上记录”这是一段值得回味的剧情”,玩家在标题画面打开回忆录,点条目跳回该 Fragment 重放。 关键点:- 演示
ctx.flow.unsafe_goToFragment(id, { chapterId })—— 它会放弃当前流程并从目标片段开始 - 存档用
shared—— 跨所有存档保留(玩家任意周目解锁的回忆都保留) - 不用快捷键,由作者自己在标题画面挂入口(避免和好感度的快捷键重复)
type Memory = {
id: string;
title: string;
fragmentId: string;
chapterId: string;
thumbUri?: string;
};
type MemorySaveMap = { memories: readonly Memory[] };
import manifest from "../extension.json";
const EXT_ID = "memory-book";
const VAR_MEMORIES = `${manifest.id}.memories`;
@extension({ id: EXT_ID, label: "回忆录" })
export class MemoryBook extends Extension {
static saveSchema = defineSave({
memories: {
type: "list",
persistence: "shared", // 跨所有存档保留
default: [] as Memory[],
},
});
// 剧本里在关键节点调用,把"当前片段"记进回忆录
static record = method({
title: "记录回忆",
schema: {
memoryId: { type: "string", label: "回忆 ID", required: true },
title: { type: "string", label: "标题", required: true },
fragment: {
type: "fragment",
label: "目标 Fragment",
required: true,
chapterField: "chapterId",
},
chapterId: { type: "string", label: "目标章节 ID", required: true },
},
run(_ctx, params) {
const save = this.save as unknown as SaveAPI<MemorySaveMap>;
const list = save.get("memories");
if (list.some((m) => m.id === params.memoryId)) return; // 幂等
save.set("memories", [...list, {
id: params.memoryId,
title: params.title,
fragmentId: params.fragment,
chapterId: params.chapterId,
}]);
},
});
render() {
return { component: MemoryBookComponent, props: {} };
}
}
const MemoryBookComponent: React.FC = () => {
const ctx = useExtensionContext();
const [raw] = ctx.variables.useValue(VAR_MEMORIES);
const memories = (raw as unknown as readonly Memory[] | undefined) ?? [];
const onPlay = (m: Memory) => {
ctx.ui.hide("memory-book");
// 关键 API:跳到指定 Fragment 重放该段剧情
ctx.flow.unsafe_goToFragment(m.fragmentId, {
chapterId: m.chapterId,
});
};
return (
<div style={{ position: "absolute", inset: 0, padding: 32, background: "#222", color: "white" }}>
<h1>回忆录</h1>
{memories.length === 0 ? (
<p>还没有记录任何回忆</p>
) : (
<ul>
{memories.map((m) => (
<li key={m.id}>
<button onClick={() => onPlay(m)}>{m.title}</button>
</li>
))}
</ul>
)}
</div>
);
};
10. 人物图鉴:差集渲染 + 已遇见解锁
问题:第一次在剧本里遇到某个角色时解锁档案;图鉴里已解锁的显示头像 + 名字 + 简介,未解锁的显示剪影。 关键点:- 用
ctx.character.list()拿项目里全部角色定义 - 跟存档里的”已解锁 id 集合”做差集渲染——这是范例 3 没演示的
ctx.character用法 - 用
customFields.bio字段读简介——Character接口有customFields: Record<string, unknown>
type CharBookSaveMap = { unlockedIds: readonly string[] };
import manifest from "../extension.json";
const EXT_ID = "character-book";
const VAR_UNLOCKED = `${manifest.id}.unlockedIds`;
@extension({ id: EXT_ID, label: "人物图鉴" })
export class CharacterBook extends Extension {
static saveSchema = defineSave({
unlockedIds: {
type: "list",
persistence: "shared", // 解锁状态跨所有存档共享
default: [] as string[],
},
});
static unlock = method({
title: "解锁角色档案",
schema: { character: { type: "character", label: "角色", required: true } },
run(_ctx, params) {
const save = this.save as unknown as SaveAPI<CharBookSaveMap>;
const list = save.get("unlockedIds");
if (list.includes(params.character)) return; // 幂等
save.set("unlockedIds", [...list, params.character]);
},
});
render() {
return { component: CharacterBookComponent, props: {} };
}
}
const CharacterBookComponent: React.FC = () => {
const ctx = useExtensionContext();
const characters = ctx.character.useAll(); // 项目全部角色
const [raw] = ctx.variables.useValue(VAR_UNLOCKED); // 已解锁集合
const unlockedSet = new Set(
(raw as unknown as readonly string[] | undefined) ?? []
);
return (
<div style={{ position: "absolute", inset: 0, padding: 32, background: "#1a1a2e", color: "white" }}>
<h1>人物图鉴</h1>
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 16 }}>
{characters.map((ch) => {
const unlocked = unlockedSet.has(ch.id);
const bio = unlocked ? String(ch.customFields?.bio ?? "") : "???";
return (
<div key={ch.id} style={{ padding: 12, background: "#272741", borderRadius: 6 }}>
{ch.avatarUri && (
<img src={ch.avatarUri} width={80} height={80}
style={{ filter: unlocked ? "none" : "brightness(0)" }} />
)}
<div>{unlocked ? ch.name : "???"}</div>
<p style={{ fontSize: 12, opacity: 0.7 }}>{bio}</p>
</div>
);
})}
</div>
</div>
);
};
11. 成就解锁:toast 通知 + archive:changed 订阅
问题:解锁新成就时弹一条 toast 通知。读档时检查”这个档比上次玩到的多解锁了哪些成就”,给个温馨提示”欢迎回来,你已解锁 N 个成就”。 关键点:- 演示
ctx.subscribe("archive:changed", () => ...)—— 之前的范例都没用过 - 自己写一个临时的 toast UI,几秒后自动消失
- toast 用 autonomous 模式常驻,但内容由订阅触发
type Achievement = { id: string; title: string; desc: string };
type AchievSaveMap = { unlocked: readonly Achievement[] };
import manifest from "../extension.json";
const EXT_ID = "achievements";
const VAR_UNLOCKED = `${manifest.id}.unlocked`;
@extension({ id: EXT_ID, label: "成就系统", autonomous: true })
export class Achievements extends Extension {
static saveSchema = defineSave({
unlocked: {
type: "list",
persistence: "shared", // 成就跨所有存档共享
default: [] as Achievement[],
},
});
static onRegister(ctx: ExtensionContext): void {
// toast UI 启动期就挂上,常驻但默认不可见(组件自己决定显隐)
void ctx.ui.show(EXT_ID, {}, {
size: "(auto, auto)",
position: "(center, top)",
interactable: false,
});
// 关键:订阅存档变化,读档时弹"欢迎回来"提示
let lastCount = -1;
ctx.subscribe("archive:changed", () => {
// handler 无参数,需要数据要主动读
const raw = ctx.variables.get(VAR_UNLOCKED);
const list = (raw as unknown as readonly Achievement[] | undefined) ?? [];
if (lastCount >= 0 && list.length > lastCount) {
console.log(`[achievements] 欢迎回来,已解锁 ${list.length} 个成就`);
}
lastCount = list.length;
});
}
static unlock = method({
title: "解锁成就",
schema: {
achievementId: { type: "string", label: "成就 ID", required: true },
title: { type: "string", label: "标题", required: true },
desc: { type: "string", label: "描述" },
},
run(_ctx, params) {
const save = this.save as unknown as SaveAPI<AchievSaveMap>;
const list = save.get("unlocked");
if (list.some((a) => a.id === params.achievementId)) return;
save.set("unlocked", [...list, {
id: params.achievementId,
title: params.title,
desc: params.desc ?? "",
}]);
},
});
render() {
return { component: ToastComponent, props: {} };
}
}
// 监听存档列表变化,新增的最后一条作为 toast 显示几秒
const ToastComponent: React.FC = () => {
const ctx = useExtensionContext();
const [raw] = ctx.variables.useValue(VAR_UNLOCKED);
const list = (raw as unknown as readonly Achievement[] | undefined) ?? [];
const [shown, setShown] = React.useState<Achievement | null>(null);
const prevLenRef = React.useRef(list.length);
React.useEffect(() => {
if (list.length > prevLenRef.current) {
const latest = list[list.length - 1];
setShown(latest);
const t = setTimeout(() => setShown(null), 3000);
return () => clearTimeout(t);
}
prevLenRef.current = list.length;
}, [list]);
if (!shown) return null;
return (
<div style={{
position: "absolute", top: 32, left: "50%", transform: "translateX(-50%)",
padding: "12px 20px", background: "rgba(0,0,0,0.85)", color: "gold",
borderRadius: 6, pointerEvents: "none",
}}>
🏆 解锁成就:{shown.title}
</div>
);
};