Compare commits

...
19 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
hypercross c29b9a43b3 feat(framework): decouple spawner update logic
Extract the core update logic from `spawnEffect` into
`createSpawnUpdate`.
This allows manual updates of spawned objects without relying on
Preact effects, which is useful for non-reactive or imperative
update cycles.

Refactor `Buff` and `BuffSpawner` in `sts-like-viewer` to use the new
`createSpawnUpdate` API for more efficient buff rendering.
2026-04-22 20:59:35 +08:00
hypercross 447a5cfbd0 feat(sts-viewer): improve card and effect visual indicators
- Update `CardContainer` to use descriptive emojis for different target
  types
- Use emoji icons for combat unit status effects instead of keys
- Update `CardSpawner` default target type to 'player'
- Fix target type check in `CombatTestScene` to use 'enemy' instead of
  'single'
2026-04-22 19:47:37 +08:00
hypercross dda290bf9c feat(framework): add hoverEffect utility
Add a `hoverEffect` utility to `boardgame-phaser` to simplify
handling pointerover and pointerout events. Refactor
`CardContainer` in `sts-like-viewer` to use this new utility and
centralize card dimensions in `CARD_CONFIG`.
2026-04-22 15:13:33 +08:00
hypercross 5d84c42b78 style: reformat CombatUnitContainer for readability 2026-04-22 11:46:39 +08:00
hypercross a6ddafb802 build: add @types/node to sts-like-viewer 2026-04-22 11:46:24 +08:00
hypercross a181932fb3 refactor: simplify CombatTestScene instantiation in App
Remove manual instantiation of CombatTestScene in favor of passing
the class directly to PhaserScene, allowing the framework to handle
scene lifecycle.
2026-04-22 00:56:45 +08:00
hypercross 887da22baa fix(framework): check instance of Scene in PhaserScene
Ensure the scene passed to PhaserScene is an instance of Phaser.Scene
before deciding whether to instantiate it.
2026-04-22 00:55:38 +08:00
hypercross 5355afb23e 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.
2026-04-22 00:53:57 +08:00
hypercross 3f2ba84a06 feat(sts-viewer): add CombatTestScene and unit visuals
Implement a new combat test scene to visualize Slay the Spire-like
combat states. This includes:

- `CombatUnitContainer`: A Phaser container for displaying unit HP,
  name, buffs, and enemy intents.
- `CombatUnitSpawner`: A spawner to manage the lifecycle of combat
  units based on the combat state.
- `
2026-04-21 23:50:47 +08:00
hypercross 9637312b7c feat(sts-viewer): implement combat state and fix inventory removal
- Add `createCombatState` to initialize combat using inventory and run
  state
- Implement `runContext` to bridge inventory data with combat logic
- Update inventory to use `removeItem` instead of `removeItemFromGrid`
2026-04-21 23:28:47 +08:00
hypercross 82c026628a fix(sts-like-viewer): correct y-coordinate comparison in
InventoryItemContainer

Fix a bug where the y-coordinate of an item's offset was being
compared against the target's x-coordinate instead of its y-coordinate.
2026-04-21 22:17:00 +08:00
hypercross a92b889dfa feat(sts-like-viewer): batch inventory item movement
Wrap item removal and placement in a batch to prevent multiple
reactive updates when moving items between inventories.
2026-04-21 16:50:48 +08:00
hypercross c97c2c4150 feat(sts-viewer): support multiple inventory surfaces
Refactor `InventoryTestScene` to support multiple inventory signals
simultaneously. This introduces `InventorySurface` to manage
positioning and signal mapping for multiple grids, allowing for
testing item movement between different inventory areas.
2026-04-21 16:48:45 +08:00
hypercross 422ddde200 refactor: support multiple inventory surfaces
Introduce `InventorySurface` to allow items to be moved between
different inventory grids.

- Update `InventoryItemContainer` to use surface-specific cell sizes
  and coordinate transformations.
- Update `InventoryItemSpawner` to handle multiple surfaces.
- Refactor `moveItem` to support transferring items from one
  `InventorySignal` to another.
- Add `InventorySurfaceState` to manage surface-specific metadata.
- Remove reliance on global `GRID_CONFIG` in favor of surface data.
2026-04-21 16:39:10 +08:00
hypercross 093eadbefd refactor: encapsulate InventoryItemContainer internals
Make `updateHitArea` and `renderGraphics` private to prevent external
access to internal rendering logic. Move the preview rotation reset
to ensure it always executes after a drag operation.
2026-04-21 09:51:10 +08:00
hypercross eff62bc0ed fix(sts-like-viewer): fix item movement detection and removal
- Update `InventoryItemContainer` to correctly detect rotation changes
- Use `removeItemFromGrid` in `InventoryTestScene` for item removal
2026-04-21 00:16:50 +08:00
26 changed files with 1626 additions and 265 deletions
+3 -2
View File
@@ -5,10 +5,11 @@ export type { IDisposable, DisposableItem } from "./utils";
// Drag & drop utilities
export { dragDropEventEffect, DragDropEventType } from "./utils";
export type { DragDropEvent, DragDropCallback } from "./utils";
export { hoverEffect } from "./utils";
// Data-driven object spawning
export { spawnEffect } from "./spawner";
export type { Spawner } from "./spawner";
export { spawnEffect, createSpawnUpdate } from "./spawner";
export type { Spawner, SpawnerCallback } from "./spawner";
// Scene base classes
export {
+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 未提供。` +
+25 -7
View File
@@ -4,9 +4,7 @@ import type Phaser from "phaser";
type GO = Phaser.GameObjects.GameObject;
export interface Spawner<TData, TObject extends GO = GO> {
/** 数据源迭代器 */
getData(): Iterable<TData>;
export interface SpawnerCallback<TData, TObject extends GO = GO> {
/** 获取数据的唯一键 */
getKey(t: TData): string;
/** 创建新对象 */
@@ -17,15 +15,33 @@ export interface Spawner<TData, TObject extends GO = GO> {
onUpdate(t: TData, obj: TObject): void;
}
export interface Spawner<
TData,
TObject extends GO = GO,
> extends SpawnerCallback<TData, TObject> {
/** 数据源迭代器 */
getData(): Iterable<TData>;
}
export function spawnEffect<TData, TObject extends GO = GO>(
spawner: Spawner<TData, TObject>,
): () => void {
const entries = new Map<string, { object: TObject; data: TData }>();
) {
const update = createSpawnUpdate(spawner);
return effect(() => {
update(spawner.getData());
});
}
export function createSpawnUpdate<TData, TObject extends GO = GO>(
spawner: SpawnerCallback<TData, TObject>,
) {
const entries = new Map<string, { object: TObject; data: TData }>();
function update(data: Iterable<TData>) {
const current = new Set<string>();
for (const t of spawner.getData()) {
for (const t of data) {
const key = spawner.getKey(t);
current.add(key);
@@ -48,5 +64,7 @@ export function spawnEffect<TData, TObject extends GO = GO>(
entries.delete(key);
}
}
});
}
return update;
}
+3 -2
View File
@@ -1,5 +1,5 @@
import { signal, useSignal, useSignalEffect } from "@preact/signals";
import Phaser, { AUTO } from "phaser";
import Phaser, { AUTO, Scene } from "phaser";
import { createContext } from "preact";
import { useContext, useEffect, useRef } from "preact/hooks";
@@ -215,7 +215,8 @@ export function PhaserScene<TData = {}>(props: PhaserSceneProps<TData>) {
const game = ctx.game;
// 注册场景到 Phaser(但不启动)
const scene = "scene" in props.scene ? props.scene : new props.scene();
const scene =
props.scene instanceof Scene ? props.scene : new props.scene();
const sceneKey = props.sceneKey ?? scene.sys.settings.key;
if (!game.scene.getScene(sceneKey)) {
const initData = {
+28
View File
@@ -0,0 +1,28 @@
type HoverCallback = (hovering: boolean) => void;
export function hoverEffect(
gameObject: Phaser.GameObjects.GameObject,
callback: HoverCallback,
) {
let isHovering = false;
const onPointerOver = () => {
if (isHovering) return;
isHovering = true;
callback(true);
};
const onPointerOut = () => {
if (!isHovering) return;
isHovering = false;
callback(false);
};
gameObject.on("pointerover", onPointerOver);
gameObject.on("pointerout", onPointerOut);
const cleanup = () => {
gameObject.off("pointerover", onPointerOver);
gameObject.off("pointerout", onPointerOut);
gameObject.off("destroy", cleanup);
};
gameObject.once("destroy", cleanup);
return cleanup;
}
+1
View File
@@ -6,3 +6,4 @@ export {
type DragDropEvent,
type DragDropCallback,
} from "./dnd";
export * from "./hover";
+1
View File
@@ -19,6 +19,7 @@
"@preact/preset-vite": "^2.8.1",
"@preact/signals": "^2.9.0",
"@tailwindcss/vite": "^4.0.0",
"@types/node": "^25.6.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.3.3",
"vite": "^5.1.0"
@@ -156,6 +156,12 @@ export const NEGATIVE_EFFECTS = new Set([
"poison",
]);
export const CARD_CONFIG = {
WIDTH: 140,
HEIGHT: 200,
CORNER_RADIUS: 8,
} as const;
export const GAME_CONFIG: Phaser.Types.Core.GameConfig = {
width: 1920,
height: 1080,
@@ -0,0 +1,152 @@
import Phaser from "phaser";
export type BuffData = {
id: string;
stacks: number;
emoji: string;
isPositive: boolean;
description?: string;
};
const ICON_SIZE = 28;
const ICON_COLOR_POSITIVE = 0x44aa44;
const ICON_COLOR_NEGATIVE = 0xaa4444;
const TOOLTIP_WIDTH = 200;
const TOOLTIP_PADDING = 8;
export class Buff extends Phaser.GameObjects.Container {
private bg!: Phaser.GameObjects.Rectangle;
private label!: Phaser.GameObjects.Text;
private tooltipContainer!: Phaser.GameObjects.Container;
private tooltipBg!: Phaser.GameObjects.Rectangle;
private tooltipText!: Phaser.GameObjects.Text;
private currentData!: BuffData;
constructor(scene: Phaser.Scene, x: number, y: number, data: BuffData) {
super(scene, x, y);
scene.add.existing(this);
this.currentData = data;
this.createVisuals();
this.createTooltip();
this.setupInteraction();
this.updateFromData(data);
}
private createVisuals(): void {
const color = this.currentData.isPositive
? ICON_COLOR_POSITIVE
: ICON_COLOR_NEGATIVE;
this.bg = this.scene.add
.rectangle(0, 0, ICON_SIZE, ICON_SIZE, color)
.setStrokeStyle(1, 0xffffff);
this.label = this.scene.add
.text(0, 0, "", {
fontSize: "12px",
color: "#ffffff",
fontStyle: "bold",
})
.setOrigin(0.5);
this.add([this.bg, this.label]);
}
private createTooltip(): void {
this.tooltipContainer = this.scene.add.container(0, 0).setVisible(false);
this.tooltipBg = this.scene.add
.rectangle(0, 0, 0, 0, 0x000000, 0.9)
.setStrokeStyle(1, 0xffffff);
this.tooltipText = this.scene.add
.text(0, 0, "", {
fontSize: "12px",
color: "#ffffff",
align: "center",
wordWrap: { width: TOOLTIP_WIDTH - TOOLTIP_PADDING * 2 },
})
.setOrigin(0.5);
this.tooltipContainer.add([this.tooltipBg, this.tooltipText]);
this.tooltipContainer.setDepth(1000);
}
private setupInteraction(): void {
this.setInteractive(
new Phaser.Geom.Rectangle(
-ICON_SIZE / 2,
-ICON_SIZE / 2,
ICON_SIZE,
ICON_SIZE,
),
Phaser.Geom.Rectangle.Contains,
);
this.on("pointerover", this.onPointerOver, this);
this.on("pointerout", this.onPointerOut, this);
}
private onPointerOver(): void {
const desc = this.currentData.description ?? "";
const content = desc;
this.tooltipText.setText(content);
const bounds = this.tooltipText.getBounds();
const width = Math.min(bounds.width + TOOLTIP_PADDING * 2, TOOLTIP_WIDTH);
const height = bounds.height + TOOLTIP_PADDING * 2;
this.tooltipBg.setDisplaySize(width, height);
const worldPos = this.getBounds();
this.tooltipContainer.setPosition(
worldPos.x + worldPos.width / 2,
worldPos.y - height / 2 - 4,
);
this.tooltipContainer.setDepth(1000);
this.scene.children.bringToTop(this.tooltipContainer);
this.tooltipContainer.setVisible(true);
}
private onPointerOut(): void {
this.tooltipContainer.setVisible(false);
}
updateFromData(data: BuffData): void {
this.currentData = data;
this.label.setText(`${data.emoji}${data.stacks}`);
const color = data.isPositive ? ICON_COLOR_POSITIVE : ICON_COLOR_NEGATIVE;
this.bg.setFillStyle(color);
}
destroy(fromScene?: boolean): void {
this.off("pointerover", this.onPointerOver, this);
this.off("pointerout", this.onPointerOut, this);
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(),
});
}
}
@@ -0,0 +1,30 @@
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: GameHostScene<CombatState>,
public buffContainer: Phaser.GameObjects.Container,
) {}
getKey(t: BuffDataWithIndex) {
return t.id;
}
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) {
this.scene.addInterruption(new Promise((r) => setTimeout(r, 50)));
obj.playDespawnTween();
}
onUpdate(t: BuffDataWithIndex, obj: Buff) {
obj.setPosition(t.x, 0);
obj.updateFromData(t);
}
}
@@ -0,0 +1,213 @@
import Phaser, { Tweens } from "phaser";
import type { GameCard } from "boardgame-core/samples/slay-the-spire-like";
import { CARD_CONFIG } from "@/config";
import { hoverEffect } from "boardgame-phaser";
export interface CardContainerOptions {
card: GameCard;
onClick?: (cardId: string) => void;
playable?: boolean;
}
const CARD_WIDTH = CARD_CONFIG.WIDTH;
const CARD_HEIGHT = CARD_CONFIG.HEIGHT;
const CORNER_RADIUS = CARD_CONFIG.CORNER_RADIUS;
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 = (() => {
switch (cardData.targetType) {
case "enemy":
return "⚔️ Enemy";
case "enemies":
return "⚔️ Enemies";
case "player":
return "🛡️ Player";
default:
return "❓ Unknown";
}
})();
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";
}
hoverEffect(this, (hovering) => {
if (!options.playable) return;
this.playHoverTween(hovering);
});
this.on("pointerdown", () => {
if (options.playable && options.onClick) {
options.onClick(this.cardId);
}
});
}
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) {
this.setAlpha(0);
this.setScale(0.5);
return this.scene.tweens.add({
targets: this,
alpha: 1,
scale: 1,
duration: 300,
delay,
ease: "Back.easeOut",
});
}
playDespawnTween(onComplete?: () => void) {
return this.scene.tweens.add({
targets: this,
alpha: 0,
scale: 0.5,
y: this.y - 50,
duration: 200,
ease: "Back.easeIn",
onComplete: () => {
if (onComplete) onComplete();
},
});
}
private playHoverTween(isHovering: boolean) {
this.scene.tweens.add({
targets: this,
y: isHovering ? this.y - 10 : this.y + 10,
scale: isHovering ? 1.08 : 1,
duration: 150,
ease: "Power2",
});
this.bg.setStrokeStyle(
isHovering ? 3 : 2,
isHovering ? 0x88aaff : 0x555577,
);
}
destroy(fromScene?: boolean): void {
this.highlight = null;
super.destroy(fromScene);
}
}
@@ -0,0 +1,115 @@
import { spawnEffect, type Spawner } from "boardgame-phaser";
import type {
CombatState,
GameCard,
} from "boardgame-core/samples/slay-the-spire-like";
import { CardContainer } from "./CardContainer";
import { GameHostScene } from "boardgame-phaser";
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: GameHostScene<CombatState>,
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);
this.scene.addInterruption(new Promise((r) => setTimeout(r, 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, data: CardSpawnData): void {
obj.playDespawnTween(() => obj.destroy());
this.scene.addInterruption(new Promise((r) => setTimeout(r, 40)));
}
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: "player",
effects: [],
},
itemId: "",
};
}
}
export function createCardSpawner(
scene: GameHostScene<CombatState>,
getState: () => CombatState,
onCardClick: (cardId: string) => void,
) {
return spawnEffect(new CardSpawner(scene, getState, onCardClick));
}
@@ -0,0 +1,243 @@
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;
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 hostScene: GameHostScene<CombatState>;
private updateBuffs!: (buffs: Iterable<BuffData & { x: number }>) => void;
private currentEntity!: CombatEntity;
private currentName: string;
private currentIsPlayer: boolean;
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;
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);
this.updateBuffs = createSpawnUpdate(
new BuffSpawner(this.hostScene, this.buffContainer),
);
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.updateBuffs(this.getBuffEntries());
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 *getBuffEntries() {
const entries = Object.values(this.currentEntity.effects);
const totalWidth =
entries.length * BUFF_ICON_SIZE + (entries.length - 1) * BUFF_ICON_GAP;
const startX = -totalWidth / 2 + BUFF_ICON_SIZE / 2;
for (let i = 0; i < entries.length; i++) {
const buff = entries[i];
yield {
...buff.data,
stacks: buff.stacks,
isPositive: true,
x: startX + i * BUFF_ICON_SIZE,
} as BuffDataWithIndex;
}
}
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);
}
}
@@ -0,0 +1,81 @@
import type { Spawner } from "boardgame-phaser";
import { spawnEffect } from "boardgame-phaser";
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: GameHostScene<CombatState>) {}
*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: GameHostScene<CombatState>) {
return spawnEffect(new CombatUnitSpawner(scene));
}
@@ -11,44 +11,44 @@ import {
} from "boardgame-core/samples/slay-the-spire-like";
import { DisposableBag } from "../../../framework/dist";
import { InventoryItemState } from "@/state/InventoryItemState";
export interface InventoryItemContainerCallbacks {
onMoveItem: (
itemId: string,
newX: number,
newY: number,
newRotation: number,
) => boolean;
}
import { moveItem } from "@/state/inventory";
import {
InventorySurface,
InventorySurfaceState,
inventoryToScene,
sceneToInventory,
} from "@/state/inventorySurfaceState";
export class InventoryItemContainer extends Phaser.GameObjects.Container {
private itemState: InventoryItemState;
private surfaceState: InventorySurfaceState;
private surfaces: Iterable<InventorySurface>;
private hitArea: Point2D[] = [];
constructor(
scene: Phaser.Scene,
private gridOffsetX: number,
private gridOffsetY: number,
private callbacks: InventoryItemContainerCallbacks,
item: GameItem,
surface: InventorySurface,
surfaces: Iterable<InventorySurface>,
) {
super(scene, gridOffsetX, gridOffsetY);
super(scene, surface.gridOffsetX, surface.gridOffsetY);
scene.add.existing(this);
const cellSize = surface.cellSize;
const graphics = this.scene.add.graphics();
graphics.setPosition(
GRID_CONFIG.VIEWER_CELL_SIZE / 2,
GRID_CONFIG.VIEWER_CELL_SIZE / 2,
);
graphics.setPosition(cellSize / 2, cellSize / 2);
const label = this.scene.add.text(
GRID_CONFIG.VIEWER_CELL_SIZE / 2,
GRID_CONFIG.VIEWER_CELL_SIZE / 2,
cellSize / 2,
cellSize / 2,
"",
GRID_CONFIG.ITEM_NAME_STYLE,
);
this.add([graphics, label]);
this.setupInteractive();
this.itemState = new InventoryItemState();
this.itemState = new InventoryItemState(item);
this.surfaceState = new InventorySurfaceState(surface);
this.surfaces = surfaces;
const disposables = new DisposableBag(this);
@@ -60,9 +60,9 @@ export class InventoryItemContainer extends Phaser.GameObjects.Container {
disposables.addEffect(() => {
graphics.clear();
if (!this.itemState.shape.value) return;
this.renderGraphics(
graphics,
this.surfaceState.cellSize,
this.itemState.shape.value,
this.itemState.color.value,
);
@@ -79,13 +79,17 @@ export class InventoryItemContainer extends Phaser.GameObjects.Container {
});
disposables.addEffect(() => {
if (!this.itemState.transform.value) return;
this.snapBack(this.itemState.transform.value);
this.itemState.setPreviewRotation(0);
});
}
updateHitArea(value: number): Point2D[] {
setSurfaceItem(surface: InventorySurface, item: GameItem): void {
this.surfaceState.setSurface(surface);
this.itemState.setItem(item);
}
private updateHitArea(value: number): Point2D[] {
const shape = this.itemState.shape.value;
if (!shape) return [];
@@ -96,7 +100,7 @@ export class InventoryItemContainer extends Phaser.GameObjects.Container {
flipX: false,
flipY: false,
});
const cellSize = GRID_CONFIG.VIEWER_CELL_SIZE;
const cellSize = this.surfaceState.cellSize;
return cells.map((cell) => ({
x: (cell.x - cells[0].x) * cellSize,
@@ -104,34 +108,30 @@ export class InventoryItemContainer extends Phaser.GameObjects.Container {
}));
}
setItem(item: GameItem): void {
this.itemState.setItem(item);
}
renderGraphics(
private renderGraphics(
graphics: Phaser.GameObjects.Graphics,
cellSize: number,
shape: ParsedShape,
itemColor: number,
) {
const cells = transformShape(shape, IDENTITY_TRANSFORM);
for (const cell of cells) {
const localX = (cell.x - cells[0].x) * GRID_CONFIG.VIEWER_CELL_SIZE;
const localY = (cell.y - cells[0].y) * GRID_CONFIG.VIEWER_CELL_SIZE;
const localX = (cell.x - cells[0].x) * cellSize;
const localY = (cell.y - cells[0].y) * cellSize;
graphics.fillStyle(itemColor);
graphics.fillRect(
localX + 2 - GRID_CONFIG.VIEWER_CELL_SIZE / 2,
localY + 2 - GRID_CONFIG.VIEWER_CELL_SIZE / 2,
GRID_CONFIG.VIEWER_CELL_SIZE - 4,
GRID_CONFIG.VIEWER_CELL_SIZE - 4,
localX + 2 - cellSize / 2,
localY + 2 - cellSize / 2,
cellSize - 4,
cellSize - 4,
);
}
}
private setupInteractive() {
this.setScrollFactor(0);
const cellSize = GRID_CONFIG.VIEWER_CELL_SIZE;
this.setInteractive({
hitArea: this,
hitAreaCallback: (
@@ -142,9 +142,9 @@ export class InventoryItemContainer extends Phaser.GameObjects.Container {
hitArea.hitArea.some(
(cell) =>
x >= cell.x &&
x < cell.x + cellSize &&
x < cell.x + this.surfaceState.cellSize &&
y >= cell.y &&
y < cell.y + cellSize,
y < cell.y + this.surfaceState.cellSize,
),
useHandCursor: true,
} as Phaser.Types.Input.InputConfiguration);
@@ -164,60 +164,68 @@ export class InventoryItemContainer extends Phaser.GameObjects.Container {
} else if (event.type === DragDropEventType.ALTBUTTON) {
this.itemState.addPreviewRotation(90);
} else if (event.type === DragDropEventType.UP) {
this.setAlpha(1);
const finalRotation = this.itemState.previewRotation.peek();
if (!this.handleDragEnd(finalRotation)) {
this.itemState.setPreviewRotation(0);
const finalX = this.x;
const finalY = this.y;
if (!this.handleDragEnd(finalX, finalY, finalRotation)) {
const t = this.itemState.transform.peek();
t && this.snapBack(t);
} else {
this.itemState.setPreviewRotation(0);
}
this.setAlpha(1);
this.itemState.setPreviewRotation(0);
startX = startY = 0;
}
});
}
private handleDragEnd(finalRotation: number): boolean {
private handleDragEnd(
finalX: number,
finalY: number,
finalRotation: number,
): boolean {
const item = this.itemState.item;
if (!item) return false;
const cellSize = GRID_CONFIG.VIEWER_CELL_SIZE;
const shapeWidth = item.shape?.width ?? 1;
const shapeHeight = item.shape?.height ?? 1;
const x = this.x - this.gridOffsetX;
const y = this.y - this.gridOffsetY;
const targetX = Math.round(x / cellSize);
const targetY = Math.round(y / cellSize);
const clampedX = Math.max(0, Math.min(targetX, 10 - shapeWidth));
const clampedY = Math.max(0, Math.min(targetY, 10 - shapeHeight));
const target = this.findDropSurface(finalX, finalY);
if (!target) return false;
if (
clampedX !== item.transform.offset.x ||
clampedY !== item.transform.offset.y ||
finalRotation !== 0
) {
return this.callbacks.onMoveItem(
this.surfaceState.surface === target.surface &&
item.transform.offset.x === target.x &&
item.transform.offset.y === target.y &&
item.transform.rotation === finalRotation
)
return false;
return moveItem(
this.surfaceState.invSignal,
target.surface.invSignal,
item.id,
clampedX,
clampedY,
target.x,
target.y,
finalRotation,
);
}
return false;
private findDropSurface(x: number, y: number) {
for (const surface of this.surfaces) {
const target = sceneToInventory(surface, x, y);
if (target)
return {
surface,
...target,
};
}
return null;
}
private snapBack(transform: Transform2D): void {
const { x, y } = transform.offset;
const targetX = this.gridOffsetX + x * GRID_CONFIG.VIEWER_CELL_SIZE;
const targetY = this.gridOffsetY + y * GRID_CONFIG.VIEWER_CELL_SIZE;
const target = inventoryToScene(this.surfaceState, x, y);
this.scene.tweens.add({
targets: this,
x: targetX,
y: targetY,
...target,
duration: 150,
ease: "Power2",
});
@@ -1,64 +1,53 @@
import Phaser from "phaser";
import type { Spawner } from "boardgame-phaser";
import type {
GameItemMeta,
InventoryItem,
} from "boardgame-core/samples/slay-the-spire-like";
import type { InventorySignal } from "@/state/inventory";
import type { GameItem } from "boardgame-core/samples/slay-the-spire-like";
import { spawnEffect } from "boardgame-phaser";
import { InventoryItemContainer } from "./InventoryItemContainer";
import type { InventoryItemContainerCallbacks } from "./InventoryItemContainer";
export interface InventoryItemSpawnerCallbacks extends InventoryItemContainerCallbacks {}
import { InventorySurface } from "@/state/inventorySurfaceState";
export class InventoryItemSpawner implements Spawner<
[string, InventoryItem<GameItemMeta>],
[InventorySurface, GameItem],
InventoryItemContainer
> {
constructor(
private scene: Phaser.Scene,
private inventorySignal: InventorySignal,
private gridOffsetX: number,
private gridOffsetY: number,
private callbacks: InventoryItemSpawnerCallbacks,
private surfaces: Iterable<InventorySurface>,
) {}
*getData(): Iterable<[string, InventoryItem<GameItemMeta>]> {
const inventory = this.inventorySignal.value;
yield* inventory.items.entries();
*getData(): Iterable<[InventorySurface, GameItem]> {
for (const surface of this.surfaces) {
const inv = surface.invSignal.value;
for (const item of inv.items.values()) {
yield [surface, item];
}
}
}
getKey(entry: [string, InventoryItem<GameItemMeta>]): string {
return entry[0];
getKey(entry: [InventorySurface, GameItem]): string {
return entry[1].id;
}
onSpawn(
entry: [string, InventoryItem<GameItemMeta>],
): InventoryItemContainer | null {
const [itemId, item] = entry;
onSpawn(entry: [InventorySurface, GameItem]): InventoryItemContainer | null {
const [surface, item] = entry;
const container = new InventoryItemContainer(
this.scene,
this.gridOffsetX,
this.gridOffsetY,
{
onMoveItem: (id, newX, newY, newRotation) => {
return this.callbacks.onMoveItem(id, newX, newY, newRotation);
},
},
item,
surface,
this.surfaces,
);
container.setItem(item);
container.setSurfaceItem(surface, item);
return container;
}
onUpdate(
entry: [string, InventoryItem<GameItemMeta>],
entry: [InventorySurface, GameItem],
container: InventoryItemContainer,
): void {
const [itemId, item] = entry;
const [surface, item] = entry;
container.setItem(item);
container.setSurfaceItem(surface, item);
}
onDespawn(container: InventoryItemContainer): void {
@@ -69,18 +58,7 @@ export class InventoryItemSpawner implements Spawner<
export function createInventoryItemSpawner(
scene: Phaser.Scene,
inventorySignal: InventorySignal,
gridOffsetX: number,
gridOffsetY: number,
callbacks: InventoryItemSpawnerCallbacks,
surfaces: Iterable<InventorySurface>,
) {
return spawnEffect(
new InventoryItemSpawner(
scene,
inventorySignal,
gridOffsetX,
gridOffsetY,
callbacks,
),
);
return spawnEffect(new InventoryItemSpawner(scene, surfaces));
}
@@ -0,0 +1,286 @@
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";
import { createButton } from "@/utils/createButton";
import { SceneKey } from "./types";
import {
CombatUnitContainer,
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,
CombatResult | null,
CombatModule
> {
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();
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 {
const state = this.state;
const card = state.player.deck.cards[cardId];
if (!card) return;
const targetType = card.cardData.targetType;
if (targetType === "enemy") {
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.prompts.tryCommit(
prompts.mainAction,
"global",
cardId,
targetId,
);
if (!error.ok) {
console.warn("Play failed:", error.reason);
}
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,21 +38,22 @@ export class IndexScene extends ReactiveScene {
scene: SceneKey;
y: number;
}[] = [
{ label: "Map Viewer", scene: SceneKey.MapViewerScene, y: centerY },
{ label: "Combat Test", scene: SceneKey.CombatTestScene, y: centerY },
{ label: "Map Viewer", scene: SceneKey.MapViewerScene, y: centerY + 70 },
{
label: "Grid Inventory Viewer",
scene: SceneKey.GridViewerScene,
y: centerY + 70,
y: centerY + 140,
},
{
label: "Shape Viewer",
scene: SceneKey.ShapeViewerScene,
y: centerY + 140,
y: centerY + 210,
},
{
label: "Inventory Test",
scene: SceneKey.InventoryTestScene,
y: centerY + 210,
y: centerY + 280,
},
];
@@ -1,15 +1,20 @@
import { ReactiveScene } from "boardgame-phaser";
import { createButton } from "@/utils/createButton";
import { GRID_CONFIG } from "@/config";
import { createInventorySignal, moveItem } from "@/state/inventory";
import { createInventorySignal, type InventorySignal } from "@/state/inventory";
import { createItemIn, data } from "boardgame-core/samples/slay-the-spire-like";
import { createInventoryItemSpawner } from "@/gameobjects/InventoryItemSpawner";
import { SceneKey } from "./types";
import { InventorySurface } from "@/state/inventorySurfaceState";
export class InventoryTestScene extends ReactiveScene {
private inventorySignal = createInventorySignal();
private gridOffsetX = 0;
private gridOffsetY = 0;
private readonly _surfaces: InventorySurface[] = [];
private _leftInv!: InventorySignal;
private _rightInv!: InventorySignal;
private _leftGridOffsetX = 0;
private _leftGridOffsetY = 0;
private _rightGridOffsetX = 0;
private _rightGridOffsetY = 0;
constructor() {
super("InventoryTestScene");
@@ -19,22 +24,62 @@ export class InventoryTestScene extends ReactiveScene {
super.create();
const { width, height } = this.scale;
const inventory = this.inventorySignal.value;
const invWidth = inventory.width;
const invHeight = inventory.height;
this.gridOffsetX = (width - invWidth * GRID_CONFIG.VIEWER_CELL_SIZE) / 2;
this.gridOffsetY =
(height - invHeight * GRID_CONFIG.VIEWER_CELL_SIZE) / 2 + 40;
this._leftInv = createInventorySignal();
this._rightInv = createInventorySignal();
this.drawGrid(invWidth, invHeight);
const invWidth = 4;
const invHeight = 6;
const cellSize = GRID_CONFIG.VIEWER_CELL_SIZE;
const gap = 80;
this._leftGridOffsetX =
(width / 2 - gap / 2) / 2 - (invWidth * cellSize) / 2;
this._leftGridOffsetY = (height - invHeight * cellSize) / 2 + 40;
this._rightGridOffsetX =
width / 2 +
gap / 2 +
(width / 2 - gap / 2) / 2 -
(invWidth * cellSize) / 2;
this._rightGridOffsetY = (height - invHeight * cellSize) / 2 + 40;
this._surfaces.push(
{
invSignal: this._leftInv,
gridOffsetX: this._leftGridOffsetX,
gridOffsetY: this._leftGridOffsetY,
cellSize,
},
{
invSignal: this._rightInv,
gridOffsetX: this._rightGridOffsetX,
gridOffsetY: this._rightGridOffsetY,
cellSize,
},
);
this.drawGrid(
this._leftInv,
invWidth,
invHeight,
this._leftGridOffsetX,
this._leftGridOffsetY,
);
this.drawGrid(
this._rightInv,
invWidth,
invHeight,
this._rightGridOffsetX,
this._rightGridOffsetY,
);
this.setupItemSpawner();
this.add
.text(
width / 2,
30,
"Inventory Signal Test (4x6)",
"Inventory Signal Test (4x6 x2)",
GRID_CONFIG.TITLE_STYLE,
)
.setOrigin(0.5);
@@ -45,24 +90,28 @@ export class InventoryTestScene extends ReactiveScene {
.text(
width / 2,
height - 40,
"Drag items to move them",
"Drag items between inventories",
GRID_CONFIG.SUBTITLE_STYLE,
)
.setOrigin(0.5);
}
private drawGrid(invWidth: number, invHeight: number): void {
private drawGrid(
inv: InventorySignal,
invWidth: number,
invHeight: number,
offsetX: number,
offsetY: number,
): void {
const graphics = this.add.graphics();
this.addEffect(() => {
for (let y = 0; y < invHeight; y++) {
for (let x = 0; x < invWidth; x++) {
const px = this.gridOffsetX + x * GRID_CONFIG.VIEWER_CELL_SIZE;
const py = this.gridOffsetY + y * GRID_CONFIG.VIEWER_CELL_SIZE;
const px = offsetX + x * GRID_CONFIG.VIEWER_CELL_SIZE;
const py = offsetY + y * GRID_CONFIG.VIEWER_CELL_SIZE;
const isOccupied = this.inventorySignal.value.occupiedCells.has(
`${x},${y}`,
);
const isOccupied = inv.value.occupiedCells.has(`${x},${y}`);
graphics.fillStyle(
isOccupied
? GRID_CONFIG.CELL_OCCUPIED_COLOR
@@ -87,28 +136,7 @@ export class InventoryTestScene extends ReactiveScene {
}
private setupItemSpawner(): void {
const spawner = createInventoryItemSpawner(
this,
this.inventorySignal,
this.gridOffsetX,
this.gridOffsetY,
{
onMoveItem: (
itemId: string,
newX: number,
newY: number,
newRotation: number,
) => {
return moveItem(
this.inventorySignal,
itemId,
newX,
newY,
newRotation,
);
},
},
);
const spawner = createInventoryItemSpawner(this, this._surfaces);
this.disposables.add(spawner);
}
@@ -127,29 +155,29 @@ export class InventoryTestScene extends ReactiveScene {
createButton({
scene: this,
label: "添加道具",
x: width - 300,
label: "添加道具(左)",
x: width - 350,
y: 40,
onClick: () => {
this.addRandomItem();
this.addRandomItem(this._leftInv);
},
});
createButton({
scene: this,
label: "移除最后一个",
x: width - 150,
label: "添加道具(右)",
x: width - 180,
y: 40,
onClick: () => {
this.removeLastItem();
this.addRandomItem(this._rightInv);
},
});
}
private addRandomItem(): void {
private addRandomItem(inv: InventorySignal): void {
const items = data.desert.getItems();
this.inventorySignal.produce((inventory) => {
inv.produce((inventory) => {
const usedIndices = new Set<number>();
for (const item of inventory.items.values()) {
@@ -173,17 +201,4 @@ export class InventoryTestScene extends ReactiveScene {
createItemIn(inventory, id, itemData);
});
}
private removeLastItem(): void {
this.inventorySignal.produce((inventory) => {
const items = Array.from(inventory.items.entries());
if (items.length === 0) {
return;
}
const [lastId] = items[items.length - 1];
inventory.items.delete(lastId);
});
}
}
@@ -1,4 +1,5 @@
export enum SceneKey {
CombatTestScene = "CombatTestScene",
GridViewerScene = "GridViewerScene",
IndexScene = "IndexScene",
InventoryTestScene = "InventoryTestScene",
@@ -7,37 +7,37 @@ import {
} from "boardgame-core/samples/slay-the-spire-like";
export class InventoryItemState {
private readonly _item: Signal<GameItem | undefined>;
private readonly _item: Signal<GameItem>;
private readonly _previewRotation: Signal<number>;
readonly name: ReadonlySignal<string>;
readonly shape: ReadonlySignal<ParsedShape | undefined>;
readonly shape: ReadonlySignal<ParsedShape>;
readonly color: ReadonlySignal<number>;
readonly transform: ReadonlySignal<Transform2D | undefined>;
readonly transform: ReadonlySignal<Transform2D>;
readonly previewRotation: ReadonlySignal<number>;
constructor(initialItem?: GameItem) {
constructor(initialItem: GameItem) {
this._item = signal(initialItem);
this._previewRotation = signal(0);
this.name = computed(() => {
const item = this._item.value;
return item?.meta?.itemData.name ?? item?.id ?? "";
return item.meta?.itemData.name ?? item?.id ?? "";
});
this.shape = computed(() => this._item.value?.shape);
this.shape = computed(() => this._item.value.shape);
this.color = computed(() => this.computeColor(this._item.value?.id ?? ""));
this.color = computed(() => this.computeColor(this._item.value.id ?? ""));
this.transform = computed(() => this._item.value?.transform);
this.transform = computed(() => this._item.value.transform);
this.previewRotation = computed(() => {
const base = this._item.value?.transform?.rotation ?? 0;
const base = this._item.value.transform.rotation ?? 0;
return (base + this._previewRotation.value) % 360;
});
}
get item(): GameItem | undefined {
get item(): GameItem {
return this._item.value;
}
@@ -0,0 +1,64 @@
import {
buildCombatState,
createRunState,
createStart,
createTriggers,
data,
generateDeckFromInventory,
getAdjacentItems,
getItemEffects,
IRunContext,
} from "boardgame-core/samples/slay-the-spire-like";
import { createInventorySignal } from "./inventory";
export type CombatModule = ReturnType<typeof createCombatModule>;
export function createCombatModule() {
const inventory = createInventorySignal(true);
const deck = generateDeckFromInventory(inventory.value);
const run = createRunState();
const encounter = data.desert.getEncounters()[0];
const effects = getItemEffects(inventory.value);
const combat = buildCombatState(
{
hp: run.currentHp,
maxHp: run.maxHp,
itemEffects: effects,
},
deck,
encounter,
);
const runContext: IRunContext = {
getItemData(id: string) {
return inventory.value.items.get(id)?.meta?.itemData || null;
},
getAdjacentItems(id: string) {
return getAdjacentItems(inventory.value, id).keys();
},
getConsumedUses(id: string) {
return inventory.value.items.get(id)?.meta?.consumedUses || 0;
},
async setConsumedUsesAsync(id: string, uses: number) {
await inventory.produceAsync((draft) => {
const item = draft.items.get(id);
if (!item?.meta) return;
item.meta.consumedUses = uses;
});
},
};
const triggers = createTriggers(runContext);
data.desert.addTriggers(triggers, runContext);
const start = createStart(triggers, runContext);
return {
start,
createInitialState: () => combat,
triggers,
runContext,
};
}
+20 -11
View File
@@ -6,10 +6,11 @@ import {
data,
GameItemMeta,
placeItem,
removeItemFromGrid,
removeItem,
Transform2D,
validatePlacement,
} from "boardgame-core/samples/slay-the-spire-like";
import { batch } from "@preact/signals-core";
function genId() {
return Math.random().toString(16).slice(-8);
@@ -17,13 +18,15 @@ function genId() {
export type InventorySignal = ReturnType<typeof createInventorySignal>;
export function createInventorySignal() {
export function createInventorySignal(giveStart = false) {
const inventory = createGridInventory<GameItemMeta>(4, 6);
if (giveStart) {
const startingItems = data.desert.getStartingItems();
for (const d of startingItems) {
createItemIn(inventory, `${d.id}-${genId()}`, d);
}
}
return mutableSignal(inventory);
}
@@ -33,13 +36,14 @@ export function createInventorySignal() {
* Returns true if the move was successful, false if the new position is invalid.
*/
export function moveItem(
inventorySignal: InventorySignal,
from: InventorySignal,
to: InventorySignal,
itemId: string,
newX: number,
newY: number,
newRotation?: number,
): boolean {
const inventory = inventorySignal.value;
const inventory = from.value;
const item = inventory.items.get(itemId);
if (!item) {
@@ -53,17 +57,22 @@ export function moveItem(
flipY: false,
};
const removed = create(inventory, (inv) => {
removeItemFromGrid(inv, itemId);
const removed = create(to.value, (inv) => {
removeItem(inv, itemId);
});
const validation = validatePlacement(removed, item.shape, newTransform);
if (!validation.valid) return false;
inventorySignal.produce((inv) => {
const item = inv.items.get(itemId)!;
removeItemFromGrid(inv, itemId);
item.transform = newTransform;
placeItem(inv, item);
batch(() => {
from.produce((inv) => {
removeItem(inv, itemId);
});
to.produce((inv) => {
placeItem(inv, {
...item,
transform: newTransform,
});
});
});
return true;
@@ -0,0 +1,68 @@
import { InventorySignal } from "./inventory";
import { MutableSignal, mutableSignal } from "boardgame-core";
export type InventorySurface = {
invSignal: InventorySignal;
gridOffsetX: number;
gridOffsetY: number;
cellSize: number;
};
export class InventorySurfaceState {
private readonly _signal: MutableSignal<InventorySurface>;
constructor(init: InventorySurface) {
this._signal = mutableSignal(init);
}
public get surface() {
return this._signal.value;
}
public get invSignal() {
return this._signal.value.invSignal;
}
public get gridOffsetX() {
return this._signal.value.gridOffsetX;
}
public get gridOffsetY() {
return this._signal.value.gridOffsetY;
}
public get cellSize() {
return this._signal.value.cellSize;
}
public setSurface(surface: InventorySurface) {
this._signal.value = surface;
}
}
export function sceneToInventory(
surface: InventorySurface | InventorySurfaceState,
x: number,
y: number,
): { x: number; y: number } | null {
const invX = Math.round((x - surface.gridOffsetX) / surface.cellSize);
const invY = Math.round((y - surface.gridOffsetY) / surface.cellSize);
const { width, height } = surface.invSignal.peek();
if (invX < 0 || invY < 0 || invX >= width || invY >= height) {
return null;
}
return { x: invX, y: invY };
}
export function inventoryToScene(
surface: InventorySurface | InventorySurfaceState,
x: number,
y: number,
): { x: number; y: number } {
return {
x: x * surface.cellSize + surface.gridOffsetX,
y: y * surface.cellSize + surface.gridOffsetY,
};
}
+22 -5
View File
@@ -1,17 +1,34 @@
import { PhaserGame, PhaserScene } from "boardgame-phaser";
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 { 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";
export default function App() {
const gameHost = useMemo(
() =>
createGameHost<CombatState, CombatResult | null, CombatModule>(
createCombatModule(),
),
[],
);
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} data={{ gameHost }} />
<PhaserScene scene={InventoryTestScene} />
<PhaserScene scene={MapViewerScene} />
<PhaserScene scene={GridViewerScene} />
+55 -37
View File
@@ -25,7 +25,7 @@ importers:
version: 8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)
vitest:
specifier: ^3.2.4
version: 3.2.4(lightningcss@1.32.0)
version: 3.2.4(@types/node@25.6.0)(lightningcss@1.32.0)
packages/boop-game:
dependencies:
@@ -53,13 +53,13 @@ importers:
devDependencies:
'@preact/preset-vite':
specifier: ^2.8.1
version: 2.10.5(@babel/core@7.29.0)(preact@10.29.0)(rollup@4.60.1)(vite@5.4.21(lightningcss@1.32.0))
version: 2.10.5(@babel/core@7.29.0)(preact@10.29.0)(rollup@4.60.1)(vite@5.4.21(@types/node@25.6.0)(lightningcss@1.32.0))
'@preact/signals':
specifier: ^2.9.0
version: 2.9.0(preact@10.29.0)
'@tailwindcss/vite':
specifier: ^4.0.0
version: 4.2.2(vite@5.4.21(lightningcss@1.32.0))
version: 4.2.2(vite@5.4.21(@types/node@25.6.0)(lightningcss@1.32.0))
tailwindcss:
specifier: ^4.0.0
version: 4.2.2
@@ -68,7 +68,7 @@ importers:
version: 5.9.3
vite:
specifier: ^5.1.0
version: 5.4.21(lightningcss@1.32.0)
version: 5.4.21(@types/node@25.6.0)(lightningcss@1.32.0)
packages/framework:
devDependencies:
@@ -98,7 +98,7 @@ importers:
version: 5.9.3
vitest:
specifier: ^3.2.4
version: 3.2.4(lightningcss@1.32.0)
version: 3.2.4(@types/node@25.6.0)(lightningcss@1.32.0)
packages/onitama-game:
dependencies:
@@ -123,13 +123,13 @@ importers:
devDependencies:
'@preact/preset-vite':
specifier: ^2.8.1
version: 2.10.5(@babel/core@7.29.0)(preact@10.29.0)(rollup@4.60.1)(vite@5.4.21(lightningcss@1.32.0))
version: 2.10.5(@babel/core@7.29.0)(preact@10.29.0)(rollup@4.60.1)(vite@5.4.21(@types/node@25.6.0)(lightningcss@1.32.0))
'@preact/signals':
specifier: ^2.9.0
version: 2.9.0(preact@10.29.0)
'@tailwindcss/vite':
specifier: ^4.0.0
version: 4.2.2(vite@5.4.21(lightningcss@1.32.0))
version: 4.2.2(vite@5.4.21(@types/node@25.6.0)(lightningcss@1.32.0))
tailwindcss:
specifier: ^4.0.0
version: 4.2.2
@@ -138,7 +138,7 @@ importers:
version: 5.9.3
vite:
specifier: ^5.1.0
version: 5.4.21(lightningcss@1.32.0)
version: 5.4.21(@types/node@25.6.0)(lightningcss@1.32.0)
packages/regicide-game:
dependencies:
@@ -163,13 +163,13 @@ importers:
devDependencies:
'@preact/preset-vite':
specifier: ^2.8.1
version: 2.10.5(@babel/core@7.29.0)(preact@10.29.0)(rollup@4.60.1)(vite@5.4.21(lightningcss@1.32.0))
version: 2.10.5(@babel/core@7.29.0)(preact@10.29.0)(rollup@4.60.1)(vite@5.4.21(@types/node@25.6.0)(lightningcss@1.32.0))
'@preact/signals':
specifier: ^2.9.0
version: 2.9.0(preact@10.29.0)
'@tailwindcss/vite':
specifier: ^4.0.0
version: 4.2.2(vite@5.4.21(lightningcss@1.32.0))
version: 4.2.2(vite@5.4.21(@types/node@25.6.0)(lightningcss@1.32.0))
tailwindcss:
specifier: ^4.0.0
version: 4.2.2
@@ -178,10 +178,10 @@ importers:
version: 5.9.3
vite:
specifier: ^5.1.0
version: 5.4.21(lightningcss@1.32.0)
version: 5.4.21(@types/node@25.6.0)(lightningcss@1.32.0)
vitest:
specifier: ^3.2.4
version: 3.2.4(lightningcss@1.32.0)
version: 3.2.4(@types/node@25.6.0)(lightningcss@1.32.0)
packages/sample-game:
dependencies:
@@ -206,13 +206,13 @@ importers:
devDependencies:
'@preact/preset-vite':
specifier: ^2.8.1
version: 2.10.5(@babel/core@7.29.0)(preact@10.29.0)(rollup@4.60.1)(vite@5.4.21(lightningcss@1.32.0))
version: 2.10.5(@babel/core@7.29.0)(preact@10.29.0)(rollup@4.60.1)(vite@5.4.21(@types/node@25.6.0)(lightningcss@1.32.0))
'@preact/signals':
specifier: ^2.9.0
version: 2.9.0(preact@10.29.0)
'@tailwindcss/vite':
specifier: ^4.0.0
version: 4.2.2(vite@5.4.21(lightningcss@1.32.0))
version: 4.2.2(vite@5.4.21(@types/node@25.6.0)(lightningcss@1.32.0))
tailwindcss:
specifier: ^4.0.0
version: 4.2.2
@@ -221,7 +221,7 @@ importers:
version: 5.9.3
vite:
specifier: ^5.1.0
version: 5.4.21(lightningcss@1.32.0)
version: 5.4.21(@types/node@25.6.0)(lightningcss@1.32.0)
packages/sts-like-viewer:
dependencies:
@@ -246,13 +246,16 @@ importers:
devDependencies:
'@preact/preset-vite':
specifier: ^2.8.1
version: 2.10.5(@babel/core@7.29.0)(preact@10.29.0)(rollup@4.60.1)(vite@5.4.21(lightningcss@1.32.0))
version: 2.10.5(@babel/core@7.29.0)(preact@10.29.0)(rollup@4.60.1)(vite@5.4.21(@types/node@25.6.0)(lightningcss@1.32.0))
'@preact/signals':
specifier: ^2.9.0
version: 2.9.0(preact@10.29.0)
'@tailwindcss/vite':
specifier: ^4.0.0
version: 4.2.2(vite@5.4.21(lightningcss@1.32.0))
version: 4.2.2(vite@5.4.21(@types/node@25.6.0)(lightningcss@1.32.0))
'@types/node':
specifier: ^25.6.0
version: 25.6.0
tailwindcss:
specifier: ^4.0.0
version: 4.2.2
@@ -261,7 +264,7 @@ importers:
version: 5.9.3
vite:
specifier: ^5.1.0
version: 5.4.21(lightningcss@1.32.0)
version: 5.4.21(@types/node@25.6.0)(lightningcss@1.32.0)
packages:
@@ -1020,6 +1023,9 @@ packages:
'@types/json5@0.0.29':
resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
'@types/node@25.6.0':
resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==}
'@typescript-eslint/eslint-plugin@8.58.2':
resolution: {integrity: sha512-aC2qc5thQahutKjP+cl8cgN9DWe3ZUqVko30CMSZHnFEHyhOYoZSzkGtAI2mcwZ38xeImDucI4dnqsHiOYuuCw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -2337,6 +2343,9 @@ packages:
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
engines: {node: '>= 0.4'}
undici-types@7.19.2:
resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==}
update-browserslist-db@1.2.3:
resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
hasBin: true
@@ -2814,19 +2823,19 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@preact/preset-vite@2.10.5(@babel/core@7.29.0)(preact@10.29.0)(rollup@4.60.1)(vite@5.4.21(lightningcss@1.32.0))':
'@preact/preset-vite@2.10.5(@babel/core@7.29.0)(preact@10.29.0)(rollup@4.60.1)(vite@5.4.21(@types/node@25.6.0)(lightningcss@1.32.0))':
dependencies:
'@babel/core': 7.29.0
'@babel/plugin-transform-react-jsx': 7.28.6(@babel/core@7.29.0)
'@babel/plugin-transform-react-jsx-development': 7.27.1(@babel/core@7.29.0)
'@prefresh/vite': 2.4.12(preact@10.29.0)(vite@5.4.21(lightningcss@1.32.0))
'@prefresh/vite': 2.4.12(preact@10.29.0)(vite@5.4.21(@types/node@25.6.0)(lightningcss@1.32.0))
'@rollup/pluginutils': 5.3.0(rollup@4.60.1)
babel-plugin-transform-hook-names: 1.0.2(@babel/core@7.29.0)
debug: 4.4.3
magic-string: 0.30.21
picocolors: 1.1.1
vite: 5.4.21(lightningcss@1.32.0)
vite-prerender-plugin: 0.5.13(vite@5.4.21(lightningcss@1.32.0))
vite: 5.4.21(@types/node@25.6.0)(lightningcss@1.32.0)
vite-prerender-plugin: 0.5.13(vite@5.4.21(@types/node@25.6.0)(lightningcss@1.32.0))
zimmerframe: 1.1.4
transitivePeerDependencies:
- preact
@@ -2848,7 +2857,7 @@ snapshots:
'@prefresh/utils@1.2.1': {}
'@prefresh/vite@2.4.12(preact@10.29.0)(vite@5.4.21(lightningcss@1.32.0))':
'@prefresh/vite@2.4.12(preact@10.29.0)(vite@5.4.21(@types/node@25.6.0)(lightningcss@1.32.0))':
dependencies:
'@babel/core': 7.29.0
'@prefresh/babel-plugin': 0.5.3
@@ -2856,7 +2865,7 @@ snapshots:
'@prefresh/utils': 1.2.1
'@rollup/pluginutils': 4.2.1
preact: 10.29.0
vite: 5.4.21(lightningcss@1.32.0)
vite: 5.4.21(@types/node@25.6.0)(lightningcss@1.32.0)
transitivePeerDependencies:
- supports-color
@@ -3011,12 +3020,12 @@ snapshots:
'@tailwindcss/oxide-win32-arm64-msvc': 4.2.2
'@tailwindcss/oxide-win32-x64-msvc': 4.2.2
'@tailwindcss/vite@4.2.2(vite@5.4.21(lightningcss@1.32.0))':
'@tailwindcss/vite@4.2.2(vite@5.4.21(@types/node@25.6.0)(lightningcss@1.32.0))':
dependencies:
'@tailwindcss/node': 4.2.2
'@tailwindcss/oxide': 4.2.2
tailwindcss: 4.2.2
vite: 5.4.21(lightningcss@1.32.0)
vite: 5.4.21(@types/node@25.6.0)(lightningcss@1.32.0)
'@types/chai@5.2.3':
dependencies:
@@ -3031,6 +3040,10 @@ snapshots:
'@types/json5@0.0.29': {}
'@types/node@25.6.0':
dependencies:
undici-types: 7.19.2
'@typescript-eslint/eslint-plugin@8.58.2(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
@@ -3130,13 +3143,13 @@ snapshots:
chai: 5.3.3
tinyrainbow: 2.0.0
'@vitest/mocker@3.2.4(vite@5.4.21(lightningcss@1.32.0))':
'@vitest/mocker@3.2.4(vite@5.4.21(@types/node@25.6.0)(lightningcss@1.32.0))':
dependencies:
'@vitest/spy': 3.2.4
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 5.4.21(lightningcss@1.32.0)
vite: 5.4.21(@types/node@25.6.0)(lightningcss@1.32.0)
'@vitest/pretty-format@3.2.4':
dependencies:
@@ -4567,6 +4580,8 @@ snapshots:
has-symbols: 1.1.0
which-boxed-primitive: 1.1.1
undici-types@7.19.2: {}
update-browserslist-db@1.2.3(browserslist@4.28.2):
dependencies:
browserslist: 4.28.2
@@ -4577,13 +4592,13 @@ snapshots:
dependencies:
punycode: 2.3.1
vite-node@3.2.4(lightningcss@1.32.0):
vite-node@3.2.4(@types/node@25.6.0)(lightningcss@1.32.0):
dependencies:
cac: 6.7.14
debug: 4.4.3
es-module-lexer: 1.7.0
pathe: 2.0.3
vite: 5.4.21(lightningcss@1.32.0)
vite: 5.4.21(@types/node@25.6.0)(lightningcss@1.32.0)
transitivePeerDependencies:
- '@types/node'
- less
@@ -4595,7 +4610,7 @@ snapshots:
- supports-color
- terser
vite-prerender-plugin@0.5.13(vite@5.4.21(lightningcss@1.32.0)):
vite-prerender-plugin@0.5.13(vite@5.4.21(@types/node@25.6.0)(lightningcss@1.32.0)):
dependencies:
kolorist: 1.8.0
magic-string: 0.30.21
@@ -4603,22 +4618,23 @@ snapshots:
simple-code-frame: 1.3.0
source-map: 0.7.6
stack-trace: 1.0.0-pre2
vite: 5.4.21(lightningcss@1.32.0)
vite: 5.4.21(@types/node@25.6.0)(lightningcss@1.32.0)
vite@5.4.21(lightningcss@1.32.0):
vite@5.4.21(@types/node@25.6.0)(lightningcss@1.32.0):
dependencies:
esbuild: 0.21.5
postcss: 8.5.8
rollup: 4.60.1
optionalDependencies:
'@types/node': 25.6.0
fsevents: 2.3.3
lightningcss: 1.32.0
vitest@3.2.4(lightningcss@1.32.0):
vitest@3.2.4(@types/node@25.6.0)(lightningcss@1.32.0):
dependencies:
'@types/chai': 5.2.3
'@vitest/expect': 3.2.4
'@vitest/mocker': 3.2.4(vite@5.4.21(lightningcss@1.32.0))
'@vitest/mocker': 3.2.4(vite@5.4.21(@types/node@25.6.0)(lightningcss@1.32.0))
'@vitest/pretty-format': 3.2.4
'@vitest/runner': 3.2.4
'@vitest/snapshot': 3.2.4
@@ -4636,9 +4652,11 @@ snapshots:
tinyglobby: 0.2.15
tinypool: 1.1.1
tinyrainbow: 2.0.0
vite: 5.4.21(lightningcss@1.32.0)
vite-node: 3.2.4(lightningcss@1.32.0)
vite: 5.4.21(@types/node@25.6.0)(lightningcss@1.32.0)
vite-node: 3.2.4(@types/node@25.6.0)(lightningcss@1.32.0)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 25.6.0
transitivePeerDependencies:
- less
- lightningcss