2026-04-22 00:53:57 +08:00
|
|
|
import Phaser from "phaser";
|
|
|
|
|
import { GameHostScene } from "boardgame-phaser";
|
2026-04-21 23:50:47 +08:00
|
|
|
import { spawnEffect, type Spawner } from "boardgame-phaser";
|
2026-04-22 00:53:57 +08:00
|
|
|
import {
|
2026-04-23 16:11:15 +08:00
|
|
|
CombatResult,
|
2026-04-22 00:53:57 +08:00
|
|
|
type CombatState,
|
|
|
|
|
prompts,
|
|
|
|
|
} from "boardgame-core/samples/slay-the-spire-like";
|
2026-04-21 23:50:47 +08:00
|
|
|
import { createButton } from "@/utils/createButton";
|
|
|
|
|
import { SceneKey } from "./types";
|
|
|
|
|
import {
|
|
|
|
|
CombatUnitContainer,
|
|
|
|
|
type CombatUnitData,
|
|
|
|
|
} from "@/gameobjects/CombatUnitContainer";
|
2026-04-22 00:53:57 +08:00
|
|
|
import { CardSpawner } from "@/gameobjects/CardSpawner";
|
2026-04-23 16:11:15 +08:00
|
|
|
import { CombatModule } from "@/state/combatState";
|
2026-04-22 00:53:57 +08:00
|
|
|
|
|
|
|
|
const CARD_SPACING = 160;
|
|
|
|
|
const HAND_MARGIN = 100;
|
|
|
|
|
const HAND_Y = 140;
|
2026-04-21 23:50:47 +08:00
|
|
|
|
2026-04-23 16:11:15 +08:00
|
|
|
export class CombatTestScene extends GameHostScene<
|
|
|
|
|
CombatState,
|
|
|
|
|
CombatResult | null,
|
|
|
|
|
CombatModule
|
|
|
|
|
> {
|
2026-04-22 00:53:57 +08:00
|
|
|
private selectedCardId: string | null = null;
|
|
|
|
|
private isTargeting = false;
|
|
|
|
|
private targetingText!: Phaser.GameObjects.Text;
|
|
|
|
|
private selectionRect!: Phaser.GameObjects.Rectangle;
|
2026-04-21 23:50:47 +08:00
|
|
|
|
|
|
|
|
constructor() {
|
|
|
|
|
super("CombatTestScene");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
create(): void {
|
|
|
|
|
super.create();
|
|
|
|
|
|
2026-04-22 00:53:57 +08:00
|
|
|
const { width, height } = this.scale;
|
2026-04-21 23:50:47 +08:00
|
|
|
|
2026-04-22 00:53:57 +08:00
|
|
|
// Title
|
2026-04-21 23:50:47 +08:00
|
|
|
this.add
|
2026-04-22 00:53:57 +08:00
|
|
|
.text(width / 2, 30, "Combat Test — Card Play", {
|
2026-04-21 23:50:47 +08:00
|
|
|
fontSize: "24px",
|
|
|
|
|
color: "#ffffff",
|
|
|
|
|
fontStyle: "bold",
|
|
|
|
|
})
|
|
|
|
|
.setOrigin(0.5);
|
|
|
|
|
|
2026-04-22 00:53:57 +08:00
|
|
|
// Info text (reactive)
|
|
|
|
|
const infoText = this.add
|
|
|
|
|
.text(width / 2, 60, "", {
|
2026-04-21 23:50:47 +08:00
|
|
|
fontSize: "14px",
|
|
|
|
|
color: "#aaaaaa",
|
|
|
|
|
})
|
|
|
|
|
.setOrigin(0.5);
|
|
|
|
|
|
2026-04-22 00:53:57 +08:00
|
|
|
this.addEffect(() => {
|
|
|
|
|
const s = this.state;
|
|
|
|
|
infoText.setText(
|
|
|
|
|
`Turn ${s.turnNumber} | Phase: ${s.phase} | Energy: ${s.player.energy}/${s.player.maxEnergy}`,
|
|
|
|
|
);
|
|
|
|
|
});
|
2026-04-21 23:50:47 +08:00
|
|
|
|
2026-04-22 00:53:57 +08:00
|
|
|
// Targeting indicator
|
|
|
|
|
this.targetingText = this.add
|
|
|
|
|
.text(width / 2, height - 220, "", {
|
|
|
|
|
fontSize: "18px",
|
|
|
|
|
color: "#fbbf24",
|
|
|
|
|
fontStyle: "bold",
|
|
|
|
|
})
|
|
|
|
|
.setOrigin(0.5)
|
|
|
|
|
.setAlpha(0);
|
2026-04-21 23:50:47 +08:00
|
|
|
|
2026-04-22 00:53:57 +08:00
|
|
|
// Selection rectangle overlay for selected card
|
|
|
|
|
this.selectionRect = this.add
|
|
|
|
|
.rectangle(0, 0, 150, 210, 0x000000, 0)
|
|
|
|
|
.setStrokeStyle(4, 0xfbbf24)
|
|
|
|
|
.setAlpha(0)
|
|
|
|
|
.setDepth(50);
|
2026-04-21 23:50:47 +08:00
|
|
|
|
2026-04-22 00:53:57 +08:00
|
|
|
// 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();
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-04-21 23:50:47 +08:00
|
|
|
|
2026-04-22 00:53:57 +08:00
|
|
|
// 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");
|
2026-04-21 23:50:47 +08:00
|
|
|
},
|
2026-04-22 00:53:57 +08:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
|
2026-04-22 19:47:37 +08:00
|
|
|
if (targetType === "enemy") {
|
2026-04-22 00:53:57 +08:00
|
|
|
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);
|
|
|
|
|
}
|
2026-04-21 23:50:47 +08:00
|
|
|
|
2026-04-22 00:53:57 +08:00
|
|
|
public getIsTargeting(): boolean {
|
|
|
|
|
return this.isTargeting;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private tryPlayCard(cardId: string, targetId?: string): void {
|
2026-04-23 16:11:15 +08:00
|
|
|
const error = this.gameHost.prompts.tryCommit(
|
2026-04-22 00:53:57 +08:00
|
|
|
prompts.mainAction,
|
2026-04-23 16:11:15 +08:00
|
|
|
"player",
|
2026-04-22 00:53:57 +08:00
|
|
|
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 {
|
2026-04-21 23:50:47 +08:00
|
|
|
key: `enemy-${i}`,
|
|
|
|
|
entity: enemy,
|
|
|
|
|
name: enemy.enemy.name,
|
|
|
|
|
isPlayer: false,
|
2026-04-22 00:53:57 +08:00
|
|
|
};
|
|
|
|
|
}
|
2026-04-21 23:50:47 +08:00
|
|
|
}
|
|
|
|
|
|
2026-04-22 00:53:57 +08:00
|
|
|
getKey(t: CombatUnitData): string {
|
|
|
|
|
return t.key;
|
|
|
|
|
}
|
2026-04-21 23:50:47 +08:00
|
|
|
|
2026-04-22 00:53:57 +08:00
|
|
|
onSpawn(t: CombatUnitData): CombatUnitContainer {
|
|
|
|
|
const { width, height } = this.scene.scale;
|
|
|
|
|
const state = this.scene.state;
|
|
|
|
|
const totalUnits = 1 + state.enemies.length;
|
2026-04-21 23:50:47 +08:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-22 00:53:57 +08:00
|
|
|
const container = new CombatUnitContainer(
|
|
|
|
|
this.scene,
|
|
|
|
|
x,
|
|
|
|
|
height / 2 - 80,
|
|
|
|
|
t,
|
|
|
|
|
);
|
2026-04-21 23:50:47 +08:00
|
|
|
container.playSpawnEffect();
|
|
|
|
|
|
2026-04-22 00:53:57 +08:00
|
|
|
// 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);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-04-21 23:50:47 +08:00
|
|
|
|
2026-04-22 00:53:57 +08:00
|
|
|
return container;
|
|
|
|
|
}
|
2026-04-21 23:50:47 +08:00
|
|
|
|
2026-04-22 00:53:57 +08:00
|
|
|
onUpdate(t: CombatUnitData, obj: CombatUnitContainer): void {
|
|
|
|
|
obj.updateFromData(t);
|
|
|
|
|
}
|
2026-04-21 23:50:47 +08:00
|
|
|
|
2026-04-22 00:53:57 +08:00
|
|
|
onDespawn(obj: CombatUnitContainer): void {
|
|
|
|
|
obj.destroy();
|
2026-04-21 23:50:47 +08:00
|
|
|
}
|
|
|
|
|
}
|