这一组范例解决“信息怎样出现在画面上、什么时候出现、玩家怎样操作”的问题。它们从轻量 HUD 开始,逐步加入事件订阅、React 状态和可视化界面控制。

跟随对话显隐的常驻工具栏

最终效果:进入对话时,底部工具栏自动出现;对话框隐藏时,工具栏自动收起。作者可以在扩展设置里决定显示哪些按钮。 组合能力autonomousonRegister · ctx.subscribe · ctx.settings · ctx.ui
@extension({ id: "dialogue-toolbar", label: "对话工具栏", autonomous: true })
export class DialogueToolbar extends Extension {
  static settings = settings((s: SettingsBuilder) => ({
    enabled: s.boolean("显示工具栏").default(true),
    showSkip: s.boolean("显示跳过").default(true).enabledWhen("enabled"),
    showAuto: s.boolean("显示自动播放").default(true).enabledWhen("enabled"),
  }));

  static onRegister(ctx: ExtensionContext): void {
    let visible = false;

    const sync = () => {
      const enabled = ctx.settings.get<boolean>("enabled") ?? true;
      const next = enabled && ctx.dialogue.line() !== null;
      if (next === visible) return;
      visible = next;
      if (next) void ctx.ui.show("dialogue-toolbar");
      else void ctx.ui.hide("dialogue-toolbar");
    };

    ctx.subscribe("dialogue:changed", sync);
    ctx.settings.subscribe<boolean>("enabled", sync);
    sync();
  }

  render() {
    return { component: Toolbar, props: {} };
  }
}

const Toolbar: React.FC = () => {
  const ctx = useExtensionContext();
  const [showSkip] = ctx.settings.useValue<boolean>("showSkip");
  const [showAuto] = ctx.settings.useValue<boolean>("showAuto");
  const skipping = ctx.dialogue.useSkipMode();
  const autoPlaying = ctx.dialogue.useAutoMode();

  return (
    <nav>
      {showSkip && (
        <button onClick={() => ctx.dialogue.toggleSkipMode()}>
          {skipping ? "停止跳过" : "跳过"}
        </button>
      )}
      {showAuto && (
        <button onClick={() => ctx.dialogue.toggleAutoMode()}>
          {autoPlaying ? "停止自动" : "自动播放"}
        </button>
      )}
    </nav>
  );
};
这里的 .enabledWhen("enabled") 只负责让 Studio 设置面板中的子项变灰。运行时仍要主动读取总开关。

当前任务提示 HUD

最终效果:剧本调用“设置当前任务”后,右上角立即显示目标;任务完成后自动消失。HUD 不接管鼠标点击,不影响推进对话。 组合能力method() · saveSchema · ctx.variables · UIShowOptions
import manifest from "../extension.json";

type QuestSave = { currentTask: string };
const TASK_VAR = `${manifest.id}.currentTask`;

@extension({ id: "quest-hud", label: "任务提示", autonomous: true })
export class QuestHud extends Extension {
  static saveSchema = defineSave({
    currentTask: { type: "string", persistence: "slot", default: "" },
  });

  static onRegister(ctx: ExtensionContext): void {
    void ctx.ui.show("quest-hud", {}, {
      size: "(auto, auto)",
      position: "(right, top)",
      interactable: false,
    });
  }

  static setTask = method({
    title: "设置当前任务",
    schema: {
      title: { type: "string", label: "任务内容", required: true },
    },
    run(_ctx, params) {
      const save = this.save as unknown as SaveAPI<QuestSave>;
      save.set("currentTask", params.title.trim());
    },
  });

  static completeTask = method({
    title: "完成当前任务",
    run() {
      const save = this.save as unknown as SaveAPI<QuestSave>;
      save.set("currentTask", "");
    },
  });

  render() {
    return { component: QuestBadge, props: {} };
  }
}

const QuestBadge: React.FC = () => {
  const ctx = useExtensionContext();
  const [task] = ctx.variables.useValue<string>(TASK_VAR);
  if (!task) return null;
  return <aside aria-live="polite">当前任务:{task}</aside>;
};
任务属于当前周目,因此使用 slot。如果要保留完整任务列表,把字符串改成只读数组,并继续使用“创建新数组后整体写回”的方式。

成就解锁通知

最终效果:剧本第一次解锁某项成就时,画面顶部弹出三秒通知;重复调用同一个成就不会再次解锁。 组合能力:shared 存档 · 幂等方法 · 变量 Hook · React 临时状态
import manifest from "../extension.json";

type Achievement = { id: string; title: string };
type AchievementSave = { unlocked: readonly Achievement[] };
const UNLOCKED_VAR = `${manifest.id}.unlocked`;

@extension({ id: "achievement-toast", label: "成就通知", autonomous: true })
export class AchievementToast extends Extension {
  static saveSchema = defineSave({
    unlocked: {
      type: "list",
      persistence: "shared",
      default: [] as Achievement[],
    },
  });

  static onRegister(ctx: ExtensionContext): void {
    void ctx.ui.show("achievement-toast", {}, {
      size: "(auto, auto)",
      position: "(center, top)",
      interactable: false,
    });
  }

  static unlock = method({
    title: "解锁成就",
    schema: {
      id: { type: "string", label: "成就 ID", required: true },
      title: { type: "string", label: "成就名称", required: true },
    },
    run(_ctx, params) {
      const save = this.save as unknown as SaveAPI<AchievementSave>;
      const list = save.get("unlocked");
      if (list.some((item) => item.id === params.id)) return;
      save.set("unlocked", [...list, { id: params.id, title: params.title }]);
    },
  });

  render() {
    return { component: AchievementNotice, props: {} };
  }
}

const AchievementNotice: React.FC = () => {
  const ctx = useExtensionContext();
  const [raw] = ctx.variables.useValue(UNLOCKED_VAR);
  const list = (raw as unknown as readonly Achievement[] | undefined) ?? [];
  const [visible, setVisible] = React.useState<Achievement | null>(null);
  const previousLength = React.useRef(list.length);

  React.useEffect(() => {
    if (list.length <= previousLength.current) {
      previousLength.current = list.length;
      return;
    }
    previousLength.current = list.length;
    setVisible(list.at(-1) ?? null);
    const timer = window.setTimeout(() => setVisible(null), 3000);
    return () => window.clearTimeout(timer);
  }, [list]);

  return visible ? <aside aria-live="polite">🏆 {visible.title}</aside> : null;
};
成就是玩家档案级数据,所以用 shared。三秒显隐只是界面临时状态,不应写进存档。

给可视化任务面板补充动态行为

最终效果:面板布局、字体和按钮仍在可视化界面编辑器中制作;程序只负责把当前任务写进文本元素,并为“标记完成”按钮添加逻辑。 先在 quest-panel 可视化界面中给两个元素设置引用名:
元素引用名
任务标题文本task-title
完成按钮complete-button
组合能力ctx.visualUI.onOpen · 元素句柄 · 点击订阅 · 生命周期清理
import manifest from "../extension.json";

const PANEL = `@${manifest.id}/quest-panel`;
const TASK_VAR = `${manifest.id}.currentTask`;

@extension({
  id: "quest-panel-controller",
  label: "任务面板控制器",
  autonomous: true,
})
export class QuestPanelController extends Extension {
  static onRegister(ctx: ExtensionContext): void {
    ctx.visualUI.onOpen(PANEL, (view) => {
      const render = () => {
        const title = ctx.variables.get<string>(TASK_VAR) || "暂无任务";
        view.get("task-title")?.setProps({ text: title });
        view.get("complete-button")?.setHidden(title === "暂无任务");
      };

      render();
      const offVariable = ctx.subscribe("variable:changed", render);
      const offClick = view.get("complete-button")?.on("click", () => {
        ctx.variables.set(TASK_VAR, "");
        render();
      });

      view.onClose(() => {
        offVariable();
        offClick?.();
      });
    });
  }
}
这个例子假设 currentTask 已由同一扩展的 saveSchema 声明。布局能由编辑器完成时,优先用“可视化界面 + 控制器”;只有复杂列表、Canvas 或高频交互才需要完整 React UI。