好感度达到阈值时记录里程碑
最终效果:剧本可以给指定角色增加好感度;第一次达到作者配置的阈值时,记录“已触发里程碑”。后续奖励或剧情判断读取这份记录,就不会因重复加点再次触发。 组合能力: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);
},
});
}
让作者选择解锁记录是否跨周目
最终效果:作者可以决定 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 中预先配置它?”