Compare commits

...
41 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
hypercross fe0621cedf feat(sts-like-viewer): animate inventory item rotation
Introduce a tween for the item's rotation and update the hit area
calculation to account for the preview rotation. The graphics are now
centered within the container to support these transformations.
2026-04-20 22:15:05 +08:00
hypercross 30e2d9ac36 refactor: improve inventory item interaction and state
- Decouple hit area calculation from the rendering effect in
  `InventoryItemContainer`.
- Implement a dedicated `hitArea` property to track cell rectangles.
- Update `setupInteractive` to use the container itself as the hit area
  with a custom callback.
- Move drag-and-drop logic into a dedicated `setupDnDEffect` method.
- Add `addPreviewRotation` to `InventoryItemState` for cleaner rotation
  updates.
- Normalize `previewRotation` to stay within 0-360 degrees.
2026-04-20 21:59:08 +08:00
hypercross c9f573ef86 feat(sts-viewer): add item rotation support in inventory
Introduces the ability to rotate inventory items using the Alt button
during drag-and-drop.

- Updates `InventoryItemContainer` to handle rotation via
  `previewRotation`
- Adds `newRotation` parameter to `onMoveItem` callback and `moveItem`
  command
- Updates `InventoryItemState` to manage rotation state and compute
  transformed shapes
- Implements rotation logic in `renderGraphics` using `transformShape`
2026-04-20 21:40:23 +08:00
hypercross cc796d1e11 feat(framework): support multi-button drag and drop
Adds support for tracking the specific mouse button used during
drag-and-drop operations. This includes a new `ALTBUTTON` event type
to handle cases where a different button is pressed while a drag is
already in progress, and disables the browser context menu by default
in the Phaser configuration to prevent interference.
2026-04-20 20:33:56 +08:00
hypercross fd347e7a62 refactor: use InventoryItemState in InventoryItemContainer
Replace local signal-based state management with the dedicated
`InventoryItemState` class to encapsulate item properties and
logic.
2026-04-20 19:50:25 +08:00
hypercross 19dfafeb30 refactor: implement cell-based hit area for inventory items
Update `InventoryItemContainer` to use a custom hit area callback
based on individual cell rectangles instead of a single bounding
box. This allows for more precise interaction with non-rectangular
item shapes.
2026-04-20 19:45:01 +08:00
hypercross b2862e34d9 fix: improve inventory item drag-and-drop behavior
- Use `snapBack` instead of immediate coordinate reset when a drag fails
- Adjust grid snapping calculation to use center-relative coordinates
- Validate item placement by simulating removal from the grid first
2026-04-20 19:22:55 +08:00
hypercross fd41db2a89 fix(sts-like-viewer): defer interactive setup until shape is available
Delay calling `setupInteractive` in `InventoryItemContainer` until
the item shape is actually rendered to ensure the hit area matches
the visual representation.
2026-04-20 18:49:56 +08:00
hypercross f03e6c5a7b refactor: move InventoryItemSpawner and update grid drawing
- Move `InventoryItemSpawner` to `@/gameobjects`
- Wrap grid drawing in `addEffect` in `InventoryTestScene` to ensure
  reactivity
- Fix return type in `onMoveItem` callback
2026-04-20 18:44:50 +08:00
hypercross 7039938a72 refactor: delegate signal effects to DisposableBag
Introduce `addEffect` to `DisposableBag` to encapsulate Preact
signal effect creation and disposal. Update `ReactiveScene` to use
this method, simplifying effect management and ensuring proper
cleanup.
2026-04-20 18:18:26 +08:00
hypercross 45844ea800 fix(sts-viewer): use local coordinates for inventory item container
Update `InventoryItemSpawner` to draw graphics and text relative to the
container's local coordinate system instead of world coordinates.
The container is then positioned at the first cell's world position.
This also fixes drag positioning by calculating movement relative to
the initial drag start point.
2026-04-20 17:20:30 +08:00
hypercross 29b516c371 feat(sts-viewer): implement drag-and-drop for inventory items
Adds interactive drag-and-drop functionality to the inventory spawner,
allowing users to move items within the grid.

- Implements `InventoryItemSpawnerCallbacks` to handle item movement
- Adds `moveItem` utility to update inventory state via signals
- Integrates `dragDropEventEffect` for item interaction
- Updates `InventoryTestScene` to demonstrate movement
- Adjusts text colors in `GRID_CONFIG` for better visibility
2026-04-20 17:06:46 +08:00
hypercross 5884e74d8d refactor: implement inventory item spawner and config
- Add cell and text styling constants to `GRID_CONFIG`
- Replace manual item drawing in `InventoryTestScene` with
  `InventoryItemSpawner`
- Update `InventoryTestScene` to use dynamic inventory dimensions for
  grid rendering
- Register `InventoryTestScene` in the main `App` component
2026-04-20 16:45:14 +08:00
hypercross b06876ccc0 feat(sts-like-viewer): add inventory test scene
Implement `InventoryTestScene` to demonstrate reactive inventory
management using signals. Includes a grid-based visualization of items
and controls to add or remove items dynamically.
2026-04-20 16:34:03 +08:00
hypercross 368d9942d2 refactor(onitama): centralize visual constants in config
Introduce `TEXT_POSITION` and `VISUAL` objects to the configuration
to manage magic numbers for positioning, radii, stroke widths, and
alphas. Update renderers and spawners to use these constants instead
of hardcoded values or direct calculations.
2026-04-20 16:04:29 +08:00
hypercross 22817945cc fix(framework): access scene key via sys.settings in PhaserScene
Use `scene.sys.settings.key` instead of `scene.scene.key` to correctly
retrieve the scene key from the Phaser scene instance.
2026-04-20 15:47:56 +08:00
hypercross 85bc4f5400 refactor: remove unused state and widget files in sts-like-viewer 2026-04-20 15:44:09 +08:00
hypercross 033a8e4a40 refactor: clean up scenes and introduce SceneKey enum
- Remove redundant `GameFlowScene` and `PlaceholderEncounterScene`
- Introduce `SceneKey` enum for type-safe scene management
- Clean up unused imports and configuration references in existing
  scenes
- Standardize scene navigation using `SceneKey`
2026-04-20 15:36:12 +08:00
hypercross 1d803dd219 refactor(sts-like-viewer): simplify scene management and config
- Move configuration to `src/config/index.ts`
- Simplify `App.tsx` by passing scene classes directly to `PhaserScene`
- Update `GameUI` initialization to use string ID for container
- Update documentation to mention `initData` for `ReactiveScene`
- Standardize quote usage and formatting in config files
2026-04-20 15:24:07 +08:00
hypercross 2d412eedb5 refactor(framework): improve PhaserScene flexibility
Allow `PhaserScene` to accept either a scene instance or a scene
constructor. This enables more flexible scene registration within
the `PhaserBridge`. Also updates `sceneKey` to be optional,
defaulting to the scene's internal key.
2026-04-20 15:11:45 +08:00
hypercross f32aca2543 refactor: update eslint configuration import for @eslint/js 2026-04-19 14:59:55 +08:00
hypercross 34a7cff964 refactor: remove inventory widget and drag controller
Removes the inventory management system and drag-and-drop
functionality from the STS-like viewer. This includes deleting the
`InventoryWidget`, `InventoryItemSpawner`, and `DragController`
classes, as well as cleaning up references in
`PlaceholderEncounterScene`.
2026-04-19 14:59:39 +08:00
52 changed files with 2314 additions and 2101 deletions
+1 -1
View File
@@ -32,8 +32,8 @@ packages/my-game/
### 1. ReactiveScene / GameHostScene
Extend `ReactiveScene`(`packages\framework\src\scenes\ReactiveScene.ts`) to use reactive integration features.
- Access game context for scene navigation
- Use `this.disposables` for auto-cleanup on shutdown.
- Use `initData` for context data passed from the `<PhaserScene/>` preact component.
### 2. Spawner Pattern
Implement `Spawner<TData, TObj>` for data-driven objects.
+1 -1
View File
@@ -1,4 +1,4 @@
import js from "@eslint/js";
import * as js from "@eslint";
import tseslint from "typescript-eslint";
import importPlugin from "eslint-plugin-import";
+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 未提供。` +
@@ -1,4 +1,4 @@
import { effect, type ReadonlySignal } from "@preact/signals-core";
import { type ReadonlySignal } from "@preact/signals-core";
import { Scene } from "phaser";
import { DisposableBag, type IDisposable } from "../utils";
@@ -85,6 +85,6 @@ export abstract class ReactiveScene<TData = object>
/** 注册响应式监听(场景关闭时自动清理) */
public addEffect(fn: () => CleanupFn): void {
this.disposables.add(effect(fn));
this.disposables.addEffect(fn);
}
}
+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;
}
+21 -17
View File
@@ -1,15 +1,15 @@
import { signal, useSignal, useSignalEffect } from '@preact/signals';
import Phaser, { AUTO } from 'phaser';
import { createContext } from 'preact';
import { useContext, useEffect, useRef } from 'preact/hooks';
import { signal, useSignal, useSignalEffect } from "@preact/signals";
import Phaser, { AUTO, Scene } from "phaser";
import { createContext } from "preact";
import { useContext, useEffect, useRef } from "preact/hooks";
import {
FadeScene as FadeSceneClass,
FADE_SCENE_KEY,
} from '../scenes/FadeScene';
} from "../scenes/FadeScene";
import type { ReactiveScene } from '../scenes';
import type { ReadonlySignal } from '@preact/signals-core';
import type { ReactiveScene } from "../scenes";
import type { ReadonlySignal } from "@preact/signals-core";
export interface SceneController {
/** 启动场景(带淡入淡出过渡) */
@@ -34,9 +34,10 @@ export const defaultPhaserConfig: Phaser.Types.Core.GameConfig = {
type: AUTO,
width: 560,
height: 560,
parent: 'phaser-container',
backgroundColor: '#f9fafb',
parent: "phaser-container",
backgroundColor: "#f9fafb",
scene: [],
disableContextMenu: true,
};
export interface PhaserGameProps {
@@ -71,7 +72,7 @@ export function PhaserGame(props: PhaserGameProps) {
const sceneController: SceneController = {
async launch(sceneKey: string) {
if (isTransitioning.value) {
console.warn('SceneController: 正在进行场景切换');
console.warn("SceneController: 正在进行场景切换");
return;
}
@@ -120,12 +121,12 @@ export function PhaserGame(props: PhaserGameProps) {
},
async restart() {
if (isTransitioning.value) {
console.warn('SceneController: 正在进行场景切换');
console.warn("SceneController: 正在进行场景切换");
return;
}
if (!currentScene.value) {
console.warn('SceneController: 没有当前场景,无法 restart');
console.warn("SceneController: 没有当前场景,无法 restart");
return;
}
@@ -192,8 +193,8 @@ export function PhaserGame(props: PhaserGameProps) {
}
export interface PhaserSceneProps<TData = {}> {
sceneKey: string;
scene: ReactiveScene<TData>;
sceneKey?: string;
scene: ReactiveScene<TData> | { new (): ReactiveScene<TData> };
data?: TData;
children?: any;
}
@@ -214,16 +215,19 @@ export function PhaserScene<TData = {}>(props: PhaserSceneProps<TData>) {
const game = ctx.game;
// 注册场景到 Phaser(但不启动)
if (!game.scene.getScene(props.sceneKey)) {
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 = {
...props.data,
phaserGame: phaserGameSignal,
sceneController: ctx.sceneController,
};
game.scene.add(props.sceneKey, props.scene, false, initData);
game.scene.add(sceneKey, props.scene, false, initData);
}
sceneSignal.value = props.scene;
sceneSignal.value = scene;
registered.current = true;
return () => {
@@ -1,3 +1,5 @@
import { effect } from "@preact/signals-core";
export interface IDisposable {
dispose(): void;
}
@@ -8,6 +10,10 @@ export class DisposableBag implements IDisposable {
private _disposables = new Set<DisposableItem>();
private _isDisposed = false;
constructor(go?: Phaser.GameObjects.GameObject) {
if (go) go.on("shutdown", () => this.dispose());
}
get isDisposed(): boolean {
return this._isDisposed;
}
@@ -20,6 +26,10 @@ export class DisposableBag implements IDisposable {
this._disposables.add(item);
}
addEffect(fn: () => void) {
this.add(effect(fn));
}
dispose(): void {
if (this._isDisposed) return;
+31 -5
View File
@@ -2,6 +2,7 @@
type PointerRecord = {
id: number;
button: number;
x: number;
y: number;
};
@@ -10,6 +11,7 @@ export enum DragDropEventType {
DOWN,
UP,
MOVE,
ALTBUTTON,
}
export type DragDropEvent = {
@@ -29,9 +31,22 @@ export function dragDropEventEffect(
let down: PointerRecord | null = null;
function onPointerDown(pointer: Phaser.Input.Pointer) {
if (isDragging) return;
if (down !== null) {
if (pointer.button === down.button) return;
callback({
type: DragDropEventType.ALTBUTTON,
deltaX: pointer.x - down.x,
deltaY: pointer.y - down.y,
});
return;
}
isDragging = true;
down = { id: pointer.id, x: pointer.x, y: pointer.y };
down = {
id: pointer.id,
button: pointer.button,
x: pointer.x,
y: pointer.y,
};
const event: DragDropEvent = {
type: DragDropEventType.DOWN,
@@ -42,12 +57,23 @@ export function dragDropEventEffect(
}
function onPointerUp(pointer: Phaser.Input.Pointer) {
if (!isDragging || !down || pointer.id !== down.id) return;
if (
!isDragging ||
!down ||
pointer.id !== down.id ||
pointer.button !== down.button
)
return;
isDragging = false;
const deltaX = pointer.x - down.x;
const deltaY = pointer.y - down.y;
const event: DragDropEvent = { type: DragDropEventType.UP, deltaX, deltaY };
isDragging = false;
const event: DragDropEvent = {
type: DragDropEventType.UP,
deltaX,
deltaY,
};
callback(event);
down = null;
}
+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";
+24
View File
@@ -113,6 +113,13 @@ export const MENU_BUTTON = {
height: 40,
} as const;
// Text positioning
export const TEXT_POSITION = {
titleX: 40,
titleY: 40,
infoX: 40,
} as const;
// Animation durations (in ms)
export const ANIMATIONS = {
pawnSpawn: 300,
@@ -139,6 +146,23 @@ export const CARD_GRID = {
gridSize: 5,
} as const;
// Visual style constants
export const VISUAL = {
pawnRadius: CELL_SIZE / 3,
pawnStrokeWidth: 2,
selectionRingOffset: 5,
selectionRingStrokeWidth: 3,
highlightOuterRadius: CELL_SIZE / 3,
highlightInnerRadius: CELL_SIZE / 4,
highlightHitAreaRadius: CELL_SIZE / 3,
cardStrokeWidth: 2,
cardTitleOffset: 16,
cardPlayerOffset: 16,
cardDisabledAlpha: 0.8,
overlayAlpha: 0.6,
cardBackgroundDepth: -1,
} as const;
// Helper function to convert board coordinates to screen coordinates
export function boardToScreen(
boardX: number,
@@ -1,7 +1,14 @@
import type { Card } from "@/game/onitama";
import type { OnitamaScene } from "@/scenes/OnitamaScene";
import { CARD_WIDTH, CARD_HEIGHT, COLORS, FONTS, CARD_GRID } from "@/config";
import {
CARD_WIDTH,
CARD_HEIGHT,
COLORS,
FONTS,
CARD_GRID,
VISUAL,
} from "@/config";
export interface CardRenderOptions {
card: Card;
@@ -28,12 +35,17 @@ export class CardRenderer {
// Create background rectangle
const bg = this.scene.add
.rectangle(0, 0, CARD_WIDTH, CARD_HEIGHT, COLORS.cardBg, 1)
.setStrokeStyle(2, COLORS.cardStroke);
.setStrokeStyle(VISUAL.cardStrokeWidth, COLORS.cardStroke);
container.add(bg);
// Create title text
const title = this.scene.add
.text(0, -CARD_HEIGHT / 2 + 16, card.id, FONTS.cardTitle)
.text(
0,
-CARD_HEIGHT / 2 + VISUAL.cardTitleOffset,
card.id,
FONTS.cardTitle,
)
.setOrigin(0.5);
container.add(title);
@@ -42,7 +54,12 @@ export class CardRenderer {
// Create starting player text
const playerText = this.scene.add
.text(0, CARD_HEIGHT / 2 - 16, card.startingPlayer, FONTS.cardPlayer)
.text(
0,
CARD_HEIGHT / 2 - VISUAL.cardPlayerOffset,
card.startingPlayer,
FONTS.cardPlayer,
)
.setOrigin(0.5);
container.add(playerText);
}
@@ -1,8 +1,8 @@
import type { OnitamaScene } from "@/scenes/OnitamaScene";
import {
CELL_SIZE,
COLORS,
VISUAL,
createHighlightInnerPulseTween,
createHighlightOuterPulseTween,
} from "@/config";
@@ -37,7 +37,7 @@ export class HighlightRenderer {
const outerCircle = this.scene.add.circle(
0,
0,
CELL_SIZE / 3,
VISUAL.highlightOuterRadius,
COLORS.black,
0.2,
);
@@ -47,7 +47,7 @@ export class HighlightRenderer {
const innerCircle = this.scene.add.circle(
0,
0,
CELL_SIZE / 4,
VISUAL.highlightInnerRadius,
COLORS.black,
0.4,
);
@@ -1,6 +1,6 @@
import type { OnitamaScene } from "@/scenes/OnitamaScene";
import { CELL_SIZE, COLORS, FONTS } from "@/config";
import { CELL_SIZE, COLORS, FONTS, VISUAL } from "@/config";
export type PawnType = "master" | "student";
export type PawnOwner = "red" | "black";
@@ -31,8 +31,8 @@ export class PawnRenderer {
// Create background circle
const bgColor = owner === "red" ? COLORS.red : COLORS.black;
const circle = this.scene.add
.circle(0, 0, CELL_SIZE / 3, bgColor, 1)
.setStrokeStyle(2, COLORS.pawnStroke);
.circle(0, 0, VISUAL.pawnRadius, bgColor, 1)
.setStrokeStyle(VISUAL.pawnStrokeWidth, COLORS.pawnStroke);
container.add(circle);
// Create label text
@@ -5,6 +5,7 @@ import type { OnitamaScene } from "@/scenes/OnitamaScene";
import {
CELL_SIZE,
COLORS,
VISUAL,
createSelectionShowTween,
createSelectionRingPulseTween,
createSelectionHideTween,
@@ -31,8 +32,21 @@ export class SelectionRenderer {
parent: Phaser.GameObjects.Container | Phaser.GameObjects.GameObject,
): Phaser.GameObjects.Arc {
const ring = this.scene.add
.arc(0, 0, CELL_SIZE / 3 + 5, 0, 360, false, COLORS.highlight, 0)
.setStrokeStyle(3, COLORS.highlightStroke, 1)
.arc(
0,
0,
VISUAL.pawnRadius + VISUAL.selectionRingOffset,
0,
360,
false,
COLORS.highlight,
0,
)
.setStrokeStyle(
VISUAL.selectionRingStrokeWidth,
COLORS.highlightStroke,
1,
)
.setAlpha(0);
// Add to parent at index 0 (behind other visuals)
@@ -10,6 +10,8 @@ import {
COLORS,
FONTS,
MENU_BUTTON,
TEXT_POSITION,
VISUAL,
getBoardCenter,
getCardLabelPosition,
colorToStr,
@@ -79,7 +81,12 @@ export class OnitamaScene extends GameHostScene<OnitamaState> {
});
// Info text
this.infoText = this.add.text(40, BOARD_OFFSET.y, "", FONTS.info);
this.infoText = this.add.text(
TEXT_POSITION.infoX,
BOARD_OFFSET.y,
"",
FONTS.info,
);
// Update info text when UI state changes
this.addEffect(() => {
@@ -149,7 +156,12 @@ export class OnitamaScene extends GameHostScene<OnitamaState> {
g.strokePath();
this.add.text(40, 40, "Onitama", FONTS.title);
this.add.text(
TEXT_POSITION.titleX,
TEXT_POSITION.titleY,
"Onitama",
FONTS.title,
);
}
private setupInput(): void {
@@ -288,7 +300,7 @@ export class OnitamaScene extends GameHostScene<OnitamaState> {
boardWidth,
boardHeight,
COLORS.overlayBg,
0.6,
VISUAL.overlayAlpha,
)
.setInteractive({ useHandCursor: true });
@@ -7,6 +7,8 @@ import type { Spawner } from "boardgame-phaser";
import {
CARD_WIDTH,
CARD_HEIGHT,
COLORS,
VISUAL,
getCardPosition,
createCardMoveTween,
createCardRotateTween,
@@ -102,7 +104,7 @@ export class CardContainer extends Phaser.GameObjects.Container {
.rectangle(0, 0, CARD_WIDTH + 8, CARD_HEIGHT + 8, color, 0)
.setStrokeStyle(lineWidth, color)
.setAlpha(0)
.setDepth(-1);
.setDepth(VISUAL.cardBackgroundDepth);
this.highlightRect = rect;
this.addAt(this.highlightRect, 0);
@@ -161,7 +163,7 @@ export class CardContainer extends Phaser.GameObjects.Container {
// 创建一个 effect 来持续监听高亮状态变化
const dispose = effect(() => {
if (scene.uiState.value.selectedCard === this._cardId) {
this.highlight(0xfbbf24, 3);
this.highlight(COLORS.highlight, VISUAL.selectionRingStrokeWidth);
} else {
this.unhighlight();
}
@@ -276,7 +278,7 @@ export class CardSpawner implements Spawner<CardSpawnData, CardContainer> {
// 设置悬停效果
container.on("pointerover", () => {
if (this.scene.uiState.value.selectedCard !== data.cardId) {
container.setAlpha(0.8);
container.setAlpha(VISUAL.cardDisabledAlpha);
}
});
@@ -7,6 +7,7 @@ import type { Spawner } from "boardgame-phaser";
import {
boardToScreen,
CELL_SIZE,
VISUAL,
createHighlightSpawnTween,
createHighlightDespawnTween,
createHighlightClickFeedbackTween,
@@ -83,7 +84,7 @@ export class HighlightSpawner implements Spawner<
this.renderer.render(container, { x: data.x, y: data.y });
// 设置交互区域
const hitArea = new Geom.Circle(0, 0, CELL_SIZE / 3);
const hitArea = new Geom.Circle(0, 0, VISUAL.highlightHitAreaRadius);
container.setInteractive(hitArea, Phaser.Geom.Circle.Contains);
if (container.input) {
container.input.cursor = "pointer";
+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"
@@ -3,6 +3,8 @@
* All magic numbers should be defined here and imported where needed.
*/
import { PhaserGameProps } from "boardgame-phaser";
// ── Map Layout ──────────────────────────────────────────────────────────────
export const MAP_CONFIG = {
@@ -31,6 +33,29 @@ export const GRID_CONFIG = {
WIDGET_CELL_SIZE: 80,
/** Gap between grid cells (pixels) */
GRID_GAP: 2,
/** Empty cell background color */
CELL_EMPTY_COLOR: 0x222233,
/** Occupied cell background color */
CELL_OCCUPIED_COLOR: 0x334455,
/** Grid line color */
GRID_LINE_COLOR: 0x555577,
/** Title text style */
TITLE_STYLE: {
fontSize: "24px",
color: "#888",
fontStyle: "bold",
} as const,
/** Subtitle/hint text style */
SUBTITLE_STYLE: {
fontSize: "14px",
color: "#aaaaaa",
} as const,
/** Item name text style */
ITEM_NAME_STYLE: {
fontSize: "11px",
color: "#888",
fontStyle: "bold",
} as const,
} as const;
// ── Shape Viewer ────────────────────────────────────────────────────────────
@@ -83,9 +108,9 @@ export const UI_CONFIG = {
/** Button border color */
BUTTON_BORDER: 0x7777aa,
/** Button text color */
BUTTON_TEXT_COLOR: '#ffffff',
BUTTON_TEXT_COLOR: "#ffffff",
/** Button font size */
BUTTON_FONT_SIZE: '16px',
BUTTON_FONT_SIZE: "16px",
} as const;
// ── Colors ──────────────────────────────────────────────────────────────────
@@ -102,14 +127,14 @@ export const NODE_COLORS = {
} as const;
export const NODE_LABELS = {
start: '起点',
end: '终点',
minion: '战斗',
elite: '精英',
event: '事件',
camp: '营地',
shop: '商店',
curio: '奇遇',
start: "起点",
end: "终点",
minion: "战斗",
elite: "精英",
event: "事件",
camp: "营地",
shop: "商店",
curio: "奇遇",
} as const;
export const ITEM_COLORS = [
@@ -118,5 +143,26 @@ export const ITEM_COLORS = [
// ── Positive/Negative Effects (for buff icons) ──────────────────────────────
export const POSITIVE_EFFECTS = new Set(['block', 'strength', 'dexterity', 'regen']);
export const NEGATIVE_EFFECTS = new Set(['weak', 'vulnerable', 'frail', 'poison']);
export const POSITIVE_EFFECTS = new Set([
"block",
"strength",
"dexterity",
"regen",
]);
export const NEGATIVE_EFFECTS = new Set([
"weak",
"vulnerable",
"frail",
"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));
}
@@ -0,0 +1,233 @@
import Phaser from "phaser";
import { dragDropEventEffect, DragDropEventType } from "boardgame-phaser";
import { GRID_CONFIG } from "@/config";
import {
IDENTITY_TRANSFORM,
ParsedShape,
Point2D,
Transform2D,
transformShape,
type GameItem,
} from "boardgame-core/samples/slay-the-spire-like";
import { DisposableBag } from "../../../framework/dist";
import { InventoryItemState } from "@/state/InventoryItemState";
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,
item: GameItem,
surface: InventorySurface,
surfaces: Iterable<InventorySurface>,
) {
super(scene, surface.gridOffsetX, surface.gridOffsetY);
scene.add.existing(this);
const cellSize = surface.cellSize;
const graphics = this.scene.add.graphics();
graphics.setPosition(cellSize / 2, cellSize / 2);
const label = this.scene.add.text(
cellSize / 2,
cellSize / 2,
"",
GRID_CONFIG.ITEM_NAME_STYLE,
);
this.add([graphics, label]);
this.setupInteractive();
this.itemState = new InventoryItemState(item);
this.surfaceState = new InventorySurfaceState(surface);
this.surfaces = surfaces;
const disposables = new DisposableBag(this);
disposables.addEffect(() => {
label.setText(this.itemState.name.value);
});
disposables.add(this.setupDnDEffect());
disposables.addEffect(() => {
graphics.clear();
this.renderGraphics(
graphics,
this.surfaceState.cellSize,
this.itemState.shape.value,
this.itemState.color.value,
);
});
disposables.addEffect(() => {
this.scene.tweens.add({
targets: graphics,
rotation: -(this.itemState.previewRotation.value * Math.PI) / 180,
duration: 150,
ease: "Power2",
});
this.hitArea = this.updateHitArea(this.itemState.previewRotation.value);
});
disposables.addEffect(() => {
this.snapBack(this.itemState.transform.value);
this.itemState.setPreviewRotation(0);
});
}
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 [];
const rotation = value;
const cells = transformShape(shape, {
rotation,
offset: { x: 0, y: 0 },
flipX: false,
flipY: false,
});
const cellSize = this.surfaceState.cellSize;
return cells.map((cell) => ({
x: (cell.x - cells[0].x) * cellSize,
y: (cell.y - cells[0].y) * cellSize,
}));
}
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) * cellSize;
const localY = (cell.y - cells[0].y) * cellSize;
graphics.fillStyle(itemColor);
graphics.fillRect(
localX + 2 - cellSize / 2,
localY + 2 - cellSize / 2,
cellSize - 4,
cellSize - 4,
);
}
}
private setupInteractive() {
this.setScrollFactor(0);
this.setInteractive({
hitArea: this,
hitAreaCallback: (
hitArea: InventoryItemContainer,
x: number,
y: number,
) =>
hitArea.hitArea.some(
(cell) =>
x >= cell.x &&
x < cell.x + this.surfaceState.cellSize &&
y >= cell.y &&
y < cell.y + this.surfaceState.cellSize,
),
useHandCursor: true,
} as Phaser.Types.Input.InputConfiguration);
}
private setupDnDEffect() {
let startX = 0;
let startY = 0;
return dragDropEventEffect(this, (event) => {
if (event.type === DragDropEventType.DOWN) {
startX = this.x;
startY = this.y;
this.setAlpha(0.7);
} else if (event.type === DragDropEventType.MOVE) {
this.x = startX + event.deltaX;
this.y = startY + event.deltaY;
} else if (event.type === DragDropEventType.ALTBUTTON) {
this.itemState.addPreviewRotation(90);
} else if (event.type === DragDropEventType.UP) {
const finalRotation = this.itemState.previewRotation.peek();
const finalX = this.x;
const finalY = this.y;
if (!this.handleDragEnd(finalX, finalY, finalRotation)) {
const t = this.itemState.transform.peek();
t && this.snapBack(t);
}
this.setAlpha(1);
this.itemState.setPreviewRotation(0);
startX = startY = 0;
}
});
}
private handleDragEnd(
finalX: number,
finalY: number,
finalRotation: number,
): boolean {
const item = this.itemState.item;
if (!item) return false;
const target = this.findDropSurface(finalX, finalY);
if (!target) return false;
if (
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,
target.x,
target.y,
finalRotation,
);
}
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 target = inventoryToScene(this.surfaceState, x, y);
this.scene.tweens.add({
targets: this,
...target,
duration: 150,
ease: "Power2",
});
}
}
@@ -0,0 +1,64 @@
import Phaser from "phaser";
import type { Spawner } from "boardgame-phaser";
import type { GameItem } from "boardgame-core/samples/slay-the-spire-like";
import { spawnEffect } from "boardgame-phaser";
import { InventoryItemContainer } from "./InventoryItemContainer";
import { InventorySurface } from "@/state/inventorySurfaceState";
export class InventoryItemSpawner implements Spawner<
[InventorySurface, GameItem],
InventoryItemContainer
> {
constructor(
private scene: Phaser.Scene,
private surfaces: Iterable<InventorySurface>,
) {}
*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: [InventorySurface, GameItem]): string {
return entry[1].id;
}
onSpawn(entry: [InventorySurface, GameItem]): InventoryItemContainer | null {
const [surface, item] = entry;
const container = new InventoryItemContainer(
this.scene,
item,
surface,
this.surfaces,
);
container.setSurfaceItem(surface, item);
return container;
}
onUpdate(
entry: [InventorySurface, GameItem],
container: InventoryItemContainer,
): void {
const [surface, item] = entry;
container.setSurfaceItem(surface, item);
}
onDespawn(container: InventoryItemContainer): void {
// TODO: add tween
container.destroy();
}
}
export function createInventoryItemSpawner(
scene: Phaser.Scene,
surfaces: Iterable<InventorySurface>,
) {
return spawnEffect(new InventoryItemSpawner(scene, surfaces));
}
+3 -4
View File
@@ -1,10 +1,9 @@
import { h } from 'preact';
import { GameUI } from 'boardgame-phaser';
import './style.css';
import { GameUI } from "boardgame-phaser";
import "./style.css";
import App from "@/ui/App";
const ui = new GameUI({
container: document.getElementById('ui-root')!,
container: "ui-root",
root: <App />,
});
@@ -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();
}
}
@@ -1,439 +0,0 @@
import Phaser from "phaser";
import { ReactiveScene } from "boardgame-phaser";
import { createButton } from "@/utils/createButton";
import { UI_CONFIG, MAP_CONFIG, NODE_COLORS, NODE_LABELS } from "@/config";
import { MutableSignal } from "boardgame-core";
import {
canMoveTo,
moveToNode,
getCurrentNode,
getReachableChildren,
isAtEndNode,
type RunState,
type MapNode,
} from "boardgame-core/samples/slay-the-spire-like";
export class GameFlowScene extends ReactiveScene {
/** 全局游戏状态(由 App.tsx 注入) */
private gameState: MutableSignal<RunState>;
// UI elements
private hudContainer!: Phaser.GameObjects.Container;
private hpText!: Phaser.GameObjects.Text;
private goldText!: Phaser.GameObjects.Text;
private nodeText!: Phaser.GameObjects.Text;
// Map elements
private mapContainer!: Phaser.GameObjects.Container;
private isDragging = false;
private dragStartX = 0;
private dragStartY = 0;
private dragStartContainerX = 0;
private dragStartContainerY = 0;
// Interaction
private hoveredNode: string | null = null;
private nodeGraphics: Map<string, Phaser.GameObjects.Graphics> = new Map();
constructor(gameState: MutableSignal<RunState>) {
super("GameFlowScene");
this.gameState = gameState;
}
create(): void {
super.create();
this.drawHUD();
this.drawMap();
this.updateHUD();
}
private drawHUD(): void {
const { width } = this.scale;
// HUD background
const hudBg = this.add.rectangle(width / 2, 25, 400, 40, 0x111122, 0.8);
this.hudContainer = this.add.container(width / 2, 25).setDepth(200);
this.hudContainer.add(hudBg);
// HP
this.hpText = this.add
.text(-150, 0, "", {
fontSize: "16px",
color: "#ff6666",
fontStyle: "bold",
})
.setOrigin(0, 0.5);
this.hudContainer.add(this.hpText);
// Gold
this.goldText = this.add
.text(-50, 0, "", {
fontSize: "16px",
color: "#ffcc44",
fontStyle: "bold",
})
.setOrigin(0, 0.5);
this.hudContainer.add(this.goldText);
// Current node
this.nodeText = this.add
.text(50, 0, "", {
fontSize: "16px",
color: "#ffffff",
})
.setOrigin(0, 0.5);
this.hudContainer.add(this.nodeText);
// Back to menu button
createButton({
scene: this,
label: "返回菜单",
x: width - 100,
y: 25,
width: UI_CONFIG.BUTTON_WIDTH_LARGE,
height: UI_CONFIG.BUTTON_HEIGHT_LARGE,
onClick: async () => {
await this.sceneController.launch("IndexScene");
},
depth: 200,
});
}
private updateHUD(): void {
const state = this.gameState.value;
const { player, currentNodeId, map } = state;
const currentNode = map.nodes.get(currentNodeId);
this.hpText.setText(`HP: ${player.currentHp}/${player.maxHp}`);
this.goldText.setText(`💰 ${player.gold}`);
if (currentNode) {
const typeLabel = NODE_LABELS[currentNode.type] ?? currentNode.type;
const encounterName = currentNode.encounter?.name ?? typeLabel;
this.nodeText.setText(`当前: ${encounterName}`);
}
}
private drawMap(): void {
const { width, height } = this.scale;
const state = this.gameState.value;
const {
LAYER_HEIGHT,
NODE_SPACING,
NODE_RADIUS,
MAX_NODES_PER_LAYER,
TOTAL_LAYERS,
} = MAP_CONFIG;
// Calculate map bounds (left-to-right: layers along X, nodes along Y)
const maxLayer = TOTAL_LAYERS - 1;
const mapWidth = maxLayer * LAYER_HEIGHT + 200;
const mapHeight = (MAX_NODES_PER_LAYER - 1) * NODE_SPACING + 200;
// Create scrollable container
this.mapContainer = this.add.container(width / 2, height / 2 + 50);
// Background panel
const bg = this.add
.rectangle(0, 0, mapWidth, mapHeight, 0x111122, 0.5)
.setOrigin(0.5);
this.mapContainer.add(bg);
const graphics = this.add.graphics();
this.mapContainer.add(graphics);
const { map, currentNodeId } = state;
const reachableChildren = getReachableChildren(state);
const reachableIds = new Set(reachableChildren.map((n) => n.id));
// Draw edges
graphics.lineStyle(2, 0x666666);
for (const [nodeId, node] of map.nodes) {
const posX = this.getNodeX(node);
const posY = this.getNodeY(node);
for (const childId of node.childIds) {
const child = map.nodes.get(childId);
if (child) {
const childX = this.getNodeX(child);
const childY = this.getNodeY(child);
graphics.lineBetween(posX, posY, childX, childY);
}
}
}
// Draw nodes
for (const [nodeId, node] of map.nodes) {
const posX = this.getNodeX(node);
const posY = this.getNodeY(node);
const isCurrent = nodeId === currentNodeId;
const isReachable = reachableIds.has(nodeId);
const baseColor = NODE_COLORS[node.type] ?? 0x888888;
// Node circle
const nodeGraphics = this.add.graphics();
this.mapContainer.add(nodeGraphics);
this.nodeGraphics.set(nodeId, nodeGraphics);
const color = isCurrent
? 0xffffff
: isReachable
? this.brightenColor(baseColor)
: baseColor;
nodeGraphics.fillStyle(color);
nodeGraphics.fillCircle(posX, posY, NODE_RADIUS);
if (isCurrent) {
nodeGraphics.lineStyle(3, 0xffff44);
} else if (isReachable) {
nodeGraphics.lineStyle(2, 0xaaddaa);
} else {
nodeGraphics.lineStyle(2, 0x888888);
}
nodeGraphics.strokeCircle(posX, posY, NODE_RADIUS);
// Node label
const label = NODE_LABELS[node.type] ?? node.type;
this.mapContainer.add(
this.add
.text(posX, posY, label, {
fontSize: "11px",
color: "#ffffff",
fontStyle: isCurrent ? "bold" : "normal",
})
.setOrigin(0.5),
);
// Encounter name
if (node.encounter) {
this.mapContainer.add(
this.add
.text(posX, posY + NODE_RADIUS + 12, node.encounter.name, {
fontSize: "10px",
color: "#cccccc",
})
.setOrigin(0.5),
);
}
// Make reachable nodes interactive
if (isReachable) {
const hitZone = this.add
.circle(posX, posY, NODE_RADIUS, 0x000000, 0)
.setInteractive({ useHandCursor: true });
this.mapContainer.add(hitZone);
hitZone.on("pointerover", () => {
this.hoveredNode = nodeId;
nodeGraphics.clear();
nodeGraphics.fillStyle(this.brightenColor(baseColor));
nodeGraphics.fillCircle(posX, posY, NODE_RADIUS);
nodeGraphics.lineStyle(3, 0xaaddaa);
nodeGraphics.strokeCircle(posX, posY, NODE_RADIUS);
});
hitZone.on("pointerout", () => {
this.hoveredNode = null;
nodeGraphics.clear();
nodeGraphics.fillStyle(baseColor);
nodeGraphics.fillCircle(posX, posY, NODE_RADIUS);
nodeGraphics.lineStyle(2, 0xaaddaa);
nodeGraphics.strokeCircle(posX, posY, NODE_RADIUS);
});
hitZone.on("pointerdown", () => {
this.onNodeClick(nodeId);
});
}
}
// Setup drag-to-scroll with disposables cleanup
const onPointerDown = (pointer: Phaser.Input.Pointer) => {
this.isDragging = true;
this.dragStartX = pointer.x;
this.dragStartY = pointer.y;
this.dragStartContainerX = this.mapContainer.x;
this.dragStartContainerY = this.mapContainer.y;
};
const onPointerMove = (pointer: Phaser.Input.Pointer) => {
if (!this.isDragging) return;
this.mapContainer.x =
this.dragStartContainerX + (pointer.x - this.dragStartX);
this.mapContainer.y =
this.dragStartContainerY + (pointer.y - this.dragStartY);
};
const onPointerUp = () => {
this.isDragging = false;
};
this.input.on("pointerdown", onPointerDown);
this.input.on("pointermove", onPointerMove);
this.input.on("pointerup", onPointerUp);
this.input.on("pointerout", onPointerUp);
this.disposables.add(() => {
this.input.off("pointerdown", onPointerDown);
this.input.off("pointermove", onPointerMove);
this.input.off("pointerup", onPointerUp);
this.input.off("pointerout", onPointerUp);
});
// Hint text
this.add
.text(
width / 2,
this.scale.height - 20,
"点击可到达的节点进入遭遇 | 拖拽滚动查看地图",
{
fontSize: "14px",
color: "#888888",
},
)
.setOrigin(0.5)
.setDepth(200);
}
private async onNodeClick(nodeId: string): Promise<void> {
const state = this.gameState.value;
if (!canMoveTo(state, nodeId)) {
return;
}
// Move to target node
const result = moveToNode(state, nodeId);
if (!result.success) {
console.warn(`无法移动到节点: ${result.reason}`);
return;
}
// Update visuals
this.updateHUD();
this.redrawMapHighlights();
// Check if at end node
if (isAtEndNode(state)) {
this.showEndScreen();
return;
}
// Launch encounter scene
const currentNode = getCurrentNode(state);
if (!currentNode || !currentNode.encounter) {
console.warn("当前节点没有遭遇数据");
return;
}
await this.sceneController.launch("PlaceholderEncounterScene");
}
private redrawMapHighlights(): void {
const state = this.gameState.value;
const { map, currentNodeId } = state;
const reachableChildren = getReachableChildren(state);
const reachableIds = new Set(reachableChildren.map((n) => n.id));
for (const [nodeId, nodeGraphics] of this.nodeGraphics) {
const node = map.nodes.get(nodeId);
if (!node) continue;
const isCurrent = nodeId === currentNodeId;
const isReachable = reachableIds.has(nodeId);
const baseColor = NODE_COLORS[node.type] ?? 0x888888;
nodeGraphics.clear();
const color = isCurrent
? 0xffffff
: isReachable
? this.brightenColor(baseColor)
: baseColor;
nodeGraphics.fillStyle(color);
nodeGraphics.fillCircle(
this.getNodeX(node),
this.getNodeY(node),
MAP_CONFIG.NODE_RADIUS,
);
if (isCurrent) {
nodeGraphics.lineStyle(3, 0xffff44);
} else if (isReachable) {
nodeGraphics.lineStyle(2, 0xaaddaa);
} else {
nodeGraphics.lineStyle(2, 0x888888);
}
nodeGraphics.strokeCircle(
this.getNodeX(node),
this.getNodeY(node),
MAP_CONFIG.NODE_RADIUS,
);
}
}
private showEndScreen(): void {
const { width, height } = this.scale;
const state = this.gameState.value;
// Overlay
const overlay = this.add
.rectangle(width / 2, height / 2, width, height, 0x000000, 0.7)
.setDepth(300);
// End message
this.add
.text(width / 2, height / 2 - 40, "恭喜通关!", {
fontSize: "36px",
color: "#ffcc44",
fontStyle: "bold",
})
.setOrigin(0.5)
.setDepth(300);
const { player } = state;
this.add
.text(
width / 2,
height / 2 + 20,
`剩余 HP: ${player.currentHp}/${player.maxHp}\n剩余金币: ${player.gold}`,
{
fontSize: "20px",
color: "#ffffff",
align: "center",
},
)
.setOrigin(0.5)
.setDepth(300);
createButton({
scene: this,
label: "返回菜单",
x: width / 2,
y: height / 2 + 100,
width: UI_CONFIG.BUTTON_WIDTH_LARGE,
height: UI_CONFIG.BUTTON_HEIGHT_LARGE,
onClick: async () => {
await this.sceneController.launch("IndexScene");
},
depth: 300,
});
}
private getNodeX(node: MapNode): number {
return -500 + node.layerIndex * MAP_CONFIG.LAYER_HEIGHT;
}
private getNodeY(node: MapNode): number {
const layer = this.gameState.value.map.layers[node.layerIndex];
const nodeIndex = layer.nodeIds.indexOf(node.id);
const totalNodes = layer.nodeIds.length;
const layerHeight = (totalNodes - 1) * MAP_CONFIG.NODE_SPACING;
return -layerHeight / 2 + nodeIndex * MAP_CONFIG.NODE_SPACING;
}
private brightenColor(color: number): number {
const r = Math.min(255, ((color >> 16) & 0xff) + 40);
const g = Math.min(255, ((color >> 8) & 0xff) + 40);
const b = Math.min(255, (color & 0xff) + 40);
return (r << 16) | (g << 8) | b;
}
}
@@ -1,7 +1,6 @@
import Phaser from "phaser";
import { ReactiveScene } from "boardgame-phaser";
import { createButton } from "@/utils/createButton";
import { GRID_CONFIG, UI_CONFIG, ITEM_COLORS } from "@/config";
import { GRID_CONFIG, ITEM_COLORS } from "@/config";
import {
createGridInventory,
placeItem,
@@ -12,6 +11,7 @@ import {
type InventoryItem,
type GameItemMeta,
} from "boardgame-core/samples/slay-the-spire-like";
import { SceneKey } from "./types";
export class GridViewerScene extends ReactiveScene {
private inventory: GridInventory<GameItemMeta>;
@@ -57,7 +57,7 @@ export class GridViewerScene extends ReactiveScene {
}
private placeSampleItems(): void {
const items = data.desert.items;
const items = data.desert.getItems();
const sampleItems = [
{ index: 0, x: 0, y: 0 },
{ index: 3, x: 3, y: 0 },
@@ -207,7 +207,7 @@ export class GridViewerScene extends ReactiveScene {
x: 100,
y: 40,
onClick: async () => {
await this.sceneController.launch("IndexScene");
await this.sceneController.launch(SceneKey.IndexScene);
},
});
@@ -242,7 +242,7 @@ export class GridViewerScene extends ReactiveScene {
GRID_CONFIG.WIDTH,
GRID_CONFIG.HEIGHT,
);
const items = data.desert.items;
const items = data.desert.getItems();
let itemIndex = 0;
for (let y = 0; y < GRID_CONFIG.HEIGHT && itemIndex < items.length; y++) {
@@ -2,6 +2,7 @@ import Phaser from "phaser";
import { ReactiveScene } from "boardgame-phaser";
import { createButton } from "@/utils/createButton";
import { UI_CONFIG } from "@/config";
import { SceneKey } from "./types";
export class IndexScene extends ReactiveScene {
constructor() {
@@ -32,15 +33,28 @@ export class IndexScene extends ReactiveScene {
.setOrigin(0.5);
// Buttons
const buttons = [
{ label: "开始游戏", scene: "GameFlowScene", y: centerY - 70 },
{ label: "Map Viewer", scene: "MapViewerScene", y: centerY },
const buttons: {
label: string;
scene: SceneKey;
y: number;
}[] = [
{ label: "Combat Test", scene: SceneKey.CombatTestScene, y: centerY },
{ label: "Map Viewer", scene: SceneKey.MapViewerScene, y: centerY + 70 },
{
label: "Grid Inventory Viewer",
scene: "GridViewerScene",
y: centerY + 70,
scene: SceneKey.GridViewerScene,
y: centerY + 140,
},
{
label: "Shape Viewer",
scene: SceneKey.ShapeViewerScene,
y: centerY + 210,
},
{
label: "Inventory Test",
scene: SceneKey.InventoryTestScene,
y: centerY + 280,
},
{ label: "Shape Viewer", scene: "ShapeViewerScene", y: centerY + 140 },
];
for (const btn of buttons) {
@@ -50,7 +64,7 @@ export class IndexScene extends ReactiveScene {
private createButton(
label: string,
targetScene: string,
targetScene: SceneKey,
x: number,
y: number,
): void {
@@ -0,0 +1,204 @@
import { ReactiveScene } from "boardgame-phaser";
import { createButton } from "@/utils/createButton";
import { GRID_CONFIG } from "@/config";
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 readonly _surfaces: InventorySurface[] = [];
private _leftInv!: InventorySignal;
private _rightInv!: InventorySignal;
private _leftGridOffsetX = 0;
private _leftGridOffsetY = 0;
private _rightGridOffsetX = 0;
private _rightGridOffsetY = 0;
constructor() {
super("InventoryTestScene");
}
create(): void {
super.create();
const { width, height } = this.scale;
this._leftInv = createInventorySignal();
this._rightInv = createInventorySignal();
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 x2)",
GRID_CONFIG.TITLE_STYLE,
)
.setOrigin(0.5);
this.createControls();
this.add
.text(
width / 2,
height - 40,
"Drag items between inventories",
GRID_CONFIG.SUBTITLE_STYLE,
)
.setOrigin(0.5);
}
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 = offsetX + x * GRID_CONFIG.VIEWER_CELL_SIZE;
const py = offsetY + y * GRID_CONFIG.VIEWER_CELL_SIZE;
const isOccupied = inv.value.occupiedCells.has(`${x},${y}`);
graphics.fillStyle(
isOccupied
? GRID_CONFIG.CELL_OCCUPIED_COLOR
: GRID_CONFIG.CELL_EMPTY_COLOR,
);
graphics.fillRect(
px + 1,
py + 1,
GRID_CONFIG.VIEWER_CELL_SIZE - 2,
GRID_CONFIG.VIEWER_CELL_SIZE - 2,
);
graphics.lineStyle(1, GRID_CONFIG.GRID_LINE_COLOR);
graphics.strokeRect(
px,
py,
GRID_CONFIG.VIEWER_CELL_SIZE,
GRID_CONFIG.VIEWER_CELL_SIZE,
);
}
}
});
}
private setupItemSpawner(): void {
const spawner = createInventoryItemSpawner(this, this._surfaces);
this.disposables.add(spawner);
}
private createControls(): void {
const { width } = this.scale;
createButton({
scene: this,
label: "返回菜单",
x: 100,
y: 40,
onClick: async () => {
await this.sceneController.launch(SceneKey.IndexScene);
},
});
createButton({
scene: this,
label: "添加道具(左)",
x: width - 350,
y: 40,
onClick: () => {
this.addRandomItem(this._leftInv);
},
});
createButton({
scene: this,
label: "添加道具(右)",
x: width - 180,
y: 40,
onClick: () => {
this.addRandomItem(this._rightInv);
},
});
}
private addRandomItem(inv: InventorySignal): void {
const items = data.desert.getItems();
inv.produce((inventory) => {
const usedIndices = new Set<number>();
for (const item of inventory.items.values()) {
const match = item.id.match(/^item-(\d+)-/);
if (match) {
usedIndices.add(parseInt(match[1], 10));
}
}
let availableIndex = 0;
while (usedIndices.has(availableIndex) && availableIndex < items.length) {
availableIndex++;
}
if (availableIndex >= items.length) {
return;
}
const itemData = items[availableIndex];
const id = `item-${availableIndex}-${Date.now().toString(16).slice(-4)}`;
createItemIn(inventory, id, itemData);
});
}
}
@@ -10,6 +10,7 @@ import {
type MapNode,
type MapNodeType,
} from "boardgame-core/samples/slay-the-spire-like";
import { SceneKey } from "./types";
export class MapViewerScene extends ReactiveScene {
private map: PointCrawlMap | null = null;
@@ -57,7 +58,7 @@ export class MapViewerScene extends ReactiveScene {
x: 100,
y: 40,
onClick: async () => {
await this.sceneController.launch("IndexScene");
await this.sceneController.launch(SceneKey.IndexScene);
},
depth: 100,
});
@@ -113,7 +114,7 @@ export class MapViewerScene extends ReactiveScene {
private drawMap(): void {
const rng = createRNG(this.seed);
this.map = generatePointCrawlMap(rng, data.desert.encounters);
this.map = generatePointCrawlMap(rng, data.desert.getEncounters());
const { width, height } = this.scale;
const {
@@ -1,235 +0,0 @@
import Phaser from "phaser";
import { ReactiveScene } from "boardgame-phaser";
import { createButton } from "@/utils/createButton";
import { UI_CONFIG, GRID_CONFIG, NODE_COLORS, NODE_LABELS } from "@/config";
import { MutableSignal } from "boardgame-core";
import {
resolveEncounter,
removeItem,
type RunState,
type EncounterResult,
type MapNodeType,
type MapNode,
} from "boardgame-core/samples/slay-the-spire-like";
import { InventoryWidget } from "@/widgets/InventoryWidget";
/**
* 占位符遭遇场景
*
* 左侧显示背包网格(使用 InventoryWidget),右侧显示遭遇信息。
*/
export class PlaceholderEncounterScene extends ReactiveScene {
private gameState: MutableSignal<RunState>;
private inventoryWidget: InventoryWidget | null = null;
constructor(gameState: MutableSignal<RunState>) {
super("PlaceholderEncounterScene");
this.gameState = gameState;
}
create(): void {
super.create();
const { width, height } = this.scale;
const state = this.gameState.value;
const gridCols = state.inventory.width;
const gridRows = state.inventory.height;
const cellSize = GRID_CONFIG.WIDGET_CELL_SIZE;
const gridW = gridCols * cellSize + (gridCols - 1) * GRID_CONFIG.GRID_GAP;
const gridH = gridRows * cellSize + (gridRows - 1) * GRID_CONFIG.GRID_GAP;
const leftPanelW = gridW + 40;
this.inventoryWidget = new InventoryWidget({
scene: this,
gameState: this.gameState,
x: 60,
y: (height - gridH) / 2 + 20,
cellSize,
gridGap: GRID_CONFIG.GRID_GAP,
});
this.cameras.main.setBounds(0, 0, width, height);
this.cameras.main.setScroll(0, 0);
// Panel background
this.add
.rectangle(
60 + leftPanelW / 2,
this.inventoryWidgetY(gridH),
leftPanelW + 10,
gridH + 50,
0x111122,
0.9,
)
.setStrokeStyle(2, 0x5555aa);
// "背包" title
this.add
.text(60 + gridW / 2, (height - gridH) / 2, "背包", {
fontSize: "22px",
color: "#ffffff",
fontStyle: "bold",
})
.setOrigin(0.5);
const node = state.map.nodes.get(state.currentNodeId);
if (!node || !node.encounter) {
const rightX = leftPanelW + 80;
this.add
.text(rightX + 300, height / 2, "没有遭遇数据", {
fontSize: "24px",
color: "#ff4444",
})
.setOrigin(0.5);
return;
}
this.drawRightPanel(
node as MapNode & { encounter: { name: string; description: string } },
leftPanelW,
width,
height,
);
}
private inventoryWidgetY(gridH: number): number {
const { height } = this.scale;
return (height - gridH) / 2 + 20 + gridH / 2;
}
private drawRightPanel(
node: MapNode & { encounter: { name: string; description: string } },
leftPanelW: number,
width: number,
height: number,
): void {
const encounter = {
type: node.type as MapNodeType,
name: node.encounter.name,
description: node.encounter.description,
};
const nodeId = node.id as string;
const rightX = leftPanelW + 60;
const rightW = width - rightX - 40;
const cx = rightX + rightW / 2;
const cy = height / 2;
this.add
.text(cx, cy - 180, "遭遇", {
fontSize: "36px",
color: "#fff",
fontStyle: "bold",
})
.setOrigin(0.5);
const typeLabel = this.getTypeLabel(encounter.type);
const badgeColor = this.getTypeColor(encounter.type);
this.add.rectangle(cx, cy - 110, 140, 40, badgeColor);
this.add
.text(cx, cy - 110, typeLabel, {
fontSize: "18px",
color: "#fff",
fontStyle: "bold",
})
.setOrigin(0.5);
this.add
.text(cx, cy - 50, encounter.name, {
fontSize: "28px",
color: "#fff",
})
.setOrigin(0.5);
this.add
.text(cx, cy + 10, encounter.description || "(暂无描述)", {
fontSize: "18px",
color: "#bbb",
wordWrap: { width: rightW - 40 },
align: "center",
})
.setOrigin(0.5);
this.add
.text(cx, cy + 80, `节点: ${nodeId}`, {
fontSize: "14px",
color: "#666",
})
.setOrigin(0.5);
this.add
.text(cx, cy + 130, "(此为占位符遭遇,后续将替换为真实遭遇场景)", {
fontSize: "14px",
color: "#ff8844",
fontStyle: "italic",
})
.setOrigin(0.5);
createButton({
scene: this,
label: "完成遭遇",
x: cx,
y: cy + 200,
width: UI_CONFIG.BUTTON_WIDTH_LARGE,
height: UI_CONFIG.BUTTON_HEIGHT_LARGE,
onClick: async () => {
await this.completeEncounter();
},
});
createButton({
scene: this,
label: "暂不处理",
x: cx,
y: cy + 270,
onClick: async () => {
await this.sceneController.launch("GameFlowScene");
},
});
}
private getTypeLabel(type: MapNodeType): string {
return NODE_LABELS[type] ?? type;
}
private getTypeColor(type: MapNodeType): number {
return NODE_COLORS[type] ?? 0x888888;
}
private async completeEncounter(): Promise<void> {
const state = this.gameState.value;
const node = state.map.nodes.get(state.currentNodeId);
if (!node || !node.encounter) return;
// Clear lost items from inventory
if (this.inventoryWidget) {
const lostIds = this.inventoryWidget.getLostItems();
for (const lostId of lostIds) {
removeItem(state, lostId);
}
this.inventoryWidget.clearLostItems();
}
const result: EncounterResult = this.generatePlaceholderResult(node.type);
resolveEncounter(state, result);
await this.sceneController.launch("GameFlowScene");
}
private generatePlaceholderResult(type: MapNodeType): EncounterResult {
switch (type) {
case "minion":
return { hpLost: 8, goldEarned: 15 };
case "elite":
return { hpLost: 15, goldEarned: 30 };
case "camp":
return { hpGained: 15 };
case "shop":
return { goldEarned: 0 };
case "curio":
case "event":
return { goldEarned: 20 };
default:
return {};
}
}
}
@@ -1,12 +1,12 @@
import Phaser from "phaser";
import { ReactiveScene } from "boardgame-phaser";
import { createButton } from "@/utils/createButton";
import { UI_CONFIG, SHAPE_CONFIG, NODE_LABELS, NODE_COLORS } from "@/config";
import { SHAPE_CONFIG } from "@/config";
import {
parseShapeString,
data,
type ParsedShape,
} from "boardgame-core/samples/slay-the-spire-like";
import { SceneKey } from "./types";
export class ShapeViewerScene extends ReactiveScene {
constructor() {
@@ -17,6 +17,19 @@ export class ShapeViewerScene extends ReactiveScene {
super.create();
this.drawShapeViewer();
this.createControls();
this.createBackButton();
}
private createBackButton(): void {
createButton({
scene: this,
label: "← Back",
x: 80,
y: 30,
onClick: async () => {
await this.sceneController.launch(SceneKey.IndexScene);
},
});
}
private drawShapeViewer(): void {
@@ -40,7 +53,7 @@ export class ShapeViewerScene extends ReactiveScene {
const startY = 80;
const { SPACING_X, SPACING_Y, ITEMS_PER_ROW, MAX_ITEMS } = SHAPE_CONFIG;
const itemsToShow = data.desert.items.slice(0, MAX_ITEMS);
const itemsToShow = data.desert.getItems().slice(0, MAX_ITEMS);
for (let i = 0; i < itemsToShow.length; i++) {
const itemData = itemsToShow[i];
@@ -145,7 +158,7 @@ export class ShapeViewerScene extends ReactiveScene {
x: 100,
y: height - 40,
onClick: async () => {
await this.sceneController.launch("IndexScene");
await this.sceneController.launch(SceneKey.IndexScene);
},
});
@@ -0,0 +1,8 @@
export enum SceneKey {
CombatTestScene = "CombatTestScene",
GridViewerScene = "GridViewerScene",
IndexScene = "IndexScene",
InventoryTestScene = "InventoryTestScene",
MapViewerScene = "MapViewerScene",
ShapeViewerScene = "ShapeViewerScene",
}
@@ -0,0 +1,61 @@
import { computed, ReadonlySignal, signal, Signal } from "@preact/signals-core";
import { ITEM_COLORS } from "@/config";
import {
type GameItem,
type ParsedShape,
type Transform2D,
} from "boardgame-core/samples/slay-the-spire-like";
export class InventoryItemState {
private readonly _item: Signal<GameItem>;
private readonly _previewRotation: Signal<number>;
readonly name: ReadonlySignal<string>;
readonly shape: ReadonlySignal<ParsedShape>;
readonly color: ReadonlySignal<number>;
readonly transform: ReadonlySignal<Transform2D>;
readonly previewRotation: ReadonlySignal<number>;
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 ?? "";
});
this.shape = computed(() => this._item.value.shape);
this.color = computed(() => this.computeColor(this._item.value.id ?? ""));
this.transform = computed(() => this._item.value.transform);
this.previewRotation = computed(() => {
const base = this._item.value.transform.rotation ?? 0;
return (base + this._previewRotation.value) % 360;
});
}
get item(): GameItem {
return this._item.value;
}
setItem(item: GameItem): void {
this._item.value = item;
this._previewRotation.value = 0;
}
setPreviewRotation(rotation: number): void {
this._previewRotation.value = rotation;
}
addPreviewRotation(rotation: number): void {
this._previewRotation.value += rotation;
}
private computeColor(itemId: string): number {
const hash = itemId.split("").reduce((acc, c) => acc + c.charCodeAt(0), 0);
return ITEM_COLORS[hash % ITEM_COLORS.length];
}
}
@@ -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,
};
}
@@ -1,34 +0,0 @@
import { MutableSignal, mutableSignal, createRNG } from 'boardgame-core';
import { createRunState, generatePointCrawlMap, data, type RunState, type ItemData } from 'boardgame-core/samples/slay-the-spire-like';
/**
* 全局游戏运行状态 Signal
*
* 在 App.tsx 中创建为单例,所有场景共享。
* 遭遇场景通过读取此 signal 的当前遭遇状态来构建 UI。
*/
export function createGameState(seed?: number): MutableSignal<RunState> {
const actualSeed = seed ?? Date.now();
const rng = createRNG(actualSeed);
const encounters = data.desert.encounters;
const map = generatePointCrawlMap(rng, encounters);
const starterItems: ItemData[] = data.desert.items.slice(0, 3);
return mutableSignal<RunState>(createRunState(map, starterItems));
}
/** 获取当前遭遇数据(computed getter) */
export function currentEncounter(
gameState: MutableSignal<RunState>
): { nodeId: string; encounter: { name: string; description: string; type: string } } | null {
const state = gameState.value;
const node = state.map.nodes.get(state.currentNodeId);
if (!node || !node.encounter) return null;
return {
nodeId: node.id,
encounter: {
type: node.type,
name: node.encounter.name,
description: node.encounter.description,
},
};
}
@@ -0,0 +1,79 @@
import { create } from "mutative";
import { mutableSignal } from "boardgame-core";
import {
createGridInventory,
createItemIn,
data,
GameItemMeta,
placeItem,
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);
}
export type InventorySignal = ReturnType<typeof 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);
}
/**
* Move an item to a new position in the inventory.
* Returns true if the move was successful, false if the new position is invalid.
*/
export function moveItem(
from: InventorySignal,
to: InventorySignal,
itemId: string,
newX: number,
newY: number,
newRotation?: number,
): boolean {
const inventory = from.value;
const item = inventory.items.get(itemId);
if (!item) {
return false;
}
const newTransform: Transform2D = {
offset: { x: newX, y: newY },
rotation: newRotation === undefined ? item.transform.rotation : newRotation,
flipX: false,
flipY: false,
};
const removed = create(to.value, (inv) => {
removeItem(inv, itemId);
});
const validation = validatePlacement(removed, item.shape, newTransform);
if (!validation.valid) return false;
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,
};
}
+25 -40
View File
@@ -1,53 +1,38 @@
import { h } from "preact";
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 { GameFlowScene } from "@/scenes/GameFlowScene";
import { PlaceholderEncounterScene } from "@/scenes/PlaceholderEncounterScene";
import { createGameState } from "@/state/gameState";
// 全局游戏状态单例
const gameState = createGameState();
export default function App() {
const indexScene = useMemo(() => new IndexScene(), []);
const mapViewerScene = useMemo(() => new MapViewerScene(), []);
const gridViewerScene = useMemo(() => new GridViewerScene(), []);
const shapeViewerScene = useMemo(() => new ShapeViewerScene(), []);
const gameFlowScene = useMemo(() => new GameFlowScene(gameState), []);
const placeholderEncounterScene = useMemo(
() => new PlaceholderEncounterScene(gameState),
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={{ width: 1920, height: 1080 }}
>
<PhaserScene sceneKey="IndexScene" scene={indexScene as any} />
<PhaserScene
sceneKey="MapViewerScene"
scene={mapViewerScene as any}
/>
<PhaserScene
sceneKey="GridViewerScene"
scene={gridViewerScene as any}
/>
<PhaserScene
sceneKey="ShapeViewerScene"
scene={shapeViewerScene as any}
/>
<PhaserScene sceneKey="GameFlowScene" scene={gameFlowScene as any} />
<PhaserScene
sceneKey="PlaceholderEncounterScene"
scene={placeholderEncounterScene as any}
/>
<PhaserGame initialScene="IndexScene" config={GAME_CONFIG}>
<PhaserScene scene={IndexScene} />
<PhaserScene scene={CombatTestScene} data={{ gameHost }} />
<PhaserScene scene={InventoryTestScene} />
<PhaserScene scene={MapViewerScene} />
<PhaserScene scene={GridViewerScene} />
<PhaserScene scene={ShapeViewerScene} />
</PhaserGame>
</div>
</div>
@@ -1,477 +0,0 @@
import Phaser from "phaser";
import {
type InventoryItem,
type GameItemMeta,
type GridInventory,
validatePlacement,
transformShape,
} from "boardgame-core/samples/slay-the-spire-like";
import { dragDropEventEffect, DragDropEventType } from "boardgame-phaser";
import { DisposableBag } from "boardgame-phaser";
export interface DragSession {
itemId: string;
itemShape: InventoryItem<GameItemMeta>["shape"];
itemTransform: InventoryItem<GameItemMeta>["transform"];
itemMeta: InventoryItem<GameItemMeta>["meta"];
ghostContainer: Phaser.GameObjects.Container;
previewGraphics: Phaser.GameObjects.Graphics;
disposables: DisposableBag;
}
export interface DragControllerOptions {
scene: Phaser.Scene;
container: Phaser.GameObjects.Container;
cellSize: number;
gridGap: number;
gridX: number;
gridY: number;
getInventory: () => GridInventory<GameItemMeta>;
getItemColor: (itemId: string) => number;
onPlaceItem: (item: InventoryItem<GameItemMeta>) => void;
onCreateLostItem: (
itemId: string,
shape: InventoryItem<GameItemMeta>["shape"],
transform: InventoryItem<GameItemMeta>["transform"],
meta: InventoryItem<GameItemMeta>["meta"],
x: number,
y: number,
) => void;
}
/**
* Event-driven drag controller using dragDropEventEffect from boardgame-phaser.
* Manages ghost visuals, placement preview, rotation, and validation.
*/
export class DragController {
private scene: Phaser.Scene;
private container: Phaser.GameObjects.Container;
private cellSize: number;
private gridGap: number;
private gridX: number;
private gridY: number;
private getInventory: () => GridInventory<GameItemMeta>;
private getItemColor: (itemId: string) => number;
private onPlaceItem: (item: InventoryItem<GameItemMeta>) => void;
private onCreateLostItem: (
itemId: string,
shape: InventoryItem<GameItemMeta>["shape"],
transform: InventoryItem<GameItemMeta>["transform"],
meta: InventoryItem<GameItemMeta>["meta"],
x: number,
y: number,
) => void;
private activeSession: DragSession | null = null;
constructor(options: DragControllerOptions) {
this.scene = options.scene;
this.container = options.container;
this.cellSize = options.cellSize;
this.gridGap = options.gridGap;
this.gridX = options.gridX;
this.gridY = options.gridY;
this.getInventory = options.getInventory;
this.getItemColor = options.getItemColor;
this.onPlaceItem = options.onPlaceItem;
this.onCreateLostItem = options.onCreateLostItem;
}
/**
* Start a drag session for an inventory item.
* Uses dragDropEventEffect for pointer tracking and event emission.
*/
startDrag(
itemId: string,
item: InventoryItem<GameItemMeta>,
itemContainer: Phaser.GameObjects.Container,
): () => void {
const cells = this.getItemCells(item);
const firstCell = cells[0];
const worldX =
this.container.x +
this.gridX +
firstCell.x * (this.cellSize + this.gridGap);
const worldY =
this.container.y +
this.gridY +
firstCell.y * (this.cellSize + this.gridGap);
const ghostContainer = this.createGhostContainer(
worldX,
worldY,
item.shape,
item.transform,
this.getItemColor(itemId),
);
const previewGraphics = this.scene.add
.graphics()
.setDepth(999)
.setAlpha(0.5);
const disposables = new DisposableBag();
const session: DragSession = {
itemId,
itemShape: item.shape,
itemTransform: {
...item.transform,
offset: { ...item.transform.offset },
},
itemMeta: item.meta,
ghostContainer,
previewGraphics,
disposables,
};
this.activeSession = session;
// Set up drag-drop event handling via framework utility
const disposeDrag = dragDropEventEffect(
itemContainer as Phaser.GameObjects.GameObject,
disposables,
);
itemContainer.on("dragstart", () => {
ghostContainer.setVisible(true);
});
itemContainer.on("dragmove", () => {
this.handleDragMove(session);
});
itemContainer.on("dragend", () => {
this.handleDragEnd(session);
disposeDrag();
this.activeSession = null;
});
return () => {
disposeDrag();
this.destroySession(session);
this.activeSession = null;
};
}
/**
* Start a drag session for a lost item.
*/
startLostItemDrag(
itemId: string,
shape: InventoryItem<GameItemMeta>["shape"],
transform: InventoryItem<GameItemMeta>["transform"],
meta: InventoryItem<GameItemMeta>["meta"],
lostContainer: Phaser.GameObjects.Container,
): () => void {
const pointer = this.scene.input.activePointer;
const ghostContainer = this.createGhostContainer(
pointer.x,
pointer.y,
shape,
transform,
this.getItemColor(itemId),
);
const previewGraphics = this.scene.add
.graphics()
.setDepth(999)
.setAlpha(0.5);
const disposables = new DisposableBag();
const session: DragSession = {
itemId,
itemShape: shape,
itemTransform: { ...transform, offset: { ...transform.offset } },
itemMeta: meta,
ghostContainer,
previewGraphics,
disposables,
};
this.activeSession = session;
const disposeDrag = dragDropEventEffect(
lostContainer as Phaser.GameObjects.GameObject,
disposables,
);
lostContainer.on("dragstart", () => {
ghostContainer.setVisible(true);
});
lostContainer.on("dragmove", () => {
this.handleDragMove(session);
});
lostContainer.on("dragend", () => {
this.handleDragEnd(session);
disposeDrag();
this.activeSession = null;
});
return () => {
disposeDrag();
this.destroySession(session);
this.activeSession = null;
};
}
/**
* Rotate the currently dragged item by 90 degrees.
*/
rotateDraggedItem(): void {
if (!this.activeSession) return;
const currentRotation =
(this.activeSession.itemTransform.rotation + 90) % 360;
this.activeSession.itemTransform = {
...this.activeSession.itemTransform,
rotation: currentRotation,
};
this.updateGhostVisuals(this.activeSession);
}
/**
* Check if currently dragging.
*/
isDragging(): boolean {
return this.activeSession !== null;
}
/**
* Get the ID of the item being dragged, or null.
*/
getDraggedItemId(): string | null {
return this.activeSession?.itemId ?? null;
}
/**
* Get the current position of the dragged ghost container.
*/
getDraggedItemPosition(): { x: number; y: number } {
if (!this.activeSession) return { x: 0, y: 0 };
return {
x: this.activeSession.ghostContainer.x,
y: this.activeSession.ghostContainer.y,
};
}
/**
* Clean up active session and destroy all visuals.
*/
destroy(): void {
if (this.activeSession) {
this.destroySession(this.activeSession);
this.activeSession = null;
}
}
private createGhostContainer(
x: number,
y: number,
shape: InventoryItem<GameItemMeta>["shape"],
transform: InventoryItem<GameItemMeta>["transform"],
color: number,
): Phaser.GameObjects.Container {
const ghostContainer = this.scene.add.container(x, y).setDepth(1000);
const ghostGraphics = this.scene.add.graphics();
const cells = transformShape(shape, transform);
for (const cell of cells) {
ghostGraphics.fillStyle(color, 0.7);
ghostGraphics.fillRect(
cell.x * (this.cellSize + this.gridGap),
cell.y * (this.cellSize + this.gridGap),
this.cellSize - 2,
this.cellSize - 2,
);
ghostGraphics.lineStyle(2, 0xffffff);
ghostGraphics.strokeRect(
cell.x * (this.cellSize + this.gridGap),
cell.y * (this.cellSize + this.gridGap),
this.cellSize,
this.cellSize,
);
}
ghostContainer.add(ghostGraphics);
return ghostContainer;
}
private updateGhostVisuals(session: DragSession): void {
session.ghostContainer.removeAll(true);
const ghostGraphics = this.scene.add.graphics();
const color = this.getItemColor(session.itemId);
const cells = transformShape(session.itemShape, session.itemTransform);
for (const cell of cells) {
ghostGraphics.fillStyle(color, 0.7);
ghostGraphics.fillRect(
cell.x * (this.cellSize + this.gridGap),
cell.y * (this.cellSize + this.gridGap),
this.cellSize - 2,
this.cellSize - 2,
);
ghostGraphics.lineStyle(2, 0xffffff);
ghostGraphics.strokeRect(
cell.x * (this.cellSize + this.gridGap),
cell.y * (this.cellSize + this.gridGap),
this.cellSize,
this.cellSize,
);
}
session.ghostContainer.add(ghostGraphics);
}
private handleDragMove(session: DragSession): void {
const pointer = this.scene.input.activePointer;
session.ghostContainer.setPosition(pointer.x, pointer.y);
const gridCell = this.getWorldGridCell(pointer.x, pointer.y);
session.previewGraphics.clear();
if (gridCell) {
const inventory = this.getInventory();
const testTransform = {
...session.itemTransform,
offset: { x: gridCell.x, y: gridCell.y },
};
const validation = validatePlacement(
inventory,
session.itemShape,
testTransform,
);
const cells = transformShape(session.itemShape, testTransform);
for (const cell of cells) {
const px = this.gridX + cell.x * (this.cellSize + this.gridGap);
const py = this.gridY + cell.y * (this.cellSize + this.gridGap);
if (validation.valid) {
session.previewGraphics.fillStyle(0x33ff33, 0.3);
session.previewGraphics.fillRect(
px,
py,
this.cellSize,
this.cellSize,
);
session.previewGraphics.lineStyle(2, 0x33ff33);
session.previewGraphics.strokeRect(
px,
py,
this.cellSize,
this.cellSize,
);
} else {
session.previewGraphics.fillStyle(0xff3333, 0.3);
session.previewGraphics.fillRect(
px,
py,
this.cellSize,
this.cellSize,
);
session.previewGraphics.lineStyle(2, 0xff3333);
session.previewGraphics.strokeRect(
px,
py,
this.cellSize,
this.cellSize,
);
}
}
}
}
private handleDragEnd(session: DragSession): void {
const pointer = this.scene.input.activePointer;
const gridCell = this.getWorldGridCell(pointer.x, pointer.y);
const inventory = this.getInventory();
session.ghostContainer.destroy();
session.previewGraphics.destroy();
session.disposables.dispose();
if (gridCell) {
const testTransform = {
...session.itemTransform,
offset: { x: gridCell.x, y: gridCell.y },
};
const validation = validatePlacement(
inventory,
session.itemShape,
testTransform,
);
if (validation.valid) {
const item: InventoryItem<GameItemMeta> = {
id: session.itemId,
shape: session.itemShape,
transform: testTransform,
meta: session.itemMeta,
};
this.onPlaceItem(item);
} else {
this.onCreateLostItem(
session.itemId,
session.itemShape,
session.itemTransform,
session.itemMeta,
session.ghostContainer.x,
session.ghostContainer.y,
);
}
} else {
this.onCreateLostItem(
session.itemId,
session.itemShape,
session.itemTransform,
session.itemMeta,
session.ghostContainer.x,
session.ghostContainer.y,
);
}
}
private getWorldGridCell(
worldX: number,
worldY: number,
): { x: number; y: number } | null {
const localX = worldX - this.container.x - this.gridX;
const localY = worldY - this.container.y - this.gridY;
const cellX = Math.floor(localX / (this.cellSize + this.gridGap));
const cellY = Math.floor(localY / (this.cellSize + this.gridGap));
const inventory = this.getInventory();
if (
cellX < 0 ||
cellY < 0 ||
cellX >= inventory.width ||
cellY >= inventory.height
) {
return null;
}
return { x: cellX, y: cellY };
}
private getItemCells(
item: InventoryItem<GameItemMeta>,
): { x: number; y: number }[] {
const cells: { x: number; y: number }[] = [];
const { offset } = item.transform;
for (let y = 0; y < item.shape.height; y++) {
for (let x = 0; x < item.shape.width; x++) {
if (item.shape.grid[y]?.[x]) {
cells.push({ x: x + offset.x, y: y + offset.y });
}
}
}
return cells;
}
private destroySession(session: DragSession): void {
session.disposables.dispose();
session.ghostContainer.destroy();
session.previewGraphics.destroy();
}
}
@@ -1,71 +0,0 @@
import Phaser from "phaser";
export interface GridBackgroundRendererOptions {
scene: Phaser.Scene;
parentContainer: Phaser.GameObjects.Container;
cellSize: number;
gridGap: number;
gridX: number;
gridY: number;
/** Background fill color for each cell */
cellBgColor?: number;
/** Border/stroke color for each cell */
cellBorderColor?: number;
}
/**
* Renders the static grid background (empty cells with borders).
* Separated from item rendering so it can be drawn once and left alone.
*/
export class GridBackgroundRenderer {
private scene: Phaser.Scene;
private parentContainer: Phaser.GameObjects.Container;
private cellSize: number;
private gridGap: number;
private gridX: number;
private gridY: number;
private cellBgColor: number;
private cellBorderColor: number;
private graphics!: Phaser.GameObjects.Graphics;
constructor(options: GridBackgroundRendererOptions) {
this.scene = options.scene;
this.parentContainer = options.parentContainer;
this.cellSize = options.cellSize;
this.gridGap = options.gridGap;
this.gridX = options.gridX;
this.gridY = options.gridY;
this.cellBgColor = options.cellBgColor ?? 0x1a1a2e;
this.cellBorderColor = options.cellBorderColor ?? 0x444477;
}
/**
* Draw the grid background for the given dimensions.
* Should be called once during initialization.
*/
draw(width: number, height: number): void {
this.graphics = this.scene.add.graphics();
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const px = this.gridX + x * (this.cellSize + this.gridGap);
const py = this.gridY + y * (this.cellSize + this.gridGap);
this.graphics.fillStyle(this.cellBgColor);
this.graphics.fillRect(px, py, this.cellSize, this.cellSize);
this.graphics.lineStyle(2, this.cellBorderColor);
this.graphics.strokeRect(px, py, this.cellSize, this.cellSize);
}
}
this.parentContainer.add(this.graphics);
}
/**
* Destroy the graphics object.
*/
destroy(): void {
this.graphics?.destroy();
}
}
@@ -1,277 +0,0 @@
import Phaser from "phaser";
import { MutableSignal } from "boardgame-core";
import {
type InventoryItem,
type GameItemMeta,
type RunState,
type GridInventory,
} from "boardgame-core/samples/slay-the-spire-like";
import type { Spawner } from "boardgame-phaser";
const ITEM_COLORS = [
0x3388ff, 0xff8833, 0x33ff88, 0xff3388, 0x8833ff, 0x33ffff, 0xffff33,
0xff6633,
];
export interface InventoryItemSpawnerOptions {
scene: Phaser.Scene;
gameState: MutableSignal<RunState>;
parentContainer: Phaser.GameObjects.Container;
cellSize: number;
gridGap: number;
gridX: number;
gridY: number;
isLocked: () => boolean;
isDragging: () => boolean;
onItemDragStart: (
itemId: string,
item: InventoryItem<GameItemMeta>,
itemContainer: Phaser.GameObjects.Container,
) => void;
}
/**
* Spawner for inventory items using the boardgame-phaser Spawner pattern.
* Reactively spawns/despawns/updates item visuals when gameState.inventory changes.
*
* Items currently being dragged are excluded from getData() to prevent
* the spawner from respawning them while they're in flight.
*/
export class InventoryItemSpawner implements Spawner<
InventoryItem<GameItemMeta>,
Phaser.GameObjects.Container
> {
private scene: Phaser.Scene;
private gameState: MutableSignal<RunState>;
private parentContainer: Phaser.GameObjects.Container;
private cellSize: number;
private gridGap: number;
private gridX: number;
private gridY: number;
private isLocked: () => boolean;
private isDragging: () => boolean;
private onItemDragStart: (
itemId: string,
item: InventoryItem<GameItemMeta>,
itemContainer: Phaser.GameObjects.Container,
) => void;
private colorMap = new Map<string, number>();
private colorIdx = 0;
private draggingIds = new Set<string>();
constructor(options: InventoryItemSpawnerOptions) {
this.scene = options.scene;
this.gameState = options.gameState;
this.parentContainer = options.parentContainer;
this.cellSize = options.cellSize;
this.gridGap = options.gridGap;
this.gridX = options.gridX;
this.gridY = options.gridY;
this.isLocked = options.isLocked;
this.isDragging = options.isDragging;
this.onItemDragStart = options.onItemDragStart;
}
*getData(): Iterable<InventoryItem<GameItemMeta>> {
const inventory = this.getInventory();
for (const [, item] of inventory.items) {
if (!this.draggingIds.has(item.id)) {
yield item;
}
}
}
getKey(item: InventoryItem<GameItemMeta>): string {
return item.id;
}
onSpawn(item: InventoryItem<GameItemMeta>): Phaser.GameObjects.Container {
const color =
this.colorMap.get(item.id) ??
ITEM_COLORS[this.colorIdx++ % ITEM_COLORS.length];
this.colorMap.set(item.id, color);
const container = this.createItemVisuals(item, color);
this.setupInteraction(item, container);
this.parentContainer.add(container);
return container;
}
onDespawn(
obj: Phaser.GameObjects.Container,
_item: InventoryItem<GameItemMeta>,
): void {
obj.removeAllListeners();
obj.destroy();
}
onUpdate(
item: InventoryItem<GameItemMeta>,
obj: Phaser.GameObjects.Container,
): void {
const color = this.colorMap.get(item.id) ?? 0x888888;
this.rebuildItemVisuals(obj, item, color);
}
/**
* Mark an item as being dragged so the spawner excludes it from getData().
* Call this before removing the item from the inventory.
*/
markDragging(itemId: string): void {
this.draggingIds.add(itemId);
}
/**
* Unmark an item after drag ends (placed or lost).
*/
unmarkDragging(itemId: string): void {
this.draggingIds.delete(itemId);
}
/**
* Get the color assigned to an item (creates one if not yet assigned).
*/
getItemColor(itemId: string): number {
if (!this.colorMap.has(itemId)) {
this.colorMap.set(
itemId,
ITEM_COLORS[this.colorIdx++ % ITEM_COLORS.length],
);
}
return this.colorMap.get(itemId)!;
}
private getInventory(): GridInventory<GameItemMeta> {
return this.gameState.value
.inventory as unknown as GridInventory<GameItemMeta>;
}
private createItemVisuals(
item: InventoryItem<GameItemMeta>,
color: number,
): Phaser.GameObjects.Container {
const container = this.scene.add.container(0, 0);
const graphics = this.scene.add.graphics();
const cells = this.getItemCells(item);
for (const cell of cells) {
const px = this.gridX + cell.x * (this.cellSize + this.gridGap);
const py = this.gridY + cell.y * (this.cellSize + this.gridGap);
graphics.fillStyle(color);
graphics.fillRect(px + 1, py + 1, this.cellSize - 2, this.cellSize - 2);
graphics.lineStyle(2, 0xffffff);
graphics.strokeRect(px, py, this.cellSize, this.cellSize);
}
const firstCell = cells[0];
const name = item.meta?.itemData.name ?? item.id;
const fontSize = Math.max(10, Math.floor(this.cellSize / 5));
const text = this.scene.add
.text(
this.gridX +
firstCell.x * (this.cellSize + this.gridGap) +
this.cellSize / 2,
this.gridY +
firstCell.y * (this.cellSize + this.gridGap) +
this.cellSize / 2,
name,
{ fontSize: `${fontSize}px`, color: "#fff", fontStyle: "bold" },
)
.setOrigin(0.5);
container.add(graphics);
container.add(text);
const hitRect = new Phaser.Geom.Rectangle(
this.gridX + firstCell.x * (this.cellSize + this.gridGap),
this.gridY + firstCell.y * (this.cellSize + this.gridGap),
this.cellSize,
this.cellSize,
);
container.setInteractive(hitRect, Phaser.Geom.Rectangle.Contains);
return container;
}
private rebuildItemVisuals(
container: Phaser.GameObjects.Container,
item: InventoryItem<GameItemMeta>,
color: number,
): void {
container.removeAll(true);
const graphics = this.scene.add.graphics();
const cells = this.getItemCells(item);
for (const cell of cells) {
const px = this.gridX + cell.x * (this.cellSize + this.gridGap);
const py = this.gridY + cell.y * (this.cellSize + this.gridGap);
graphics.fillStyle(color);
graphics.fillRect(px + 1, py + 1, this.cellSize - 2, this.cellSize - 2);
graphics.lineStyle(2, 0xffffff);
graphics.strokeRect(px, py, this.cellSize, this.cellSize);
}
const firstCell = cells[0];
const name = item.meta?.itemData.name ?? item.id;
const fontSize = Math.max(10, Math.floor(this.cellSize / 5));
const text = this.scene.add
.text(
this.gridX +
firstCell.x * (this.cellSize + this.gridGap) +
this.cellSize / 2,
this.gridY +
firstCell.y * (this.cellSize + this.gridGap) +
this.cellSize / 2,
name,
{ fontSize: `${fontSize}px`, color: "#fff", fontStyle: "bold" },
)
.setOrigin(0.5);
container.add(graphics);
container.add(text);
const hitRect = new Phaser.Geom.Rectangle(
this.gridX + firstCell.x * (this.cellSize + this.gridGap),
this.gridY + firstCell.y * (this.cellSize + this.gridGap),
this.cellSize,
this.cellSize,
);
container.setInteractive(hitRect, Phaser.Geom.Rectangle.Contains);
}
private setupInteraction(
item: InventoryItem<GameItemMeta>,
container: Phaser.GameObjects.Container,
): void {
container.on("pointerdown", (pointer: Phaser.Input.Pointer) => {
// Guard against stale events firing on destroyed containers
if (!container.scene || !container.active) return;
if (this.isLocked()) return;
if (this.isDragging()) return;
if (pointer.button === 0) {
this.onItemDragStart(item.id, item, container);
}
});
}
private getItemCells(
item: InventoryItem<GameItemMeta>,
): { x: number; y: number }[] {
const cells: { x: number; y: number }[] = [];
const { offset } = item.transform;
for (let y = 0; y < item.shape.height; y++) {
for (let x = 0; x < item.shape.width; x++) {
if (item.shape.grid[y]?.[x]) {
cells.push({ x: x + offset.x, y: y + offset.y });
}
}
}
return cells;
}
}
@@ -1,239 +0,0 @@
import Phaser from "phaser";
import { MutableSignal } from "boardgame-core";
import { spawnEffect } from "boardgame-phaser";
import {
type GridInventory,
type InventoryItem,
type GameItemMeta,
type RunState,
removeItemFromGrid,
placeItem,
} from "boardgame-core/samples/slay-the-spire-like";
import { InventoryItemSpawner } from "./InventoryItemSpawner";
import { GridBackgroundRenderer } from "./GridBackgroundRenderer";
import { DragController } from "./DragController";
import { LostItemManager } from "./LostItemManager";
export interface InventoryWidgetOptions {
scene: Phaser.Scene;
gameState: MutableSignal<RunState>;
x: number;
y: number;
cellSize: number;
gridGap?: number;
isLocked?: boolean;
}
/**
* Inventory widget using the Spawner pattern for reactive item rendering.
*
* Architecture:
* - InventoryItemSpawner + spawnEffect: reactive spawn/despawn/update of item visuals
* - GridBackgroundRenderer: static grid background drawn once
* - DragController: event-driven drag logic via dragDropEventEffect
* - LostItemManager: tracks items dropped outside valid placement
*/
export class InventoryWidget {
private scene: Phaser.Scene;
private gameState: MutableSignal<RunState>;
private container: Phaser.GameObjects.Container;
private cellSize: number;
private gridGap: number;
private gridX = 0;
private gridY = 0;
private isLocked: boolean;
private itemSpawner: InventoryItemSpawner;
private backgroundRenderer: GridBackgroundRenderer;
private dragController: DragController;
private lostItemManager: LostItemManager;
private spawnDispose: (() => void) | null = null;
private rightClickHandler!: (pointer: Phaser.Input.Pointer) => void;
constructor(options: InventoryWidgetOptions) {
this.scene = options.scene;
this.gameState = options.gameState;
this.cellSize = options.cellSize;
this.gridGap = options.gridGap ?? 2;
this.isLocked = options.isLocked ?? false;
const inventory = this.getInventory();
this.container = this.scene.add.container(options.x, options.y);
// 1. Static grid background (drawn once)
this.backgroundRenderer = new GridBackgroundRenderer({
scene: this.scene,
parentContainer: this.container,
cellSize: this.cellSize,
gridGap: this.gridGap,
gridX: this.gridX,
gridY: this.gridY,
});
this.backgroundRenderer.draw(inventory.width, inventory.height);
// 2. Reactive item spawner
this.itemSpawner = new InventoryItemSpawner({
scene: this.scene,
gameState: this.gameState,
parentContainer: this.container,
cellSize: this.cellSize,
gridGap: this.gridGap,
gridX: this.gridX,
gridY: this.gridY,
isLocked: () => this.isLocked,
isDragging: () => this.dragController.isDragging(),
onItemDragStart: (itemId, item, itemContainer) => {
this.handleItemDragStart(itemId, item, itemContainer);
},
});
// 3. Drag controller
this.dragController = new DragController({
scene: this.scene,
container: this.container,
cellSize: this.cellSize,
gridGap: this.gridGap,
gridX: this.gridX,
gridY: this.gridY,
getInventory: () => this.getInventory(),
getItemColor: (id) => this.itemSpawner.getItemColor(id),
onPlaceItem: (item) => this.handlePlaceItem(item),
onCreateLostItem: (id, shape, transform, meta, x, y) =>
this.handleCreateLostItem(id, shape, transform, meta, x, y),
});
// 4. Lost item manager
this.lostItemManager = new LostItemManager({
scene: this.scene,
cellSize: this.cellSize,
gridGap: this.gridGap,
getItemColor: (id) => this.itemSpawner.getItemColor(id),
onLostItemDragStart: (id, lostContainer) =>
this.dragController.startLostItemDrag(
id,
this.getLostItemShape(id),
this.getLostItemTransform(id),
this.getLostItemMeta(id),
lostContainer,
),
isDragging: () => this.dragController.isDragging(),
});
// Activate the spawner effect (auto-cleans up on dispose)
this.spawnDispose = spawnEffect(this.itemSpawner);
// Right-click rotation handler
this.setupInput();
this.scene.events.once("shutdown", () => this.destroy());
}
private getInventory(): GridInventory<GameItemMeta> {
return this.gameState.value
.inventory as unknown as GridInventory<GameItemMeta>;
}
private handleItemDragStart(
itemId: string,
item: InventoryItem<GameItemMeta>,
itemContainer: Phaser.GameObjects.Container,
): void {
// Mark as dragging FIRST so spawner excludes it from getData().
// This prevents the spawner effect from destroying the container
// when we later update the inventory state.
this.itemSpawner.markDragging(itemId);
// Start drag session
this.dragController.startDrag(itemId, item, itemContainer);
}
private handlePlaceItem(item: InventoryItem<GameItemMeta>): void {
this.gameState.produce((state) => {
placeItem(state.inventory, item);
});
// Unmark dragging so spawner picks it up on next effect run
this.itemSpawner.unmarkDragging(item.id);
}
private handleCreateLostItem(
itemId: string,
shape: InventoryItem<GameItemMeta>["shape"],
transform: InventoryItem<GameItemMeta>["transform"],
meta: InventoryItem<GameItemMeta>["meta"],
x: number,
y: number,
): void {
// Remove from inventory since it's dropped outside valid placement
this.gameState.produce((state) => {
removeItemFromGrid(state.inventory, itemId);
});
this.lostItemManager.createLostItem(itemId, shape, transform, meta, x, y);
// Unmark dragging — item is now "lost" and managed by LostItemManager
this.itemSpawner.unmarkDragging(itemId);
}
private getLostItemShape(itemId: string) {
return this.lostItemManager.getLostItem(itemId)?.shape!;
}
private getLostItemTransform(itemId: string) {
return this.lostItemManager.getLostItem(itemId)?.transform!;
}
private getLostItemMeta(itemId: string) {
return this.lostItemManager.getLostItem(itemId)?.meta!;
}
private setupInput(): void {
this.rightClickHandler = (pointer: Phaser.Input.Pointer) => {
if (!this.dragController.isDragging()) return;
if (pointer.button === 1) {
this.dragController.rotateDraggedItem();
}
};
this.scene.input.on("pointerdown", this.rightClickHandler);
}
public setLocked(locked: boolean): void {
this.isLocked = locked;
}
public getLostItems(): string[] {
return this.lostItemManager.getLostItemIds();
}
public clearLostItems(): void {
this.lostItemManager.clear();
}
/**
* Force re-sync of item visuals with current inventory state.
* With spawnEffect this is usually automatic, but useful after
* external state changes that don't trigger the effect.
*/
public refresh(): void {
// The spawner effect automatically re-syncs when gameState.value changes.
// If immediate refresh is needed, reading the signal triggers the effect.
void this.gameState.value;
}
public destroy(): void {
this.scene.input.off("pointerdown", this.rightClickHandler);
if (this.spawnDispose) {
this.spawnDispose();
this.spawnDispose = null;
}
this.dragController.destroy();
this.lostItemManager.destroy();
this.backgroundRenderer.destroy();
this.container.destroy();
}
}
@@ -1,157 +0,0 @@
import Phaser from "phaser";
import {
type InventoryItem,
type GameItemMeta,
} from "boardgame-core/samples/slay-the-spire-like";
export interface LostItem {
id: string;
container: Phaser.GameObjects.Container;
shape: InventoryItem<GameItemMeta>["shape"];
transform: InventoryItem<GameItemMeta>["transform"];
meta: InventoryItem<GameItemMeta>["meta"];
}
export interface LostItemManagerOptions {
scene: Phaser.Scene;
cellSize: number;
gridGap: number;
getItemColor: (itemId: string) => number;
onLostItemDragStart: (
itemId: string,
container: Phaser.GameObjects.Container,
) => void;
isDragging: () => boolean;
}
/**
* Manages "lost" items — items that were dropped outside valid grid placement.
* Renders them as semi-transparent red-bordered containers that can be re-dragged.
*/
export class LostItemManager {
private scene: Phaser.Scene;
private cellSize: number;
private gridGap: number;
private getItemColor: (itemId: string) => number;
private onLostItemDragStart: (
itemId: string,
container: Phaser.GameObjects.Container,
) => void;
private isDragging: () => boolean;
private lostItems = new Map<string, LostItem>();
constructor(options: LostItemManagerOptions) {
this.scene = options.scene;
this.cellSize = options.cellSize;
this.gridGap = options.gridGap;
this.getItemColor = options.getItemColor;
this.onLostItemDragStart = options.onLostItemDragStart;
this.isDragging = options.isDragging;
}
/**
* Create a visual representation of a lost item at the given position.
*/
createLostItem(
itemId: string,
shape: InventoryItem<GameItemMeta>["shape"],
transform: InventoryItem<GameItemMeta>["transform"],
meta: InventoryItem<GameItemMeta>["meta"],
x: number,
y: number,
): void {
const container = this.scene.add.container(x, y).setDepth(500);
const graphics = this.scene.add.graphics();
const color = this.getItemColor(itemId);
for (let gy = 0; gy < shape.height; gy++) {
for (let gx = 0; gx < shape.width; gx++) {
if (shape.grid[gy]?.[gx]) {
graphics.fillStyle(color, 0.5);
graphics.fillRect(
gx * (this.cellSize + this.gridGap),
gy * (this.cellSize + this.gridGap),
this.cellSize - 2,
this.cellSize - 2,
);
graphics.lineStyle(2, 0xff4444);
graphics.strokeRect(
gx * (this.cellSize + this.gridGap),
gy * (this.cellSize + this.gridGap),
this.cellSize,
this.cellSize,
);
}
}
}
container.add(graphics);
const name = meta?.itemData.name ?? itemId;
const text = this.scene.add
.text(0, -20, `${name} (lost)`, {
fontSize: "12px",
color: "#ff4444",
fontStyle: "italic",
})
.setOrigin(0.5);
container.add(text);
const hitRect = new Phaser.Geom.Rectangle(
0,
0,
this.cellSize,
this.cellSize,
);
container.setInteractive(hitRect, Phaser.Geom.Rectangle.Contains);
container.on("pointerdown", (pointer: Phaser.Input.Pointer) => {
// Guard against stale events firing on destroyed containers
if (!container.scene || !container.active) return;
if (this.isDragging()) return;
if (pointer.button === 0) {
this.onLostItemDragStart(itemId, container);
}
});
this.lostItems.set(itemId, {
id: itemId,
container,
shape,
transform: { ...transform },
meta,
});
}
/**
* Get all lost item IDs.
*/
getLostItemIds(): string[] {
return Array.from(this.lostItems.keys());
}
/**
* Get a lost item by ID, or undefined if not found.
*/
getLostItem(itemId: string): LostItem | undefined {
return this.lostItems.get(itemId);
}
/**
* Destroy and clear all lost items.
*/
clear(): void {
for (const lost of this.lostItems.values()) {
lost.container.destroy();
}
this.lostItems.clear();
}
/**
* Destroy all managed visuals.
*/
destroy(): void {
this.clear();
}
}
+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