Compare commits

..
3 Commits
Author SHA1 Message Date
hyper ad2c808482 feat(sts-viewer): add effect application logging and fix command error
handling

- Log effect application details in `CombatTestScene`
- Update `tryPlayCard` to use "global" scope and handle error reasons
- Register desert triggers in `createCombatModule`
2026-04-23 16:28:10 +08:00
hyper f893ac9ff7 feat(framework): add generic types to GameHostScene
Update GameHostScene to support generic types for TResult and TModule,
allowing better type safety when interacting with the game host and
its modules.
2026-04-23 16:11:15 +08:00
hypercross dfbdaa3499 feat(sts-viewer): add spawn/despawn tweens and async interruptions
Add visual tweens for Buff and Card game objects. Update spawners to
use `GameHostScene` and `addInterruption` to ensure animations
complete before the next state change occurs.
2026-04-22 21:29:43 +08:00
10 changed files with 117 additions and 46 deletions
+12 -6
View File
@@ -1,17 +1,23 @@
import { ReactiveScene } from "./ReactiveScene";
import type { GameHost } from "boardgame-core";
import type { GameHost, GameModule } from "boardgame-core";
export interface GameHostSceneOptions<TState extends Record<string, unknown>> {
gameHost: GameHost<TState>;
export interface GameHostSceneOptions<
TState extends Record<string, unknown>,
TResult,
TModule extends GameModule<TState, TResult>,
> {
gameHost: GameHost<TState, TResult, TModule>;
[key: string]: unknown;
}
export abstract class GameHostScene<
TState extends Record<string, unknown>,
> extends ReactiveScene<GameHostSceneOptions<TState>> {
public get gameHost(): GameHost<TState> {
const gameHost = this.initData.gameHost as GameHost<TState>;
TResult,
TModule extends GameModule<TState, TResult>,
> extends ReactiveScene<GameHostSceneOptions<TState, TResult, TModule>> {
public get gameHost(): GameHost<TState, TResult, TModule> {
const gameHost = this.initData.gameHost;
if (!gameHost) {
throw new Error(
`GameHostScene (${this.scene.key}): gameHost 未提供。` +
@@ -129,4 +129,24 @@ export class Buff extends Phaser.GameObjects.Container {
this.tooltipContainer.destroy(fromScene);
super.destroy(fromScene);
}
playSpawnTween() {
this.setScale(0);
this.scene.tweens.add({
targets: this,
scale: 1,
duration: 200,
ease: "Back.Out",
});
}
playDespawnTween() {
this.scene.tweens.add({
targets: this,
scale: 0,
duration: 200,
ease: "Back.In",
onComplete: () => this.destroy(),
});
}
}
@@ -1,10 +1,12 @@
import { GameHostScene } from "boardgame-phaser";
import { BuffData, Buff } from "./Buff";
import { SpawnerCallback } from "boardgame-phaser";
import { CombatState } from "boardgame-core/samples/slay-the-spire-like";
export type BuffDataWithIndex = BuffData & { x: number };
export class BuffSpawner implements SpawnerCallback<BuffDataWithIndex, Buff> {
constructor(
public scene: Phaser.Scene,
public scene: GameHostScene<CombatState>,
public buffContainer: Phaser.GameObjects.Container,
) {}
getKey(t: BuffDataWithIndex) {
@@ -13,10 +15,13 @@ export class BuffSpawner implements SpawnerCallback<BuffDataWithIndex, Buff> {
onSpawn(t: BuffDataWithIndex) {
const buff = new Buff(this.scene, t.x, 0, t);
this.buffContainer.add(buff);
buff.playSpawnTween();
this.scene.addInterruption(new Promise((r) => setTimeout(r, 50)));
return buff;
}
onDespawn(obj: Buff) {
obj.destroy();
this.scene.addInterruption(new Promise((r) => setTimeout(r, 50)));
obj.playDespawnTween();
}
onUpdate(t: BuffDataWithIndex, obj: Buff) {
obj.setPosition(t.x, 0);
@@ -165,10 +165,10 @@ export class CardContainer extends Phaser.GameObjects.Container {
return this._selected;
}
playSpawnTween(delay = 0): void {
playSpawnTween(delay = 0) {
this.setAlpha(0);
this.setScale(0.5);
this.scene.tweens.add({
return this.scene.tweens.add({
targets: this,
alpha: 1,
scale: 1,
@@ -178,8 +178,8 @@ export class CardContainer extends Phaser.GameObjects.Container {
});
}
playDespawnTween(onComplete?: () => void): void {
this.scene.tweens.add({
playDespawnTween(onComplete?: () => void) {
return this.scene.tweens.add({
targets: this,
alpha: 0,
scale: 0.5,
@@ -4,6 +4,7 @@ import type {
GameCard,
} from "boardgame-core/samples/slay-the-spire-like";
import { CardContainer } from "./CardContainer";
import { GameHostScene } from "boardgame-phaser";
export interface CardSpawnData {
cardId: string;
@@ -17,7 +18,7 @@ const HAND_MARGIN = 100;
export class CardSpawner implements Spawner<CardSpawnData, CardContainer> {
constructor(
private scene: Phaser.Scene,
private scene: GameHostScene<CombatState>,
private getState: () => CombatState,
private onCardClick: (cardId: string) => void,
) {}
@@ -47,6 +48,7 @@ export class CardSpawner implements Spawner<CardSpawnData, CardContainer> {
});
container.playSpawnTween(data.index * 40);
this.scene.addInterruption(new Promise((r) => setTimeout(r, 40)));
return container;
}
@@ -63,8 +65,9 @@ export class CardSpawner implements Spawner<CardSpawnData, CardContainer> {
}
}
onDespawn(obj: CardContainer): void {
onDespawn(obj: CardContainer, data: CardSpawnData): void {
obj.playDespawnTween(() => obj.destroy());
this.scene.addInterruption(new Promise((r) => setTimeout(r, 40)));
}
private getCardPosition(
@@ -104,7 +107,7 @@ export class CardSpawner implements Spawner<CardSpawnData, CardContainer> {
}
export function createCardSpawner(
scene: Phaser.Scene,
scene: GameHostScene<CombatState>,
getState: () => CombatState,
onCardClick: (cardId: string) => void,
) {
@@ -1,12 +1,14 @@
import Phaser from "phaser";
import type {
CombatEntity,
CombatState,
EffectTable,
EnemyEntity,
} from "boardgame-core/samples/slay-the-spire-like";
import { BuffData } from "./Buff";
import { createSpawnUpdate } from "boardgame-phaser";
import { BuffDataWithIndex, BuffSpawner } from "./BuffSpawner";
import { GameHostScene } from "boardgame-phaser";
export type CombatUnitData = {
key: string;
@@ -30,15 +32,22 @@ export class CombatUnitContainer extends Phaser.GameObjects.Container {
private hpText!: Phaser.GameObjects.Text;
private buffContainer!: Phaser.GameObjects.Container;
private intentText!: Phaser.GameObjects.Text | null;
private hostScene: GameHostScene<CombatState>;
private updateBuffs!: (buffs: Iterable<BuffData & { x: number }>) => void;
private currentEntity!: CombatEntity;
private currentName: string;
private currentIsPlayer: boolean;
constructor(scene: Phaser.Scene, x: number, y: number, data: CombatUnitData) {
constructor(
scene: GameHostScene<CombatState>,
x: number,
y: number,
data: CombatUnitData,
) {
super(scene, x, y);
scene.add.existing(this);
this.hostScene = scene;
this.currentEntity = data.entity;
this.currentName = data.name;
@@ -92,7 +101,7 @@ export class CombatUnitContainer extends Phaser.GameObjects.Container {
this.buffContainer = this.scene.add.container(0, CONTAINER_HEIGHT / 2 - 40);
this.updateBuffs = createSpawnUpdate(
new BuffSpawner(this.scene, this.buffContainer),
new BuffSpawner(this.hostScene, this.buffContainer),
);
if (!this.currentIsPlayer) {
@@ -1,11 +1,17 @@
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";
import type { CombatState } from "boardgame-core/samples/slay-the-spire-like";
import {
CombatUnitContainer,
type CombatUnitData,
} from "./CombatUnitContainer";
import { GameHostScene } from "boardgame-phaser";
export class CombatUnitSpawner implements Spawner<CombatUnitData, CombatUnitContainer> {
constructor(private scene: Phaser.Scene) {}
export class CombatUnitSpawner implements Spawner<
CombatUnitData,
CombatUnitContainer
> {
constructor(private scene: GameHostScene<CombatState>) {}
*getData(): Iterable<CombatUnitData> {
const combat = this.getCombatState();
@@ -70,6 +76,6 @@ export class CombatUnitSpawner implements Spawner<CombatUnitData, CombatUnitCont
}
}
export function createCombatUnitSpawner(scene: Phaser.Scene) {
export function createCombatUnitSpawner(scene: GameHostScene<CombatState>) {
return spawnEffect(new CombatUnitSpawner(scene));
}
@@ -2,6 +2,7 @@ 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";
@@ -12,12 +13,17 @@ import {
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> {
export class CombatTestScene extends GameHostScene<
CombatState,
CombatResult | null,
CombatModule
> {
private selectedCardId: string | null = null;
private isTargeting = false;
private targetingText!: Phaser.GameObjects.Text;
@@ -120,6 +126,13 @@ export class CombatTestScene extends GameHostScene<CombatState> {
// Start the game loop
this.gameHost.start();
this.gameHost.gameModule.triggers.onEffectApplied.use(async (ctx, next) => {
const { effect, entityId, stacks, targetId, cardId } = ctx;
console.log(
`${effect.name} x${stacks} ${entityId}/${cardId} :${targetId}`,
);
await next();
});
}
private onCardClick(cardId: string): void {
@@ -150,13 +163,14 @@ export class CombatTestScene extends GameHostScene<CombatState> {
}
private tryPlayCard(cardId: string, targetId?: string): void {
const error = this.gameHost.tryAnswerPrompt(
const error = this.gameHost.prompts.tryCommit(
prompts.mainAction,
"global",
cardId,
targetId,
);
if (error) {
console.warn("Play failed:", error);
if (!error.ok) {
console.warn("Play failed:", error.reason);
}
this.clearTargeting();
}
@@ -1,8 +1,8 @@
import {
buildCombatState,
CombatState,
createRunState,
createStartWith,
createStart,
createTriggers,
data,
generateDeckFromInventory,
getAdjacentItems,
@@ -10,9 +10,9 @@ import {
IRunContext,
} from "boardgame-core/samples/slay-the-spire-like";
import { createInventorySignal } from "./inventory";
import { GameModule } from "boardgame-core";
export function createCombatState() {
export type CombatModule = ReturnType<typeof createCombatModule>;
export function createCombatModule() {
const inventory = createInventorySignal(true);
const deck = generateDeckFromInventory(inventory.value);
@@ -51,12 +51,14 @@ export function createCombatState() {
},
};
const start = createStartWith((triggers, ctx) => {
data.desert.addTriggers(triggers, runContext);
}, runContext);
const triggers = createTriggers(runContext);
data.desert.addTriggers(triggers, runContext);
const start = createStart(triggers, runContext);
return {
start,
createInitialState: () => combat,
} as GameModule<CombatState>;
triggers,
runContext,
};
}
+17 -11
View File
@@ -1,19 +1,25 @@
import { createGameHost, type GameModule } from "boardgame-core";
import { GAME_CONFIG } from "@/config";
import { CombatTestScene } from "@/scenes/CombatTestScene";
import { GridViewerScene } from "@/scenes/GridViewerScene";
import { IndexScene } from "@/scenes/IndexScene";
import { InventoryTestScene } from "@/scenes/InventoryTestScene";
import { MapViewerScene } from "@/scenes/MapViewerScene";
import { ShapeViewerScene } from "@/scenes/ShapeViewerScene";
import { CombatModule, createCombatModule } from "@/state/combatState";
import { createGameHost } from "boardgame-core";
import {
CombatResult,
CombatState,
} from "boardgame-core/samples/slay-the-spire-like";
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";
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 gameHost = useMemo(
() => createGameHost(createCombatState() as GameModule<CombatState>),
() =>
createGameHost<CombatState, CombatResult | null, CombatModule>(
createCombatModule(),
),
[],
);