跟随对话显隐的常驻工具栏
最终效果:进入对话时,底部工具栏自动出现;对话框隐藏时,工具栏自动收起。作者可以在扩展设置里决定显示哪些按钮。 组合能力:autonomous 与 onRegister · ctx.subscribe · ctx.settings · ctx.ui
@extension({ id: "dialogue-toolbar", label: "对话工具栏", autonomous: true })
export class DialogueToolbar extends Extension {
static settings = settings((s: SettingsBuilder) => ({
enabled: s.boolean("显示工具栏").default(true),
showSkip: s.boolean("显示跳过").default(true).enabledWhen("enabled"),
showAuto: s.boolean("显示自动播放").default(true).enabledWhen("enabled"),
}));
static onRegister(ctx: ExtensionContext): void {
let visible = false;
const sync = () => {
const enabled = ctx.settings.get<boolean>("enabled") ?? true;
const next = enabled && ctx.dialogue.line() !== null;
if (next === visible) return;
visible = next;
if (next) void ctx.ui.show("dialogue-toolbar");
else void ctx.ui.hide("dialogue-toolbar");
};
ctx.subscribe("dialogue:changed", sync);
ctx.settings.subscribe<boolean>("enabled", sync);
sync();
}
render() {
return { component: Toolbar, props: {} };
}
}
const Toolbar: React.FC = () => {
const ctx = useExtensionContext();
const [showSkip] = ctx.settings.useValue<boolean>("showSkip");
const [showAuto] = ctx.settings.useValue<boolean>("showAuto");
const skipping = ctx.dialogue.useSkipMode();
const autoPlaying = ctx.dialogue.useAutoMode();
return (
<nav>
{showSkip && (
<button onClick={() => ctx.dialogue.toggleSkipMode()}>
{skipping ? "停止跳过" : "跳过"}
</button>
)}
{showAuto && (
<button onClick={() => ctx.dialogue.toggleAutoMode()}>
{autoPlaying ? "停止自动" : "自动播放"}
</button>
)}
</nav>
);
};
.enabledWhen("enabled") 只负责让 Studio 设置面板中的子项变灰。运行时仍要主动读取总开关。
当前任务提示 HUD
最终效果:剧本调用“设置当前任务”后,右上角立即显示目标;任务完成后自动消失。HUD 不接管鼠标点击,不影响推进对话。 组合能力:method() · saveSchema · ctx.variables · UIShowOptions
import manifest from "../extension.json";
type QuestSave = { currentTask: string };
const TASK_VAR = `${manifest.id}.currentTask`;
@extension({ id: "quest-hud", label: "任务提示", autonomous: true })
export class QuestHud extends Extension {
static saveSchema = defineSave({
currentTask: { type: "string", persistence: "slot", default: "" },
});
static onRegister(ctx: ExtensionContext): void {
void ctx.ui.show("quest-hud", {}, {
size: "(auto, auto)",
position: "(right, top)",
interactable: false,
});
}
static setTask = method({
title: "设置当前任务",
schema: {
title: { type: "string", label: "任务内容", required: true },
},
run(_ctx, params) {
const save = this.save as unknown as SaveAPI<QuestSave>;
save.set("currentTask", params.title.trim());
},
});
static completeTask = method({
title: "完成当前任务",
run() {
const save = this.save as unknown as SaveAPI<QuestSave>;
save.set("currentTask", "");
},
});
render() {
return { component: QuestBadge, props: {} };
}
}
const QuestBadge: React.FC = () => {
const ctx = useExtensionContext();
const [task] = ctx.variables.useValue<string>(TASK_VAR);
if (!task) return null;
return <aside aria-live="polite">当前任务:{task}</aside>;
};
slot。如果要保留完整任务列表,把字符串改成只读数组,并继续使用“创建新数组后整体写回”的方式。
成就解锁通知
最终效果:剧本第一次解锁某项成就时,画面顶部弹出三秒通知;重复调用同一个成就不会再次解锁。 组合能力:shared 存档 · 幂等方法 · 变量 Hook · React 临时状态import manifest from "../extension.json";
type Achievement = { id: string; title: string };
type AchievementSave = { unlocked: readonly Achievement[] };
const UNLOCKED_VAR = `${manifest.id}.unlocked`;
@extension({ id: "achievement-toast", label: "成就通知", autonomous: true })
export class AchievementToast extends Extension {
static saveSchema = defineSave({
unlocked: {
type: "list",
persistence: "shared",
default: [] as Achievement[],
},
});
static onRegister(ctx: ExtensionContext): void {
void ctx.ui.show("achievement-toast", {}, {
size: "(auto, auto)",
position: "(center, top)",
interactable: false,
});
}
static unlock = method({
title: "解锁成就",
schema: {
id: { type: "string", label: "成就 ID", required: true },
title: { type: "string", label: "成就名称", required: true },
},
run(_ctx, params) {
const save = this.save as unknown as SaveAPI<AchievementSave>;
const list = save.get("unlocked");
if (list.some((item) => item.id === params.id)) return;
save.set("unlocked", [...list, { id: params.id, title: params.title }]);
},
});
render() {
return { component: AchievementNotice, props: {} };
}
}
const AchievementNotice: React.FC = () => {
const ctx = useExtensionContext();
const [raw] = ctx.variables.useValue(UNLOCKED_VAR);
const list = (raw as unknown as readonly Achievement[] | undefined) ?? [];
const [visible, setVisible] = React.useState<Achievement | null>(null);
const previousLength = React.useRef(list.length);
React.useEffect(() => {
if (list.length <= previousLength.current) {
previousLength.current = list.length;
return;
}
previousLength.current = list.length;
setVisible(list.at(-1) ?? null);
const timer = window.setTimeout(() => setVisible(null), 3000);
return () => window.clearTimeout(timer);
}, [list]);
return visible ? <aside aria-live="polite">🏆 {visible.title}</aside> : null;
};
shared。三秒显隐只是界面临时状态,不应写进存档。
给可视化任务面板补充动态行为
最终效果:面板布局、字体和按钮仍在可视化界面编辑器中制作;程序只负责把当前任务写进文本元素,并为“标记完成”按钮添加逻辑。 先在quest-panel 可视化界面中给两个元素设置引用名:
| 元素 | 引用名 |
|---|---|
| 任务标题文本 | task-title |
| 完成按钮 | complete-button |
ctx.visualUI.onOpen · 元素句柄 · 点击订阅 · 生命周期清理
import manifest from "../extension.json";
const PANEL = `@${manifest.id}/quest-panel`;
const TASK_VAR = `${manifest.id}.currentTask`;
@extension({
id: "quest-panel-controller",
label: "任务面板控制器",
autonomous: true,
})
export class QuestPanelController extends Extension {
static onRegister(ctx: ExtensionContext): void {
ctx.visualUI.onOpen(PANEL, (view) => {
const render = () => {
const title = ctx.variables.get<string>(TASK_VAR) || "暂无任务";
view.get("task-title")?.setProps({ text: title });
view.get("complete-button")?.setHidden(title === "暂无任务");
};
render();
const offVariable = ctx.subscribe("variable:changed", render);
const offClick = view.get("complete-button")?.on("click", () => {
ctx.variables.set(TASK_VAR, "");
render();
});
view.onClose(() => {
offVariable();
offClick?.();
});
});
}
}
这个例子假设
currentTask 已由同一扩展的 saveSchema 声明。布局能由编辑器完成时,优先用“可视化界面 + 控制器”;只有复杂列表、Canvas 或高频交互才需要完整 React UI。