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:
parent
447a5cfbd0
commit
c29b9a43b3
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue