这一组范例把重复的演出动作封装成剧本方法,并区分“执行后返回原流程”和“放弃当前流程跳到新位置”两种剧情控制方式。
一键完成转场演出
最终效果:剧本选择场景、角色、立绘和背景音乐后,一个扩展方法完成黑场、换景、显示角色、镜头推进和开幕。
组合能力:ctx.curtain · ctx.scene · ctx.character · ctx.sound · ctx.camera
@extension({ id: "scene-director", label: "场景导演" })
export class SceneDirector extends Extension {
static enterScene = method({
title: "进入场景并安排角色",
schema: {
scene: { type: "scene", label: "目标场景", required: true },
character: { type: "character", label: "出场角色", required: true },
portrait: {
type: "characterPortrait",
label: "立绘",
characterField: "character",
},
bgm: { type: "asset", assetType: "audio", label: "背景音乐" },
},
async run(ctx, params) {
await ctx.curtain.fadeIn({ duration: 500, color: "#000000" });
if (params.bgm) await ctx.sound.play(params.bgm, { id: "scene-bgm", loop: true });
await ctx.scene.change(params.scene);
await ctx.character.show(params.character, {
portraitId: params.portrait,
position: "center",
});
await ctx.camera.pan({ scale: 1.08, duration: 600 });
await ctx.curtain.fadeOut({ duration: 500 });
},
// 快进时保留最终状态,但跳过动画等待。
skip(ctx, params) {
ctx.curtain.show({ color: "#000000" });
if (params.bgm) void ctx.sound.play(params.bgm, { id: "scene-bgm", loop: true });
void ctx.scene.change(params.scene);
void ctx.character.show(params.character, {
portraitId: params.portrait,
position: "center",
});
void ctx.camera.pan({ scale: 1.08, duration: 0 });
ctx.curtain.hide();
},
});
}
把重复演出封装成方法后,作者仍在 Studio 的参数面板中选择真实项目资源,不需要在代码中写死场景或角色 ID。
调用一段会返回的支线剧情
最终效果:玩家调查物件时进入一个支线 Fragment;支线结束后回到原位置,继续执行调查后的剧本。
组合能力:ctx.flow.callFragment · Fragment 参数 · 异步方法
@extension({ id: "side-story", label: "支线剧情" })
export class SideStory extends Extension {
static play = method({
title: "播放支线后返回",
schema: {
chapterId: { type: "string", label: "章节 ID", required: true },
fragment: {
type: "fragment",
label: "支线 Fragment",
chapterField: "chapterId",
required: true,
},
},
async run(ctx, params) {
await ctx.flow.callFragment(params.fragment, {
chapterId: params.chapterId,
});
// Fragment 执行结束后,从这里继续。
ctx.variables.set("lastSideStory", params.fragment);
},
});
}
callFragment 像调用函数:目标结束后返回。如果目标本身不会结束,当前方法也不会继续。
从回忆录跳到已解锁剧情
最终效果:剧本在关键节点解锁回忆;玩家从标题画面打开回忆录并选择条目后,直接从目标 Fragment 重放。
组合能力:ctx.flow.unsafe_goToFragment · shared 存档 · React UI
import manifest from "../extension.json";
type Memory = { id: string; title: string; chapterId: string; fragmentId: string };
type MemorySave = { memories: readonly Memory[] };
const MEMORY_VAR = `${manifest.id}.memories`;
@extension({ id: "memory-book", label: "回忆录" })
export class MemoryBook extends Extension {
static saveSchema = defineSave({
memories: {
type: "list",
persistence: "shared",
default: [] as Memory[],
},
});
static unlock = method({
title: "解锁回忆",
schema: {
id: { type: "string", label: "回忆 ID", required: true },
title: { type: "string", label: "标题", required: true },
chapterId: { type: "string", label: "章节 ID", required: true },
fragment: {
type: "fragment",
label: "目标 Fragment",
chapterField: "chapterId",
required: true,
},
},
run(_ctx, params) {
const save = this.save as unknown as SaveAPI<MemorySave>;
if (save.get("memories").some((item) => item.id === params.id)) return;
save.set("memories", [
...save.get("memories"),
{
id: params.id,
title: params.title,
chapterId: params.chapterId,
fragmentId: params.fragment,
},
]);
},
});
render() {
return { component: MemoryList, props: {} };
}
}
const MemoryList: React.FC = () => {
const ctx = useExtensionContext();
const [raw] = ctx.variables.useValue(MEMORY_VAR);
const memories = (raw as unknown as readonly Memory[] | undefined) ?? [];
const replay = (memory: Memory) => {
void ctx.ui.hide("memory-book");
ctx.flow.unsafe_goToFragment(memory.fragmentId, {
chapterId: memory.chapterId,
});
};
return (
<section>
<h1>回忆录</h1>
{memories.map((memory) => (
<button key={memory.id} onClick={() => replay(memory)}>
{memory.title}
</button>
))}
</section>
);
};
unsafe_goToFragment 会放弃当前流程,目标结束后不会回到原位置。需要临时插入一段剧情时,使用上一例的 callFragment。
按角色代表色改变名字牌
最终效果:每句角色对话上屏前,名字牌文字自动使用该角色在 Studio 中选择的代表色;旁白和未设置颜色的角色保持默认样式。
组合能力:ctx.dialogue.useStyle · ctx.character.get · 注册期行为
@extension({
id: "character-name-color",
label: "角色名字配色",
autonomous: true,
})
export class CharacterNameColor extends Extension {
static onRegister(ctx: ExtensionContext): void {
ctx.dialogue.useStyle((line) => {
if (!line.characterId) return null;
const color = ctx.character.get(line.characterId)?.themeColor?.fg;
if (!color) return null;
return {
styles: {
name_card: {
text_styles: { color },
},
},
};
});
}
}
样式 Hook 在每句对话上屏前同步运行,不要在里面发请求或读取完整剧本。多个扩展可以同时修改不同样式字段。