这一组范例关注“作者怎样配置规则、剧本怎样修改状态、玩家进度存到哪里”。关键不是字段越多越好,而是把项目设置、当前存档和跨周目档案分清楚。

好感度达到阈值时记录里程碑

最终效果:剧本可以给指定角色增加好感度;第一次达到作者配置的阈值时,记录“已触发里程碑”。后续奖励或剧情判断读取这份记录,就不会因重复加点再次触发。 组合能力method() 参数 Schema · settings() · slot 存档 · 角色选择器
type AffectionEntry = {
  characterId: string;
  value: number;
  milestoneReached: boolean;
};
type AffectionSave = { entries: readonly AffectionEntry[] };

@extension({ id: "affection", label: "好感度" })
export class Affection extends Extension {
  static settings = settings((s: SettingsBuilder) => ({
    milestone: s.number("剧情解锁阈值").default(80).range(1, 100),
  }));

  static saveSchema = defineSave({
    entries: {
      type: "list",
      persistence: "slot",
      default: [] as AffectionEntry[],
    },
  });

  static add = method({
    title: "增加好感度",
    schema: {
      character: { type: "character", label: "角色", required: true },
      amount: { type: "number", label: "增减量", default: 1 },
    },
    run(ctx, params) {
      const save = this.save as unknown as SaveAPI<AffectionSave>;
      const threshold = ctx.settings.get<number>("milestone") ?? 80;
      const list = save.get("entries");
      const current = list.find((item) => item.characterId === params.character);
      const nextValue = Math.max(0, (current?.value ?? 0) + params.amount);
      const justReached = !current?.milestoneReached && nextValue >= threshold;
      const nextEntry: AffectionEntry = {
        characterId: params.character,
        value: nextValue,
        milestoneReached: current?.milestoneReached || justReached,
      };

      save.set(
        "entries",
        current
          ? list.map((item) =>
              item.characterId === params.character ? nextEntry : item,
            )
          : [...list, nextEntry],
      );

    },
  });
}
阈值是作者制定的玩法规则,放在设置中;每个存档的好感度和是否触发过里程碑是玩家进度,放在 slot

可发放、消耗的背包

最终效果:剧本可以发放或消耗物品;同类物品合并数量,数量归零时从列表中移除。 组合能力:多个剧本方法 · 文本候选 · list 存档 · 不可变更新
type ItemStack = { id: string; count: number };
type InventorySave = { items: readonly ItemStack[] };

const itemField = {
  type: "string" as const,
  label: "物品 ID",
  required: true,
  suggestions: {
    key: "inventory-items",
  },
};

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

  static grant = method({
    title: "发放物品",
    schema: {
      itemId: itemField,
      count: { type: "number", label: "数量", default: 1, min: 1 },
    },
    run(_ctx, params) {
      const save = this.save as unknown as SaveAPI<InventorySave>;
      const list = save.get("items");
      const exists = list.some((item) => item.id === params.itemId);
      save.set(
        "items",
        exists
          ? list.map((item) =>
              item.id === params.itemId
                ? { ...item, count: item.count + params.count }
                : item,
            )
          : [...list, { id: params.itemId, count: params.count }],
      );
    },
  });

  static consume = method({
    title: "消耗物品",
    schema: {
      itemId: itemField,
      count: { type: "number", label: "数量", default: 1, min: 1 },
    },
    run(_ctx, params) {
      const save = this.save as unknown as SaveAPI<InventorySave>;
      const next = save
        .get("items")
        .map((item) =>
          item.id === params.itemId
            ? { ...item, count: Math.max(0, item.count - params.count) }
            : item,
        )
        .filter((item) => item.count > 0);
      save.set("items", next);
    },
  });
}
候选池会收集项目剧本里已经填过的物品 ID,帮助作者少输错,但不限制实际输入。项目后续增加物品时,可以继续输入新 ID。

让作者选择解锁记录是否跨周目

最终效果:作者可以决定 CG 或词条解锁是当前存档独立,还是所有周目共享;切换选项不会让已经解锁的内容消失。 组合能力persistence · 枚举设置 · 双桶存储 · 并集去重
type UnlockSave = {
  unlockedShared: readonly string[];
  unlockedSlot: readonly string[];
};

@extension({ id: "unlock-library", label: "解锁收藏" })
export class UnlockLibrary extends Extension {
  static settings = settings((s: SettingsBuilder) => ({
    scope: s
      .enum("解锁范围", ["shared", "slot"] as const)
      .labels({ shared: "跨周目共享", slot: "跟随当前存档" })
      .default("shared"),
  }));

  static saveSchema = defineSave({
    unlockedShared: {
      type: "list",
      persistence: "shared",
      default: [] as string[],
    },
    unlockedSlot: {
      type: "list",
      persistence: "slot",
      default: [] as string[],
    },
  });

  static unlock = method({
    title: "解锁收藏条目",
    schema: {
      entryId: { type: "string", label: "条目 ID", required: true },
    },
    run(ctx, params) {
      const save = this.save as unknown as SaveAPI<UnlockSave>;
      const shared = save.get("unlockedShared");
      const slot = save.get("unlockedSlot");
      if ([...shared, ...slot].includes(params.entryId)) return;

      const key =
        ctx.settings.get<"shared" | "slot">("scope") === "slot"
          ? "unlockedSlot"
          : "unlockedShared";
      save.set(key, [...save.get(key), params.entryId]);
    },
  });
}

function mergeUnlocks(shared: readonly string[], slot: readonly string[]) {
  return [...new Set([...shared, ...slot])];
}
不要尝试在运行时修改一个字段的 persistence。它是静态声明。双桶方案让设置只影响“下一次写到哪里”,读取时始终合并两个桶。 常见数据的选择:
数据建议
背包、好感度、任务进度slot
成就、已解锁结局、总游玩档案shared
CG、音乐、人物词条根据作品设计,可使用上面的双桶方案

作者配置提示上限,玩家消耗提示次数

最终效果:作者在 Studio 中配置每章最多可用几次调查提示;玩家每次请求提示都会消耗当前存档中的次数。 组合能力:设置与存档的职责边界 · 数字字段约束 · 方法中的规则校验
type HintSave = { usedHints: number };
type HintPopupProps = { message: string; remaining: number };

@extension({ id: "investigation-hints", label: "调查提示" })
export class InvestigationHints extends Extension<HintPopupProps> {
  static settings = settings((s: SettingsBuilder) => ({
    maxHints: s.number("每章可用提示次数").default(3).range(0, 20),
    exhaustedText: s
      .string("次数用尽提示")
      .default("本章的调查提示已经用完了。"),
  }));

  static saveSchema = defineSave({
    usedHints: { type: "number", persistence: "slot", default: 0 },
  });

  static requestHint = method({
    title: "请求调查提示",
    schema: {
      text: { type: "string", label: "提示内容", required: true, multiline: true },
    },
    run(ctx, params) {
      const save = this.save as unknown as SaveAPI<HintSave>;
      const used = save.get("usedHints");
      const max = ctx.settings.get<number>("maxHints") ?? 3;
      const message =
        used < max
          ? params.text
          : ctx.settings.get<string>("exhaustedText") ?? "提示次数已用完";

      if (used < max) save.set("usedHints", used + 1);
      void ctx.ui.show("investigation-hints", {
        message,
        remaining: Math.max(0, max - used - 1),
      });
    },
  });

  static resetForChapter = method({
    title: "重置本章提示次数",
    run() {
      const save = this.save as unknown as SaveAPI<HintSave>;
      save.set("usedHints", 0);
    },
  });

  render() {
    return {
      component: HintPopup,
      props: this.data ?? { message: "", remaining: 0 },
    };
  }
}

const HintPopup: React.FC<HintPopupProps> = ({ message, remaining }) => {
  const ctx = useExtensionContext();
  return (
    <aside>
      <p>{message}</p>
      <small>剩余提示:{remaining}</small>
      <button onClick={() => void ctx.ui.hide("investigation-hints")}>知道了</button>
    </aside>
  );
};
项目设置面向创作者,存档数据面向玩家。判断一个字段放哪里时,可以问:“换一个存档后,这个值应该跟着变吗?”以及“作者是否需要在 Studio 中预先配置它?”