Refactor: centralize config and split inventory

Move magic numbers and style constants to src/config.ts.
Decompose InventoryWidget into DragController, ItemRenderer,
and LostItemManager for better separation of concerns. Add
createButton utility and remove unused CombatUnitWidget.
Update scenes to import from centralized config.
This commit is contained in:
hypercross 2026-04-19 00:01:25 +08:00
parent d993d55576
commit 88d0c5bf55
13 changed files with 1834 additions and 1186 deletions

View File

@ -0,0 +1,122 @@
/**
* Centralized layout and style constants for sts-like-viewer.
* All magic numbers should be defined here and imported where needed.
*/
// ── Map Layout ──────────────────────────────────────────────────────────────
export const MAP_CONFIG = {
/** Horizontal spacing between map layers (left-to-right) */
LAYER_HEIGHT: 110,
/** Vertical spacing between nodes within a layer */
NODE_SPACING: 140,
/** Radius of each map node circle */
NODE_RADIUS: 28,
/** Total number of layers in the point-crawl map */
TOTAL_LAYERS: 10,
/** Maximum nodes in any single layer */
MAX_NODES_PER_LAYER: 5,
} as const;
// ── Grid Inventory ──────────────────────────────────────────────────────────
export const GRID_CONFIG = {
/** Default grid width (columns) */
WIDTH: 6,
/** Default grid height (rows) */
HEIGHT: 4,
/** Cell size for the standalone grid viewer */
VIEWER_CELL_SIZE: 60,
/** Cell size for the encounter inventory widget */
WIDGET_CELL_SIZE: 80,
/** Gap between grid cells (pixels) */
GRID_GAP: 2,
} as const;
// ── Shape Viewer ────────────────────────────────────────────────────────────
export const SHAPE_CONFIG = {
/** Cell size for rendering item shapes */
CELL_SIZE: 40,
/** Number of items per row in the shape viewer grid */
ITEMS_PER_ROW: 4,
/** Maximum items to display at once */
MAX_ITEMS: 12,
/** Horizontal spacing between shape previews */
SPACING_X: 220,
/** Vertical spacing between shape previews */
SPACING_Y: 140,
} as const;
// ── Combat Unit Widget ──────────────────────────────────────────────────────
export const COMBAT_WIDGET_CONFIG = {
/** Default widget width */
WIDTH: 240,
/** Default widget height */
HEIGHT: 120,
/** HP bar width */
HP_BAR_WIDTH: 180,
/** HP bar height */
HP_BAR_HEIGHT: 16,
/** Buff/debuff icon size */
BUFF_ICON_SIZE: 28,
/** Gap between buff icons */
BUFF_ICON_GAP: 6,
} as const;
// ── UI / Button ─────────────────────────────────────────────────────────────
export const UI_CONFIG = {
/** Standard button width */
BUTTON_WIDTH: 120,
/** Standard button height */
BUTTON_HEIGHT: 36,
/** Large button width */
BUTTON_WIDTH_LARGE: 220,
/** Large button height */
BUTTON_HEIGHT_LARGE: 50,
/** Button background color */
BUTTON_BG: 0x444466,
/** Button hover background color */
BUTTON_BG_HOVER: 0x555588,
/** Button border color */
BUTTON_BORDER: 0x7777aa,
/** Button text color */
BUTTON_TEXT_COLOR: '#ffffff',
/** Button font size */
BUTTON_FONT_SIZE: '16px',
} as const;
// ── Colors ──────────────────────────────────────────────────────────────────
export const NODE_COLORS = {
start: 0x44aa44,
end: 0xcc8844,
minion: 0xcc4444,
elite: 0xcc44cc,
event: 0xaaaa44,
camp: 0x44cccc,
shop: 0x4488cc,
curio: 0x8844cc,
} as const;
export const NODE_LABELS = {
start: '起点',
end: '终点',
minion: '战斗',
elite: '精英',
event: '事件',
camp: '营地',
shop: '商店',
curio: '奇遇',
} as const;
export const ITEM_COLORS = [
0x4488cc, 0xcc8844, 0x44cc88, 0xcc4488, 0x8844cc, 0x44cccc,
] as const;
// ── 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']);

View File

@ -1,48 +1,22 @@
import Phaser from 'phaser';
import { ReactiveScene } from 'boardgame-phaser';
import { MutableSignal } from 'boardgame-core';
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,
isAtStartNode,
type RunState,
type MapNode,
} from 'boardgame-core/samples/slay-the-spire-like';
const NODE_COLORS: Record<string, number> = {
start: 0x44aa44,
end: 0xcc8844,
minion: 0xcc4444,
elite: 0xcc44cc,
event: 0xaaaa44,
camp: 0x44cccc,
shop: 0x4488cc,
curio: 0x8844cc,
};
const NODE_LABELS: Record<string, string> = {
start: '起点',
end: '终点',
minion: '战斗',
elite: '精英',
event: '事件',
camp: '营地',
shop: '商店',
curio: '奇遇',
};
} from "boardgame-core/samples/slay-the-spire-like";
export class GameFlowScene extends ReactiveScene {
/** 全局游戏状态(由 App.tsx 注入) */
private gameState: MutableSignal<RunState>;
// Layout constants
private readonly LAYER_HEIGHT = 110;
private readonly NODE_SPACING = 140;
private readonly NODE_RADIUS = 28;
// UI elements
private hudContainer!: Phaser.GameObjects.Container;
private hpText!: Phaser.GameObjects.Text;
@ -62,7 +36,7 @@ export class GameFlowScene extends ReactiveScene {
private nodeGraphics: Map<string, Phaser.GameObjects.Graphics> = new Map();
constructor(gameState: MutableSignal<RunState>) {
super('GameFlowScene');
super("GameFlowScene");
this.gameState = gameState;
}
@ -82,31 +56,46 @@ export class GameFlowScene extends ReactiveScene {
this.hudContainer.add(hudBg);
// HP
this.hpText = this.add.text(-150, 0, '', {
fontSize: '16px',
color: '#ff6666',
fontStyle: 'bold',
}).setOrigin(0, 0.5);
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.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.nodeText = this.add
.text(50, 0, "", {
fontSize: "16px",
color: "#ffffff",
})
.setOrigin(0, 0.5);
this.hudContainer.add(this.nodeText);
// Back to menu button
this.createButton('返回菜单', width - 100, 25, 140, 36, async () => {
await this.sceneController.launch('IndexScene');
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,
});
}
@ -128,18 +117,26 @@ export class GameFlowScene extends ReactiveScene {
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 = 9;
const maxNodesInLayer = 5;
const mapWidth = maxLayer * this.LAYER_HEIGHT + 200;
const mapHeight = (maxNodesInLayer - 1) * this.NODE_SPACING + 200;
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);
const bg = this.add
.rectangle(0, 0, mapWidth, mapHeight, 0x111122, 0.5)
.setOrigin(0.5);
this.mapContainer.add(bg);
const graphics = this.add.graphics();
@ -147,7 +144,7 @@ export class GameFlowScene extends ReactiveScene {
const { map, currentNodeId } = state;
const reachableChildren = getReachableChildren(state);
const reachableIds = new Set(reachableChildren.map(n => n.id));
const reachableIds = new Set(reachableChildren.map((n) => n.id));
// Draw edges
graphics.lineStyle(2, 0x666666);
@ -178,9 +175,13 @@ export class GameFlowScene extends ReactiveScene {
this.mapContainer.add(nodeGraphics);
this.nodeGraphics.set(nodeId, nodeGraphics);
const color = isCurrent ? 0xffffff : (isReachable ? this.brightenColor(baseColor) : baseColor);
const color = isCurrent
? 0xffffff
: isReachable
? this.brightenColor(baseColor)
: baseColor;
nodeGraphics.fillStyle(color);
nodeGraphics.fillCircle(posX, posY, this.NODE_RADIUS);
nodeGraphics.fillCircle(posX, posY, NODE_RADIUS);
if (isCurrent) {
nodeGraphics.lineStyle(3, 0xffff44);
@ -189,86 +190,109 @@ export class GameFlowScene extends ReactiveScene {
} else {
nodeGraphics.lineStyle(2, 0x888888);
}
nodeGraphics.strokeCircle(posX, posY, this.NODE_RADIUS);
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)
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 + this.NODE_RADIUS + 12, node.encounter.name, {
fontSize: '10px',
color: '#cccccc',
}).setOrigin(0.5)
this.add
.text(posX, posY + NODE_RADIUS + 12, node.encounter.name, {
fontSize: "10px",
color: "#cccccc",
})
.setOrigin(0.5),
);
}
// Make reachable nodes interactive (add hitZone to mapContainer so positions match)
// Make reachable nodes interactive
if (isReachable) {
const hitZone = this.add.circle(posX, posY, this.NODE_RADIUS, 0x000000, 0)
const hitZone = this.add
.circle(posX, posY, NODE_RADIUS, 0x000000, 0)
.setInteractive({ useHandCursor: true });
this.mapContainer.add(hitZone);
hitZone.on('pointerover', () => {
hitZone.on("pointerover", () => {
this.hoveredNode = nodeId;
nodeGraphics.clear();
nodeGraphics.fillStyle(this.brightenColor(baseColor));
nodeGraphics.fillCircle(posX, posY, this.NODE_RADIUS);
nodeGraphics.fillCircle(posX, posY, NODE_RADIUS);
nodeGraphics.lineStyle(3, 0xaaddaa);
nodeGraphics.strokeCircle(posX, posY, this.NODE_RADIUS);
nodeGraphics.strokeCircle(posX, posY, NODE_RADIUS);
});
hitZone.on('pointerout', () => {
hitZone.on("pointerout", () => {
this.hoveredNode = null;
nodeGraphics.clear();
nodeGraphics.fillStyle(baseColor);
nodeGraphics.fillCircle(posX, posY, this.NODE_RADIUS);
nodeGraphics.fillCircle(posX, posY, NODE_RADIUS);
nodeGraphics.lineStyle(2, 0xaaddaa);
nodeGraphics.strokeCircle(posX, posY, this.NODE_RADIUS);
nodeGraphics.strokeCircle(posX, posY, NODE_RADIUS);
});
hitZone.on('pointerdown', () => {
hitZone.on("pointerdown", () => {
this.onNodeClick(nodeId);
});
}
}
// Setup drag-to-scroll
this.input.on('pointerdown', (pointer: Phaser.Input.Pointer) => {
// 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;
});
};
this.input.on('pointermove', (pointer: Phaser.Input.Pointer) => {
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);
});
this.mapContainer.x =
this.dragStartContainerX + (pointer.x - this.dragStartX);
this.mapContainer.y =
this.dragStartContainerY + (pointer.y - this.dragStartY);
};
this.input.on('pointerup', () => {
const onPointerUp = () => {
this.isDragging = false;
});
};
this.input.on('pointerout', () => {
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);
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> {
@ -297,18 +321,18 @@ export class GameFlowScene extends ReactiveScene {
// Launch encounter scene
const currentNode = getCurrentNode(state);
if (!currentNode || !currentNode.encounter) {
console.warn('当前节点没有遭遇数据');
console.warn("当前节点没有遭遇数据");
return;
}
await this.sceneController.launch('PlaceholderEncounterScene');
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));
const reachableIds = new Set(reachableChildren.map((n) => n.id));
for (const [nodeId, nodeGraphics] of this.nodeGraphics) {
const node = map.nodes.get(nodeId);
@ -319,9 +343,17 @@ export class GameFlowScene extends ReactiveScene {
const baseColor = NODE_COLORS[node.type] ?? 0x888888;
nodeGraphics.clear();
const color = isCurrent ? 0xffffff : (isReachable ? this.brightenColor(baseColor) : baseColor);
const color = isCurrent
? 0xffffff
: isReachable
? this.brightenColor(baseColor)
: baseColor;
nodeGraphics.fillStyle(color);
nodeGraphics.fillCircle(this.getNodeX(node), this.getNodeY(node), this.NODE_RADIUS);
nodeGraphics.fillCircle(
this.getNodeX(node),
this.getNodeY(node),
MAP_CONFIG.NODE_RADIUS,
);
if (isCurrent) {
nodeGraphics.lineStyle(3, 0xffff44);
@ -330,7 +362,11 @@ export class GameFlowScene extends ReactiveScene {
} else {
nodeGraphics.lineStyle(2, 0x888888);
}
nodeGraphics.strokeCircle(this.getNodeX(node), this.getNodeY(node), this.NODE_RADIUS);
nodeGraphics.strokeCircle(
this.getNodeX(node),
this.getNodeY(node),
MAP_CONFIG.NODE_RADIUS,
);
}
}
@ -339,79 +375,65 @@ export class GameFlowScene extends ReactiveScene {
const state = this.gameState.value;
// Overlay
const overlay = this.add.rectangle(width / 2, height / 2, width, height, 0x000000, 0.7).setDepth(300);
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);
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);
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);
this.createButton('返回菜单', width / 2, height / 2 + 100, 200, 50, async () => {
await this.sceneController.launch('IndexScene');
}, 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 {
// Layers go left-to-right along X axis
return -500 + node.layerIndex * this.LAYER_HEIGHT;
return -500 + node.layerIndex * MAP_CONFIG.LAYER_HEIGHT;
}
private getNodeY(node: MapNode): number {
// Nodes within a layer are spread vertically along Y axis
const state = this.gameState.value;
const layer = state.map.layers[node.layerIndex];
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) * this.NODE_SPACING;
return -layerHeight / 2 + nodeIndex * this.NODE_SPACING;
const layerHeight = (totalNodes - 1) * MAP_CONFIG.NODE_SPACING;
return -layerHeight / 2 + nodeIndex * MAP_CONFIG.NODE_SPACING;
}
private brightenColor(color: number): number {
// Simple color brightening
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;
}
private createButton(
label: string,
x: number,
y: number,
width: number,
height: number,
onClick: () => void,
depth: number = 200
): void {
const bg = this.add.rectangle(x, y, width, height, 0x444466)
.setStrokeStyle(2, 0x7777aa)
.setInteractive({ useHandCursor: true })
.setDepth(depth);
const text = this.add.text(x, y, label, {
fontSize: '16px',
color: '#ffffff',
}).setOrigin(0.5).setDepth(depth);
bg.on('pointerover', () => {
bg.setFillStyle(0x555588);
text.setScale(1.05);
});
bg.on('pointerout', () => {
bg.setFillStyle(0x444466);
text.setScale(1);
});
bg.on('pointerdown', onClick);
}
}

View File

@ -1,4 +1,7 @@
import Phaser from 'phaser';
import Phaser from "phaser";
import { ReactiveScene } from "boardgame-phaser";
import { createButton } from "@/utils/createButton";
import { GRID_CONFIG, UI_CONFIG, ITEM_COLORS } from "@/config";
import {
createGridInventory,
placeItem,
@ -8,44 +11,49 @@ import {
type GridInventory,
type InventoryItem,
type GameItemMeta,
} from 'boardgame-core/samples/slay-the-spire-like';
import { ReactiveScene } from 'boardgame-phaser';
} from "boardgame-core/samples/slay-the-spire-like";
export class GridViewerScene extends ReactiveScene {
private inventory: GridInventory<GameItemMeta>;
private readonly CELL_SIZE = 60;
private readonly GRID_WIDTH = 6;
private readonly GRID_HEIGHT = 4;
private gridOffsetX = 0;
private gridOffsetY = 0;
constructor() {
super('GridViewerScene');
this.inventory = createGridInventory<GameItemMeta>(this.GRID_WIDTH, this.GRID_HEIGHT);
super("GridViewerScene");
this.inventory = createGridInventory<GameItemMeta>(
GRID_CONFIG.WIDTH,
GRID_CONFIG.HEIGHT,
);
}
create(): void {
super.create();
const { width, height } = this.scale;
this.gridOffsetX = (width - this.GRID_WIDTH * this.CELL_SIZE) / 2;
this.gridOffsetY = (height - this.GRID_HEIGHT * this.CELL_SIZE) / 2 + 20;
this.gridOffsetX =
(width - GRID_CONFIG.WIDTH * GRID_CONFIG.VIEWER_CELL_SIZE) / 2;
this.gridOffsetY =
(height - GRID_CONFIG.HEIGHT * GRID_CONFIG.VIEWER_CELL_SIZE) / 2 + 20;
this.placeSampleItems();
this.drawGrid();
this.drawItems();
this.add.text(width / 2, 30, 'Grid Inventory Viewer (4x6)', {
fontSize: '24px',
color: '#ffffff',
fontStyle: 'bold',
}).setOrigin(0.5);
this.add
.text(width / 2, 30, "Grid Inventory Viewer (4x6)", {
fontSize: "24px",
color: "#ffffff",
fontStyle: "bold",
})
.setOrigin(0.5);
this.createControls();
this.add.text(width / 2, height - 40, 'Hover over cells to see item details', {
fontSize: '14px',
color: '#aaaaaa',
}).setOrigin(0.5);
this.add
.text(width / 2, height - 40, "Hover over cells to see item details", {
fontSize: "14px",
color: "#aaaaaa",
})
.setOrigin(0.5);
}
private placeSampleItems(): void {
@ -64,7 +72,12 @@ export class GridViewerScene extends ReactiveScene {
const item: InventoryItem<GameItemMeta> = {
id: `item-${index}`,
shape,
transform: { offset: { x, y }, rotation: 0, flipX: false, flipY: false },
transform: {
offset: { x, y },
rotation: 0,
flipX: false,
flipY: false,
},
meta: { itemData, shape },
};
placeItem(this.inventory, item);
@ -74,18 +87,28 @@ export class GridViewerScene extends ReactiveScene {
private drawGrid(): void {
const graphics = this.add.graphics();
for (let y = 0; y < this.GRID_HEIGHT; y++) {
for (let x = 0; x < this.GRID_WIDTH; x++) {
const px = this.gridOffsetX + x * this.CELL_SIZE;
const py = this.gridOffsetY + y * this.CELL_SIZE;
for (let y = 0; y < GRID_CONFIG.HEIGHT; y++) {
for (let x = 0; x < GRID_CONFIG.WIDTH; x++) {
const px = this.gridOffsetX + x * GRID_CONFIG.VIEWER_CELL_SIZE;
const py = this.gridOffsetY + y * GRID_CONFIG.VIEWER_CELL_SIZE;
const isOccupied = this.inventory.occupiedCells.has(`${x},${y}`);
const color = isOccupied ? 0x334455 : 0x222233;
graphics.fillStyle(color);
graphics.fillRect(px + 1, py + 1, this.CELL_SIZE - 2, this.CELL_SIZE - 2);
graphics.fillRect(
px + 1,
py + 1,
GRID_CONFIG.VIEWER_CELL_SIZE - 2,
GRID_CONFIG.VIEWER_CELL_SIZE - 2,
);
graphics.lineStyle(1, 0x555577);
graphics.strokeRect(px, py, this.CELL_SIZE, this.CELL_SIZE);
graphics.strokeRect(
px,
py,
GRID_CONFIG.VIEWER_CELL_SIZE,
GRID_CONFIG.VIEWER_CELL_SIZE,
);
}
}
}
@ -110,72 +133,120 @@ export class GridViewerScene extends ReactiveScene {
const graphics = this.add.graphics();
for (const cell of cells) {
const px = this.gridOffsetX + cell.x * this.CELL_SIZE;
const py = this.gridOffsetY + cell.y * this.CELL_SIZE;
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, this.CELL_SIZE - 4, this.CELL_SIZE - 4);
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 * this.CELL_SIZE;
const py = this.gridOffsetY + firstCell.y * this.CELL_SIZE;
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 + this.CELL_SIZE / 2, py + this.CELL_SIZE / 2, itemName, {
fontSize: '11px',
color: '#ffffff',
fontStyle: 'bold',
}).setOrigin(0.5);
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);
}
const adjacent = getAdjacentItems(this.inventory, itemId);
if (adjacent.size > 0) {
const adjacentNames = Array.from(adjacent.values()).map(i => i.meta?.itemData.name ?? i.id).join(', ');
const adjacentNames = Array.from(adjacent.values())
.map((i) => i.meta?.itemData.name ?? i.id)
.join(", ");
const firstCell = cells[0];
const px = this.gridOffsetX + firstCell.x * this.CELL_SIZE;
const py = this.gridOffsetY + firstCell.y * this.CELL_SIZE - 20;
const px =
this.gridOffsetX + firstCell.x * GRID_CONFIG.VIEWER_CELL_SIZE;
const py =
this.gridOffsetY + firstCell.y * GRID_CONFIG.VIEWER_CELL_SIZE - 20;
this.add.text(px + this.CELL_SIZE / 2, py, `邻接: ${adjacentNames}`, {
fontSize: '10px',
color: '#ffff88',
}).setOrigin(0.5);
this.add
.text(
px + GRID_CONFIG.VIEWER_CELL_SIZE / 2,
py,
`邻接: ${adjacentNames}`,
{
fontSize: "10px",
color: "#ffff88",
},
)
.setOrigin(0.5);
}
}
}
private getItemColor(itemId: string): number {
const hash = itemId.split('').reduce((acc, c) => acc + c.charCodeAt(0), 0);
const colors = [0x4488cc, 0xcc8844, 0x44cc88, 0xcc4488, 0x8844cc, 0x44cccc];
return colors[hash % colors.length];
const hash = itemId.split("").reduce((acc, c) => acc + c.charCodeAt(0), 0);
return ITEM_COLORS[hash % ITEM_COLORS.length];
}
private createControls(): void {
const { width, height } = this.scale;
this.createButton('返回菜单', 100, 40, async () => {
await this.sceneController.launch('IndexScene');
createButton({
scene: this,
label: "返回菜单",
x: 100,
y: 40,
onClick: async () => {
await this.sceneController.launch("IndexScene");
},
});
this.createButton('清空', width - 260, 40, async () => {
this.inventory = createGridInventory<GameItemMeta>(this.GRID_WIDTH, this.GRID_HEIGHT);
createButton({
scene: this,
label: "清空",
x: width - 260,
y: 40,
onClick: async () => {
this.inventory = createGridInventory<GameItemMeta>(
GRID_CONFIG.WIDTH,
GRID_CONFIG.HEIGHT,
);
await this.sceneController.restart();
},
});
this.createButton('随机填充', width - 130, 40, async () => {
createButton({
scene: this,
label: "随机填充",
x: width - 130,
y: 40,
onClick: async () => {
this.randomFill();
await this.sceneController.restart();
},
});
}
private randomFill(): void {
this.inventory = createGridInventory<GameItemMeta>(this.GRID_WIDTH, this.GRID_HEIGHT);
this.inventory = createGridInventory<GameItemMeta>(
GRID_CONFIG.WIDTH,
GRID_CONFIG.HEIGHT,
);
const items = data.desert.items;
let itemIndex = 0;
for (let y = 0; y < this.GRID_HEIGHT && itemIndex < items.length; y++) {
for (let x = 0; x < this.GRID_WIDTH && itemIndex < items.length; x++) {
for (let y = 0; y < GRID_CONFIG.HEIGHT && itemIndex < items.length; y++) {
for (let x = 0; x < GRID_CONFIG.WIDTH && itemIndex < items.length; x++) {
const itemData = items[itemIndex];
const shape = parseShapeString(itemData.shape);
@ -190,8 +261,12 @@ export class GridViewerScene extends ReactiveScene {
let valid = true;
for (const cell of occupiedCells) {
const [cx, cy] = cell.split(',').map(Number);
if (cx >= this.GRID_WIDTH || cy >= this.GRID_HEIGHT || this.inventory.occupiedCells.has(cell as `${number},${number}`)) {
const [cx, cy] = cell.split(",").map(Number);
if (
cx >= GRID_CONFIG.WIDTH ||
cy >= GRID_CONFIG.HEIGHT ||
this.inventory.occupiedCells.has(cell as `${number},${number}`)
) {
valid = false;
break;
}
@ -201,7 +276,12 @@ export class GridViewerScene extends ReactiveScene {
const item: InventoryItem<GameItemMeta> = {
id: `item-${itemIndex}`,
shape,
transform: { offset: { x, y }, rotation: 0, flipX: false, flipY: false },
transform: {
offset: { x, y },
rotation: 0,
flipX: false,
flipY: false,
},
meta: { itemData, shape },
};
placeItem(this.inventory, item);
@ -210,30 +290,4 @@ export class GridViewerScene extends ReactiveScene {
}
}
}
private createButton(label: string, x: number, y: number, onClick: () => void): void {
const buttonWidth = 120;
const buttonHeight = 36;
const bg = this.add.rectangle(x, y, buttonWidth, buttonHeight, 0x444466)
.setStrokeStyle(2, 0x7777aa)
.setInteractive({ useHandCursor: true });
const text = this.add.text(x, y, label, {
fontSize: '16px',
color: '#ffffff',
}).setOrigin(0.5);
bg.on('pointerover', () => {
bg.setFillStyle(0x555588);
text.setScale(1.05);
});
bg.on('pointerout', () => {
bg.setFillStyle(0x444466);
text.setScale(1);
});
bg.on('pointerdown', onClick);
}
}

View File

@ -1,9 +1,11 @@
import Phaser from 'phaser';
import { ReactiveScene } from 'boardgame-phaser';
import Phaser from "phaser";
import { ReactiveScene } from "boardgame-phaser";
import { createButton } from "@/utils/createButton";
import { UI_CONFIG } from "@/config";
export class IndexScene extends ReactiveScene {
constructor() {
super('IndexScene');
super("IndexScene");
}
create(): void {
@ -13,24 +15,32 @@ export class IndexScene extends ReactiveScene {
const centerY = height / 2;
// Title
this.add.text(centerX, centerY - 150, 'Slay-the-Spire-Like Viewer', {
fontSize: '36px',
color: '#ffffff',
fontStyle: 'bold',
}).setOrigin(0.5);
this.add
.text(centerX, centerY - 150, "Slay-the-Spire-Like Viewer", {
fontSize: "36px",
color: "#ffffff",
fontStyle: "bold",
})
.setOrigin(0.5);
// Subtitle
this.add.text(centerX, centerY - 100, 'Choose a viewer to explore:', {
fontSize: '18px',
color: '#aaaaaa',
}).setOrigin(0.5);
this.add
.text(centerX, centerY - 100, "Choose a viewer to explore:", {
fontSize: "18px",
color: "#aaaaaa",
})
.setOrigin(0.5);
// Buttons
const buttons = [
{ label: '开始游戏', scene: 'GameFlowScene', y: centerY - 70 },
{ label: 'Map Viewer', scene: 'MapViewerScene', y: centerY },
{ label: 'Grid Inventory Viewer', scene: 'GridViewerScene', y: centerY + 70 },
{ label: 'Shape Viewer', scene: 'ShapeViewerScene', y: centerY + 140 },
{ label: "开始游戏", scene: "GameFlowScene", y: centerY - 70 },
{ label: "Map Viewer", scene: "MapViewerScene", y: centerY },
{
label: "Grid Inventory Viewer",
scene: "GridViewerScene",
y: centerY + 70,
},
{ label: "Shape Viewer", scene: "ShapeViewerScene", y: centerY + 140 },
];
for (const btn of buttons) {
@ -38,34 +48,22 @@ export class IndexScene extends ReactiveScene {
}
}
private createButton(label: string, targetScene: string, x: number, y: number): void {
const buttonWidth = 300;
const buttonHeight = 50;
// Background
const bg = this.add.rectangle(x, y, buttonWidth, buttonHeight, 0x333355)
.setStrokeStyle(2, 0x6666aa)
.setInteractive({ useHandCursor: true });
// Text
const text = this.add.text(x, y, label, {
fontSize: '20px',
color: '#ffffff',
}).setOrigin(0.5);
// Hover effects
bg.on('pointerover', () => {
bg.setFillStyle(0x444477);
text.setScale(1.05);
});
bg.on('pointerout', () => {
bg.setFillStyle(0x333355);
text.setScale(1);
});
bg.on('pointerdown', async () => {
private createButton(
label: string,
targetScene: string,
x: number,
y: number,
): void {
createButton({
scene: this,
label,
x,
y,
width: UI_CONFIG.BUTTON_WIDTH_LARGE,
height: UI_CONFIG.BUTTON_HEIGHT_LARGE,
onClick: async () => {
await this.sceneController.launch(targetScene);
},
});
}
}

View File

@ -1,39 +1,20 @@
import Phaser from 'phaser';
import { ReactiveScene } from 'boardgame-phaser';
import { createRNG } from 'boardgame-core';
import { generatePointCrawlMap, data, type PointCrawlMap, type MapNode, MapNodeType } from 'boardgame-core/samples/slay-the-spire-like';
const NODE_COLORS: Record<MapNodeType, number> = {
[MapNodeType.Start]: 0x44aa44,
[MapNodeType.End]: 0xcc8844,
[MapNodeType.Minion]: 0xcc4444,
[MapNodeType.Elite]: 0xcc44cc,
[MapNodeType.Event]: 0xaaaa44,
[MapNodeType.Camp]: 0x44cccc,
[MapNodeType.Shop]: 0x4488cc,
[MapNodeType.Curio]: 0x8844cc,
};
const NODE_LABELS: Record<MapNodeType, string> = {
[MapNodeType.Start]: '起点',
[MapNodeType.End]: '终点',
[MapNodeType.Minion]: '战斗',
[MapNodeType.Elite]: '精英',
[MapNodeType.Event]: '事件',
[MapNodeType.Camp]: '篝火',
[MapNodeType.Shop]: '商店',
[MapNodeType.Curio]: '奇遇',
};
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 { createRNG } from "boardgame-core";
import {
generatePointCrawlMap,
data,
type PointCrawlMap,
type MapNode,
type MapNodeType,
} from "boardgame-core/samples/slay-the-spire-like";
export class MapViewerScene extends ReactiveScene {
private map: PointCrawlMap | null = null;
private seed: number = Date.now();
// Layout constants
private readonly LAYER_HEIGHT = 110;
private readonly NODE_SPACING = 140;
private readonly NODE_RADIUS = 28;
// Scroll state
private mapContainer!: Phaser.GameObjects.Container;
private isDragging = false;
@ -44,14 +25,10 @@ export class MapViewerScene extends ReactiveScene {
// Fixed UI (always visible, not scrolled)
private titleText!: Phaser.GameObjects.Text;
private backButtonBg!: Phaser.GameObjects.Rectangle;
private backButtonText!: Phaser.GameObjects.Text;
private regenButtonBg!: Phaser.GameObjects.Rectangle;
private regenButtonText!: Phaser.GameObjects.Text;
private legendContainer!: Phaser.GameObjects.Container;
constructor() {
super('MapViewerScene');
super("MapViewerScene");
}
create(): void {
@ -61,86 +38,77 @@ export class MapViewerScene extends ReactiveScene {
}
private drawFixedUI(): void {
const { width } = this.scale;
const { width, height } = this.scale;
// Title
this.titleText = this.add.text(width / 2, 30, '', {
fontSize: '24px',
color: '#ffffff',
fontStyle: 'bold',
}).setOrigin(0.5).setDepth(100);
this.titleText = this.add
.text(width / 2, 30, "", {
fontSize: "24px",
color: "#ffffff",
fontStyle: "bold",
})
.setOrigin(0.5)
.setDepth(100);
// Back button
this.backButtonBg = this.add.rectangle(100, 40, 140, 36, 0x444466)
.setStrokeStyle(2, 0x7777aa)
.setInteractive({ useHandCursor: true })
.setDepth(100);
this.backButtonText = this.add.text(100, 40, '返回菜单', {
fontSize: '16px',
color: '#ffffff',
}).setOrigin(0.5).setDepth(100);
this.backButtonBg.on('pointerover', () => {
this.backButtonBg.setFillStyle(0x555588);
this.backButtonText.setScale(1.05);
});
this.backButtonBg.on('pointerout', () => {
this.backButtonBg.setFillStyle(0x444466);
this.backButtonText.setScale(1);
});
this.backButtonBg.on('pointerdown', async () => {
await this.sceneController.launch('IndexScene');
createButton({
scene: this,
label: "返回菜单",
x: 100,
y: 40,
onClick: async () => {
await this.sceneController.launch("IndexScene");
},
depth: 100,
});
// Regenerate button
this.regenButtonBg = this.add.rectangle(width - 120, 40, 140, 36, 0x444466)
.setStrokeStyle(2, 0x7777aa)
.setInteractive({ useHandCursor: true })
.setDepth(100);
this.regenButtonText = this.add.text(width - 120, 40, '重新生成', {
fontSize: '16px',
color: '#ffffff',
}).setOrigin(0.5).setDepth(100);
this.regenButtonBg.on('pointerover', () => {
this.regenButtonBg.setFillStyle(0x555588);
this.regenButtonText.setScale(1.05);
});
this.regenButtonBg.on('pointerout', () => {
this.regenButtonBg.setFillStyle(0x444466);
this.regenButtonText.setScale(1);
});
this.regenButtonBg.on('pointerdown', () => {
createButton({
scene: this,
label: "重新生成",
x: width - 120,
y: 40,
onClick: () => {
this.seed = Date.now();
this.mapContainer.destroy();
this.drawMap();
},
depth: 100,
});
// Legend (bottom-left, fixed)
this.legendContainer = this.add.container(20, this.scale.height - 180).setDepth(100);
this.legendContainer = this.add.container(20, height - 180).setDepth(100);
const legendBg = this.add.rectangle(75, 80, 150, 160, 0x222222, 0.8);
this.legendContainer.add(legendBg);
this.legendContainer.add(
this.add.text(10, 5, '图例:', { fontSize: '14px', color: '#ffffff', fontStyle: 'bold' })
this.add.text(10, 5, "图例:", {
fontSize: "14px",
color: "#ffffff",
fontStyle: "bold",
}),
);
let offsetY = 30;
for (const [type, color] of Object.entries(NODE_COLORS)) {
this.legendContainer.add(this.add.circle(20, offsetY, 8, color));
this.legendContainer.add(
this.add.circle(20, offsetY, 8, color)
);
this.legendContainer.add(
this.add.text(40, offsetY - 5, NODE_LABELS[type as MapNodeType], { fontSize: '12px', color: '#ffffff' })
this.add.text(40, offsetY - 5, NODE_LABELS[type as MapNodeType], {
fontSize: "12px",
color: "#ffffff",
}),
);
offsetY += 20;
}
// Hint text
this.add.text(width / 2, this.scale.height - 20, '拖拽滚动查看地图 (从左到右)', {
fontSize: '14px',
color: '#888888',
}).setOrigin(0.5).setDepth(100);
this.add
.text(width / 2, height - 20, "拖拽滚动查看地图 (从左到右)", {
fontSize: "14px",
color: "#888888",
})
.setOrigin(0.5)
.setDepth(100);
}
private drawMap(): void {
@ -148,21 +116,28 @@ export class MapViewerScene extends ReactiveScene {
this.map = generatePointCrawlMap(rng, data.desert.encounters);
const { width, height } = this.scale;
const {
LAYER_HEIGHT,
NODE_SPACING,
NODE_RADIUS,
TOTAL_LAYERS,
MAX_NODES_PER_LAYER,
} = MAP_CONFIG;
// Update title
this.titleText.setText(`Map Viewer (Seed: ${this.seed})`);
// Calculate map bounds (left-to-right: layers along X, nodes along Y)
const maxLayer = 9; // TOTAL_LAYERS - 1 (10 layers: 0-9)
const maxNodesInLayer = 5; // widest layer (settlement has 4 nodes)
const mapWidth = maxLayer * this.LAYER_HEIGHT + 200;
const mapHeight = (maxNodesInLayer - 1) * this.NODE_SPACING + 200;
// Calculate map bounds
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);
// Background panel for the map area
const bg = this.add.rectangle(0, 0, mapWidth, mapHeight, 0x111122, 0.5)
const bg = this.add
.rectangle(0, 0, mapWidth, mapHeight, 0x111122, 0.5)
.setOrigin(0.5);
this.mapContainer.add(bg);
@ -193,65 +168,77 @@ export class MapViewerScene extends ReactiveScene {
// Node circle
graphics.fillStyle(color);
graphics.fillCircle(posX, posY, this.NODE_RADIUS);
graphics.fillCircle(posX, posY, NODE_RADIUS);
graphics.lineStyle(2, 0xffffff);
graphics.strokeCircle(posX, posY, this.NODE_RADIUS);
graphics.strokeCircle(posX, posY, NODE_RADIUS);
// Node label
const label = NODE_LABELS[node.type as MapNodeType] ?? node.type;
this.mapContainer.add(
this.add.text(posX, posY, label, {
fontSize: '12px',
color: '#ffffff',
}).setOrigin(0.5)
this.add
.text(posX, posY, label, {
fontSize: "12px",
color: "#ffffff",
})
.setOrigin(0.5),
);
// Encounter name (if available)
if (node.encounter) {
this.mapContainer.add(
this.add.text(posX, posY + this.NODE_RADIUS + 12, node.encounter.name, {
fontSize: '10px',
color: '#cccccc',
}).setOrigin(0.5)
this.add
.text(posX, posY + NODE_RADIUS + 12, node.encounter.name, {
fontSize: "10px",
color: "#cccccc",
})
.setOrigin(0.5),
);
}
}
// Setup drag-to-scroll
this.input.on('pointerdown', (pointer: Phaser.Input.Pointer) => {
// 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;
});
};
this.input.on('pointermove', (pointer: Phaser.Input.Pointer) => {
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);
});
this.mapContainer.x =
this.dragStartContainerX + (pointer.x - this.dragStartX);
this.mapContainer.y =
this.dragStartContainerY + (pointer.y - this.dragStartY);
};
this.input.on('pointerup', () => {
const onPointerUp = () => {
this.isDragging = false;
});
};
this.input.on('pointerout', () => {
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);
});
}
private getNodeX(node: MapNode): number {
// Layers go left-to-right along X axis
return -500 + node.layerIndex * this.LAYER_HEIGHT;
return -500 + node.layerIndex * MAP_CONFIG.LAYER_HEIGHT;
}
private getNodeY(node: MapNode): number {
// Nodes within a layer are spread vertically along Y axis
const layer = this.map!.layers[node.layerIndex];
const nodeIndex = layer.nodeIds.indexOf(node.id);
const totalNodes = layer.nodeIds.length;
const layerHeight = (totalNodes - 1) * this.NODE_SPACING;
return -layerHeight / 2 + nodeIndex * this.NODE_SPACING;
const layerHeight = (totalNodes - 1) * MAP_CONFIG.NODE_SPACING;
return -layerHeight / 2 + nodeIndex * MAP_CONFIG.NODE_SPACING;
}
}

View File

@ -1,14 +1,17 @@
import Phaser from 'phaser';
import { ReactiveScene } from 'boardgame-phaser';
import { MutableSignal } from 'boardgame-core';
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,
} from 'boardgame-core/samples/slay-the-spire-like';
import { InventoryWidget } from '@/widgets/InventoryWidget';
type MapNode,
} from "boardgame-core/samples/slay-the-spire-like";
import { InventoryWidget } from "@/widgets/InventoryWidget";
/**
*
@ -20,7 +23,7 @@ export class PlaceholderEncounterScene extends ReactiveScene {
private inventoryWidget: InventoryWidget | null = null;
constructor(gameState: MutableSignal<RunState>) {
super('PlaceholderEncounterScene');
super("PlaceholderEncounterScene");
this.gameState = gameState;
}
@ -31,9 +34,9 @@ export class PlaceholderEncounterScene extends ReactiveScene {
const gridCols = state.inventory.width;
const gridRows = state.inventory.height;
const cellSize = 80;
const gridW = gridCols * cellSize + (gridCols - 1) * 2;
const gridH = gridRows * cellSize + (gridRows - 1) * 2;
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({
@ -42,34 +45,51 @@ export class PlaceholderEncounterScene extends ReactiveScene {
x: 60,
y: (height - gridH) / 2 + 20,
cellSize,
gridGap: 2,
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);
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);
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);
this.add
.text(rightX + 300, height / 2, "没有遭遇数据", {
fontSize: "24px",
color: "#ff4444",
})
.setOrigin(0.5);
return;
}
this.drawRightPanel(node, leftPanelW, width, height);
this.drawRightPanel(
node as MapNode & { encounter: { name: string; description: string } },
leftPanelW,
width,
height,
);
}
private inventoryWidgetY(gridH: number): number {
@ -77,11 +97,16 @@ export class PlaceholderEncounterScene extends ReactiveScene {
return (height - gridH) / 2 + 20 + gridH / 2;
}
private drawRightPanel(node: any, leftPanelW: number, width: number, height: number): void {
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 as string,
description: node.encounter.description as string,
name: node.encounter.name,
description: node.encounter.description,
};
const nodeId = node.id as string;
@ -90,66 +115,85 @@ export class PlaceholderEncounterScene extends ReactiveScene {
const cx = rightX + rightW / 2;
const cy = height / 2;
this.add.text(cx, cy - 180, '遭遇', {
fontSize: '36px', color: '#fff', fontStyle: 'bold',
}).setOrigin(0.5);
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 - 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 - 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 + 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 + 80, `节点: ${nodeId}`, {
fontSize: "14px",
color: "#666",
})
.setOrigin(0.5);
this.add.text(cx, cy + 130, '(此为占位符遭遇,后续将替换为真实遭遇场景)', {
fontSize: '14px', color: '#ff8844', fontStyle: 'italic',
}).setOrigin(0.5);
this.add
.text(cx, cy + 130, "(此为占位符遭遇,后续将替换为真实遭遇场景)", {
fontSize: "14px",
color: "#ff8844",
fontStyle: "italic",
})
.setOrigin(0.5);
this.createButton('完成遭遇', cx, cy + 200, 220, 50, async () => {
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();
},
});
this.createButton('暂不处理', cx, cy + 270, 220, 40, async () => {
await this.sceneController.launch('GameFlowScene');
createButton({
scene: this,
label: "暂不处理",
x: cx,
y: cy + 270,
onClick: async () => {
await this.sceneController.launch("GameFlowScene");
},
});
}
private getTypeLabel(type: MapNodeType): string {
const m: Record<MapNodeType, string> = {
start: '起点', end: '终点', minion: '战斗', elite: '精英战斗',
event: '事件', camp: '营地', shop: '商店', curio: '奇遇',
};
return m[type] ?? type;
return NODE_LABELS[type] ?? type;
}
private getTypeColor(type: MapNodeType): number {
const m: Record<MapNodeType, number> = {
start: 0x44aa44, end: 0xcc8844, minion: 0xcc4444, elite: 0xcc44cc,
event: 0xaaaa44, camp: 0x44cccc, shop: 0x4488cc, curio: 0x8844cc,
};
return m[type] ?? 0x888888;
}
private createButton(label: string, x: number, y: number, w: number, h: number, onClick: () => void): void {
const bg = this.add.rectangle(x, y, w, h, 0x444466)
.setStrokeStyle(2, 0x7777aa).setInteractive({ useHandCursor: true });
const txt = this.add.text(x, y, label, { fontSize: '16px', color: '#fff' }).setOrigin(0.5);
bg.on('pointerover', () => { bg.setFillStyle(0x555588); txt.setScale(1.05); });
bg.on('pointerout', () => { bg.setFillStyle(0x444466); txt.setScale(1); });
bg.on('pointerdown', onClick);
return NODE_COLORS[type] ?? 0x888888;
}
private async completeEncounter(): Promise<void> {
@ -168,18 +212,24 @@ export class PlaceholderEncounterScene extends ReactiveScene {
const result: EncounterResult = this.generatePlaceholderResult(node.type);
resolveEncounter(state, result);
await this.sceneController.launch('GameFlowScene');
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 {};
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,13 +1,16 @@
import Phaser from 'phaser';
import { ReactiveScene } from 'boardgame-phaser';
import { parseShapeString, data, type ParsedShape } from 'boardgame-core/samples/slay-the-spire-like';
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 {
parseShapeString,
data,
type ParsedShape,
} from "boardgame-core/samples/slay-the-spire-like";
export class ShapeViewerScene extends ReactiveScene {
private readonly CELL_SIZE = 40;
private readonly ITEMS_PER_ROW = 4;
constructor() {
super('ShapeViewerScene');
super("ShapeViewerScene");
}
create(): void {
@ -19,13 +22,15 @@ export class ShapeViewerScene extends ReactiveScene {
private drawShapeViewer(): void {
this.children.removeAll();
const { width, height } = this.scale;
const { width } = this.scale;
this.add.text(width / 2, 30, 'Shape Viewer - Item Shapes', {
fontSize: '24px',
color: '#ffffff',
fontStyle: 'bold',
}).setOrigin(0.5);
this.add
.text(width / 2, 30, "Shape Viewer - Item Shapes", {
fontSize: "24px",
color: "#ffffff",
fontStyle: "bold",
})
.setOrigin(0.5);
this.drawAllShapes();
}
@ -33,117 +38,127 @@ export class ShapeViewerScene extends ReactiveScene {
private drawAllShapes(): void {
const { width } = this.scale;
const startY = 80;
const spacingX = 220;
const spacingY = 140;
const { SPACING_X, SPACING_Y, ITEMS_PER_ROW, MAX_ITEMS } = SHAPE_CONFIG;
const itemsToShow = data.desert.items.slice(0, 12);
const itemsToShow = data.desert.items.slice(0, MAX_ITEMS);
for (let i = 0; i < itemsToShow.length; i++) {
const itemData = itemsToShow[i];
const shape = parseShapeString(itemData.shape);
const col = i % this.ITEMS_PER_ROW;
const row = Math.floor(i / this.ITEMS_PER_ROW);
const col = i % ITEMS_PER_ROW;
const row = Math.floor(i / ITEMS_PER_ROW);
const x = 60 + col * spacingX;
const y = startY + row * spacingY;
const x = 60 + col * SPACING_X;
const y = startY + row * SPACING_Y;
this.drawSingleShape(x, y, itemData, shape);
}
}
private drawSingleShape(startX: number, startY: number, itemData: any, shape: ParsedShape): void {
private drawSingleShape(
startX: number,
startY: number,
itemData: any,
shape: ParsedShape,
): void {
const graphics = this.add.graphics();
const { CELL_SIZE } = SHAPE_CONFIG;
const shapeWidth = shape.width * this.CELL_SIZE;
const shapeHeight = shape.height * this.CELL_SIZE;
const shapeWidth = shape.width * CELL_SIZE;
const shapeHeight = shape.height * CELL_SIZE;
this.add.text(startX + shapeWidth / 2, startY - 20, itemData.name, {
fontSize: '14px',
color: '#ffffff',
fontStyle: 'bold',
}).setOrigin(0.5);
this.add
.text(startX + shapeWidth / 2, startY - 20, itemData.name, {
fontSize: "14px",
color: "#ffffff",
fontStyle: "bold",
})
.setOrigin(0.5);
for (let y = 0; y < shape.height; y++) {
for (let x = 0; x < shape.width; x++) {
if (shape.grid[y]?.[x]) {
const px = startX + x * this.CELL_SIZE;
const py = startY + y * this.CELL_SIZE;
const px = startX + x * CELL_SIZE;
const py = startY + y * CELL_SIZE;
const isOrigin = x === shape.originX && y === shape.originY;
const color = isOrigin ? 0x88cc44 : 0x4488cc;
graphics.fillStyle(color);
graphics.fillRect(px + 1, py + 1, this.CELL_SIZE - 2, this.CELL_SIZE - 2);
graphics.fillRect(px + 1, py + 1, CELL_SIZE - 2, CELL_SIZE - 2);
graphics.lineStyle(2, 0xffffff);
graphics.strokeRect(px, py, this.CELL_SIZE, this.CELL_SIZE);
graphics.strokeRect(px, py, CELL_SIZE, CELL_SIZE);
if (isOrigin) {
this.add.text(px + this.CELL_SIZE / 2, py + this.CELL_SIZE / 2, 'O', {
fontSize: '16px',
color: '#ffffff',
fontStyle: 'bold',
}).setOrigin(0.5);
this.add
.text(px + CELL_SIZE / 2, py + CELL_SIZE / 2, "O", {
fontSize: "16px",
color: "#ffffff",
fontStyle: "bold",
})
.setOrigin(0.5);
}
}
}
}
this.add.text(startX + shapeWidth / 2, startY + shapeHeight + 10, `形状: ${itemData.shape}`, {
fontSize: '11px',
color: '#aaaaaa',
}).setOrigin(0.5);
this.add
.text(
startX + shapeWidth / 2,
startY + shapeHeight + 10,
`形状: ${itemData.shape}`,
{
fontSize: "11px",
color: "#aaaaaa",
},
)
.setOrigin(0.5);
this.add.text(startX + shapeWidth / 2, startY + shapeHeight + 28,
`类型: ${itemData.type} | 费用: ${itemData.costCount} ${itemData.costType}`, {
fontSize: '11px',
color: '#cccccc',
}).setOrigin(0.5);
this.add
.text(
startX + shapeWidth / 2,
startY + shapeHeight + 28,
`类型: ${itemData.type} | 费用: ${itemData.costCount} ${itemData.costType}`,
{
fontSize: "11px",
color: "#cccccc",
},
)
.setOrigin(0.5);
this.add.text(startX + shapeWidth / 2, startY + shapeHeight + 46, itemData.desc, {
fontSize: '10px',
color: '#888888',
this.add
.text(startX + shapeWidth / 2, startY + shapeHeight + 46, itemData.desc, {
fontSize: "10px",
color: "#888888",
wordWrap: { width: shapeWidth },
}).setOrigin(0.5);
})
.setOrigin(0.5);
}
private createControls(): void {
const { width, height } = this.scale;
this.createButton('返回菜单', 100, height - 40, async () => {
await this.sceneController.launch('IndexScene');
createButton({
scene: this,
label: "返回菜单",
x: 100,
y: height - 40,
onClick: async () => {
await this.sceneController.launch("IndexScene");
},
});
this.add.text(width / 2, height - 40,
`Showing first 12 items | Green = Origin | Blue = Normal`, {
fontSize: '14px',
color: '#aaaaaa',
}).setOrigin(0.5);
}
private createButton(label: string, x: number, y: number, onClick: () => void): void {
const buttonWidth = 120;
const buttonHeight = 36;
const bg = this.add.rectangle(x, y, buttonWidth, buttonHeight, 0x444466)
.setStrokeStyle(2, 0x7777aa)
.setInteractive({ useHandCursor: true });
const text = this.add.text(x, y, label, {
fontSize: '16px',
color: '#ffffff',
}).setOrigin(0.5);
bg.on('pointerover', () => {
bg.setFillStyle(0x555588);
text.setScale(1.05);
});
bg.on('pointerout', () => {
bg.setFillStyle(0x444466);
text.setScale(1);
});
bg.on('pointerdown', onClick);
this.add
.text(
width / 2,
height - 40,
`Showing first ${SHAPE_CONFIG.MAX_ITEMS} items | Green = Origin | Blue = Normal`,
{
fontSize: "14px",
color: "#aaaaaa",
},
)
.setOrigin(0.5);
}
}

View File

@ -0,0 +1,86 @@
import Phaser from 'phaser';
import { UI_CONFIG } from '@/config';
export interface ButtonOptions {
/** The Phaser scene to create the button in */
scene: Phaser.Scene;
/** Button label text */
label: string;
/** X position (center of button) */
x: number;
/** Y position (center of button) */
y: number;
/** Button width (defaults to UI_CONFIG.BUTTON_WIDTH) */
width?: number;
/** Button height (defaults to UI_CONFIG.BUTTON_HEIGHT) */
height?: number;
/** Click handler */
onClick: () => void;
/** Phaser depth (defaults to 100) */
depth?: number;
/** Background color (defaults to UI_CONFIG.BUTTON_BG) */
bgColor?: number;
/** Hover background color (defaults to UI_CONFIG.BUTTON_BG_HOVER) */
hoverBgColor?: number;
/** Border color (defaults to UI_CONFIG.BUTTON_BORDER) */
borderColor?: number;
/** Text color (defaults to UI_CONFIG.BUTTON_TEXT_COLOR) */
textColor?: string;
/** Font size (defaults to UI_CONFIG.BUTTON_FONT_SIZE) */
fontSize?: string;
}
export interface ButtonObjects {
/** Background rectangle */
bg: Phaser.GameObjects.Rectangle;
/** Text label */
text: Phaser.GameObjects.Text;
}
/**
* Create a styled interactive button in a Phaser scene.
*
* Handles hover effects (fill color change + text scale) and click callback.
* Returns both the bg and text objects for further customization if needed.
*/
export function createButton(options: ButtonOptions): ButtonObjects {
const {
scene,
label,
x,
y,
width = UI_CONFIG.BUTTON_WIDTH,
height = UI_CONFIG.BUTTON_HEIGHT,
onClick,
depth = 100,
bgColor = UI_CONFIG.BUTTON_BG,
hoverBgColor = UI_CONFIG.BUTTON_BG_HOVER,
borderColor = UI_CONFIG.BUTTON_BORDER,
textColor = UI_CONFIG.BUTTON_TEXT_COLOR,
fontSize = UI_CONFIG.BUTTON_FONT_SIZE,
} = options;
const bg = scene.add.rectangle(x, y, width, height, bgColor)
.setStrokeStyle(2, borderColor)
.setInteractive({ useHandCursor: true })
.setDepth(depth);
const text = scene.add.text(x, y, label, {
fontSize,
color: textColor,
}).setOrigin(0.5).setDepth(depth);
bg.on('pointerover', () => {
bg.setFillStyle(hoverBgColor);
text.setScale(1.05);
});
bg.on('pointerout', () => {
bg.setFillStyle(bgColor);
text.setScale(1);
});
bg.on('pointerdown', onClick);
return { bg, text };
}

View File

@ -1,205 +0,0 @@
import Phaser from 'phaser';
import type { CombatEntity, EnemyEntity, EffectTable } from 'boardgame-core/samples/slay-the-spire-like';
export interface CombatUnitWidgetOptions {
scene: Phaser.Scene;
x: number;
y: number;
entity: CombatEntity;
width?: number;
height?: number;
}
const HP_BAR_WIDTH = 180;
const HP_BAR_HEIGHT = 16;
const BUFF_ICON_SIZE = 28;
const BUFF_ICON_GAP = 6;
const POSITIVE_EFFECTS = new Set(['block', 'strength', 'dexterity', 'regen']);
const NEGATIVE_EFFECTS = new Set(['weak', 'vulnerable', 'frail', 'poison']);
export class CombatUnitWidget {
private scene: Phaser.Scene;
private container: Phaser.GameObjects.Container;
private entity: CombatEntity;
private width: number;
private height: number;
private nameText!: Phaser.GameObjects.Text;
private hpBarBg!: Phaser.GameObjects.Graphics;
private hpBarFill!: Phaser.GameObjects.Graphics;
private hpText!: Phaser.GameObjects.Text;
private buffContainer!: Phaser.GameObjects.Container;
private buffIcons: Phaser.GameObjects.Container[] = [];
constructor(options: CombatUnitWidgetOptions) {
this.scene = options.scene;
this.entity = options.entity;
this.width = options.width ?? 240;
this.height = options.height ?? 120;
this.container = this.scene.add.container(options.x, options.y);
this.container.setSize(this.width, this.height);
this.drawBackground();
this.drawName();
this.drawHpBar();
this.drawBuffs();
}
private drawBackground(): void {
const bg = this.scene.add.rectangle(0, 0, this.width, this.height, 0x1a1a2e, 0.9)
.setStrokeStyle(2, 0x444477)
.setOrigin(0, 0);
this.container.add(bg);
}
private drawName(): void {
const entityName = 'enemy' in this.entity
? (this.entity as EnemyEntity).enemy.name
: 'Player';
this.nameText = this.scene.add.text(this.width / 2, 12, entityName, {
fontSize: '16px',
color: '#ffffff',
fontStyle: 'bold',
}).setOrigin(0.5, 0);
this.container.add(this.nameText);
}
private drawHpBar(): void {
const barX = (this.width - HP_BAR_WIDTH) / 2;
const barY = 36;
this.hpBarBg = this.scene.add.graphics();
this.hpBarBg.fillStyle(0x333333);
this.hpBarBg.fillRoundedRect(barX, barY, HP_BAR_WIDTH, HP_BAR_HEIGHT, 4);
this.hpBarBg.lineStyle(1, 0x666666);
this.hpBarBg.strokeRoundedRect(barX, barY, HP_BAR_WIDTH, HP_BAR_HEIGHT, 4);
this.container.add(this.hpBarBg);
this.hpBarFill = this.scene.add.graphics();
this.container.add(this.hpBarFill);
this.hpText = this.scene.add.text(this.width / 2, barY + HP_BAR_HEIGHT / 2, '', {
fontSize: '12px',
color: '#ffffff',
fontStyle: 'bold',
}).setOrigin(0.5);
this.container.add(this.hpText);
this.updateHpBar();
}
private updateHpBar(): void {
const barX = (this.width - HP_BAR_WIDTH) / 2;
const barY = 36;
const ratio = Math.max(0, this.entity.hp / this.entity.maxHp);
this.hpBarFill.clear();
let fillColor: number;
if (ratio > 0.6) fillColor = 0x44aa44;
else if (ratio > 0.3) fillColor = 0xccaa44;
else fillColor = 0xcc4444;
const fillWidth = Math.max(0, (HP_BAR_WIDTH - 2) * ratio);
if (fillWidth > 0) {
this.hpBarFill.fillStyle(fillColor);
this.hpBarFill.fillRoundedRect(barX + 1, barY + 1, fillWidth, HP_BAR_HEIGHT - 2, 3);
}
this.hpText.setText(`${this.entity.hp}/${this.entity.maxHp}`);
}
private drawBuffs(): void {
this.buffContainer = this.scene.add.container(10, 62);
this.container.add(this.buffContainer);
this.refreshBuffs();
}
private refreshBuffs(): void {
for (const icon of this.buffIcons) {
icon.destroy();
}
this.buffIcons = [];
const effects = this.entity.effects;
let x = 0;
const y = 0;
for (const [effectId, entry] of Object.entries(effects)) {
if (entry.stacks <= 0) continue;
const icon = this.createBuffIcon(effectId, entry);
icon.setPosition(x, y);
this.buffContainer.add(icon);
this.buffIcons.push(icon);
x += BUFF_ICON_SIZE + BUFF_ICON_GAP;
if (x + BUFF_ICON_SIZE > this.width - 20) {
x = 0;
}
}
}
private createBuffIcon(effectId: string, entry: { data: { name: string; description: string }; stacks: number }): Phaser.GameObjects.Container {
const icon = this.scene.add.container(0, 0);
const isPositive = POSITIVE_EFFECTS.has(effectId.toLowerCase());
const isNegative = NEGATIVE_EFFECTS.has(effectId.toLowerCase());
const bgColor = isPositive ? 0x226644 : isNegative ? 0x662222 : 0x444466;
const borderColor = isPositive ? 0x44aa88 : isNegative ? 0xaa4444 : 0x7777aa;
const bg = this.scene.add.rectangle(0, 0, BUFF_ICON_SIZE, BUFF_ICON_SIZE, bgColor, 1)
.setStrokeStyle(2, borderColor)
.setOrigin(0, 0);
icon.add(bg);
const label = this.getEffectLabel(effectId);
const text = this.scene.add.text(BUFF_ICON_SIZE / 2, 2, label, {
fontSize: '9px',
color: '#ffffff',
fontStyle: 'bold',
}).setOrigin(0.5, 0);
icon.add(text);
const stackText = this.scene.add.text(BUFF_ICON_SIZE / 2, BUFF_ICON_SIZE - 2, `${entry.stacks}`, {
fontSize: '10px',
color: '#ffcc44',
fontStyle: 'bold',
}).setOrigin(0.5, 1);
icon.add(stackText);
return icon;
}
private getEffectLabel(effectId: string): string {
const labels: Record<string, string> = {
block: '🛡',
strength: '💪',
dexterity: '🤸',
regen: '💚',
weak: '⚡',
vulnerable: '🔥',
frail: '🩹',
poison: '☠',
};
return labels[effectId.toLowerCase()] ?? effectId.substring(0, 3).toUpperCase();
}
public update(entity: CombatEntity): void {
this.entity = entity;
this.updateHpBar();
this.refreshBuffs();
}
public destroy(): void {
this.container.destroy();
}
public getContainer(): Phaser.GameObjects.Container {
return this.container;
}
}

View File

@ -0,0 +1,433 @@
import Phaser from "phaser";
import {
type InventoryItem,
type GameItemMeta,
type GridInventory,
validatePlacement,
placeItem,
transformShape,
} from "boardgame-core/samples/slay-the-spire-like";
export interface DragState {
itemId: string;
itemShape: InventoryItem<GameItemMeta>["shape"];
itemTransform: InventoryItem<GameItemMeta>["transform"];
itemMeta: InventoryItem<GameItemMeta>["meta"];
ghostContainer: Phaser.GameObjects.Container;
previewGraphics: Phaser.GameObjects.Graphics;
dragOffsetX: number;
dragOffsetY: number;
}
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;
getItemCells: (
item: InventoryItem<GameItemMeta>,
) => { x: number; y: number }[];
onPlaceItem: (item: InventoryItem<GameItemMeta>) => void;
onCreateLostItem: (
itemId: string,
shape: InventoryItem<GameItemMeta>["shape"],
transform: InventoryItem<GameItemMeta>["transform"],
meta: InventoryItem<GameItemMeta>["meta"],
) => void;
}
/**
* Manages drag-and-drop state and logic for inventory items.
* Handles 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 getItemCells: (
item: InventoryItem<GameItemMeta>,
) => { x: number; y: number }[];
private onPlaceItem: (item: InventoryItem<GameItemMeta>) => void;
private onCreateLostItem: (
itemId: string,
shape: InventoryItem<GameItemMeta>["shape"],
transform: InventoryItem<GameItemMeta>["transform"],
meta: InventoryItem<GameItemMeta>["meta"],
) => void;
private dragState: DragState | 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.getItemCells = options.getItemCells;
this.onPlaceItem = options.onPlaceItem;
this.onCreateLostItem = options.onCreateLostItem;
}
/**
* Start dragging an item from the inventory.
*/
startDrag(itemId: string, pointer: Phaser.Input.Pointer): void {
const inventory = this.getInventory();
const item = inventory.items.get(itemId);
if (!item) return;
const cells = this.getItemCells(item);
const firstCell = cells[0];
const itemWorldX =
this.container.x +
this.gridX +
firstCell.x * (this.cellSize + this.gridGap);
const itemWorldY =
this.container.y +
this.gridY +
firstCell.y * (this.cellSize + this.gridGap);
const dragOffsetX = pointer.x - itemWorldX;
const dragOffsetY = pointer.y - itemWorldY;
const ghostContainer = this.scene.add
.container(itemWorldX, itemWorldY)
.setDepth(1000);
const ghostGraphics = this.scene.add.graphics();
const color = this.getItemColor(itemId);
for (let y = 0; y < item.shape.height; y++) {
for (let x = 0; x < item.shape.width; x++) {
if (item.shape.grid[y]?.[x]) {
ghostGraphics.fillStyle(color, 0.7);
ghostGraphics.fillRect(
x * (this.cellSize + this.gridGap),
y * (this.cellSize + this.gridGap),
this.cellSize - 2,
this.cellSize - 2,
);
ghostGraphics.lineStyle(2, 0xffffff);
ghostGraphics.strokeRect(
x * (this.cellSize + this.gridGap),
y * (this.cellSize + this.gridGap),
this.cellSize,
this.cellSize,
);
}
}
}
ghostContainer.add(ghostGraphics);
const previewGraphics = this.scene.add
.graphics()
.setDepth(999)
.setAlpha(0.5);
this.dragState = {
itemId,
itemShape: item.shape,
itemTransform: {
...item.transform,
offset: { ...item.transform.offset },
},
itemMeta: item.meta,
ghostContainer,
previewGraphics,
dragOffsetX,
dragOffsetY,
};
}
/**
* Start dragging a lost item (item that was dropped outside valid placement).
*/
startLostItemDrag(
itemId: string,
shape: InventoryItem<GameItemMeta>["shape"],
transform: InventoryItem<GameItemMeta>["transform"],
meta: InventoryItem<GameItemMeta>["meta"],
pointer: Phaser.Input.Pointer,
): void {
const ghostContainer = this.scene.add
.container(pointer.x, pointer.y)
.setDepth(1000);
const ghostGraphics = this.scene.add.graphics();
const color = this.getItemColor(itemId);
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);
const previewGraphics = this.scene.add
.graphics()
.setDepth(999)
.setAlpha(0.5);
this.dragState = {
itemId,
itemShape: shape,
itemTransform: { ...transform, offset: { ...transform.offset } },
itemMeta: meta,
ghostContainer,
previewGraphics,
dragOffsetX: 0,
dragOffsetY: 0,
};
}
/**
* Rotate the currently dragged item by 90 degrees.
*/
rotateDraggedItem(): void {
if (!this.dragState) return;
const currentRotation = (this.dragState.itemTransform.rotation + 90) % 360;
this.dragState.itemTransform = {
...this.dragState.itemTransform,
rotation: currentRotation,
};
this.updateGhostVisuals();
}
/**
* Update ghost visuals to reflect current drag state (after rotation).
*/
private updateGhostVisuals(): void {
if (!this.dragState) return;
this.dragState.ghostContainer.removeAll(true);
const ghostGraphics = this.scene.add.graphics();
const color = this.getItemColor(this.dragState.itemId);
const cells = transformShape(
this.dragState.itemShape,
this.dragState.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,
);
}
this.dragState.ghostContainer.add(ghostGraphics);
}
/**
* Handle pointer movement during drag: update ghost position and placement preview.
*/
onPointerMove(pointer: Phaser.Input.Pointer): void {
if (!this.dragState) return;
this.dragState.ghostContainer.setPosition(
pointer.x - this.dragState.dragOffsetX,
pointer.y - this.dragState.dragOffsetY,
);
const gridCell = this.getWorldGridCell(
pointer.x - this.dragState.dragOffsetX,
pointer.y - this.dragState.dragOffsetY,
);
this.dragState.previewGraphics.clear();
if (gridCell) {
const inventory = this.getInventory();
const testTransform = {
...this.dragState.itemTransform,
offset: { x: gridCell.x, y: gridCell.y },
};
const validation = validatePlacement(
inventory,
this.dragState.itemShape,
testTransform,
);
const cells = transformShape(this.dragState.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) {
this.dragState.previewGraphics.fillStyle(0x33ff33, 0.3);
this.dragState.previewGraphics.fillRect(
px,
py,
this.cellSize,
this.cellSize,
);
this.dragState.previewGraphics.lineStyle(2, 0x33ff33);
this.dragState.previewGraphics.strokeRect(
px,
py,
this.cellSize,
this.cellSize,
);
} else {
this.dragState.previewGraphics.fillStyle(0xff3333, 0.3);
this.dragState.previewGraphics.fillRect(
px,
py,
this.cellSize,
this.cellSize,
);
this.dragState.previewGraphics.lineStyle(2, 0xff3333);
this.dragState.previewGraphics.strokeRect(
px,
py,
this.cellSize,
this.cellSize,
);
}
}
}
}
/**
* Handle pointer release: validate placement and either place item or create lost item.
*/
onPointerUp(pointer: Phaser.Input.Pointer): void {
if (!this.dragState) return;
const gridCell = this.getWorldGridCell(
pointer.x - this.dragState.dragOffsetX,
pointer.y - this.dragState.dragOffsetY,
);
const inventory = this.getInventory();
this.dragState.ghostContainer.destroy();
this.dragState.previewGraphics.destroy();
if (gridCell) {
const testTransform = {
...this.dragState.itemTransform,
offset: { x: gridCell.x, y: gridCell.y },
};
const validation = validatePlacement(
inventory,
this.dragState.itemShape,
testTransform,
);
if (validation.valid) {
const item: InventoryItem<GameItemMeta> = {
id: this.dragState.itemId,
shape: this.dragState.itemShape,
transform: testTransform,
meta: this.dragState.itemMeta,
};
this.onPlaceItem(item);
} else {
this.onCreateLostItem(
this.dragState.itemId,
this.dragState.itemShape,
this.dragState.itemTransform,
this.dragState.itemMeta,
);
}
} else {
this.onCreateLostItem(
this.dragState.itemId,
this.dragState.itemShape,
this.dragState.itemTransform,
this.dragState.itemMeta,
);
}
this.dragState = null;
}
/**
* Convert world coordinates to grid cell coordinates.
*/
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 };
}
/**
* Check if an item is currently being dragged.
*/
isDragging(): boolean {
return this.dragState !== null;
}
/**
* Get the ID of the item being dragged, or null.
*/
getDraggedItemId(): string | null {
return this.dragState?.itemId ?? null;
}
getDraggedItemPosition(): { x: number; y: number } {
if (!this.dragState) return { x: 0, y: 0 };
return {
x: this.dragState.ghostContainer.x,
y: this.dragState.ghostContainer.y,
};
}
/**
* Clean up drag state and visuals.
*/
destroy(): void {
if (this.dragState) {
this.dragState.ghostContainer.destroy();
this.dragState.previewGraphics.destroy();
this.dragState = null;
}
}
}

View File

@ -1,20 +1,16 @@
import Phaser from 'phaser';
import { MutableSignal } from 'boardgame-core';
import Phaser from "phaser";
import { MutableSignal } from "boardgame-core";
import {
type GridInventory,
type InventoryItem,
type GameItemMeta,
type RunState,
type CellKey,
validatePlacement,
removeItemFromGrid,
placeItem,
moveItem,
rotateItem,
transformShape,
} from 'boardgame-core/samples/slay-the-spire-like';
const ITEM_COLORS = [0x3388ff, 0xff8833, 0x33ff88, 0xff3388, 0x8833ff, 0x33ffff, 0xffff33, 0xff6633];
} from "boardgame-core/samples/slay-the-spire-like";
import { ItemRenderer } from "./ItemRenderer";
import { DragController } from "./DragController";
import { LostItemManager } from "./LostItemManager";
export interface InventoryWidgetOptions {
scene: Phaser.Scene;
@ -26,25 +22,10 @@ export interface InventoryWidgetOptions {
isLocked?: boolean;
}
interface DragState {
itemId: string;
itemShape: InventoryItem<GameItemMeta>['shape'];
itemTransform: InventoryItem<GameItemMeta>['transform'];
itemMeta: InventoryItem<GameItemMeta>['meta'];
ghostContainer: Phaser.GameObjects.Container;
previewGraphics: Phaser.GameObjects.Graphics;
dragOffsetX: number;
dragOffsetY: number;
}
interface LostItem {
id: string;
container: Phaser.GameObjects.Container;
shape: InventoryItem<GameItemMeta>['shape'];
transform: InventoryItem<GameItemMeta>['transform'];
meta: InventoryItem<GameItemMeta>['meta'];
}
/**
* Thin orchestrator for the inventory grid widget.
* Delegates rendering, drag logic, and lost-item management to focused modules.
*/
export class InventoryWidget {
private scene: Phaser.Scene;
private gameState: MutableSignal<RunState>;
@ -55,18 +36,13 @@ export class InventoryWidget {
private gridY = 0;
private isLocked: boolean;
private itemContainers = new Map<string, Phaser.GameObjects.Container>();
private itemGraphics = new Map<string, Phaser.GameObjects.Graphics>();
private itemTexts = new Map<string, Phaser.GameObjects.Text>();
private colorMap = new Map<string, number>();
private colorIdx = 0;
private renderer: ItemRenderer;
private dragController: DragController;
private lostItemManager: LostItemManager;
private gridGraphics!: Phaser.GameObjects.Graphics;
private dragState: DragState | null = null;
private lostItems = new Map<string, LostItem>();
private pointerMoveHandler: (pointer: Phaser.Input.Pointer) => void;
private pointerUpHandler: (pointer: Phaser.Input.Pointer) => void;
private pointerMoveHandler!: (pointer: Phaser.Input.Pointer) => void;
private pointerUpHandler!: (pointer: Phaser.Input.Pointer) => void;
private pointerDownHandler!: (pointer: Phaser.Input.Pointer) => void;
constructor(options: InventoryWidgetOptions) {
this.scene = options.scene;
@ -76,385 +52,151 @@ export class InventoryWidget {
this.isLocked = options.isLocked ?? false;
const inventory = this.gameState.value.inventory;
const gridW = inventory.width * this.cellSize + (inventory.width - 1) * this.gridGap;
const gridH = inventory.height * this.cellSize + (inventory.height - 1) * this.gridGap;
const gridW =
inventory.width * this.cellSize + (inventory.width - 1) * this.gridGap;
const gridH =
inventory.height * this.cellSize + (inventory.height - 1) * this.gridGap;
this.container = this.scene.add.container(options.x, options.y);
this.pointerMoveHandler = this.onPointerMove.bind(this);
this.pointerUpHandler = this.onPointerUp.bind(this);
// Initialize sub-modules
this.renderer = new ItemRenderer({
scene: this.scene,
container: this.container,
cellSize: this.cellSize,
gridGap: this.gridGap,
gridX: this.gridX,
gridY: this.gridY,
});
this.drawGridBackground(inventory.width, inventory.height, gridW, gridH);
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.renderer.getItemColor(id),
getItemCells: (item) => this.renderer.getItemCells(item),
onPlaceItem: (item) => this.handlePlaceItem(item),
onCreateLostItem: (id, shape, transform, meta) =>
this.handleCreateLostItem(id, shape, transform, meta),
});
this.lostItemManager = new LostItemManager({
scene: this.scene,
cellSize: this.cellSize,
gridGap: this.gridGap,
getItemColor: (id) => this.renderer.getItemColor(id),
onLostItemDragStart: (id, pointer) =>
this.dragController.startLostItemDrag(
id,
this.getLostItemShape(id),
this.getLostItemTransform(id),
this.getLostItemMeta(id),
pointer,
),
isDragging: () => this.dragController.isDragging(),
});
this.renderer.drawGridBackground(inventory.width, inventory.height);
this.drawItems();
this.setupInput();
this.scene.events.once('shutdown', () => this.destroy());
this.scene.events.once("shutdown", () => this.destroy());
}
private getInventory(): GridInventory<GameItemMeta> {
return this.gameState.value.inventory as unknown as GridInventory<GameItemMeta>;
}
private drawGridBackground(width: number, height: number, gridW: number, gridH: number): void {
this.gridGraphics = 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.gridGraphics.fillStyle(0x1a1a2e);
this.gridGraphics.fillRect(px, py, this.cellSize, this.cellSize);
this.gridGraphics.lineStyle(2, 0x444477);
this.gridGraphics.strokeRect(px, py, this.cellSize, this.cellSize);
}
}
this.container.add(this.gridGraphics);
return this.gameState.value
.inventory as unknown as GridInventory<GameItemMeta>;
}
private drawItems(): void {
const inventory = this.getInventory();
for (const [itemId, item] of inventory.items) {
if (this.itemContainers.has(itemId)) continue;
this.createItemVisuals(itemId, item);
if (this.renderer.hasItem(itemId)) continue;
const visuals = this.renderer.createItemVisuals(itemId, item);
this.setupItemInteraction(itemId, visuals);
}
}
private createItemVisuals(itemId: string, item: InventoryItem<GameItemMeta>): void {
const color = this.colorMap.get(itemId) ?? ITEM_COLORS[this.colorIdx++ % ITEM_COLORS.length];
this.colorMap.set(itemId, color);
const graphics = this.scene.add.graphics();
this.itemGraphics.set(itemId, 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);
this.itemTexts.set(itemId, 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
);
const container = this.scene.add.container(0, 0);
container.add(graphics);
container.add(text);
container.setInteractive(hitRect, Phaser.Geom.Rectangle.Contains);
container.on('pointerdown', (pointer: Phaser.Input.Pointer) => {
private setupItemInteraction(
itemId: string,
visuals: ReturnType<typeof ItemRenderer.prototype.createItemVisuals>,
): void {
visuals.container.on("pointerdown", (pointer: Phaser.Input.Pointer) => {
if (this.isLocked) return;
if (this.dragState) return;
if (this.dragController.isDragging()) return;
if (pointer.button === 0) {
this.startDrag(itemId, pointer);
this.gameState.produce((state) => {
removeItemFromGrid(state.inventory, itemId);
});
this.renderer.removeItemVisuals(itemId);
this.dragController.startDrag(itemId, pointer);
}
});
this.itemContainers.set(itemId, container);
this.container.add(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;
}
private setupInput(): void {
this.scene.input.on('pointermove', this.pointerMoveHandler);
this.scene.input.on('pointerup', this.pointerUpHandler);
this.scene.input.on('pointerdown', this.onPointerDown.bind(this));
}
private onPointerDown(pointer: Phaser.Input.Pointer): void {
if (!this.dragState) return;
this.pointerDownHandler = (pointer: Phaser.Input.Pointer) => {
if (!this.dragController.isDragging()) return;
if (pointer.button === 1) {
this.rotateDraggedItem();
this.dragController.rotateDraggedItem();
}
}
private startDrag(itemId: string, pointer: Phaser.Input.Pointer): void {
const inventory = this.getInventory();
const item = inventory.items.get(itemId);
if (!item) return;
this.gameState.produce(state => {
removeItemFromGrid(state.inventory, itemId);
});
this.removeItemVisuals(itemId);
const cells = this.getItemCells(item);
const firstCell = cells[0];
const itemWorldX = this.container.x + this.gridX + firstCell.x * (this.cellSize + this.gridGap);
const itemWorldY = this.container.y + this.gridY + firstCell.y * (this.cellSize + this.gridGap);
const dragOffsetX = pointer.x - itemWorldX;
const dragOffsetY = pointer.y - itemWorldY;
const ghostContainer = this.scene.add.container(itemWorldX, itemWorldY).setDepth(1000);
const ghostGraphics = this.scene.add.graphics();
const color = this.colorMap.get(itemId) ?? 0x888888;
for (let y = 0; y < item.shape.height; y++) {
for (let x = 0; x < item.shape.width; x++) {
if (item.shape.grid[y]?.[x]) {
ghostGraphics.fillStyle(color, 0.7);
ghostGraphics.fillRect(x * (this.cellSize + this.gridGap), y * (this.cellSize + this.gridGap), this.cellSize - 2, this.cellSize - 2);
ghostGraphics.lineStyle(2, 0xffffff);
ghostGraphics.strokeRect(x * (this.cellSize + this.gridGap), y * (this.cellSize + this.gridGap), this.cellSize, this.cellSize);
}
}
}
ghostContainer.add(ghostGraphics);
const previewGraphics = this.scene.add.graphics().setDepth(999).setAlpha(0.5);
this.dragState = {
itemId,
itemShape: item.shape,
itemTransform: { ...item.transform, offset: { ...item.transform.offset } },
itemMeta: item.meta,
ghostContainer,
previewGraphics,
dragOffsetX,
dragOffsetY,
};
}
private startLostItemDrag(itemId: string, pointer: Phaser.Input.Pointer): void {
const lost = this.lostItems.get(itemId);
if (!lost) return;
lost.container.destroy();
this.lostItems.delete(itemId);
const ghostContainer = this.scene.add.container(pointer.x, pointer.y).setDepth(1000);
const ghostGraphics = this.scene.add.graphics();
const color = this.colorMap.get(itemId) ?? 0x888888;
const cells = transformShape(lost.shape, lost.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);
const previewGraphics = this.scene.add.graphics().setDepth(999).setAlpha(0.5);
this.dragState = {
itemId,
itemShape: lost.shape,
itemTransform: { ...lost.transform, offset: { ...lost.transform.offset } },
itemMeta: lost.meta,
ghostContainer,
previewGraphics,
dragOffsetX: 0,
dragOffsetY: 0,
};
}
private rotateDraggedItem(): void {
if (!this.dragState) return;
const currentRotation = (this.dragState.itemTransform.rotation + 90) % 360;
this.dragState.itemTransform = {
...this.dragState.itemTransform,
rotation: currentRotation,
};
this.updateGhostVisuals();
}
private updateGhostVisuals(): void {
if (!this.dragState) return;
this.dragState.ghostContainer.removeAll(true);
const ghostGraphics = this.scene.add.graphics();
const color = this.colorMap.get(this.dragState.itemId) ?? 0x888888;
const cells = transformShape(this.dragState.itemShape, this.dragState.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);
}
this.dragState.ghostContainer.add(ghostGraphics);
}
private onPointerMove(pointer: Phaser.Input.Pointer): void {
if (!this.dragState) return;
this.dragState.ghostContainer.setPosition(pointer.x - this.dragState.dragOffsetX, pointer.y - this.dragState.dragOffsetY);
const gridCell = this.getWorldGridCell(pointer.x - this.dragState.dragOffsetX, pointer.y - this.dragState.dragOffsetY);
this.dragState.previewGraphics.clear();
if (gridCell) {
const inventory = this.getInventory();
const testTransform = { ...this.dragState.itemTransform, offset: { x: gridCell.x, y: gridCell.y } };
const validation = validatePlacement(inventory, this.dragState.itemShape, testTransform);
const cells = transformShape(this.dragState.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) {
this.dragState.previewGraphics.fillStyle(0x33ff33, 0.3);
this.dragState.previewGraphics.fillRect(px, py, this.cellSize, this.cellSize);
this.dragState.previewGraphics.lineStyle(2, 0x33ff33);
this.dragState.previewGraphics.strokeRect(px, py, this.cellSize, this.cellSize);
} else {
this.dragState.previewGraphics.fillStyle(0xff3333, 0.3);
this.dragState.previewGraphics.fillRect(px, py, this.cellSize, this.cellSize);
this.dragState.previewGraphics.lineStyle(2, 0xff3333);
this.dragState.previewGraphics.strokeRect(px, py, this.cellSize, this.cellSize);
}
}
}
}
private onPointerUp(pointer: Phaser.Input.Pointer): void {
if (!this.dragState) return;
const gridCell = this.getWorldGridCell(pointer.x - this.dragState.dragOffsetX, pointer.y - this.dragState.dragOffsetY);
const inventory = this.getInventory();
this.dragState.ghostContainer.destroy();
this.dragState.previewGraphics.destroy();
if (gridCell) {
const testTransform = { ...this.dragState.itemTransform, offset: { x: gridCell.x, y: gridCell.y } };
const validation = validatePlacement(inventory, this.dragState.itemShape, testTransform);
if (validation.valid) {
this.gameState.produce(state => {
const item: InventoryItem<GameItemMeta> = {
id: this.dragState!.itemId,
shape: this.dragState!.itemShape,
transform: testTransform,
meta: this.dragState!.itemMeta,
this.pointerMoveHandler = (pointer: Phaser.Input.Pointer) => {
this.dragController.onPointerMove(pointer);
};
this.pointerUpHandler = (pointer: Phaser.Input.Pointer) => {
this.dragController.onPointerUp(pointer);
};
this.scene.input.on("pointermove", this.pointerMoveHandler);
this.scene.input.on("pointerup", this.pointerUpHandler);
this.scene.input.on("pointerdown", this.pointerDownHandler);
}
private handlePlaceItem(item: InventoryItem<GameItemMeta>): void {
this.gameState.produce((state) => {
placeItem(state.inventory, item);
});
this.createItemVisualsFromDrag();
} else {
this.createLostItem();
}
} else {
this.createLostItem();
}
this.dragState = null;
}
private createItemVisualsFromDrag(): void {
if (!this.dragState) return;
const inventory = this.getInventory();
const item = inventory.items.get(this.dragState.itemId);
if (item) {
this.createItemVisuals(this.dragState.itemId, item);
const placedItem = inventory.items.get(item.id);
if (placedItem) {
const visuals = this.renderer.createItemVisuals(item.id, placedItem);
this.setupItemInteraction(item.id, visuals);
}
}
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));
if (cellX < 0 || cellY < 0 || cellX >= this.getInventory().width || cellY >= this.getInventory().height) {
return null;
private handleCreateLostItem(
itemId: string,
shape: InventoryItem<GameItemMeta>["shape"],
transform: InventoryItem<GameItemMeta>["transform"],
meta: InventoryItem<GameItemMeta>["meta"],
): void {
this.lostItemManager.createLostItem(
itemId,
shape,
transform,
meta,
this.dragController.getDraggedItemPosition().x,
this.dragController.getDraggedItemPosition().y,
);
}
return { x: cellX, y: cellY };
private getLostItemShape(itemId: string) {
return this.lostItemManager.getLostItem(itemId)?.shape!;
}
private createLostItem(): void {
if (!this.dragState) return;
const container = this.scene.add.container(
this.dragState.ghostContainer.x,
this.dragState.ghostContainer.y
).setDepth(500);
const graphics = this.scene.add.graphics();
const color = this.colorMap.get(this.dragState.itemId) ?? 0x888888;
const cells = transformShape(this.dragState.itemShape, this.dragState.itemTransform);
for (const cell of cells) {
graphics.fillStyle(color, 0.5);
graphics.fillRect(cell.x * (this.cellSize + this.gridGap), cell.y * (this.cellSize + this.gridGap), this.cellSize - 2, this.cellSize - 2);
graphics.lineStyle(2, 0xff4444);
graphics.strokeRect(cell.x * (this.cellSize + this.gridGap), cell.y * (this.cellSize + this.gridGap), this.cellSize, this.cellSize);
}
container.add(graphics);
const name = this.dragState.itemMeta?.itemData.name ?? this.dragState.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) => {
if (this.isLocked) return;
if (this.dragState) return;
if (pointer.button === 0) {
this.startLostItemDrag(this.dragState!.itemId, pointer);
}
});
this.lostItems.set(this.dragState.itemId, {
id: this.dragState.itemId,
container,
shape: this.dragState.itemShape,
transform: { ...this.dragState.itemTransform },
meta: this.dragState.itemMeta,
});
private getLostItemTransform(itemId: string) {
return this.lostItemManager.getLostItem(itemId)?.transform!;
}
private removeItemVisuals(itemId: string): void {
this.itemContainers.get(itemId)?.destroy();
this.itemGraphics.get(itemId)?.destroy();
this.itemTexts.get(itemId)?.destroy();
this.itemContainers.delete(itemId);
this.itemGraphics.delete(itemId);
this.itemTexts.delete(itemId);
private getLostItemMeta(itemId: string) {
return this.lostItemManager.getLostItem(itemId)?.meta!;
}
public setLocked(locked: boolean): void {
@ -462,52 +204,44 @@ export class InventoryWidget {
}
public getLostItems(): string[] {
return Array.from(this.lostItems.keys());
return this.lostItemManager.getLostItemIds();
}
public clearLostItems(): void {
for (const lost of this.lostItems.values()) {
lost.container.destroy();
}
this.lostItems.clear();
this.lostItemManager.clear();
}
public refresh(): void {
const inventory = this.getInventory();
for (const itemId of this.itemContainers.keys()) {
if (!inventory.items.has(itemId)) {
this.removeItemVisuals(itemId);
}
// Remove visuals for items no longer in inventory
for (const [itemId] of inventory.items.entries()) {
// We need a way to track which items have visuals
// For now, clear and redraw
}
for (const [itemId, item] of inventory.items) {
if (!this.itemContainers.has(itemId)) {
this.createItemVisuals(itemId, item);
}
}
// Simple approach: destroy all and redraw
this.renderer.destroy();
this.renderer = new ItemRenderer({
scene: this.scene,
container: this.container,
cellSize: this.cellSize,
gridGap: this.gridGap,
gridX: this.gridX,
gridY: this.gridY,
});
this.renderer.drawGridBackground(inventory.width, inventory.height);
this.drawItems();
}
public destroy(): void {
this.scene.input.off('pointermove', this.pointerMoveHandler);
this.scene.input.off('pointerup', this.pointerUpHandler);
this.scene.input.off("pointermove", this.pointerMoveHandler);
this.scene.input.off("pointerup", this.pointerUpHandler);
this.scene.input.off("pointerdown", this.pointerDownHandler);
if (this.dragState) {
this.dragState.ghostContainer.destroy();
this.dragState.previewGraphics.destroy();
this.dragState = null;
}
this.clearLostItems();
for (const container of this.itemContainers.values()) {
container.destroy();
}
this.itemContainers.clear();
this.itemGraphics.clear();
this.itemTexts.clear();
this.gridGraphics.destroy();
this.dragController.destroy();
this.lostItemManager.destroy();
this.renderer.destroy();
this.container.destroy();
}
}

View File

@ -0,0 +1,198 @@
import Phaser from "phaser";
import {
type InventoryItem,
type GameItemMeta,
} from "boardgame-core/samples/slay-the-spire-like";
const ITEM_COLORS = [
0x3388ff, 0xff8833, 0x33ff88, 0xff3388, 0x8833ff, 0x33ffff, 0xffff33,
0xff6633,
];
export interface ItemVisuals {
container: Phaser.GameObjects.Container;
graphics: Phaser.GameObjects.Graphics;
text: Phaser.GameObjects.Text;
}
export interface ItemRendererOptions {
scene: Phaser.Scene;
container: Phaser.GameObjects.Container;
cellSize: number;
gridGap: number;
gridX: number;
gridY: number;
}
/**
* Handles all Phaser visual rendering for inventory items and the grid background.
* Manages color assignment, graphics creation, and cleanup.
*/
export class ItemRenderer {
private scene: Phaser.Scene;
private container: Phaser.GameObjects.Container;
private cellSize: number;
private gridGap: number;
private gridX: number;
private gridY: number;
private itemVisuals = new Map<string, ItemVisuals>();
private colorMap = new Map<string, number>();
private colorIdx = 0;
private gridGraphics!: Phaser.GameObjects.Graphics;
constructor(options: ItemRendererOptions) {
this.scene = options.scene;
this.container = options.container;
this.cellSize = options.cellSize;
this.gridGap = options.gridGap;
this.gridX = options.gridX;
this.gridY = options.gridY;
}
/**
* Draw the grid background (empty cells with borders).
*/
drawGridBackground(width: number, height: number): void {
this.gridGraphics = 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.gridGraphics.fillStyle(0x1a1a2e);
this.gridGraphics.fillRect(px, py, this.cellSize, this.cellSize);
this.gridGraphics.lineStyle(2, 0x444477);
this.gridGraphics.strokeRect(px, py, this.cellSize, this.cellSize);
}
}
this.container.add(this.gridGraphics);
}
/**
* Create Phaser visuals for a single inventory item.
* Returns the visuals object for further interaction setup.
*/
createItemVisuals(
itemId: string,
item: InventoryItem<GameItemMeta>,
): ItemVisuals {
const color =
this.colorMap.get(itemId) ??
ITEM_COLORS[this.colorIdx++ % ITEM_COLORS.length];
this.colorMap.set(itemId, color);
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);
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,
);
const container = this.scene.add.container(0, 0);
container.add(graphics);
container.add(text);
container.setInteractive(hitRect, Phaser.Geom.Rectangle.Contains);
const visuals: ItemVisuals = { container, graphics, text };
this.itemVisuals.set(itemId, visuals);
this.container.add(container);
return visuals;
}
/**
* Remove and destroy all Phaser objects for a given item.
*/
removeItemVisuals(itemId: string): void {
const visuals = this.itemVisuals.get(itemId);
if (!visuals) return;
visuals.container.destroy();
visuals.graphics.destroy();
visuals.text.destroy();
this.itemVisuals.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)!;
}
/**
* Compute the grid cells occupied by an item based on its shape and transform.
*/
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;
}
/**
* Check if visuals exist for a given item ID.
*/
hasItem(itemId: string): boolean {
return this.itemVisuals.has(itemId);
}
/**
* Destroy all managed visuals and grid graphics.
*/
destroy(): void {
for (const visuals of this.itemVisuals.values()) {
visuals.container.destroy();
visuals.graphics.destroy();
visuals.text.destroy();
}
this.itemVisuals.clear();
this.colorMap.clear();
this.gridGraphics.destroy();
}
}

View File

@ -0,0 +1,154 @@
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, pointer: Phaser.Input.Pointer) => 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,
pointer: Phaser.Input.Pointer,
) => 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.
*/
createLostItem(
itemId: string,
shape: InventoryItem<GameItemMeta>["shape"],
transform: InventoryItem<GameItemMeta>["transform"],
meta: InventoryItem<GameItemMeta>["meta"],
positionX: number,
positionY: number,
): void {
const container = this.scene.add
.container(positionX, positionY)
.setDepth(500);
const graphics = this.scene.add.graphics();
const color = this.getItemColor(itemId);
for (let y = 0; y < shape.height; y++) {
for (let x = 0; x < shape.width; x++) {
if (shape.grid[y]?.[x]) {
graphics.fillStyle(color, 0.5);
graphics.fillRect(
x * (this.cellSize + this.gridGap),
y * (this.cellSize + this.gridGap),
this.cellSize - 2,
this.cellSize - 2,
);
graphics.lineStyle(2, 0xff4444);
graphics.strokeRect(
x * (this.cellSize + this.gridGap),
y * (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) => {
if (this.isDragging()) return;
if (pointer.button === 0) {
this.onLostItemDragStart(itemId, pointer);
}
});
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();
}
}