引擎内置的 Default Shell(标题画面 / 存档 / 工具栏 / 鉴赏 / 设置 / 历史)就是 6 个 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>
  );
};