Compare commits

..

No commits in common. "368d9942d22b0c8ac2c5ca41bd26a091b072101c" and "82df3f2a2f83242b062a69ba1be04c711523ef41" have entirely different histories.

28 changed files with 2041 additions and 194 deletions

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.

View File

@ -1,4 +1,4 @@
import * as js from "@eslint";
import js from "@eslint/js";
import tseslint from "typescript-eslint";
import importPlugin from "eslint-plugin-import";

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 } 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,8 +34,8 @@ export const defaultPhaserConfig: Phaser.Types.Core.GameConfig = {
type: AUTO,
width: 560,
height: 560,
parent: "phaser-container",
backgroundColor: "#f9fafb",
parent: 'phaser-container',
backgroundColor: '#f9fafb',
scene: [],
};
@ -71,7 +71,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 +120,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 +192,8 @@ export function PhaserGame(props: PhaserGameProps) {
}
export interface PhaserSceneProps<TData = {}> {
sceneKey?: string;
scene: ReactiveScene<TData> | { new (): ReactiveScene<TData> };
sceneKey: string;
scene: ReactiveScene<TData>;
data?: TData;
children?: any;
}
@ -214,18 +214,16 @@ export function PhaserScene<TData = {}>(props: PhaserSceneProps<TData>) {
const game = ctx.game;
// 注册场景到 Phaser但不启动
const scene = "scene" in props.scene ? props.scene : new props.scene();
const sceneKey = props.sceneKey ?? scene.sys.settings.key;
if (!game.scene.getScene(sceneKey)) {
if (!game.scene.getScene(props.sceneKey)) {
const initData = {
...props.data,
phaserGame: phaserGameSignal,
sceneController: ctx.sceneController,
};
game.scene.add(sceneKey, props.scene, false, initData);
game.scene.add(props.sceneKey, props.scene, false, initData);
}
sceneSignal.value = scene;
sceneSignal.value = props.scene;
registered.current = true;
return () => {

View File

@ -113,13 +113,6 @@ 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,
@ -146,23 +139,6 @@ 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,

View File

@ -1,14 +1,7 @@
import type { Card } from "@/game/onitama";
import type { OnitamaScene } from "@/scenes/OnitamaScene";
import {
CARD_WIDTH,
CARD_HEIGHT,
COLORS,
FONTS,
CARD_GRID,
VISUAL,
} from "@/config";
import { CARD_WIDTH, CARD_HEIGHT, COLORS, FONTS, CARD_GRID } from "@/config";
export interface CardRenderOptions {
card: Card;
@ -35,17 +28,12 @@ export class CardRenderer {
// Create background rectangle
const bg = this.scene.add
.rectangle(0, 0, CARD_WIDTH, CARD_HEIGHT, COLORS.cardBg, 1)
.setStrokeStyle(VISUAL.cardStrokeWidth, COLORS.cardStroke);
.setStrokeStyle(2, COLORS.cardStroke);
container.add(bg);
// Create title text
const title = this.scene.add
.text(
0,
-CARD_HEIGHT / 2 + VISUAL.cardTitleOffset,
card.id,
FONTS.cardTitle,
)
.text(0, -CARD_HEIGHT / 2 + 16, card.id, FONTS.cardTitle)
.setOrigin(0.5);
container.add(title);
@ -54,12 +42,7 @@ export class CardRenderer {
// Create starting player text
const playerText = this.scene.add
.text(
0,
CARD_HEIGHT / 2 - VISUAL.cardPlayerOffset,
card.startingPlayer,
FONTS.cardPlayer,
)
.text(0, CARD_HEIGHT / 2 - 16, card.startingPlayer, FONTS.cardPlayer)
.setOrigin(0.5);
container.add(playerText);
}

View File

@ -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,
VISUAL.highlightOuterRadius,
CELL_SIZE / 3,
COLORS.black,
0.2,
);
@ -47,7 +47,7 @@ export class HighlightRenderer {
const innerCircle = this.scene.add.circle(
0,
0,
VISUAL.highlightInnerRadius,
CELL_SIZE / 4,
COLORS.black,
0.4,
);

View File

@ -1,6 +1,6 @@
import type { OnitamaScene } from "@/scenes/OnitamaScene";
import { CELL_SIZE, COLORS, FONTS, VISUAL } from "@/config";
import { CELL_SIZE, COLORS, FONTS } 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, VISUAL.pawnRadius, bgColor, 1)
.setStrokeStyle(VISUAL.pawnStrokeWidth, COLORS.pawnStroke);
.circle(0, 0, CELL_SIZE / 3, bgColor, 1)
.setStrokeStyle(2, COLORS.pawnStroke);
container.add(circle);
// Create label text

View File

@ -5,7 +5,6 @@ import type { OnitamaScene } from "@/scenes/OnitamaScene";
import {
CELL_SIZE,
COLORS,
VISUAL,
createSelectionShowTween,
createSelectionRingPulseTween,
createSelectionHideTween,
@ -32,21 +31,8 @@ export class SelectionRenderer {
parent: Phaser.GameObjects.Container | Phaser.GameObjects.GameObject,
): Phaser.GameObjects.Arc {
const ring = this.scene.add
.arc(
0,
0,
VISUAL.pawnRadius + VISUAL.selectionRingOffset,
0,
360,
false,
COLORS.highlight,
0,
)
.setStrokeStyle(
VISUAL.selectionRingStrokeWidth,
COLORS.highlightStroke,
1,
)
.arc(0, 0, CELL_SIZE / 3 + 5, 0, 360, false, COLORS.highlight, 0)
.setStrokeStyle(3, COLORS.highlightStroke, 1)
.setAlpha(0);
// Add to parent at index 0 (behind other visuals)

View File

@ -10,8 +10,6 @@ import {
COLORS,
FONTS,
MENU_BUTTON,
TEXT_POSITION,
VISUAL,
getBoardCenter,
getCardLabelPosition,
colorToStr,
@ -81,12 +79,7 @@ export class OnitamaScene extends GameHostScene<OnitamaState> {
});
// Info text
this.infoText = this.add.text(
TEXT_POSITION.infoX,
BOARD_OFFSET.y,
"",
FONTS.info,
);
this.infoText = this.add.text(40, BOARD_OFFSET.y, "", FONTS.info);
// Update info text when UI state changes
this.addEffect(() => {
@ -156,12 +149,7 @@ export class OnitamaScene extends GameHostScene<OnitamaState> {
g.strokePath();
this.add.text(
TEXT_POSITION.titleX,
TEXT_POSITION.titleY,
"Onitama",
FONTS.title,
);
this.add.text(40, 40, "Onitama", FONTS.title);
}
private setupInput(): void {
@ -300,7 +288,7 @@ export class OnitamaScene extends GameHostScene<OnitamaState> {
boardWidth,
boardHeight,
COLORS.overlayBg,
VISUAL.overlayAlpha,
0.6,
)
.setInteractive({ useHandCursor: true });

View File

@ -7,8 +7,6 @@ import type { Spawner } from "boardgame-phaser";
import {
CARD_WIDTH,
CARD_HEIGHT,
COLORS,
VISUAL,
getCardPosition,
createCardMoveTween,
createCardRotateTween,
@ -104,7 +102,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(VISUAL.cardBackgroundDepth);
.setDepth(-1);
this.highlightRect = rect;
this.addAt(this.highlightRect, 0);
@ -163,7 +161,7 @@ export class CardContainer extends Phaser.GameObjects.Container {
// 创建一个 effect 来持续监听高亮状态变化
const dispose = effect(() => {
if (scene.uiState.value.selectedCard === this._cardId) {
this.highlight(COLORS.highlight, VISUAL.selectionRingStrokeWidth);
this.highlight(0xfbbf24, 3);
} else {
this.unhighlight();
}
@ -278,7 +276,7 @@ export class CardSpawner implements Spawner<CardSpawnData, CardContainer> {
// 设置悬停效果
container.on("pointerover", () => {
if (this.scene.uiState.value.selectedCard !== data.cardId) {
container.setAlpha(VISUAL.cardDisabledAlpha);
container.setAlpha(0.8);
}
});

View File

@ -7,7 +7,6 @@ import type { Spawner } from "boardgame-phaser";
import {
boardToScreen,
CELL_SIZE,
VISUAL,
createHighlightSpawnTween,
createHighlightDespawnTween,
createHighlightClickFeedbackTween,
@ -84,7 +83,7 @@ export class HighlightSpawner implements Spawner<
this.renderer.render(container, { x: data.x, y: data.y });
// 设置交互区域
const hitArea = new Geom.Circle(0, 0, VISUAL.highlightHitAreaRadius);
const hitArea = new Geom.Circle(0, 0, CELL_SIZE / 3);
container.setInteractive(hitArea, Phaser.Geom.Circle.Contains);
if (container.input) {
container.input.cursor = "pointer";

View File

@ -3,8 +3,6 @@
* All magic numbers should be defined here and imported where needed.
*/
import { PhaserGameProps } from "boardgame-phaser";
// ── Map Layout ──────────────────────────────────────────────────────────────
export const MAP_CONFIG = {
@ -85,9 +83,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 ──────────────────────────────────────────────────────────────────
@ -104,14 +102,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 = [
@ -120,20 +118,5 @@ 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 GAME_CONFIG: Phaser.Types.Core.GameConfig = {
width: 1920,
height: 1080,
};
export const POSITIVE_EFFECTS = new Set(['block', 'strength', 'dexterity', 'regen']);
export const NEGATIVE_EFFECTS = new Set(['weak', 'vulnerable', 'frail', 'poison']);

View File

@ -1,9 +1,10 @@
import { GameUI } from "boardgame-phaser";
import "./style.css";
import { h } from 'preact';
import { GameUI } from 'boardgame-phaser';
import './style.css';
import App from "@/ui/App";
const ui = new GameUI({
container: "ui-root",
container: document.getElementById('ui-root')!,
root: <App/>,
});

View File

@ -0,0 +1,439 @@
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;
}
}

View File

@ -1,6 +1,7 @@
import Phaser from "phaser";
import { ReactiveScene } from "boardgame-phaser";
import { createButton } from "@/utils/createButton";
import { GRID_CONFIG, ITEM_COLORS } from "@/config";
import { GRID_CONFIG, UI_CONFIG, ITEM_COLORS } from "@/config";
import {
createGridInventory,
placeItem,
@ -11,7 +12,6 @@ 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.getItems();
const items = data.desert.items;
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(SceneKey.IndexScene);
await this.sceneController.launch("IndexScene");
},
});
@ -242,7 +242,7 @@ export class GridViewerScene extends ReactiveScene {
GRID_CONFIG.WIDTH,
GRID_CONFIG.HEIGHT,
);
const items = data.desert.getItems();
const items = data.desert.items;
let itemIndex = 0;
for (let y = 0; y < GRID_CONFIG.HEIGHT && itemIndex < items.length; y++) {

View File

@ -2,7 +2,6 @@ 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() {
@ -33,22 +32,15 @@ export class IndexScene extends ReactiveScene {
.setOrigin(0.5);
// Buttons
const buttons: {
label: string;
scene: SceneKey;
y: number;
}[] = [
{ label: "Map Viewer", scene: SceneKey.MapViewerScene, y: centerY },
const buttons = [
{ label: "开始游戏", scene: "GameFlowScene", y: centerY - 70 },
{ label: "Map Viewer", scene: "MapViewerScene", y: centerY },
{
label: "Grid Inventory Viewer",
scene: SceneKey.GridViewerScene,
scene: "GridViewerScene",
y: centerY + 70,
},
{
label: "Shape Viewer",
scene: SceneKey.ShapeViewerScene,
y: centerY + 140,
},
{ label: "Shape Viewer", scene: "ShapeViewerScene", y: centerY + 140 },
];
for (const btn of buttons) {
@ -58,7 +50,7 @@ export class IndexScene extends ReactiveScene {
private createButton(
label: string,
targetScene: SceneKey,
targetScene: string,
x: number,
y: number,
): void {

View File

@ -10,7 +10,6 @@ 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;
@ -58,7 +57,7 @@ export class MapViewerScene extends ReactiveScene {
x: 100,
y: 40,
onClick: async () => {
await this.sceneController.launch(SceneKey.IndexScene);
await this.sceneController.launch("IndexScene");
},
depth: 100,
});
@ -114,7 +113,7 @@ export class MapViewerScene extends ReactiveScene {
private drawMap(): void {
const rng = createRNG(this.seed);
this.map = generatePointCrawlMap(rng, data.desert.getEncounters());
this.map = generatePointCrawlMap(rng, data.desert.encounters);
const { width, height } = this.scale;
const {

View File

@ -0,0 +1,235 @@
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 {};
}
}
}

View File

@ -1,12 +1,12 @@
import Phaser from "phaser";
import { ReactiveScene } from "boardgame-phaser";
import { createButton } from "@/utils/createButton";
import { SHAPE_CONFIG } from "@/config";
import { UI_CONFIG, SHAPE_CONFIG, NODE_LABELS, NODE_COLORS } 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,19 +17,6 @@ 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 {
@ -53,7 +40,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.getItems().slice(0, MAX_ITEMS);
const itemsToShow = data.desert.items.slice(0, MAX_ITEMS);
for (let i = 0; i < itemsToShow.length; i++) {
const itemData = itemsToShow[i];
@ -158,7 +145,7 @@ export class ShapeViewerScene extends ReactiveScene {
x: 100,
y: height - 40,
onClick: async () => {
await this.sceneController.launch(SceneKey.IndexScene);
await this.sceneController.launch("IndexScene");
},
});

View File

@ -1,6 +0,0 @@
export enum SceneKey {
GridViewerScene = "GridViewerScene",
IndexScene = "IndexScene",
MapViewerScene = "MapViewerScene",
ShapeViewerScene = "ShapeViewerScene",
}

View File

@ -0,0 +1,34 @@
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,
},
};
}

View File

@ -1,19 +1,53 @@
import { h } from "preact";
import { PhaserGame, PhaserScene } from "boardgame-phaser";
import { useMemo } from "preact/hooks";
import { IndexScene } from "@/scenes/IndexScene";
import { MapViewerScene } from "@/scenes/MapViewerScene";
import { GridViewerScene } from "@/scenes/GridViewerScene";
import { ShapeViewerScene } from "@/scenes/ShapeViewerScene";
import { GAME_CONFIG } from "@/config";
import { 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),
[],
);
return (
<div className="flex flex-col h-screen">
<div className="flex-1 flex relative justify-center items-center">
<PhaserGame initialScene="IndexScene" config={GAME_CONFIG}>
<PhaserScene scene={IndexScene} />
<PhaserScene scene={MapViewerScene} />
<PhaserScene scene={GridViewerScene} />
<PhaserScene scene={ShapeViewerScene} />
<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>
</div>
</div>

View File

@ -0,0 +1,477 @@
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();
}
}

View File

@ -0,0 +1,71 @@
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();
}
}

View File

@ -0,0 +1,277 @@
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;
}
}

View File

@ -0,0 +1,239 @@
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();
}
}

View File

@ -0,0 +1,157 @@
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();
}
}