Extension 是写扩展的统一基类。一个 Extension 子类 = 一个完整的游戏系统——它可以同时拥有界面、剧本可调用的方法、自己的项目设置和需要持久化的存档数据。 按需声明四种能力:
你想要怎么写
一个界面(背包面板、HUD、标题画面…)实现 render(),返回 { component, props }
一个可被剧本调用的方法static 方法名 = method({ ... })
扩展自己的项目设置static settings = settings((s) => ({ ... }))
需要存进存档的数据static saveSchema = defineSave({ ... })
只实现其中一项就是一个”纯方法模块”或”纯界面模块”;几项都写就是一个完整的游戏系统。引擎内置的默认游戏壳包含 6 个 Extension 子类(标题、存读档、历史、设置、工具栏、鉴赏),是这套写法的完整范例。

最小例子

import { Extension, extension } from "@avg-studio/sdk";

@extension({ id: "my-panel", label: "我的面板" })
export class MyPanel extends Extension {
  render() {
    return {
      component: () => <div>这是一个自定义面板</div>,
      props: {},
    };
  }
}
剧本里用「显示界面」action block 选中「我的面板」,就能让它出现在游戏画面上。

@extension 装饰器

@extension({ ... }) 声明扩展模块的身份信息。等价于写 static meta = meta({ ... }),前者更简洁。
字段类型说明
idstring模块标识符(kebab-case),在 ui-ref 路径里被引用,缺省时由 SDK 从 class 名推导
labelstring显示名,Studio 在「显示界面」选择器和扩展设置面板里用
descriptionstring简要说明
categorystring分类(暂未使用)
autonomousboolean启动期是否自动注册——见下文「Autonomous 模式」
supportsSlotstring | string[]声明该模块实现哪个系统插槽,详见 系统插槽

静态钩子 vs 实例钩子

Extension 上的钩子分两类,性质完全不同:

static onRegister(模块级,启动期跑一次)

引擎在启动时对每个 Extension 模块调用一次 static onRegister(ctx)——无论 autonomous 是否为 true。适合「整个游戏期间只需要做一次」的事:注册全局快捷键、订阅引擎事件、声明语义动作。
@extension({ id: "save-screen", label: "存档画面" })
export class SaveScreen extends Extension {
  static onRegister(ctx: ExtensionContext) {
    ctx.input.registerAction({
      id: "save-screen.quick-save",
      label: "快速存档",
      defaultKeys: ["F5"],
    });
    ctx.input.onAction("save-screen.quick-save", () => {
      ctx.archive.quickSave();
    });
  }
}

onInit / onShow / onClose(实例级,每次显示界面跑)

每次 ctx.ui.show(uiId) 都会 new 一个实例。这三个钩子定义在 ExtensionBase 上,都是普通实例方法,按需实现:
钩子时机
onInit()实例化、attach 完 host 之后,render 之前
onShow()UI 变为可见
onClose()UI 被关闭、实例销毁前
export class SaveScreen extends Extension {
  onInit() {
    // 进 DOM 前先截一张游戏画面,供存档缩略图复用
    this.context.archive.cacheGameSnapshot();
  }

  onClose() {
    this.context.archive.clearGameSnapshot();
  }

  render() { ... }
}
注意 static onRegister 拿的是 ctx 参数,实例钩子里通过 this.context 访问 ctx——它们的作用域不同。

render 方法

render() 是实例方法,返回 { component, props }
render() {
  return {
    component: MyPanelComponent,   // React FC
    props: { data: this.data },    // 传给该组件的 props
  };
}
实现了 render() 的扩展模块会出现在「显示界面」action block 的选择器里。不实现 render() 就是一个纯方法/纯订阅型模块(比如只声明 method()onRegister)。

实例属性

下面这些属性只在类的方法/钩子内部使用(this.xxx),不要从外部访问实例。
属性说明
this.context当前扩展的 ExtensionContext(protected)。所有引擎 API 的入口
this.data从「显示界面」block 传入的 props 数据,由 schema 解析得到
this.save存档数据读写代理。需先声明 static saveSchema,详见下文
this.id该实例的运行时 id
this.close()主动关闭这个 UI 实例(等价于外部 ctx.ui.hide(uiId)
在 React 组件里通过 hook 拿 ctx,而不是 this.context
import { useExtensionContext } from "@avg-studio/sdk";

function MyPanelComponent() {
  const ctx = useExtensionContext();
  const [bgmVolume] = ctx.config.useValue("bgmVolume");
  return <div>当前 BGM 音量:{bgmVolume}</div>;
}

this.save 的强类型访问

saveSchema 声明的字段通过 this.save.get/set/useValue 读写。在类内部的钩子onInitrender 等)里 TypeScript 能自动从 saveSchema 推断出 key 和 value 类型。 method()run 回调里,TypeScript 没办法把方法定义和外层类的 saveSchema 关联起来,this.save 的类型是 EmptySaveAPI——任何 key 都报错。 这时需要显式收窄:
import type { SaveAPI } from "@avg-studio/sdk";

type MySaveMap = {
  items: readonly string[];
  gold: number;
};

@extension({ id: "inventory", label: "背包" })
export class Inventory extends Extension {
  static saveSchema = defineSave({
    items: { type: "list", persistence: "slot", default: [] as string[] },
    gold: { type: "number", persistence: "slot", default: 0 },
  });

  static addItem = method({
    title: "增加物品",
    schema: { itemId: { type: "string", label: "物品 ID", required: true } },
    run(_ctx, params) {
      // 显式收窄 this.save 类型,避免 method() 里类型丢失
      const save = this.save as unknown as SaveAPI<MySaveMap>;
      save.set("items", [...save.get("items"), params.itemId]);
    },
  });
}
这是引擎内置 gallery-screen 在用的标准模式。详见 存档 Schema

Autonomous 模式

@extension({ autonomous: true }) 的扩展模块在引擎启动后自动加载并显示,不需要剧本调用 ctx.ui.show()。适合常驻 UI:
  • 对话工具栏(跟着对话显隐的底部按钮栏)
  • 全局快捷键监听器
  • HUD 元素(生命值、好感度提示等)
@extension({ id: "toolbar", label: "工具栏", autonomous: true })
export class Toolbar extends Extension {
  static onRegister(ctx) {
    // 对话出现时显示工具栏,对话消失时隐藏
    ctx.subscribe("dialogue:changed", () => {
      if (ctx.dialogue.line()) {
        ctx.ui.show("toolbar");
      } else {
        ctx.ui.hide("toolbar");
      }
    });
  }

  render() {
    return { component: ToolbarComponent, props: {} };
  }
}
autonomous 改变的是加载策略——非 autonomous 模块也会跑 static onRegister,只是不会自动显示界面。

跟系统插槽配合

通过 supportsSlot 声明该模块实现了哪个引擎内置 UI 槽位(标题、存档、设置等),就能替换 Default Shell:
import { Extension, extension, INTERNAL_SYSTEM_SLOT } from "@avg-studio/sdk";

@extension({
  id: "title-screen",
  label: "标题画面",
  supportsSlot: INTERNAL_SYSTEM_SLOT.Title,
})
export class MyTitleScreen extends Extension {
  render() {
    return { component: TitleScreenComponent, props: {} };
  }
}
支持单值和数组两种形态。详见 系统插槽

完整范例:CG 鉴赏

这是引擎内置 gallery-screen 的精简版——一个 Extension 子类同时包含界面、3 个剧本方法、1 个设置、两种存档作用域并存。当前完整实现还会声明 supportsSlot: INTERNAL_SYSTEM_SLOT.Gallery,因此可以在「游戏系统」中替换鉴赏界面。
import {
  Extension, extension, defineSave, method, settings,
  type SettingsBuilder, type SaveAPI,
} from "@avg-studio/sdk";

type GallerySaveMap = {
  unlockedShared: readonly GalleryEntry[];
  unlockedSlot: readonly GalleryEntry[];
};

@extension({ id: "gallery-screen", label: "鉴赏画面" })
export class GalleryScreen extends Extension {
  // 两种存档作用域:shared 跨存档全局解锁,slot 跟随当前存档
  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"),
  }));

  // 剧本方法:加入鉴赏
  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);
      if (scope === "slot") {
        save.set("unlockedSlot", [...save.get("unlockedSlot"), entry]);
      } else {
        save.set("unlockedShared", [...save.get("unlockedShared"), entry]);
      }
    },
  });

  // 剧本方法:移除/清空 ……(同款写法,略)

  // 界面
  render() {
    return { component: GalleryScreenComponent, props: {} };
  }
}
进一步细节看相邻几页:剧本方法设置 Schema存档 Schema系统插槽