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
This commit is contained in:
hypercross 2026-04-20 16:45:14 +08:00
parent b06876ccc0
commit 5884e74d8d
4 changed files with 212 additions and 101 deletions

View File

@ -33,6 +33,29 @@ export const GRID_CONFIG = {
WIDGET_CELL_SIZE: 80, WIDGET_CELL_SIZE: 80,
/** Gap between grid cells (pixels) */ /** Gap between grid cells (pixels) */
GRID_GAP: 2, 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: "#ffffff",
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: "#ffffff",
fontStyle: "bold",
} as const,
} as const; } as const;
// ── Shape Viewer ──────────────────────────────────────────────────────────── // ── Shape Viewer ────────────────────────────────────────────────────────────

View File

@ -0,0 +1,142 @@
import Phaser from "phaser";
import { spawnEffect, type Spawner } from "boardgame-phaser";
import { GRID_CONFIG, ITEM_COLORS } from "@/config";
import type {
GameItemMeta,
InventoryItem,
} from "boardgame-core/samples/slay-the-spire-like";
import type { InventorySignal } from "@/state/inventory";
export class InventoryItemSpawner implements Spawner<
[string, InventoryItem<GameItemMeta>],
Phaser.GameObjects.Container
> {
constructor(
private scene: Phaser.Scene,
private inventorySignal: InventorySignal,
private gridOffsetX: number,
private gridOffsetY: number,
) {}
*getData(): Iterable<[string, InventoryItem<GameItemMeta>]> {
const inventory = this.inventorySignal.value;
yield* inventory.items.entries();
}
getKey(entry: [string, InventoryItem<GameItemMeta>]): string {
return entry[0];
}
private getCells(
item: InventoryItem<GameItemMeta>,
): { x: number; y: number }[] {
const cells: { x: number; y: number }[] = [];
const shape = item.shape;
const transform = item.transform;
for (let y = 0; y < shape.height; y++) {
for (let x = 0; x < shape.width; x++) {
if (shape.grid[y]?.[x]) {
const finalX = x + transform.offset.x;
const finalY = y + transform.offset.y;
cells.push({ x: finalX, y: finalY });
}
}
}
return cells;
}
onSpawn(
entry: [string, InventoryItem<GameItemMeta>],
): Phaser.GameObjects.Container | null {
const [itemId, item] = entry;
const container = this.scene.add.container(0, 0);
const cells = this.getCells(item);
const itemColor = this.getItemColor(itemId);
const graphics = this.scene.add.graphics();
for (const cell of cells) {
const px = this.gridOffsetX + cell.x * GRID_CONFIG.VIEWER_CELL_SIZE;
const py = this.gridOffsetY + cell.y * GRID_CONFIG.VIEWER_CELL_SIZE;
graphics.fillStyle(itemColor);
graphics.fillRect(
px + 2,
py + 2,
GRID_CONFIG.VIEWER_CELL_SIZE - 4,
GRID_CONFIG.VIEWER_CELL_SIZE - 4,
);
}
if (cells.length > 0) {
const firstCell = cells[0];
const px = this.gridOffsetX + firstCell.x * GRID_CONFIG.VIEWER_CELL_SIZE;
const py = this.gridOffsetY + firstCell.y * GRID_CONFIG.VIEWER_CELL_SIZE;
const itemName = item.meta?.itemData.name ?? item.id;
const text = this.scene.add.text(
px + GRID_CONFIG.VIEWER_CELL_SIZE / 2,
py + GRID_CONFIG.VIEWER_CELL_SIZE / 2,
itemName,
GRID_CONFIG.ITEM_NAME_STYLE,
);
text.setOrigin(0.5);
container.add([graphics, text]);
} else {
container.add(graphics);
}
return container;
}
onUpdate(
entry: [string, InventoryItem<GameItemMeta>],
obj: Phaser.GameObjects.Container,
): void {
const [, item] = entry;
const cells = this.getCells(item);
if (cells.length > 0) {
const firstCell = cells[0];
const px = this.gridOffsetX + firstCell.x * GRID_CONFIG.VIEWER_CELL_SIZE;
const py = this.gridOffsetY + firstCell.y * GRID_CONFIG.VIEWER_CELL_SIZE;
this.scene.tweens.add({
targets: obj,
x: px,
y: py,
duration: 200,
ease: "Power2",
});
}
}
onDespawn(obj: Phaser.GameObjects.Container): void {
this.scene.tweens.add({
targets: obj,
alpha: 0,
scale: 0.5,
duration: 200,
ease: "Back.easeIn",
onComplete: () => obj.destroy(),
});
}
private getItemColor(itemId: string): number {
const hash = itemId.split("").reduce((acc, c) => acc + c.charCodeAt(0), 0);
return ITEM_COLORS[hash % ITEM_COLORS.length];
}
}
export function createInventoryItemSpawner(
scene: Phaser.Scene,
inventorySignal: InventorySignal,
gridOffsetX: number,
gridOffsetY: number,
) {
return spawnEffect(
new InventoryItemSpawner(scene, inventorySignal, gridOffsetX, gridOffsetY),
);
}

View File

@ -1,18 +1,13 @@
import { ReactiveScene } from "boardgame-phaser"; import { ReactiveScene } from "boardgame-phaser";
import { createButton } from "@/utils/createButton"; import { createButton } from "@/utils/createButton";
import { GRID_CONFIG, ITEM_COLORS } from "@/config"; import { GRID_CONFIG } from "@/config";
import { createInventorySignal } from "@/state/inventory"; import { createInventorySignal } from "@/state/inventory";
import { import { createItemIn, data } from "boardgame-core/samples/slay-the-spire-like";
createItemIn, import { createInventoryItemSpawner } from "./InventoryItemSpawner";
data,
type GameItemMeta,
} from "boardgame-core/samples/slay-the-spire-like";
import type { InventorySignal } from "@/state/inventory";
import type { InventoryItem } from "boardgame-core/samples/slay-the-spire-like";
import { SceneKey } from "./types"; import { SceneKey } from "./types";
export class InventoryTestScene extends ReactiveScene { export class InventoryTestScene extends ReactiveScene {
private inventorySignal!: InventorySignal; private inventorySignal = createInventorySignal();
private gridOffsetX = 0; private gridOffsetX = 0;
private gridOffsetY = 0; private gridOffsetY = 0;
@ -23,59 +18,62 @@ export class InventoryTestScene extends ReactiveScene {
create(): void { create(): void {
super.create(); super.create();
this.inventorySignal = createInventorySignal();
const { width, height } = this.scale; const { width, height } = this.scale;
this.gridOffsetX = const inventory = this.inventorySignal.value;
(width - GRID_CONFIG.WIDTH * GRID_CONFIG.VIEWER_CELL_SIZE) / 2; const invWidth = inventory.width;
this.gridOffsetY = const invHeight = inventory.height;
(height - GRID_CONFIG.HEIGHT * GRID_CONFIG.VIEWER_CELL_SIZE) / 2 + 40;
this.drawGrid(); this.gridOffsetX = (width - invWidth * GRID_CONFIG.VIEWER_CELL_SIZE) / 2;
this.drawItems(); this.gridOffsetY =
(height - invHeight * GRID_CONFIG.VIEWER_CELL_SIZE) / 2 + 40;
this.drawGrid(invWidth, invHeight);
this.setupItemSpawner();
this.add this.add
.text(width / 2, 30, "Inventory Signal Test (4x6)", { .text(
fontSize: "24px", width / 2,
color: "#ffffff", 30,
fontStyle: "bold", "Inventory Signal Test (4x6)",
}) GRID_CONFIG.TITLE_STYLE,
)
.setOrigin(0.5); .setOrigin(0.5);
this.createControls(); this.createControls();
this.add this.add
.text(width / 2, height - 40, "Items update reactively via signals", { .text(
fontSize: "14px", width / 2,
color: "#aaaaaa", height - 40,
}) "Items update reactively via signals",
GRID_CONFIG.SUBTITLE_STYLE,
)
.setOrigin(0.5); .setOrigin(0.5);
// React to inventory changes by re-rendering
this.addEffect(() => {
const inventory = this.inventorySignal.value;
// Track item count for reactivity
const itemCount = inventory.items.size;
return () => {};
});
} }
private drawGrid(): void { private drawGrid(invWidth: number, invHeight: number): void {
const graphics = this.add.graphics(); const graphics = this.add.graphics();
for (let y = 0; y < GRID_CONFIG.HEIGHT; y++) { for (let y = 0; y < invHeight; y++) {
for (let x = 0; x < GRID_CONFIG.WIDTH; x++) { for (let x = 0; x < invWidth; x++) {
const px = this.gridOffsetX + x * GRID_CONFIG.VIEWER_CELL_SIZE; const px = this.gridOffsetX + x * GRID_CONFIG.VIEWER_CELL_SIZE;
const py = this.gridOffsetY + y * GRID_CONFIG.VIEWER_CELL_SIZE; const py = this.gridOffsetY + y * GRID_CONFIG.VIEWER_CELL_SIZE;
graphics.fillStyle(0x222233); const isOccupied = this.inventorySignal.value.occupiedCells.has(
`${x},${y}`,
);
graphics.fillStyle(
isOccupied
? GRID_CONFIG.CELL_OCCUPIED_COLOR
: GRID_CONFIG.CELL_EMPTY_COLOR,
);
graphics.fillRect( graphics.fillRect(
px + 1, px + 1,
py + 1, py + 1,
GRID_CONFIG.VIEWER_CELL_SIZE - 2, GRID_CONFIG.VIEWER_CELL_SIZE - 2,
GRID_CONFIG.VIEWER_CELL_SIZE - 2, GRID_CONFIG.VIEWER_CELL_SIZE - 2,
); );
graphics.lineStyle(1, 0x555577); graphics.lineStyle(1, GRID_CONFIG.GRID_LINE_COLOR);
graphics.strokeRect( graphics.strokeRect(
px, px,
py, py,
@ -86,68 +84,14 @@ export class InventoryTestScene extends ReactiveScene {
} }
} }
private drawItems(): void { private setupItemSpawner(): void {
const inventory = this.inventorySignal.value; const spawner = createInventoryItemSpawner(
for (const [itemId, item] of inventory.items) { this,
this.drawItem(itemId, item); this.inventorySignal,
} this.gridOffsetX,
} this.gridOffsetY,
);
private drawItem(itemId: string, item: InventoryItem<GameItemMeta>): void { this.disposables.add(spawner);
const shape = item.shape;
const transform = item.transform;
const cells: { x: number; y: number }[] = [];
for (let y = 0; y < shape.height; y++) {
for (let x = 0; x < shape.width; x++) {
if (shape.grid[y]?.[x]) {
const finalX = x + transform.offset.x;
const finalY = y + transform.offset.y;
cells.push({ x: finalX, y: finalY });
}
}
}
const itemColor = this.getItemColor(itemId);
const graphics = this.add.graphics();
for (const cell of cells) {
const px = this.gridOffsetX + cell.x * GRID_CONFIG.VIEWER_CELL_SIZE;
const py = this.gridOffsetY + cell.y * GRID_CONFIG.VIEWER_CELL_SIZE;
graphics.fillStyle(itemColor);
graphics.fillRect(
px + 2,
py + 2,
GRID_CONFIG.VIEWER_CELL_SIZE - 4,
GRID_CONFIG.VIEWER_CELL_SIZE - 4,
);
}
if (cells.length > 0) {
const firstCell = cells[0];
const px = this.gridOffsetX + firstCell.x * GRID_CONFIG.VIEWER_CELL_SIZE;
const py = this.gridOffsetY + firstCell.y * GRID_CONFIG.VIEWER_CELL_SIZE;
const itemName = item.meta?.itemData.name ?? item.id;
this.add
.text(
px + GRID_CONFIG.VIEWER_CELL_SIZE / 2,
py + GRID_CONFIG.VIEWER_CELL_SIZE / 2,
itemName,
{
fontSize: "11px",
color: "#ffffff",
fontStyle: "bold",
},
)
.setOrigin(0.5);
}
}
private getItemColor(itemId: string): number {
const hash = itemId.split("").reduce((acc, c) => acc + c.charCodeAt(0), 0);
return ITEM_COLORS[hash % ITEM_COLORS.length];
} }
private createControls(): void { private createControls(): void {

View File

@ -4,6 +4,7 @@ import { MapViewerScene } from "@/scenes/MapViewerScene";
import { GridViewerScene } from "@/scenes/GridViewerScene"; import { GridViewerScene } from "@/scenes/GridViewerScene";
import { ShapeViewerScene } from "@/scenes/ShapeViewerScene"; import { ShapeViewerScene } from "@/scenes/ShapeViewerScene";
import { GAME_CONFIG } from "@/config"; import { GAME_CONFIG } from "@/config";
import { InventoryTestScene } from "@/scenes/InventoryTestScene";
export default function App() { export default function App() {
return ( return (
@ -11,6 +12,7 @@ export default function App() {
<div className="flex-1 flex relative justify-center items-center"> <div className="flex-1 flex relative justify-center items-center">
<PhaserGame initialScene="IndexScene" config={GAME_CONFIG}> <PhaserGame initialScene="IndexScene" config={GAME_CONFIG}>
<PhaserScene scene={IndexScene} /> <PhaserScene scene={IndexScene} />
<PhaserScene scene={InventoryTestScene} />
<PhaserScene scene={MapViewerScene} /> <PhaserScene scene={MapViewerScene} />
<PhaserScene scene={GridViewerScene} /> <PhaserScene scene={GridViewerScene} />
<PhaserScene scene={ShapeViewerScene} /> <PhaserScene scene={ShapeViewerScene} />