diff --git a/packages/sts-like-viewer/src/gameobjects/CombatUnitContainer.ts b/packages/sts-like-viewer/src/gameobjects/CombatUnitContainer.ts new file mode 100644 index 0000000..7dab60b --- /dev/null +++ b/packages/sts-like-viewer/src/gameobjects/CombatUnitContainer.ts @@ -0,0 +1,228 @@ +import Phaser from "phaser"; +import type { CombatEntity, EffectTable, EnemyEntity } from "boardgame-core/samples/slay-the-spire-like"; + +export type CombatUnitData = { + key: string; + entity: CombatEntity; + name: string; + isPlayer: boolean; +}; + +const CONTAINER_WIDTH = 200; +const CONTAINER_HEIGHT = 260; +const HP_BAR_WIDTH = 160; +const HP_BAR_HEIGHT = 16; +const BUFF_ICON_SIZE = 28; +const BUFF_ICON_GAP = 6; + +export class CombatUnitContainer extends Phaser.GameObjects.Container { + private bg!: Phaser.GameObjects.Rectangle; + private nameText!: Phaser.GameObjects.Text; + private hpBarBg!: Phaser.GameObjects.Rectangle; + private hpBarFill!: Phaser.GameObjects.Rectangle; + private hpText!: Phaser.GameObjects.Text; + private buffContainer!: Phaser.GameObjects.Container; + private intentText!: Phaser.GameObjects.Text | null; + + private currentEntity!: CombatEntity; + private currentName: string; + private currentIsPlayer: boolean; + + constructor(scene: Phaser.Scene, x: number, y: number, data: CombatUnitData) { + super(scene, x, y); + scene.add.existing(this); + + this.currentEntity = data.entity; + this.currentName = data.name; + this.currentIsPlayer = data.isPlayer; + + this.createVisuals(); + this.updateFromData(data); + } + + private createVisuals(): void { + const bgColor = this.currentIsPlayer ? 0x224466 : 0x662222; + const borderColor = this.currentIsPlayer ? 0x44aaff : 0xff4444; + + this.bg = this.scene.add + .rectangle(0, 0, CONTAINER_WIDTH, CONTAINER_HEIGHT, bgColor) + .setStrokeStyle(3, borderColor); + + this.nameText = this.scene.add + .text(0, -CONTAINER_HEIGHT / 2 + 24, this.currentName, { + fontSize: "18px", + color: "#ffffff", + fontStyle: "bold", + }) + .setOrigin(0.5); + + this.hpBarBg = this.scene.add + .rectangle(0, -CONTAINER_HEIGHT / 2 + 60, HP_BAR_WIDTH, HP_BAR_HEIGHT, 0x333333); + + this.hpBarFill = this.scene.add + .rectangle(-HP_BAR_WIDTH / 2, -CONTAINER_HEIGHT / 2 + 60, HP_BAR_WIDTH, HP_BAR_HEIGHT, 0x22c55e) + .setOrigin(0, 0.5); + + this.hpText = this.scene.add + .text(0, -CONTAINER_HEIGHT / 2 + 60, "", { + fontSize: "12px", + color: "#ffffff", + fontStyle: "bold", + }) + .setOrigin(0.5); + + this.buffContainer = this.scene.add.container(0, CONTAINER_HEIGHT / 2 - 40); + + if (!this.currentIsPlayer) { + this.intentText = this.scene.add + .text(0, -CONTAINER_HEIGHT / 2 + 86, "", { + fontSize: "12px", + color: "#ffcc88", + }) + .setOrigin(0.5); + } else { + this.intentText = null; + } + + this.add([ + this.bg, + this.nameText, + this.hpBarBg, + this.hpBarFill, + this.hpText, + this.buffContainer, + ...(this.intentText ? [this.intentText] : []), + ]); + } + + updateFromData(data: CombatUnitData): void { + this.currentEntity = data.entity; + this.currentName = data.name; + + this.nameText.setText(this.currentName); + + const hpPercent = this.currentEntity.maxHp > 0 + ? this.currentEntity.hp / this.currentEntity.maxHp + : 0; + + const fillWidth = Math.max(0, HP_BAR_WIDTH * hpPercent); + this.hpBarFill.setDisplaySize(fillWidth, HP_BAR_HEIGHT); + + const hpColor = hpPercent > 0.5 ? 0x22c55e : hpPercent > 0.25 ? 0xf59e0b : 0xef4444; + this.hpBarFill.setFillStyle(hpColor); + + this.hpText.setText(`${this.currentEntity.hp} / ${this.currentEntity.maxHp} HP`); + + this.renderBuffs(this.currentEntity.effects); + + if (!this.currentIsPlayer && this.intentText) { + const enemyEntity = data.entity as EnemyEntity; + const intent = enemyEntity.currentIntent; + const intentName = intent?.id ?? "unknown"; + this.intentText.setText(`Intent: ${intentName}`); + } + } + + private renderBuffs(effects: EffectTable): void { + this.buffContainer.removeAll(true); + + const entries = Object.entries(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 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, `${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 { + this.setScale(0); + this.setAlpha(0); + + this.scene.tweens.add({ + targets: this, + scale: 1, + alpha: 1, + duration: 400, + ease: "Back.easeOut", + }); + } + + playDamageEffect(): void { + const flash = this.scene.add + .rectangle(this.x, this.y, CONTAINER_WIDTH, CONTAINER_HEIGHT, 0xff0000, 0.4) + .setDepth(200); + + this.scene.tweens.add({ + targets: this, + x: this.x + 8, + duration: 60, + yoyo: true, + repeat: 3, + onComplete: () => { + this.scene.tweens.add({ + targets: flash, + alpha: 0, + duration: 200, + onComplete: () => flash.destroy(), + }); + }, + }); + } + + playHealEffect(): void { + const healText = this.scene.add + .text(this.x, this.y - 40, "+HP", { + fontSize: "20px", + color: "#22c55e", + fontStyle: "bold", + }) + .setOrigin(0.5) + .setDepth(200); + + this.scene.tweens.add({ + targets: healText, + y: this.y - 80, + alpha: 0, + duration: 800, + ease: "Power2", + onComplete: () => healText.destroy(), + }); + } + + destroy(fromScene?: boolean): void { + this.buffContainer.removeAll(true); + super.destroy(fromScene); + } +} diff --git a/packages/sts-like-viewer/src/gameobjects/CombatUnitSpawner.ts b/packages/sts-like-viewer/src/gameobjects/CombatUnitSpawner.ts new file mode 100644 index 0000000..8e40b7a --- /dev/null +++ b/packages/sts-like-viewer/src/gameobjects/CombatUnitSpawner.ts @@ -0,0 +1,75 @@ +import Phaser from "phaser"; +import type { Spawner } from "boardgame-phaser"; +import { spawnEffect } from "boardgame-phaser"; +import type { CombatState, CombatEntity, EnemyEntity } from "boardgame-core/samples/slay-the-spire-like"; +import { CombatUnitContainer, type CombatUnitData } from "./CombatUnitContainer"; + +export class CombatUnitSpawner implements Spawner { + constructor(private scene: Phaser.Scene) {} + + *getData(): Iterable { + const combat = this.getCombatState(); + if (!combat) return; + + yield { + key: "player", + entity: combat.player, + name: "Player", + isPlayer: true, + }; + + for (let i = 0; i < combat.enemies.length; i++) { + const enemy = combat.enemies[i]; + yield { + key: `enemy-${i}`, + entity: enemy, + name: enemy.enemy.name, + isPlayer: false, + }; + } + } + + getKey(t: CombatUnitData): string { + return t.key; + } + + onSpawn(t: CombatUnitData): CombatUnitContainer | null { + const { width, height } = this.scene.scale; + const combat = this.getCombatState(); + if (!combat) return null; + + const totalUnits = 1 + combat.enemies.length; + const spacing = 220; + const totalWidth = (totalUnits - 1) * spacing; + const startX = width / 2 - totalWidth / 2; + + let x = startX; + if (t.key.startsWith("enemy-")) { + const index = parseInt(t.key.replace("enemy-", ""), 10); + x = startX + (index + 1) * spacing; + } + + const y = height / 2; + + const container = new CombatUnitContainer(this.scene, x, y, t); + container.playSpawnEffect(); + return container; + } + + onUpdate(t: CombatUnitData, obj: CombatUnitContainer): void { + obj.updateFromData(t); + } + + onDespawn(obj: CombatUnitContainer): void { + obj.destroy(); + } + + private getCombatState(): CombatState | null { + const registry = this.scene.registry; + return (registry.get("combatState") as CombatState | undefined) ?? null; + } +} + +export function createCombatUnitSpawner(scene: Phaser.Scene) { + return spawnEffect(new CombatUnitSpawner(scene)); +} diff --git a/packages/sts-like-viewer/src/scenes/CombatTestScene.ts b/packages/sts-like-viewer/src/scenes/CombatTestScene.ts new file mode 100644 index 0000000..0a885f9 --- /dev/null +++ b/packages/sts-like-viewer/src/scenes/CombatTestScene.ts @@ -0,0 +1,183 @@ +import { ReactiveScene } from "boardgame-phaser"; +import { spawnEffect, type Spawner } from "boardgame-phaser"; +import { mutableSignal } from "boardgame-core"; +import { type CombatState } from "boardgame-core/samples/slay-the-spire-like"; +import { createCombatState } from "@/state/combatState"; +import { createButton } from "@/utils/createButton"; +import { SceneKey } from "./types"; +import { + CombatUnitContainer, + type CombatUnitData, +} from "@/gameobjects/CombatUnitContainer"; + +export class CombatTestScene extends ReactiveScene { + private combatSignal = mutableSignal(null); + + constructor() { + super("CombatTestScene"); + } + + create(): void { + super.create(); + const { width, height } = this.scale; + + const module = createCombatState(); + this.combatSignal.value = module.createInitialState(); + + this.add + .text(width / 2, 30, "Combat State Test", { + fontSize: "24px", + color: "#ffffff", + fontStyle: "bold", + }) + .setOrigin(0.5); + + this.add + .text(width / 2, 60, "Player & Enemies with Buffs / HP", { + fontSize: "14px", + color: "#aaaaaa", + }) + .setOrigin(0.5); + + const unitSpawner: Spawner = { + getData: () => this.generateUnitData(), + getKey: (t) => t.key, + onSpawn: (t) => this.spawnUnit(t, width, height), + onUpdate: (t, obj) => obj.updateFromData(t), + onDespawn: (obj) => obj.destroy(), + }; + + const disposeSpawner = spawnEffect(unitSpawner); + this.disposables.add(disposeSpawner); + + this.createControls(width, height); + } + + private generateUnitData(): CombatUnitData[] { + const combat = this.combatSignal.value; + if (!combat) return []; + + const units: CombatUnitData[] = [ + { + key: "player", + entity: combat.player, + name: "Player", + isPlayer: true, + }, + ]; + + combat.enemies.forEach((enemy, i) => { + units.push({ + key: `enemy-${i}`, + entity: enemy, + name: enemy.enemy.name, + isPlayer: false, + }); + }); + + return units; + } + + private spawnUnit( + t: CombatUnitData, + width: number, + height: number, + ): CombatUnitContainer | null { + const combat = this.combatSignal.value; + if (!combat) return null; + + const totalUnits = 1 + combat.enemies.length; + const spacing = 220; + const totalWidth = (totalUnits - 1) * spacing; + const startX = width / 2 - totalWidth / 2; + + let x = startX; + if (t.key.startsWith("enemy-")) { + const index = parseInt(t.key.replace("enemy-", ""), 10); + x = startX + (index + 1) * spacing; + } + + const container = new CombatUnitContainer(this, x, height / 2, t); + container.playSpawnEffect(); + return container; + } + + private createControls(width: number, height: number): void { + createButton({ + scene: this, + label: "θΏ”ε›žθœε•", + x: 100, + y: 40, + onClick: async () => { + await this.sceneController.launch(SceneKey.IndexScene); + }, + }); + + createButton({ + scene: this, + label: "Damage Player", + x: width - 520, + y: height - 40, + onClick: () => { + this.combatSignal.produce((draft) => { + if (!draft) return; + draft.player.hp = Math.max(0, draft.player.hp - 5); + if (draft.player.hp <= 0) draft.player.isAlive = false; + }); + }, + }); + + createButton({ + scene: this, + label: "Damage Enemy", + x: width - 380, + y: height - 40, + onClick: () => { + this.combatSignal.produce((draft) => { + if (!draft) return; + const enemy = draft.enemies[0]; + if (enemy) { + enemy.hp = Math.max(0, enemy.hp - 5); + if (enemy.hp <= 0) enemy.isAlive = false; + } + }); + }, + }); + + createButton({ + scene: this, + label: "Heal Player", + x: width - 240, + y: height - 40, + onClick: () => { + this.combatSignal.produce((draft) => { + if (!draft) return; + draft.player.hp = Math.min(draft.player.maxHp, draft.player.hp + 5); + if (draft.player.hp > 0) draft.player.isAlive = true; + }); + }, + }); + + createButton({ + scene: this, + label: "Add Buff", + x: width - 100, + y: height - 40, + onClick: () => { + this.combatSignal.produce((draft) => { + if (!draft) return; + const current = draft.player.effects["strength"]; + draft.player.effects["strength"] = { + data: { + id: "strength", + name: "Strength", + description: "Deal +1 damage per stack", + lifecycle: "temporary", + }, + stacks: (current?.stacks ?? 0) + 1, + }; + }); + }, + }); + } +} diff --git a/packages/sts-like-viewer/src/scenes/IndexScene.ts b/packages/sts-like-viewer/src/scenes/IndexScene.ts index c097614..86b53d5 100644 --- a/packages/sts-like-viewer/src/scenes/IndexScene.ts +++ b/packages/sts-like-viewer/src/scenes/IndexScene.ts @@ -38,21 +38,22 @@ export class IndexScene extends ReactiveScene { scene: SceneKey; y: number; }[] = [ - { label: "Map Viewer", scene: SceneKey.MapViewerScene, y: centerY }, + { label: "Combat Test", scene: SceneKey.CombatTestScene, y: centerY }, + { label: "Map Viewer", scene: SceneKey.MapViewerScene, y: centerY + 70 }, { label: "Grid Inventory Viewer", scene: SceneKey.GridViewerScene, - y: centerY + 70, + y: centerY + 140, }, { label: "Shape Viewer", scene: SceneKey.ShapeViewerScene, - y: centerY + 140, + y: centerY + 210, }, { label: "Inventory Test", scene: SceneKey.InventoryTestScene, - y: centerY + 210, + y: centerY + 280, }, ]; diff --git a/packages/sts-like-viewer/src/scenes/types.ts b/packages/sts-like-viewer/src/scenes/types.ts index 9c00f55..5250045 100644 --- a/packages/sts-like-viewer/src/scenes/types.ts +++ b/packages/sts-like-viewer/src/scenes/types.ts @@ -1,4 +1,5 @@ export enum SceneKey { + CombatTestScene = "CombatTestScene", GridViewerScene = "GridViewerScene", IndexScene = "IndexScene", InventoryTestScene = "InventoryTestScene", diff --git a/packages/sts-like-viewer/src/ui/App.tsx b/packages/sts-like-viewer/src/ui/App.tsx index cf14ba3..47d92b3 100644 --- a/packages/sts-like-viewer/src/ui/App.tsx +++ b/packages/sts-like-viewer/src/ui/App.tsx @@ -5,6 +5,7 @@ import { GridViewerScene } from "@/scenes/GridViewerScene"; import { ShapeViewerScene } from "@/scenes/ShapeViewerScene"; import { GAME_CONFIG } from "@/config"; import { InventoryTestScene } from "@/scenes/InventoryTestScene"; +import { CombatTestScene } from "@/scenes/CombatTestScene"; export default function App() { return ( @@ -12,6 +13,7 @@ export default function App() {
+