已遇见角色才显示的人物图鉴
最终效果:剧本第一次遇到角色时解锁档案;图鉴始终列出项目中的全部角色,未解锁者显示剪影和问号。 组合能力: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>
);
};
getAllChapters()。先用 listChapters() 立即显示目录,再按需读取正文,界面会更快出现。