这一组范例适合鉴赏、图鉴、音乐室和历史页。共同原则是:收藏状态可以持久化,浏览和试听不应污染玩家当前剧情现场。

已遇见角色才显示的人物图鉴

最终效果:剧本第一次遇到角色时解锁档案;图鉴始终列出项目中的全部角色,未解锁者显示剪影和问号。 组合能力ctx.character.useAll · shared 存档 · 差集渲染
import manifest from "../extension.json";

type CharacterBookSave = { unlockedIds: readonly string[] };
const UNLOCKED_VAR = `${manifest.id}.unlockedIds`;

@extension({ id: "character-book", 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<CharacterBookSave>;
      const list = save.get("unlockedIds");
      if (!list.includes(params.character)) {
        save.set("unlockedIds", [...list, params.character]);
      }
    },
  });

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

const CharacterGrid: React.FC = () => {
  const ctx = useExtensionContext();
  const characters = ctx.character.useAll();
  const [raw] = ctx.variables.useValue(UNLOCKED_VAR);
  const unlocked = new Set(
    (raw as unknown as readonly string[] | undefined) ?? [],
  );

  return (
    <section>
      {characters.map((character) => {
        const visible = unlocked.has(character.id);
        return (
          <article key={character.id}>
            {character.avatarUri && (
              <img
                src={character.avatarUri}
                alt=""
                style={{ filter: visible ? "none" : "brightness(0)" }}
              />
            )}
            <h2>{visible ? character.name : "???"}</h2>
            <p>{visible ? String(character.customFields.bio ?? "") : "尚未遇见"}</p>
          </article>
        );
      })}
    </section>
  );
};
角色列表来自项目定义,解锁集合来自玩家档案。两者做差集后,不需要为“未解锁角色”额外保存占位数据。

在 CG 鉴赏中隔离渲染场景

最终效果:作者从剧本中解锁一组背景与前景图层;玩家点开 CG 时,扩展在自己的容器中创建临时场景,不改变主剧情正在显示的场景。 组合能力:素材参数 · ctx.sceneRender.mount · React effect 清理
import manifest from "../extension.json";

type GalleryEntry = {
  id: string;
  title: string;
  layers: readonly GalleryLayer[];
};
type GallerySave = { entries: readonly GalleryEntry[] };
const GALLERY_VAR = `${manifest.id}.entries`;

@extension({ id: "cg-gallery", label: "CG 鉴赏" })
export class CgGallery extends Extension {
  static saveSchema = defineSave({
    entries: {
      type: "list",
      persistence: "shared",
      default: [] as GalleryEntry[],
    },
  });

  static unlock = method({
    title: "解锁 CG",
    schema: {
      id: { type: "string", label: "CG ID", required: true },
      title: { type: "string", label: "标题", required: true },
      background: {
        type: "asset",
        assetType: "image",
        label: "背景图",
        required: true,
      },
      foreground: {
        type: "asset",
        assetType: "image",
        label: "前景图",
      },
    },
    run(_ctx, params) {
      const save = this.save as unknown as SaveAPI<GallerySave>;
      if (save.get("entries").some((entry) => entry.id === params.id)) return;

      const layers: GalleryLayer[] = [
        { assetPath: params.background, distance: 4, name: "背景" },
        ...(params.foreground
          ? [{ assetPath: params.foreground, distance: 1, name: "前景" }]
          : []),
      ];
      save.set("entries", [
        ...save.get("entries"),
        { id: params.id, title: params.title, layers },
      ]);
    },
  });

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

const GalleryWall: React.FC = () => {
  const ctx = useExtensionContext();
  const [raw] = ctx.variables.useValue(GALLERY_VAR);
  const entries = (raw as unknown as readonly GalleryEntry[] | undefined) ?? [];
  const [selected, setSelected] = React.useState<GalleryEntry | null>(null);

  return selected ? (
    <button onClick={() => setSelected(null)}>
      <ScenePreview entry={selected} />
    </button>
  ) : (
    <section>
      {entries.map((entry) => (
        <button key={entry.id} onClick={() => setSelected(entry)}>
          {entry.title}
        </button>
      ))}
    </section>
  );
};

function ScenePreview({ entry }: { entry: GalleryEntry }) {
  const ctx = useExtensionContext();
  const stageRef = React.useRef<HTMLDivElement>(null);

  React.useEffect(() => {
    const stage = stageRef.current;
    if (!stage) return;

    let disposed = false;
    let handle: SceneRenderHandle | null = null;
    void ctx.sceneRender
      .mount(stage, entry.layers, { displayType: "cover" })
      .then((nextHandle) => {
        if (disposed) nextHandle.dispose();
        else handle = nextHandle;
      });

    return () => {
      disposed = true;
      handle?.dispose();
    };
  }, [ctx.sceneRender, entry]);

  return <div ref={stageRef} aria-label={entry.title} />;
}
界面关闭或切换条目时必须调用 dispose()。不要用 ctx.scene.change() 做鉴赏预览,它会改变玩家主舞台。

可以解锁曲目的音乐室

最终效果:剧本把音乐资源加入收藏;音乐室展示曲目,点击后试听,再次点击停止。 组合能力:音频素材选择器 · shared 存档 · ctx.asset.resolve · ctx.sound
import manifest from "../extension.json";

type Track = { id: string; title: string; audioUri: string; coverUri?: string };
type MusicSave = { tracks: readonly Track[] };
const MUSIC_VAR = `${manifest.id}.tracks`;

@extension({ id: "music-room", label: "音乐室" })
export class MusicRoom extends Extension {
  static saveSchema = defineSave({
    tracks: { type: "list", persistence: "shared", default: [] as Track[] },
  });

  static unlock = method({
    title: "解锁曲目",
    schema: {
      title: { type: "string", label: "曲名", required: true },
      audio: {
        type: "asset",
        assetType: "audio",
        label: "音乐资源",
        required: true,
      },
      cover: {
        type: "asset",
        assetType: "image",
        label: "封面图",
      },
    },
    run(_ctx, params) {
      const save = this.save as unknown as SaveAPI<MusicSave>;
      if (save.get("tracks").some((track) => track.audioUri === params.audio)) return;
      save.set("tracks", [
        ...save.get("tracks"),
        {
          id: params.audio,
          title: params.title,
          audioUri: params.audio,
          coverUri: params.cover || undefined,
        },
      ]);
    },
  });

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

const TrackList: React.FC = () => {
  const ctx = useExtensionContext();
  const [raw] = ctx.variables.useValue(MUSIC_VAR);
  const tracks = (raw as unknown as readonly Track[] | undefined) ?? [];
  const [playing, setPlaying] = React.useState<string | null>(null);

  const toggle = (track: Track) => {
    if (playing === track.id) {
      void ctx.sound.stop(track.audioUri);
      setPlaying(null);
      return;
    }
    if (playing) void ctx.sound.stop(playing);
    void ctx.sound.play(track.audioUri, { id: track.id });
    setPlaying(track.id);
  };

  return (
    <ol>
      {tracks.map((track) => (
        <li key={track.id}>
          {track.coverUri && (
            <img src={ctx.asset.resolve(track.coverUri).url} alt="" />
          )}
          <button onClick={() => toggle(track)}>
            {playing === track.id ? "停止" : "试听"} {track.title}
          </button>
        </li>
      ))}
    </ol>
  );
};
ctx.asset.resolve() 适合把项目素材交给 <img><audio> 等 DOM 元素;通过引擎播放、暂停和停止时,继续使用原始素材 URI 调用 ctx.sound

可重放语音的历史页

最终效果:历史页随剧情实时更新;带语音的台词显示“重放”按钮,普通旁白仍可阅读。 组合能力ctx.history.useSnapshot · 语音重放 · 响应式列表
@extension({
  id: "history-screen",
  label: "历史记录",
  supportsSlot: INTERNAL_SYSTEM_SLOT.History,
})
export class HistoryScreen extends Extension {
  render() {
    return { component: HistoryList, props: {} };
  }
}

const HistoryList: React.FC = () => {
  const ctx = useExtensionContext();
  const { entries } = ctx.history.useSnapshot();

  return (
    <section>
      <h1>历史记录</h1>
      {entries.map((entry, index) => (
        <article key={entry.uuid ?? index}>
          <strong>{entry.name ?? "旁白"}</strong>
          <p>{entry.text}</p>
          {entry.voiceUri && (
            <button onClick={() => void ctx.history.replayVoice(entry.voiceUri!)}>
              重放语音
            </button>
          )}
        </article>
      ))}
    </section>
  );
};
ctx.history 只包含玩家已经播放过的内容。需要制作“全剧本台词检索”时,改用 ctx.story 异步读取章节。

按章节统计全剧本台词

最终效果:扩展先显示轻量章节目录,再逐章读取完整内容并统计对话 Block 数量。未播放过的章节也会进入统计。 组合能力ctx.story.listChapters · getChapter 异步加载 · 只读剧本快照
@extension({ id: "story-statistics", label: "剧本统计" })
export class StoryStatistics extends Extension {
  render() {
    return { component: ChapterStatistics, props: {} };
  }
}

function countDialogueBlocks(blocks: readonly StoryBlock[]): number {
  return blocks.reduce(
    (total, block) =>
      total +
      (block.type === "dialogue" ? 1 : 0) +
      countDialogueBlocks(block.children ?? []),
    0,
  );
}

const ChapterStatistics: React.FC = () => {
  const ctx = useExtensionContext();
  const [rows, setRows] = React.useState<
    Array<{ id: string; name: string; dialogueCount: number | null }>
  >(() =>
    ctx.story.listChapters().map((chapter) => ({
      id: chapter.id,
      name: chapter.name,
      dialogueCount: null,
    })),
  );

  React.useEffect(() => {
    let cancelled = false;
    const pendingRows = ctx.story.listChapters().map((chapter) => ({
      id: chapter.id,
      name: chapter.name,
      dialogueCount: null,
    }));
    setRows(pendingRows);

    void Promise.all(
      pendingRows.map(async (row) => {
        const chapter = await ctx.story.getChapter(row.id);
        const dialogueCount =
          chapter?.fragments.reduce(
            (sum, fragment) => sum + countDialogueBlocks(fragment.blocks),
            0,
          ) ?? 0;
        return { ...row, dialogueCount };
      }),
    ).then((nextRows) => {
      if (!cancelled) setRows(nextRows);
    });

    return () => {
      cancelled = true;
    };
  }, [ctx.story]);

  return (
    <ol>
      {rows.map((row) => (
        <li key={row.id}>
          {row.name}{row.dialogueCount ?? "统计中…"} 条对话
        </li>
      ))}
    </ol>
  );
};
大项目不要在每次 render 中调用 getAllChapters()。先用 listChapters() 立即显示目录,再按需读取正文,界面会更快出现。