feat(sts-viewer): implement card spawning and combat interaction

Add `CardContainer` and `CardSpawner` to handle visual representation
of cards in the hand. Refactor `CombatTestScene` to use `GameHostScene`
and implement card selection/targeting logic for combat simulation.
This commit is contained in:
hypercross 2026-04-22 00:53:57 +08:00
parent 3f2ba84a06
commit 5355afb23e
4 changed files with 564 additions and 130 deletions

View File

@ -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);
}
}

View File

@ -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<CardSpawnData, CardContainer> {
constructor(
private scene: Phaser.Scene,
private getState: () => CombatState,
private onCardClick: (cardId: string) => void,
) {}
*getData(): Iterable<CardSpawnData> {
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));
}

View File

@ -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<CombatState | null>(null);
const CARD_SPACING = 160;
const HAND_MARGIN = 100;
const HAND_Y = 140;
export class CombatTestScene extends GameHostScene<CombatState> {
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<CombatUnitData, CombatUnitContainer> = {
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);
// 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();
}
});
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 {
// 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<CombatUnitData, CombatUnitContainer> {
constructor(private scene: CombatTestScene) {}
*getData(): Iterable<CombatUnitData> {
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();
}
}

View File

@ -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<CombatState>),
[],
);
return (
<div className="flex flex-col h-screen">
<div className="flex-1 flex relative justify-center items-center">
<PhaserGame initialScene="IndexScene" config={GAME_CONFIG}>
<PhaserScene scene={IndexScene} />
<PhaserScene scene={CombatTestScene} />
<PhaserScene
sceneKey="CombatTestScene"
scene={combatScene}
data={{ gameHost }}
/>
<PhaserScene scene={InventoryTestScene} />
<PhaserScene scene={MapViewerScene} />
<PhaserScene scene={GridViewerScene} />