feat(framework): decouple spawner update logic

Extract the core update logic from `spawnEffect` into
`createSpawnUpdate`.
This allows manual updates of spawned objects without relying on
Preact effects, which is useful for non-reactive or imperative
update cycles.

Refactor `Buff` and `BuffSpawner` in `sts-like-viewer` to use the new
`createSpawnUpdate` API for more efficient buff rendering.
This commit is contained in:
hypercross 2026-04-22 20:59:35 +08:00
parent 447a5cfbd0
commit c29b9a43b3
5 changed files with 203 additions and 50 deletions

View File

@ -8,8 +8,8 @@ export type { DragDropEvent, DragDropCallback } from "./utils";
export { hoverEffect } from "./utils"; export { hoverEffect } from "./utils";
// Data-driven object spawning // Data-driven object spawning
export { spawnEffect } from "./spawner"; export { spawnEffect, createSpawnUpdate } from "./spawner";
export type { Spawner } from "./spawner"; export type { Spawner, SpawnerCallback } from "./spawner";
// Scene base classes // Scene base classes
export { export {

View File

@ -4,9 +4,7 @@ import type Phaser from "phaser";
type GO = Phaser.GameObjects.GameObject; type GO = Phaser.GameObjects.GameObject;
export interface Spawner<TData, TObject extends GO = GO> { export interface SpawnerCallback<TData, TObject extends GO = GO> {
/** 数据源迭代器 */
getData(): Iterable<TData>;
/** 获取数据的唯一键 */ /** 获取数据的唯一键 */
getKey(t: TData): string; getKey(t: TData): string;
/** 创建新对象 */ /** 创建新对象 */
@ -17,15 +15,33 @@ export interface Spawner<TData, TObject extends GO = GO> {
onUpdate(t: TData, obj: TObject): void; onUpdate(t: TData, obj: TObject): void;
} }
export interface Spawner<
TData,
TObject extends GO = GO,
> extends SpawnerCallback<TData, TObject> {
/** 数据源迭代器 */
getData(): Iterable<TData>;
}
export function spawnEffect<TData, TObject extends GO = GO>( export function spawnEffect<TData, TObject extends GO = GO>(
spawner: Spawner<TData, TObject>, spawner: Spawner<TData, TObject>,
): () => void { ) {
const entries = new Map<string, { object: TObject; data: TData }>(); const update = createSpawnUpdate(spawner);
return effect(() => { return effect(() => {
update(spawner.getData());
});
}
export function createSpawnUpdate<TData, TObject extends GO = GO>(
spawner: SpawnerCallback<TData, TObject>,
) {
const entries = new Map<string, { object: TObject; data: TData }>();
function update(data: Iterable<TData>) {
const current = new Set<string>(); const current = new Set<string>();
for (const t of spawner.getData()) { for (const t of data) {
const key = spawner.getKey(t); const key = spawner.getKey(t);
current.add(key); current.add(key);
@ -48,5 +64,7 @@ export function spawnEffect<TData, TObject extends GO = GO>(
entries.delete(key); entries.delete(key);
} }
} }
}); }
return update;
} }

View File

@ -0,0 +1,132 @@
import Phaser from "phaser";
export type BuffData = {
id: string;
stacks: number;
emoji: string;
isPositive: boolean;
description?: string;
};
const ICON_SIZE = 28;
const ICON_COLOR_POSITIVE = 0x44aa44;
const ICON_COLOR_NEGATIVE = 0xaa4444;
const TOOLTIP_WIDTH = 200;
const TOOLTIP_PADDING = 8;
export class Buff extends Phaser.GameObjects.Container {
private bg!: Phaser.GameObjects.Rectangle;
private label!: Phaser.GameObjects.Text;
private tooltipContainer!: Phaser.GameObjects.Container;
private tooltipBg!: Phaser.GameObjects.Rectangle;
private tooltipText!: Phaser.GameObjects.Text;
private currentData!: BuffData;
constructor(scene: Phaser.Scene, x: number, y: number, data: BuffData) {
super(scene, x, y);
scene.add.existing(this);
this.currentData = data;
this.createVisuals();
this.createTooltip();
this.setupInteraction();
this.updateFromData(data);
}
private createVisuals(): void {
const color = this.currentData.isPositive
? ICON_COLOR_POSITIVE
: ICON_COLOR_NEGATIVE;
this.bg = this.scene.add
.rectangle(0, 0, ICON_SIZE, ICON_SIZE, color)
.setStrokeStyle(1, 0xffffff);
this.label = this.scene.add
.text(0, 0, "", {
fontSize: "12px",
color: "#ffffff",
fontStyle: "bold",
})
.setOrigin(0.5);
this.add([this.bg, this.label]);
}
private createTooltip(): void {
this.tooltipContainer = this.scene.add.container(0, 0).setVisible(false);
this.tooltipBg = this.scene.add
.rectangle(0, 0, 0, 0, 0x000000, 0.9)
.setStrokeStyle(1, 0xffffff);
this.tooltipText = this.scene.add
.text(0, 0, "", {
fontSize: "12px",
color: "#ffffff",
align: "center",
wordWrap: { width: TOOLTIP_WIDTH - TOOLTIP_PADDING * 2 },
})
.setOrigin(0.5);
this.tooltipContainer.add([this.tooltipBg, this.tooltipText]);
this.tooltipContainer.setDepth(1000);
}
private setupInteraction(): void {
this.setInteractive(
new Phaser.Geom.Rectangle(
-ICON_SIZE / 2,
-ICON_SIZE / 2,
ICON_SIZE,
ICON_SIZE,
),
Phaser.Geom.Rectangle.Contains,
);
this.on("pointerover", this.onPointerOver, this);
this.on("pointerout", this.onPointerOut, this);
}
private onPointerOver(): void {
const desc = this.currentData.description ?? "";
const content = desc;
this.tooltipText.setText(content);
const bounds = this.tooltipText.getBounds();
const width = Math.min(bounds.width + TOOLTIP_PADDING * 2, TOOLTIP_WIDTH);
const height = bounds.height + TOOLTIP_PADDING * 2;
this.tooltipBg.setDisplaySize(width, height);
const worldPos = this.getBounds();
this.tooltipContainer.setPosition(
worldPos.x + worldPos.width / 2,
worldPos.y - height / 2 - 4,
);
this.tooltipContainer.setDepth(1000);
this.scene.children.bringToTop(this.tooltipContainer);
this.tooltipContainer.setVisible(true);
}
private onPointerOut(): void {
this.tooltipContainer.setVisible(false);
}
updateFromData(data: BuffData): void {
this.currentData = data;
this.label.setText(`${data.emoji}${data.stacks}`);
const color = data.isPositive ? ICON_COLOR_POSITIVE : ICON_COLOR_NEGATIVE;
this.bg.setFillStyle(color);
}
destroy(fromScene?: boolean): void {
this.off("pointerover", this.onPointerOver, this);
this.off("pointerout", this.onPointerOut, this);
this.tooltipContainer.destroy(fromScene);
super.destroy(fromScene);
}
}

View File

@ -0,0 +1,25 @@
import { BuffData, Buff } from "./Buff";
import { SpawnerCallback } from "boardgame-phaser";
export type BuffDataWithIndex = BuffData & { x: number };
export class BuffSpawner implements SpawnerCallback<BuffDataWithIndex, Buff> {
constructor(
public scene: Phaser.Scene,
public buffContainer: Phaser.GameObjects.Container,
) {}
getKey(t: BuffDataWithIndex) {
return t.id;
}
onSpawn(t: BuffDataWithIndex) {
const buff = new Buff(this.scene, t.x, 0, t);
this.buffContainer.add(buff);
return buff;
}
onDespawn(obj: Buff) {
obj.destroy();
}
onUpdate(t: BuffDataWithIndex, obj: Buff) {
obj.setPosition(t.x, 0);
obj.updateFromData(t);
}
}

View File

@ -4,6 +4,9 @@ import type {
EffectTable, EffectTable,
EnemyEntity, EnemyEntity,
} from "boardgame-core/samples/slay-the-spire-like"; } from "boardgame-core/samples/slay-the-spire-like";
import { BuffData } from "./Buff";
import { createSpawnUpdate } from "boardgame-phaser";
import { BuffDataWithIndex, BuffSpawner } from "./BuffSpawner";
export type CombatUnitData = { export type CombatUnitData = {
key: string; key: string;
@ -27,6 +30,7 @@ export class CombatUnitContainer extends Phaser.GameObjects.Container {
private hpText!: Phaser.GameObjects.Text; private hpText!: Phaser.GameObjects.Text;
private buffContainer!: Phaser.GameObjects.Container; private buffContainer!: Phaser.GameObjects.Container;
private intentText!: Phaser.GameObjects.Text | null; private intentText!: Phaser.GameObjects.Text | null;
private updateBuffs!: (buffs: Iterable<BuffData & { x: number }>) => void;
private currentEntity!: CombatEntity; private currentEntity!: CombatEntity;
private currentName: string; private currentName: string;
@ -87,6 +91,9 @@ export class CombatUnitContainer extends Phaser.GameObjects.Container {
.setOrigin(0.5); .setOrigin(0.5);
this.buffContainer = this.scene.add.container(0, CONTAINER_HEIGHT / 2 - 40); this.buffContainer = this.scene.add.container(0, CONTAINER_HEIGHT / 2 - 40);
this.updateBuffs = createSpawnUpdate(
new BuffSpawner(this.scene, this.buffContainer),
);
if (!this.currentIsPlayer) { if (!this.currentIsPlayer) {
this.intentText = this.scene.add this.intentText = this.scene.add
@ -132,7 +139,7 @@ export class CombatUnitContainer extends Phaser.GameObjects.Container {
`${this.currentEntity.hp} / ${this.currentEntity.maxHp} HP`, `${this.currentEntity.hp} / ${this.currentEntity.maxHp} HP`,
); );
this.renderBuffs(this.currentEntity.effects); this.updateBuffs(this.getBuffEntries());
if (!this.currentIsPlayer && this.intentText) { if (!this.currentIsPlayer && this.intentText) {
const enemyEntity = data.entity as EnemyEntity; const enemyEntity = data.entity as EnemyEntity;
@ -142,49 +149,20 @@ export class CombatUnitContainer extends Phaser.GameObjects.Container {
} }
} }
private renderBuffs(effects: EffectTable): void { private *getBuffEntries() {
this.buffContainer.removeAll(true); const entries = Object.values(this.currentEntity.effects);
const entries = Object.entries(effects);
const totalWidth = const totalWidth =
entries.length * BUFF_ICON_SIZE + (entries.length - 1) * BUFF_ICON_GAP; entries.length * BUFF_ICON_SIZE + (entries.length - 1) * BUFF_ICON_GAP;
const startX = -totalWidth / 2 + BUFF_ICON_SIZE / 2; const startX = -totalWidth / 2 + BUFF_ICON_SIZE / 2;
for (let i = 0; i < entries.length; i++) {
entries.forEach(([key, entry], index) => { const buff = entries[i];
const x = startX + index * (BUFF_ICON_SIZE + BUFF_ICON_GAP); yield {
const stacks = entry.stacks; ...buff.data,
const emoji = entry.data.emoji; stacks: buff.stacks,
isPositive: true,
const isPositive = this.isPositiveEffect(key); x: startX + i * BUFF_ICON_SIZE,
const iconColor = isPositive ? 0x44aa44 : 0xaa4444; } as BuffDataWithIndex;
}
const bg = this.scene.add
.rectangle(x, 0, BUFF_ICON_SIZE, BUFF_ICON_SIZE, iconColor)
.setStrokeStyle(1, 0xffffff);
const text = this.scene.add
.text(x, 0, `${emoji} ${stacks}`, {
fontSize: "12px",
color: "#ffffff",
fontStyle: "bold",
})
.setOrigin(0.5);
this.buffContainer.add([bg, text]);
});
}
private isPositiveEffect(effectId: string): boolean {
const positive = new Set([
"block",
"strength",
"dexterity",
"regen",
"armor",
"barrier",
"momentum",
]);
return positive.has(effectId.toLowerCase());
} }
playSpawnEffect(): void { playSpawnEffect(): void {