这一组范例把重复的演出动作封装成剧本方法,并区分“执行后返回原流程”和“放弃当前流程跳到新位置”两种剧情控制方式。

一键完成转场演出

最终效果:剧本选择场景、角色、立绘和背景音乐后,一个扩展方法完成黑场、换景、显示角色、镜头推进和开幕。 组合能力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 在每句对话上屏前同步运行,不要在里面发请求或读取完整剧本。多个扩展可以同时修改不同样式字段。