diff --git a/packages/framework/src/index.ts b/packages/framework/src/index.ts index e2ebaf7..e6f700f 100644 --- a/packages/framework/src/index.ts +++ b/packages/framework/src/index.ts @@ -8,8 +8,8 @@ export type { DragDropEvent, DragDropCallback } from "./utils"; export { hoverEffect } from "./utils"; // Data-driven object spawning -export { spawnEffect } from "./spawner"; -export type { Spawner } from "./spawner"; +export { spawnEffect, createSpawnUpdate } from "./spawner"; +export type { Spawner, SpawnerCallback } from "./spawner"; // Scene base classes export { diff --git a/packages/framework/src/spawner/index.ts b/packages/framework/src/spawner/index.ts index ef5c590..04e812e 100644 --- a/packages/framework/src/spawner/index.ts +++ b/packages/framework/src/spawner/index.ts @@ -4,9 +4,7 @@ import type Phaser from "phaser"; type GO = Phaser.GameObjects.GameObject; -export interface Spawner { - /** 数据源迭代器 */ - getData(): Iterable; +export interface SpawnerCallback { /** 获取数据的唯一键 */ getKey(t: TData): string; /** 创建新对象 */ @@ -17,15 +15,33 @@ export interface Spawner { onUpdate(t: TData, obj: TObject): void; } +export interface Spawner< + TData, + TObject extends GO = GO, +> extends SpawnerCallback { + /** 数据源迭代器 */ + getData(): Iterable; +} + export function spawnEffect( spawner: Spawner, -): () => void { - const entries = new Map(); +) { + const update = createSpawnUpdate(spawner); return effect(() => { + update(spawner.getData()); + }); +} + +export function createSpawnUpdate( + spawner: SpawnerCallback, +) { + const entries = new Map(); + + function update(data: Iterable) { const current = new Set(); - for (const t of spawner.getData()) { + for (const t of data) { const key = spawner.getKey(t); current.add(key); @@ -48,5 +64,7 @@ export function spawnEffect( entries.delete(key); } } - }); + } + + return update; } diff --git a/packages/sts-like-viewer/src/gameobjects/Buff.ts b/packages/sts-like-viewer/src/gameobjects/Buff.ts new file mode 100644 index 0000000..4c6fbb2 --- /dev/null +++ b/packages/sts-like-viewer/src/gameobjects/Buff.ts @@ -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); + } +} diff --git a/packages/sts-like-viewer/src/gameobjects/BuffSpawner.ts b/packages/sts-like-viewer/src/gameobjects/BuffSpawner.ts new file mode 100644 index 0000000..6567daa --- /dev/null +++ b/packages/sts-like-viewer/src/gameobjects/BuffSpawner.ts @@ -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 { + 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); + } +} diff --git a/packages/sts-like-viewer/src/gameobjects/CombatUnitContainer.ts b/packages/sts-like-viewer/src/gameobjects/CombatUnitContainer.ts index 085deaa..4d6b6c8 100644 --- a/packages/sts-like-viewer/src/gameobjects/CombatUnitContainer.ts +++ b/packages/sts-like-viewer/src/gameobjects/CombatUnitContainer.ts @@ -4,6 +4,9 @@ import type { EffectTable, EnemyEntity, } 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 = { key: string; @@ -27,6 +30,7 @@ export class CombatUnitContainer extends Phaser.GameObjects.Container { private hpText!: Phaser.GameObjects.Text; private buffContainer!: Phaser.GameObjects.Container; private intentText!: Phaser.GameObjects.Text | null; + private updateBuffs!: (buffs: Iterable) => void; private currentEntity!: CombatEntity; private currentName: string; @@ -87,6 +91,9 @@ export class CombatUnitContainer extends Phaser.GameObjects.Container { .setOrigin(0.5); this.buffContainer = this.scene.add.container(0, CONTAINER_HEIGHT / 2 - 40); + this.updateBuffs = createSpawnUpdate( + new BuffSpawner(this.scene, this.buffContainer), + ); if (!this.currentIsPlayer) { this.intentText = this.scene.add @@ -132,7 +139,7 @@ export class CombatUnitContainer extends Phaser.GameObjects.Container { `${this.currentEntity.hp} / ${this.currentEntity.maxHp} HP`, ); - this.renderBuffs(this.currentEntity.effects); + this.updateBuffs(this.getBuffEntries()); if (!this.currentIsPlayer && this.intentText) { const enemyEntity = data.entity as EnemyEntity; @@ -142,49 +149,20 @@ export class CombatUnitContainer extends Phaser.GameObjects.Container { } } - private renderBuffs(effects: EffectTable): void { - this.buffContainer.removeAll(true); - - const entries = Object.entries(effects); + private *getBuffEntries() { + const entries = Object.values(this.currentEntity.effects); const totalWidth = entries.length * BUFF_ICON_SIZE + (entries.length - 1) * BUFF_ICON_GAP; const startX = -totalWidth / 2 + BUFF_ICON_SIZE / 2; - - entries.forEach(([key, entry], index) => { - const x = startX + index * (BUFF_ICON_SIZE + BUFF_ICON_GAP); - const stacks = entry.stacks; - const emoji = entry.data.emoji; - - const isPositive = this.isPositiveEffect(key); - const iconColor = isPositive ? 0x44aa44 : 0xaa4444; - - 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()); + for (let i = 0; i < entries.length; i++) { + const buff = entries[i]; + yield { + ...buff.data, + stacks: buff.stacks, + isPositive: true, + x: startX + i * BUFF_ICON_SIZE, + } as BuffDataWithIndex; + } } playSpawnEffect(): void {