Compare commits
No commits in common. "887da22baa3044f8614e1dc20ab3ffba719e94f4" and "9637312b7c0ab8997d5a96df5bc25f2ce9bebc21" have entirely different histories.
887da22baa
...
9637312b7c
|
|
@ -1,5 +1,5 @@
|
||||||
import { signal, useSignal, useSignalEffect } from "@preact/signals";
|
import { signal, useSignal, useSignalEffect } from "@preact/signals";
|
||||||
import Phaser, { AUTO, Scene } from "phaser";
|
import Phaser, { AUTO } from "phaser";
|
||||||
import { createContext } from "preact";
|
import { createContext } from "preact";
|
||||||
import { useContext, useEffect, useRef } from "preact/hooks";
|
import { useContext, useEffect, useRef } from "preact/hooks";
|
||||||
|
|
||||||
|
|
@ -215,8 +215,7 @@ export function PhaserScene<TData = {}>(props: PhaserSceneProps<TData>) {
|
||||||
const game = ctx.game;
|
const game = ctx.game;
|
||||||
|
|
||||||
// 注册场景到 Phaser(但不启动)
|
// 注册场景到 Phaser(但不启动)
|
||||||
const scene =
|
const scene = "scene" in props.scene ? props.scene : new props.scene();
|
||||||
props.scene instanceof Scene ? props.scene : new props.scene();
|
|
||||||
const sceneKey = props.sceneKey ?? scene.sys.settings.key;
|
const sceneKey = props.sceneKey ?? scene.sys.settings.key;
|
||||||
if (!game.scene.getScene(sceneKey)) {
|
if (!game.scene.getScene(sceneKey)) {
|
||||||
const initData = {
|
const initData = {
|
||||||
|
|
|
||||||
|
|
@ -1,219 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,112 +0,0 @@
|
||||||
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));
|
|
||||||
}
|
|
||||||
|
|
@ -1,228 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
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<CombatUnitData, CombatUnitContainer> {
|
|
||||||
constructor(private scene: Phaser.Scene) {}
|
|
||||||
|
|
||||||
*getData(): Iterable<CombatUnitData> {
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
|
|
@ -1,272 +0,0 @@
|
||||||
import Phaser from "phaser";
|
|
||||||
import { GameHostScene } from "boardgame-phaser";
|
|
||||||
import { spawnEffect, type Spawner } from "boardgame-phaser";
|
|
||||||
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";
|
|
||||||
|
|
||||||
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");
|
|
||||||
}
|
|
||||||
|
|
||||||
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 === "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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -38,22 +38,21 @@ export class IndexScene extends ReactiveScene {
|
||||||
scene: SceneKey;
|
scene: SceneKey;
|
||||||
y: number;
|
y: number;
|
||||||
}[] = [
|
}[] = [
|
||||||
{ label: "Combat Test", scene: SceneKey.CombatTestScene, y: centerY },
|
{ label: "Map Viewer", scene: SceneKey.MapViewerScene, y: centerY },
|
||||||
{ label: "Map Viewer", scene: SceneKey.MapViewerScene, y: centerY + 70 },
|
|
||||||
{
|
{
|
||||||
label: "Grid Inventory Viewer",
|
label: "Grid Inventory Viewer",
|
||||||
scene: SceneKey.GridViewerScene,
|
scene: SceneKey.GridViewerScene,
|
||||||
y: centerY + 140,
|
y: centerY + 70,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Shape Viewer",
|
label: "Shape Viewer",
|
||||||
scene: SceneKey.ShapeViewerScene,
|
scene: SceneKey.ShapeViewerScene,
|
||||||
y: centerY + 210,
|
y: centerY + 140,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Inventory Test",
|
label: "Inventory Test",
|
||||||
scene: SceneKey.InventoryTestScene,
|
scene: SceneKey.InventoryTestScene,
|
||||||
y: centerY + 280,
|
y: centerY + 210,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
export enum SceneKey {
|
export enum SceneKey {
|
||||||
CombatTestScene = "CombatTestScene",
|
|
||||||
GridViewerScene = "GridViewerScene",
|
GridViewerScene = "GridViewerScene",
|
||||||
IndexScene = "IndexScene",
|
IndexScene = "IndexScene",
|
||||||
InventoryTestScene = "InventoryTestScene",
|
InventoryTestScene = "InventoryTestScene",
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,17 @@
|
||||||
import { createGameHost, type GameModule } from "boardgame-core";
|
|
||||||
import { PhaserGame, PhaserScene } from "boardgame-phaser";
|
import { PhaserGame, PhaserScene } from "boardgame-phaser";
|
||||||
import { useMemo } from "preact/hooks";
|
|
||||||
import { IndexScene } from "@/scenes/IndexScene";
|
import { IndexScene } from "@/scenes/IndexScene";
|
||||||
import { MapViewerScene } from "@/scenes/MapViewerScene";
|
import { MapViewerScene } from "@/scenes/MapViewerScene";
|
||||||
import { GridViewerScene } from "@/scenes/GridViewerScene";
|
import { GridViewerScene } from "@/scenes/GridViewerScene";
|
||||||
import { ShapeViewerScene } from "@/scenes/ShapeViewerScene";
|
import { ShapeViewerScene } from "@/scenes/ShapeViewerScene";
|
||||||
import { GAME_CONFIG } from "@/config";
|
import { GAME_CONFIG } from "@/config";
|
||||||
import { InventoryTestScene } from "@/scenes/InventoryTestScene";
|
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() {
|
export default function App() {
|
||||||
const combatScene = useMemo(() => new CombatTestScene(), []);
|
|
||||||
const gameHost = useMemo(
|
|
||||||
() => createGameHost(createCombatState() as GameModule<CombatState>),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-screen">
|
<div className="flex flex-col h-screen">
|
||||||
<div className="flex-1 flex relative justify-center items-center">
|
<div className="flex-1 flex relative justify-center items-center">
|
||||||
<PhaserGame initialScene="IndexScene" config={GAME_CONFIG}>
|
<PhaserGame initialScene="IndexScene" config={GAME_CONFIG}>
|
||||||
<PhaserScene scene={IndexScene} />
|
<PhaserScene scene={IndexScene} />
|
||||||
<PhaserScene
|
|
||||||
sceneKey="CombatTestScene"
|
|
||||||
scene={combatScene}
|
|
||||||
data={{ gameHost }}
|
|
||||||
/>
|
|
||||||
<PhaserScene scene={InventoryTestScene} />
|
<PhaserScene scene={InventoryTestScene} />
|
||||||
<PhaserScene scene={MapViewerScene} />
|
<PhaserScene scene={MapViewerScene} />
|
||||||
<PhaserScene scene={GridViewerScene} />
|
<PhaserScene scene={GridViewerScene} />
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue