From 28a2623bd192bbf8c2d1699e8bc811304a2752ad Mon Sep 17 00:00:00 2001 From: hypercross Date: Fri, 17 Apr 2026 18:08:51 +0800 Subject: [PATCH] feat: adding widgets to the game --- .../src/scenes/PlaceholderEncounterScene.ts | 156 ++---- .../src/widgets/CombatUnitWidget.ts | 205 ++++++++ .../src/widgets/InventoryWidget.ts | 444 ++++++++++++++++++ 3 files changed, 690 insertions(+), 115 deletions(-) create mode 100644 packages/sts-like-viewer/src/widgets/CombatUnitWidget.ts create mode 100644 packages/sts-like-viewer/src/widgets/InventoryWidget.ts diff --git a/packages/sts-like-viewer/src/scenes/PlaceholderEncounterScene.ts b/packages/sts-like-viewer/src/scenes/PlaceholderEncounterScene.ts index 12ada5a..5775755 100644 --- a/packages/sts-like-viewer/src/scenes/PlaceholderEncounterScene.ts +++ b/packages/sts-like-viewer/src/scenes/PlaceholderEncounterScene.ts @@ -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; - - // 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) { 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(); - 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): { 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 = { 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'); diff --git a/packages/sts-like-viewer/src/widgets/CombatUnitWidget.ts b/packages/sts-like-viewer/src/widgets/CombatUnitWidget.ts new file mode 100644 index 0000000..5fc16b0 --- /dev/null +++ b/packages/sts-like-viewer/src/widgets/CombatUnitWidget.ts @@ -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 = { + 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; + } +} diff --git a/packages/sts-like-viewer/src/widgets/InventoryWidget.ts b/packages/sts-like-viewer/src/widgets/InventoryWidget.ts new file mode 100644 index 0000000..d268b91 --- /dev/null +++ b/packages/sts-like-viewer/src/widgets/InventoryWidget.ts @@ -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; + x: number; + y: number; + cellSize: number; + gridGap?: number; + isLocked?: boolean; +} + +interface DragState { + itemId: string; + itemShape: InventoryItem['shape']; + itemTransform: InventoryItem['transform']; + itemMeta: InventoryItem['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; + private container: Phaser.GameObjects.Container; + private cellSize: number; + private gridGap: number; + private gridX = 0; + private gridY = 0; + private isLocked: boolean; + + private itemContainers = new Map(); + private itemGraphics = new Map(); + private itemTexts = new Map(); + private colorMap = new Map(); + private colorIdx = 0; + + private gridGraphics!: Phaser.GameObjects.Graphics; + private dragState: DragState | null = null; + private lostItems = new Map(); + + 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 { + return this.gameState.value.inventory as unknown as GridInventory; + } + + 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): 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): { 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 = { + 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(); + } +}