diff --git a/packages/sts-like-viewer/src/gameobjects/CardContainer.ts b/packages/sts-like-viewer/src/gameobjects/CardContainer.ts new file mode 100644 index 0000000..9e0343e --- /dev/null +++ b/packages/sts-like-viewer/src/gameobjects/CardContainer.ts @@ -0,0 +1,219 @@ +import Phaser from "phaser"; +import type { + CardData, + GameCard, +} from "boardgame-core/samples/slay-the-spire-like"; + +export interface CardContainerOptions { + card: GameCard; + onClick?: (cardId: string) => void; + playable?: boolean; +} + +const CARD_WIDTH = 140; +const CARD_HEIGHT = 200; +const CORNER_RADIUS = 8; + +export class CardContainer extends Phaser.GameObjects.Container { + private bg!: Phaser.GameObjects.Rectangle; + private nameText!: Phaser.GameObjects.Text; + private costBg!: Phaser.GameObjects.Container; + private costText!: Phaser.GameObjects.Text; + private descText!: Phaser.GameObjects.Text; + private highlight!: Phaser.GameObjects.Rectangle | null; + private cardId: string; + private _selected = false; + + constructor( + scene: Phaser.Scene, + x: number, + y: number, + options: CardContainerOptions, + ) { + super(scene, x, y); + scene.add.existing(this); + + this.cardId = options.card.id; + this.createVisuals(options); + this.setupInteractive(options); + } + + private createVisuals(options: CardContainerOptions): void { + const cardData = options.card.cardData; + const isPlayable = options.playable ?? true; + + // Background + const bgColor = isPlayable ? 0x2a2a3a : 0x1a1a24; + this.bg = this.scene.add + .rectangle(0, 0, CARD_WIDTH, CARD_HEIGHT, bgColor) + .setStrokeStyle(2, isPlayable ? 0x555577 : 0x333344); + this.add(this.bg); + + // Name + this.nameText = this.scene.add + .text(0, -CARD_HEIGHT / 2 + 24, cardData.name, { + fontSize: "13px", + color: isPlayable ? "#ffffff" : "#666666", + fontStyle: "bold", + }) + .setOrigin(0.5); + this.add(this.nameText); + + // Cost badge (top-right) + this.costBg = this.scene.add.container( + CARD_WIDTH / 2 - 18, + -CARD_HEIGHT / 2 + 18, + ); + const costCircle = this.scene.add.circle(0, 0, 14, 0x3b82f6); + this.costText = this.scene.add + .text(0, 0, `${cardData.costCount}`, { + fontSize: "14px", + color: "#ffffff", + fontStyle: "bold", + }) + .setOrigin(0.5); + this.costBg.add([costCircle, this.costText]); + this.add(this.costBg); + + // Description + this.descText = this.scene.add + .text(0, 16, cardData.desc, { + fontSize: "10px", + color: isPlayable ? "#aaaaaa" : "#444444", + align: "center", + wordWrap: { width: CARD_WIDTH - 16 }, + }) + .setOrigin(0.5); + this.add(this.descText); + + // Target indicator + const targetLabel = + cardData.targetType === "single" ? "🎯 Single" : "✨ Self"; + const targetText = this.scene.add + .text(0, CARD_HEIGHT / 2 - 20, targetLabel, { + fontSize: "10px", + color: isPlayable ? "#8888cc" : "#444466", + }) + .setOrigin(0.5); + this.add(targetText); + } + + private setupInteractive(options: CardContainerOptions): void { + const hitArea = new Phaser.Geom.Rectangle( + -CARD_WIDTH / 2, + -CARD_HEIGHT / 2, + CARD_WIDTH, + CARD_HEIGHT, + ); + this.setInteractive(hitArea, Phaser.Geom.Rectangle.Contains); + if (this.input) { + this.input.cursor = options.playable ? "pointer" : "not-allowed"; + } + + this.on("pointerover", () => { + if (options.playable && !this._selected) { + this.hoverIn(); + } + }); + + this.on("pointerout", () => { + if (!this._selected) { + this.hoverOut(); + } + }); + + this.on("pointerdown", () => { + if (options.playable && options.onClick) { + options.onClick(this.cardId); + } + }); + } + + private hoverIn(): void { + this.scene.tweens.add({ + targets: this, + y: this.y - 10, + scale: 1.08, + duration: 150, + ease: "Power2", + }); + this.bg.setStrokeStyle(3, 0x88aaff); + } + + private hoverOut(): void { + this.scene.tweens.add({ + targets: this, + y: this.y + 10, + scale: 1, + duration: 150, + ease: "Power2", + }); + this.bg.setStrokeStyle(2, 0x555577); + } + + setSelected(selected: boolean): void { + this._selected = selected; + if (selected) { + if (!this.highlight) { + this.highlight = this.scene.add + .rectangle(0, 0, CARD_WIDTH + 10, CARD_HEIGHT + 10, 0x000000, 0) + .setStrokeStyle(4, 0xfbbf24) + .setDepth(-1); + this.addAt(this.highlight, 0); + } + this.highlight.setAlpha(1); + this.scene.tweens.add({ + targets: this, + scale: 1.05, + duration: 100, + ease: "Power2", + }); + } else { + if (this.highlight) { + this.highlight.setAlpha(0); + } + this.scene.tweens.add({ + targets: this, + scale: 1, + duration: 100, + ease: "Power2", + }); + } + } + + get selected(): boolean { + return this._selected; + } + + playSpawnTween(delay = 0): void { + this.setAlpha(0); + this.setScale(0.5); + this.scene.tweens.add({ + targets: this, + alpha: 1, + scale: 1, + duration: 300, + delay, + ease: "Back.easeOut", + }); + } + + playDespawnTween(onComplete?: () => void): void { + this.scene.tweens.add({ + targets: this, + alpha: 0, + scale: 0.5, + y: this.y - 50, + duration: 200, + ease: "Back.easeIn", + onComplete: () => { + if (onComplete) onComplete(); + }, + }); + } + + destroy(fromScene?: boolean): void { + this.highlight = null; + super.destroy(fromScene); + } +} diff --git a/packages/sts-like-viewer/src/gameobjects/CardSpawner.ts b/packages/sts-like-viewer/src/gameobjects/CardSpawner.ts new file mode 100644 index 0000000..ef1c987 --- /dev/null +++ b/packages/sts-like-viewer/src/gameobjects/CardSpawner.ts @@ -0,0 +1,112 @@ +import { spawnEffect, type Spawner } from "boardgame-phaser"; +import type { + CombatState, + GameCard, +} from "boardgame-core/samples/slay-the-spire-like"; +import { CardContainer } from "./CardContainer"; + +export interface CardSpawnData { + cardId: string; + index: number; + total: number; +} + +const HAND_Y = 140; +const CARD_SPACING = 160; +const HAND_MARGIN = 100; + +export class CardSpawner implements Spawner { + constructor( + private scene: Phaser.Scene, + private getState: () => CombatState, + private onCardClick: (cardId: string) => void, + ) {} + + *getData(): Iterable { + const state = this.getState(); + const handIds = state.player.deck.regions.hand.childIds; + const total = handIds.length; + for (let i = 0; i < total; i++) { + yield { cardId: handIds[i], index: i, total }; + } + } + + getKey(data: CardSpawnData): string { + return data.cardId; + } + + onSpawn(data: CardSpawnData): CardContainer { + const state = this.getState(); + const card = state.player.deck.cards[data.cardId]; + const { x, y } = this.getCardPosition(data.index, data.total); + + const container = new CardContainer(this.scene, x, y, { + card: card ?? this.createFallbackCard(data.cardId), + onClick: this.onCardClick, + playable: true, + }); + + container.playSpawnTween(data.index * 40); + return container; + } + + onUpdate(data: CardSpawnData, obj: CardContainer): void { + const { x, y } = this.getCardPosition(data.index, data.total); + if (obj.x !== x || obj.y !== y) { + this.scene.tweens.add({ + targets: obj, + x, + y, + duration: 250, + ease: "Power2", + }); + } + } + + onDespawn(obj: CardContainer): void { + obj.playDespawnTween(() => obj.destroy()); + } + + private getCardPosition( + index: number, + total: number, + ): { x: number; y: number } { + const { width, height } = this.scene.scale; + const spacing = Math.min( + CARD_SPACING, + (width - HAND_MARGIN * 2) / Math.max(1, total), + ); + const startX = width / 2 - ((total - 1) * spacing) / 2; + return { + x: startX + index * spacing, + y: height - HAND_Y, + }; + } + + private createFallbackCard(id: string): GameCard { + return { + id, + regionId: "", + position: [], + cardData: { + id, + name: "Unknown", + desc: "Card data missing", + type: "item", + costType: "none", + costCount: 0, + targetType: "none", + effects: [], + }, + itemId: "", + }; + } +} + +export function createCardSpawner( + scene: Phaser.Scene, + getState: () => CombatState, + onCardClick: (cardId: string) => void, +) { + return spawnEffect(new CardSpawner(scene, getState, onCardClick)); +} diff --git a/packages/sts-like-viewer/src/scenes/CombatTestScene.ts b/packages/sts-like-viewer/src/scenes/CombatTestScene.ts index 0a885f9..c922305 100644 --- a/packages/sts-like-viewer/src/scenes/CombatTestScene.ts +++ b/packages/sts-like-viewer/src/scenes/CombatTestScene.ts @@ -1,17 +1,27 @@ -import { ReactiveScene } from "boardgame-phaser"; +import Phaser from "phaser"; +import { GameHostScene } 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 { + type CombatState, + prompts, +} from "boardgame-core/samples/slay-the-spire-like"; import { createButton } from "@/utils/createButton"; import { SceneKey } from "./types"; import { CombatUnitContainer, type CombatUnitData, } from "@/gameobjects/CombatUnitContainer"; +import { CardSpawner } from "@/gameobjects/CardSpawner"; -export class CombatTestScene extends ReactiveScene { - private combatSignal = mutableSignal(null); +const CARD_SPACING = 160; +const HAND_MARGIN = 100; +const HAND_Y = 140; + +export class CombatTestScene extends GameHostScene { + private selectedCardId: string | null = null; + private isTargeting = false; + private targetingText!: Phaser.GameObjects.Text; + private selectionRect!: Phaser.GameObjects.Rectangle; constructor() { super("CombatTestScene"); @@ -19,90 +29,75 @@ export class CombatTestScene extends ReactiveScene { create(): void { super.create(); + const { width, height } = this.scale; - const module = createCombatState(); - this.combatSignal.value = module.createInitialState(); - + // Title this.add - .text(width / 2, 30, "Combat State Test", { + .text(width / 2, 30, "Combat Test β€” Card Play", { fontSize: "24px", color: "#ffffff", fontStyle: "bold", }) .setOrigin(0.5); - this.add - .text(width / 2, 60, "Player & Enemies with Buffs / HP", { + // Info text (reactive) + const infoText = this.add + .text(width / 2, 60, "", { 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, - }); + this.addEffect(() => { + const s = this.state; + infoText.setText( + `Turn ${s.turnNumber} | Phase: ${s.phase} | Energy: ${s.player.energy}/${s.player.maxEnergy}`, + ); }); - return units; - } + // Targeting indicator + this.targetingText = this.add + .text(width / 2, height - 220, "", { + fontSize: "18px", + color: "#fbbf24", + fontStyle: "bold", + }) + .setOrigin(0.5) + .setAlpha(0); - private spawnUnit( - t: CombatUnitData, - width: number, - height: number, - ): CombatUnitContainer | null { - const combat = this.combatSignal.value; - if (!combat) return null; + // Selection rectangle overlay for selected card + this.selectionRect = this.add + .rectangle(0, 0, 150, 210, 0x000000, 0) + .setStrokeStyle(4, 0xfbbf24) + .setAlpha(0) + .setDepth(50); - const totalUnits = 1 + combat.enemies.length; - const spacing = 220; - const totalWidth = (totalUnits - 1) * spacing; - const startX = width / 2 - totalWidth / 2; + // Unit spawner (player + enemies) + this.disposables.add(spawnEffect(new UnitSpawner(this))); - let x = startX; - if (t.key.startsWith("enemy-")) { - const index = parseInt(t.key.replace("enemy-", ""), 10); - x = startX + (index + 1) * spacing; - } + // Card spawner (hand) + this.disposables.add( + spawnEffect( + new CardSpawner( + this, + () => this.state, + (cardId) => this.onCardClick(cardId), + ), + ), + ); - const container = new CombatUnitContainer(this, x, height / 2, t); - container.playSpawnEffect(); - return container; - } + // Watch hand changes to clear stale selection + this.addEffect(() => { + const handIds = this.state.player.deck.regions.hand.childIds; + if (this.selectedCardId && !handIds.includes(this.selectedCardId)) { + this.clearTargeting(); + } else { + this.updateSelectionRect(); + } + }); - private createControls(width: number, height: number): void { + // Controls createButton({ scene: this, label: "θΏ”ε›žθœε•", @@ -115,69 +110,163 @@ export class CombatTestScene extends ReactiveScene { 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", + label: "End Turn", x: width - 100, - y: height - 40, + y: 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, - }; - }); + this.tryPlayCard("end-turn"); }, }); + + // Start the game loop + this.gameHost.start(); + } + + private onCardClick(cardId: string): void { + const state = this.state; + const card = state.player.deck.cards[cardId]; + if (!card) return; + + const targetType = card.cardData.targetType; + + if (targetType === "single") { + this.selectedCardId = cardId; + this.isTargeting = true; + this.targetingText.setText("Select a target!"); + this.targetingText.setAlpha(1); + this.updateSelectionRect(); + } else { + this.tryPlayCard(cardId); + } + } + + public onEnemyClick(enemyId: string): void { + if (!this.isTargeting || !this.selectedCardId) return; + this.tryPlayCard(this.selectedCardId, enemyId); + } + + public getIsTargeting(): boolean { + return this.isTargeting; + } + + private tryPlayCard(cardId: string, targetId?: string): void { + const error = this.gameHost.tryAnswerPrompt( + prompts.mainAction, + cardId, + targetId, + ); + if (error) { + console.warn("Play failed:", error); + } + this.clearTargeting(); + } + + private clearTargeting(): void { + this.selectedCardId = null; + this.isTargeting = false; + this.targetingText.setAlpha(0); + this.selectionRect.setAlpha(0); + } + + private updateSelectionRect(): void { + if (!this.selectedCardId) { + this.selectionRect.setAlpha(0); + return; + } + + const state = this.state; + const handIds = state.player.deck.regions.hand.childIds; + const index = handIds.indexOf(this.selectedCardId); + if (index === -1) { + this.selectionRect.setAlpha(0); + return; + } + + const { width, height } = this.scale; + const total = handIds.length; + const spacing = Math.min( + CARD_SPACING, + (width - HAND_MARGIN * 2) / Math.max(1, total), + ); + const startX = width / 2 - ((total - 1) * spacing) / 2; + const x = startX + index * spacing; + const y = height - HAND_Y; + + this.selectionRect.setPosition(x, y); + this.selectionRect.setAlpha(1); + } +} + +class UnitSpawner implements Spawner { + constructor(private scene: CombatTestScene) {} + + *getData(): Iterable { + const state = this.scene.state; + yield { + key: "player", + entity: state.player, + name: "Player", + isPlayer: true, + }; + for (let i = 0; i < state.enemies.length; i++) { + const enemy = state.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 { + const { width, height } = this.scene.scale; + const state = this.scene.state; + const totalUnits = 1 + state.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.scene, + x, + height / 2 - 80, + t, + ); + container.playSpawnEffect(); + + // Make enemies clickable when targeting + if (!t.isPlayer) { + const hitArea = new Phaser.Geom.Rectangle(-100, -130, 200, 260); + container.setInteractive(hitArea, Phaser.Geom.Rectangle.Contains); + if (container.input) { + container.input.cursor = "pointer"; + } + container.on("pointerdown", () => { + if (this.scene.getIsTargeting()) { + this.scene.onEnemyClick(t.entity.id); + } + }); + } + + return container; + } + + onUpdate(t: CombatUnitData, obj: CombatUnitContainer): void { + obj.updateFromData(t); + } + + onDespawn(obj: CombatUnitContainer): void { + obj.destroy(); } } diff --git a/packages/sts-like-viewer/src/ui/App.tsx b/packages/sts-like-viewer/src/ui/App.tsx index 47d92b3..9a00227 100644 --- a/packages/sts-like-viewer/src/ui/App.tsx +++ b/packages/sts-like-viewer/src/ui/App.tsx @@ -1,4 +1,6 @@ +import { createGameHost, type GameModule } from "boardgame-core"; import { PhaserGame, PhaserScene } from "boardgame-phaser"; +import { useMemo } from "preact/hooks"; import { IndexScene } from "@/scenes/IndexScene"; import { MapViewerScene } from "@/scenes/MapViewerScene"; import { GridViewerScene } from "@/scenes/GridViewerScene"; @@ -6,14 +8,26 @@ import { ShapeViewerScene } from "@/scenes/ShapeViewerScene"; import { GAME_CONFIG } from "@/config"; import { InventoryTestScene } from "@/scenes/InventoryTestScene"; import { CombatTestScene } from "@/scenes/CombatTestScene"; +import { createCombatState } from "@/state/combatState"; +import type { CombatState } from "boardgame-core/samples/slay-the-spire-like"; export default function App() { + const combatScene = useMemo(() => new CombatTestScene(), []); + const gameHost = useMemo( + () => createGameHost(createCombatState() as GameModule), + [], + ); + return (
- +