feat: adding widgets to the game

This commit is contained in:
hypercross 2026-04-17 18:08:51 +08:00
parent f7a6154c68
commit 28a2623bd1
3 changed files with 690 additions and 115 deletions

View File

@ -3,26 +3,21 @@ import { ReactiveScene } from 'boardgame-phaser';
import { MutableSignal } from 'boardgame-core';
import {
resolveEncounter,
removeItem,
type RunState,
type EncounterResult,
type MapNodeType,
type InventoryItem,
type GameItemMeta,
} from 'boardgame-core/samples/slay-the-spire-like';
import { InventoryWidget } from '@/widgets/InventoryWidget';
/**
*
*
* 80x80
* 使 InventoryWidget
*/
export class PlaceholderEncounterScene extends ReactiveScene {
private gameState: MutableSignal<RunState>;
// Grid constants
private readonly CELL_SIZE = 80;
private readonly GRID_GAP = 2;
private gridX = 0;
private gridY = 0;
private inventoryWidget: InventoryWidget | null = null;
constructor(gameState: MutableSignal<RunState>) {
super('PlaceholderEncounterScene');
@ -34,24 +29,37 @@ export class PlaceholderEncounterScene extends ReactiveScene {
const { width, height } = this.scale;
const state = this.gameState.value;
// ── Layout: split screen into left (grid) and right (encounter) ──
const gridCols = state.inventory.width; // 6
const gridRows = state.inventory.height; // 4
const gridW = gridCols * this.CELL_SIZE;
const gridH = gridRows * this.CELL_SIZE;
const leftPanelW = gridW + 40; // panel padding
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 leftPanelW = gridW + 40;
this.gridX = 60;
this.gridY = (height - gridH) / 2 + 20;
this.inventoryWidget = new InventoryWidget({
scene: this,
gameState: this.gameState,
x: 60,
y: (height - gridH) / 2 + 20,
cellSize,
gridGap: 2,
});
// Ensure camera shows the full grid
this.cameras.main.setBounds(0, 0, width, height);
this.cameras.main.setScroll(0, 0);
// ── LEFT PANEL: inventory grid ──
this.drawLeftPanel(leftPanelW, gridW, gridH);
// Panel background
this.add.rectangle(
60 + leftPanelW / 2, this.inventoryWidgetY(gridH),
leftPanelW + 10, gridH + 50,
0x111122, 0.9
).setStrokeStyle(2, 0x5555aa);
// "背包" title
this.add.text(60 + gridW / 2, (height - gridH) / 2, '背包', {
fontSize: '22px', color: '#ffffff', fontStyle: 'bold',
}).setOrigin(0.5);
// ── RIGHT PANEL: encounter info ──
const node = state.map.nodes.get(state.currentNodeId);
if (!node || !node.encounter) {
const rightX = leftPanelW + 80;
@ -64,80 +72,11 @@ export class PlaceholderEncounterScene extends ReactiveScene {
this.drawRightPanel(node, leftPanelW, width, height);
}
// ───────────────────── LEFT PANEL ─────────────────────
private drawLeftPanel(panelW: number, gridW: number, gridH: number): void {
// Panel background
this.add.rectangle(
this.gridX + panelW / 2, this.gridY + gridH / 2,
panelW + 10, gridH + 50,
0x111122, 0.9
).setStrokeStyle(2, 0x5555aa);
// "背包" title
this.add.text(this.gridX + gridW / 2, this.gridY - 20, '背包', {
fontSize: '22px', color: '#ffffff', fontStyle: 'bold',
}).setOrigin(0.5);
const graphics = this.add.graphics();
// Draw empty cell backgrounds
for (let y = 0; y < 4; y++) {
for (let x = 0; x < 6; x++) {
const px = this.gridX + x * this.CELL_SIZE;
const py = this.gridY + y * this.CELL_SIZE;
// Dark cell fill
graphics.fillStyle(0x1a1a2e);
graphics.fillRect(px + 1, py + 1, this.CELL_SIZE - 2, this.CELL_SIZE - 2);
// Cell border
graphics.lineStyle(2, 0x444477);
graphics.strokeRect(px, py, this.CELL_SIZE, this.CELL_SIZE);
}
}
// Draw items on top
this.drawItems(graphics);
private inventoryWidgetY(gridH: number): number {
const { height } = this.scale;
return (height - gridH) / 2 + 20 + gridH / 2;
}
private drawItems(graphics: Phaser.GameObjects.Graphics): void {
const state = this.gameState.value;
const palette = [0x3388ff, 0xff8833, 0x33ff88, 0xff3388, 0x8833ff, 0x33ffff, 0xffff33, 0xff6633];
const colorMap = new Map<string, number>();
let idx = 0;
for (const [id, item] of state.inventory.items) {
const color = colorMap.get(id) ?? palette[idx++ % palette.length];
colorMap.set(id, color);
const cells = this.getItemCells(item);
if (cells.length === 0) continue;
// Filled cells
for (const c of cells) {
const px = this.gridX + c.x * this.CELL_SIZE;
const py = this.gridY + c.y * this.CELL_SIZE;
graphics.fillStyle(color);
graphics.fillRect(px + 2, py + 2, this.CELL_SIZE - 4, this.CELL_SIZE - 4);
graphics.lineStyle(2, 0xffffff);
graphics.strokeRect(px, py, this.CELL_SIZE, this.CELL_SIZE);
}
// Item name
const first = cells[0];
const name = item.meta?.itemData.name ?? item.id;
this.add.text(
this.gridX + first.x * this.CELL_SIZE + this.CELL_SIZE / 2,
this.gridY + first.y * this.CELL_SIZE + this.CELL_SIZE / 2,
name, { fontSize: '12px', color: '#fff', fontStyle: 'bold' }
).setOrigin(0.5);
}
}
// ───────────────────── RIGHT PANEL ─────────────────────
private drawRightPanel(node: any, leftPanelW: number, width: number, height: number): void {
const encounter = {
type: node.type as MapNodeType,
@ -151,12 +90,10 @@ export class PlaceholderEncounterScene extends ReactiveScene {
const cx = rightX + rightW / 2;
const cy = height / 2;
// Title
this.add.text(cx, cy - 180, '遭遇', {
fontSize: '36px', color: '#fff', fontStyle: 'bold',
}).setOrigin(0.5);
// Type badge
const typeLabel = this.getTypeLabel(encounter.type);
const badgeColor = this.getTypeColor(encounter.type);
this.add.rectangle(cx, cy - 110, 140, 40, badgeColor);
@ -164,28 +101,23 @@ export class PlaceholderEncounterScene extends ReactiveScene {
fontSize: '18px', color: '#fff', fontStyle: 'bold',
}).setOrigin(0.5);
// Name
this.add.text(cx, cy - 50, encounter.name, {
fontSize: '28px', color: '#fff',
}).setOrigin(0.5);
// Description
this.add.text(cx, cy + 10, encounter.description || '(暂无描述)', {
fontSize: '18px', color: '#bbb',
wordWrap: { width: rightW - 40 }, align: 'center',
}).setOrigin(0.5);
// Node id
this.add.text(cx, cy + 80, `节点: ${nodeId}`, {
fontSize: '14px', color: '#666',
}).setOrigin(0.5);
// Placeholder notice
this.add.text(cx, cy + 130, '(此为占位符遭遇,后续将替换为真实遭遇场景)', {
fontSize: '14px', color: '#ff8844', fontStyle: 'italic',
}).setOrigin(0.5);
// Buttons
this.createButton('完成遭遇', cx, cy + 200, 220, 50, async () => {
await this.completeEncounter();
});
@ -194,21 +126,6 @@ export class PlaceholderEncounterScene extends ReactiveScene {
});
}
// ───────────────────── Helpers ─────────────────────
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 getTypeLabel(type: MapNodeType): string {
const m: Record<MapNodeType, string> = {
start: '起点', end: '终点', minion: '战斗', elite: '精英战斗',
@ -240,6 +157,15 @@ export class PlaceholderEncounterScene extends ReactiveScene {
const node = state.map.nodes.get(state.currentNodeId);
if (!node || !node.encounter) return;
// Clear lost items from inventory
if (this.inventoryWidget) {
const lostIds = this.inventoryWidget.getLostItems();
for (const lostId of lostIds) {
removeItem(state, lostId);
}
this.inventoryWidget.clearLostItems();
}
const result: EncounterResult = this.generatePlaceholderResult(node.type);
resolveEncounter(state, result);
await this.sceneController.launch('GameFlowScene');

View File

@ -0,0 +1,205 @@
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,444 @@
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];
export interface InventoryWidgetOptions {
scene: Phaser.Scene;
gameState: MutableSignal<RunState>;
x: number;
y: number;
cellSize: number;
gridGap?: number;
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;
}
interface LostItem {
id: string;
container: Phaser.GameObjects.Container;
}
export class InventoryWidget {
private scene: Phaser.Scene;
private gameState: MutableSignal<RunState>;
private container: Phaser.GameObjects.Container;
private cellSize: number;
private gridGap: number;
private gridX = 0;
private gridY = 0;
private isLocked: boolean;
private 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 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;
constructor(options: InventoryWidgetOptions) {
this.scene = options.scene;
this.gameState = options.gameState;
this.cellSize = options.cellSize;
this.gridGap = options.gridGap ?? 2;
this.isLocked = options.isLocked ?? false;
const inventory = this.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;
this.container = this.scene.add.container(options.x, options.y);
this.drawGridBackground(inventory.width, inventory.height, gridW, gridH);
this.drawItems();
this.setupInput();
this.pointerMoveHandler = this.onPointerMove.bind(this);
this.pointerUpHandler = this.onPointerUp.bind(this);
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);
}
private drawItems(): void {
const inventory = this.getInventory();
for (const [itemId, item] of inventory.items) {
if (this.itemContainers.has(itemId)) continue;
this.createItemVisuals(itemId, item);
}
}
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) => {
if (this.isLocked) return;
if (this.dragState) return;
if (pointer.button === 0) {
this.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;
if (pointer.button === 1) {
this.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 ghostContainer = this.scene.add.container(pointer.x, pointer.y).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,
};
}
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, pointer.y);
const gridCell = this.getWorldGridCell(pointer.x, pointer.y);
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, pointer.y);
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,
};
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);
}
}
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));
return { x: cellX, y: cellY };
}
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);
this.lostItems.set(this.dragState.itemId, { id: this.dragState.itemId, container });
}
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);
}
public setLocked(locked: boolean): void {
this.isLocked = locked;
}
public getLostItems(): string[] {
return Array.from(this.lostItems.keys());
}
public clearLostItems(): void {
for (const lost of this.lostItems.values()) {
lost.container.destroy();
}
this.lostItems.clear();
}
public refresh(): void {
const inventory = this.getInventory();
for (const itemId of this.itemContainers.keys()) {
if (!inventory.items.has(itemId)) {
this.removeItemVisuals(itemId);
}
}
for (const [itemId, item] of inventory.items) {
if (!this.itemContainers.has(itemId)) {
this.createItemVisuals(itemId, item);
}
}
}
public destroy(): void {
this.scene.input.off('pointermove', this.pointerMoveHandler);
this.scene.input.off('pointerup', this.pointerUpHandler);
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.container.destroy();
}
}