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 { ReactiveScene } from "./ReactiveScene";
import type { GameHost } from "boardgame-core"; import type { GameHost, GameModule } from "boardgame-core";
export interface GameHostSceneOptions<TState extends Record<string, unknown>> { export interface GameHostSceneOptions<
gameHost: GameHost<TState>; TState extends Record<string, unknown>,
TResult,
TModule extends GameModule<TState, TResult>,
> {
gameHost: GameHost<TState, TResult, TModule>;
[key: string]: unknown; [key: string]: unknown;
} }
export abstract class GameHostScene< export abstract class GameHostScene<
TState extends Record<string, unknown>, TState extends Record<string, unknown>,
> extends ReactiveScene<GameHostSceneOptions<TState>> { TResult,
public get gameHost(): GameHost<TState> { TModule extends GameModule<TState, TResult>,
const gameHost = this.initData.gameHost as GameHost<TState>; > extends ReactiveScene<GameHostSceneOptions<TState, TResult, TModule>> {
public get gameHost(): GameHost<TState, TResult, TModule> {
const gameHost = this.initData.gameHost;
if (!gameHost) { if (!gameHost) {
throw new Error( throw new Error(
`GameHostScene (${this.scene.key}): gameHost 未提供。` + `GameHostScene (${this.scene.key}): gameHost 未提供。` +
@@ -129,4 +129,24 @@ export class Buff extends Phaser.GameObjects.Container {
this.tooltipContainer.destroy(fromScene); this.tooltipContainer.destroy(fromScene);
super.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 { BuffData, Buff } from "./Buff";
import { SpawnerCallback } from "boardgame-phaser"; import { SpawnerCallback } from "boardgame-phaser";
import { CombatState } from "boardgame-core/samples/slay-the-spire-like";
export type BuffDataWithIndex = BuffData & { x: number }; export type BuffDataWithIndex = BuffData & { x: number };
export class BuffSpawner implements SpawnerCallback<BuffDataWithIndex, Buff> { export class BuffSpawner implements SpawnerCallback<BuffDataWithIndex, Buff> {
constructor( constructor(
public scene: Phaser.Scene, public scene: GameHostScene<CombatState>,
public buffContainer: Phaser.GameObjects.Container, public buffContainer: Phaser.GameObjects.Container,
) {} ) {}
getKey(t: BuffDataWithIndex) { getKey(t: BuffDataWithIndex) {
@@ -13,10 +15,13 @@ export class BuffSpawner implements SpawnerCallback<BuffDataWithIndex, Buff> {
onSpawn(t: BuffDataWithIndex) { onSpawn(t: BuffDataWithIndex) {
const buff = new Buff(this.scene, t.x, 0, t); const buff = new Buff(this.scene, t.x, 0, t);
this.buffContainer.add(buff); this.buffContainer.add(buff);
buff.playSpawnTween();
this.scene.addInterruption(new Promise((r) => setTimeout(r, 50)));
return buff; return buff;
} }
onDespawn(obj: Buff) { onDespawn(obj: Buff) {
obj.destroy(); this.scene.addInterruption(new Promise((r) => setTimeout(r, 50)));
obj.playDespawnTween();
} }
onUpdate(t: BuffDataWithIndex, obj: Buff) { onUpdate(t: BuffDataWithIndex, obj: Buff) {
obj.setPosition(t.x, 0); obj.setPosition(t.x, 0);
@@ -165,10 +165,10 @@ export class CardContainer extends Phaser.GameObjects.Container {
return this._selected; return this._selected;
} }
playSpawnTween(delay = 0): void { playSpawnTween(delay = 0) {
this.setAlpha(0); this.setAlpha(0);
this.setScale(0.5); this.setScale(0.5);
this.scene.tweens.add({ return this.scene.tweens.add({
targets: this, targets: this,
alpha: 1, alpha: 1,
scale: 1, scale: 1,
@@ -178,8 +178,8 @@ export class CardContainer extends Phaser.GameObjects.Container {
}); });
} }
playDespawnTween(onComplete?: () => void): void { playDespawnTween(onComplete?: () => void) {
this.scene.tweens.add({ return this.scene.tweens.add({
targets: this, targets: this,
alpha: 0, alpha: 0,
scale: 0.5, scale: 0.5,
@@ -4,6 +4,7 @@ import type {
GameCard, GameCard,
} from "boardgame-core/samples/slay-the-spire-like"; } from "boardgame-core/samples/slay-the-spire-like";
import { CardContainer } from "./CardContainer"; import { CardContainer } from "./CardContainer";
import { GameHostScene } from "boardgame-phaser";
export interface CardSpawnData { export interface CardSpawnData {
cardId: string; cardId: string;
@@ -17,7 +18,7 @@ const HAND_MARGIN = 100;
export class CardSpawner implements Spawner<CardSpawnData, CardContainer> { export class CardSpawner implements Spawner<CardSpawnData, CardContainer> {
constructor( constructor(
private scene: Phaser.Scene, private scene: GameHostScene<CombatState>,
private getState: () => CombatState, private getState: () => CombatState,
private onCardClick: (cardId: string) => void, private onCardClick: (cardId: string) => void,
) {} ) {}
@@ -47,6 +48,7 @@ export class CardSpawner implements Spawner<CardSpawnData, CardContainer> {
}); });
container.playSpawnTween(data.index * 40); container.playSpawnTween(data.index * 40);
this.scene.addInterruption(new Promise((r) => setTimeout(r, 40)));
return container; 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()); obj.playDespawnTween(() => obj.destroy());
this.scene.addInterruption(new Promise((r) => setTimeout(r, 40)));
} }
private getCardPosition( private getCardPosition(
@@ -104,7 +107,7 @@ export class CardSpawner implements Spawner<CardSpawnData, CardContainer> {
} }
export function createCardSpawner( export function createCardSpawner(
scene: Phaser.Scene, scene: GameHostScene<CombatState>,
getState: () => CombatState, getState: () => CombatState,
onCardClick: (cardId: string) => void, onCardClick: (cardId: string) => void,
) { ) {
@@ -1,12 +1,14 @@
import Phaser from "phaser"; import Phaser from "phaser";
import type { import type {
CombatEntity, CombatEntity,
CombatState,
EffectTable, EffectTable,
EnemyEntity, EnemyEntity,
} from "boardgame-core/samples/slay-the-spire-like"; } from "boardgame-core/samples/slay-the-spire-like";
import { BuffData } from "./Buff"; import { BuffData } from "./Buff";
import { createSpawnUpdate } from "boardgame-phaser"; import { createSpawnUpdate } from "boardgame-phaser";
import { BuffDataWithIndex, BuffSpawner } from "./BuffSpawner"; import { BuffDataWithIndex, BuffSpawner } from "./BuffSpawner";
import { GameHostScene } from "boardgame-phaser";
export type CombatUnitData = { export type CombatUnitData = {
key: string; key: string;
@@ -30,15 +32,22 @@ export class CombatUnitContainer extends Phaser.GameObjects.Container {
private hpText!: Phaser.GameObjects.Text; private hpText!: Phaser.GameObjects.Text;
private buffContainer!: Phaser.GameObjects.Container; private buffContainer!: Phaser.GameObjects.Container;
private intentText!: Phaser.GameObjects.Text | null; private intentText!: Phaser.GameObjects.Text | null;
private hostScene: GameHostScene<CombatState>;
private updateBuffs!: (buffs: Iterable<BuffData & { x: number }>) => void; private updateBuffs!: (buffs: Iterable<BuffData & { x: number }>) => void;
private currentEntity!: CombatEntity; private currentEntity!: CombatEntity;
private currentName: string; private currentName: string;
private currentIsPlayer: boolean; 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); super(scene, x, y);
scene.add.existing(this); scene.add.existing(this);
this.hostScene = scene;
this.currentEntity = data.entity; this.currentEntity = data.entity;
this.currentName = data.name; 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.buffContainer = this.scene.add.container(0, CONTAINER_HEIGHT / 2 - 40);
this.updateBuffs = createSpawnUpdate( this.updateBuffs = createSpawnUpdate(
new BuffSpawner(this.scene, this.buffContainer), new BuffSpawner(this.hostScene, this.buffContainer),
); );
if (!this.currentIsPlayer) { if (!this.currentIsPlayer) {
@@ -1,11 +1,17 @@
import Phaser from "phaser";
import type { Spawner } from "boardgame-phaser"; import type { Spawner } from "boardgame-phaser";
import { spawnEffect } from "boardgame-phaser"; import { spawnEffect } from "boardgame-phaser";
import type { CombatState, CombatEntity, EnemyEntity } from "boardgame-core/samples/slay-the-spire-like"; import type { CombatState } from "boardgame-core/samples/slay-the-spire-like";
import { CombatUnitContainer, type CombatUnitData } from "./CombatUnitContainer"; import {
CombatUnitContainer,
type CombatUnitData,
} from "./CombatUnitContainer";
import { GameHostScene } from "boardgame-phaser";
export class CombatUnitSpawner implements Spawner<CombatUnitData, CombatUnitContainer> { export class CombatUnitSpawner implements Spawner<
constructor(private scene: Phaser.Scene) {} CombatUnitData,
CombatUnitContainer
> {
constructor(private scene: GameHostScene<CombatState>) {}
*getData(): Iterable<CombatUnitData> { *getData(): Iterable<CombatUnitData> {
const combat = this.getCombatState(); 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)); return spawnEffect(new CombatUnitSpawner(scene));
} }
@@ -2,6 +2,7 @@ import Phaser from "phaser";
import { GameHostScene } from "boardgame-phaser"; import { GameHostScene } from "boardgame-phaser";
import { spawnEffect, type Spawner } from "boardgame-phaser"; import { spawnEffect, type Spawner } from "boardgame-phaser";
import { import {
CombatResult,
type CombatState, type CombatState,
prompts, prompts,
} from "boardgame-core/samples/slay-the-spire-like"; } from "boardgame-core/samples/slay-the-spire-like";
@@ -12,12 +13,17 @@ import {
type CombatUnitData, type CombatUnitData,
} from "@/gameobjects/CombatUnitContainer"; } from "@/gameobjects/CombatUnitContainer";
import { CardSpawner } from "@/gameobjects/CardSpawner"; import { CardSpawner } from "@/gameobjects/CardSpawner";
import { CombatModule } from "@/state/combatState";
const CARD_SPACING = 160; const CARD_SPACING = 160;
const HAND_MARGIN = 100; const HAND_MARGIN = 100;
const HAND_Y = 140; 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 selectedCardId: string | null = null;
private isTargeting = false; private isTargeting = false;
private targetingText!: Phaser.GameObjects.Text; private targetingText!: Phaser.GameObjects.Text;
@@ -120,6 +126,13 @@ export class CombatTestScene extends GameHostScene<CombatState> {
// Start the game loop // Start the game loop
this.gameHost.start(); 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 { private onCardClick(cardId: string): void {
@@ -150,13 +163,14 @@ export class CombatTestScene extends GameHostScene<CombatState> {
} }
private tryPlayCard(cardId: string, targetId?: string): void { private tryPlayCard(cardId: string, targetId?: string): void {
const error = this.gameHost.tryAnswerPrompt( const error = this.gameHost.prompts.tryCommit(
prompts.mainAction, prompts.mainAction,
"global",
cardId, cardId,
targetId, targetId,
); );
if (error) { if (!error.ok) {
console.warn("Play failed:", error); console.warn("Play failed:", error.reason);
} }
this.clearTargeting(); this.clearTargeting();
} }
@@ -1,8 +1,8 @@
import { import {
buildCombatState, buildCombatState,
CombatState,
createRunState, createRunState,
createStartWith, createStart,
createTriggers,
data, data,
generateDeckFromInventory, generateDeckFromInventory,
getAdjacentItems, getAdjacentItems,
@@ -10,9 +10,9 @@ import {
IRunContext, IRunContext,
} from "boardgame-core/samples/slay-the-spire-like"; } from "boardgame-core/samples/slay-the-spire-like";
import { createInventorySignal } from "./inventory"; 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 inventory = createInventorySignal(true);
const deck = generateDeckFromInventory(inventory.value); const deck = generateDeckFromInventory(inventory.value);
@@ -51,12 +51,14 @@ export function createCombatState() {
}, },
}; };
const start = createStartWith((triggers, ctx) => { const triggers = createTriggers(runContext);
data.desert.addTriggers(triggers, runContext); data.desert.addTriggers(triggers, runContext);
}, runContext); const start = createStart(triggers, runContext);
return { return {
start, start,
createInitialState: () => combat, 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 { PhaserGame, PhaserScene } from "boardgame-phaser";
import { useMemo } from "preact/hooks"; 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() { export default function App() {
const gameHost = useMemo( const gameHost = useMemo(
() => createGameHost(createCombatState() as GameModule<CombatState>), () =>
createGameHost<CombatState, CombatResult | null, CombatModule>(
createCombatModule(),
),
[], [],
); );