import Phaser from "phaser"; import { GameHostScene } from "boardgame-phaser"; import { spawnEffect, type Spawner } from "boardgame-phaser"; import { CombatResult, 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"; import { CombatModule } from "@/state/combatState"; const CARD_SPACING = 160; const HAND_MARGIN = 100; const HAND_Y = 140; export class CombatTestScene extends GameHostScene< CombatState, CombatResult | null, CombatModule > { private selectedCardId: string | null = null; private isTargeting = false; private targetingText!: Phaser.GameObjects.Text; private selectionRect!: Phaser.GameObjects.Rectangle; constructor() { super("CombatTestScene"); } create(): void { super.create(); const { width, height } = this.scale; // Title this.add .text(width / 2, 30, "Combat Test — Card Play", { fontSize: "24px", color: "#ffffff", fontStyle: "bold", }) .setOrigin(0.5); // Info text (reactive) const infoText = this.add .text(width / 2, 60, "", { fontSize: "14px", color: "#aaaaaa", }) .setOrigin(0.5); this.addEffect(() => { const s = this.state; infoText.setText( `Turn ${s.turnNumber} | Phase: ${s.phase} | Energy: ${s.player.energy}/${s.player.maxEnergy}`, ); }); // Targeting indicator this.targetingText = this.add .text(width / 2, height - 220, "", { fontSize: "18px", color: "#fbbf24", fontStyle: "bold", }) .setOrigin(0.5) .setAlpha(0); // Selection rectangle overlay for selected card this.selectionRect = this.add .rectangle(0, 0, 150, 210, 0x000000, 0) .setStrokeStyle(4, 0xfbbf24) .setAlpha(0) .setDepth(50); // Unit spawner (player + enemies) this.disposables.add(spawnEffect(new UnitSpawner(this))); // Card spawner (hand) this.disposables.add( spawnEffect( new CardSpawner( this, () => this.state, (cardId) => this.onCardClick(cardId), ), ), ); // 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(); } }); // Controls createButton({ scene: this, label: "返回菜单", x: 100, y: 40, onClick: async () => { await this.sceneController.launch(SceneKey.IndexScene); }, }); createButton({ scene: this, label: "End Turn", x: width - 100, y: 40, onClick: () => { 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 === "enemy") { 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.prompts.tryCommit( prompts.mainAction, "player", 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(); } }