diff --git a/packages/sts-like-viewer/src/gameobjects/InventoryItemContainer.ts b/packages/sts-like-viewer/src/gameobjects/InventoryItemContainer.ts new file mode 100644 index 0000000..61b3ef8 --- /dev/null +++ b/packages/sts-like-viewer/src/gameobjects/InventoryItemContainer.ts @@ -0,0 +1,173 @@ +import Phaser from "phaser"; +import { dragDropEventEffect, DragDropEventType } from "boardgame-phaser"; +import { GRID_CONFIG, ITEM_COLORS } from "@/config"; +import { + IDENTITY_TRANSFORM, + ParsedShape, + Transform2D, + transformShape, + type GameItem, + type GameItemMeta, + type InventoryItem, +} from "boardgame-core/samples/slay-the-spire-like"; +import { computed, signal, Signal } from "@preact/signals-core"; +import { DisposableBag } from "../../../framework/dist"; + +export interface InventoryItemContainerCallbacks { + onMoveItem: (itemId: string, newX: number, newY: number) => boolean; +} + +export class InventoryItemContainer extends Phaser.GameObjects.Container { + private item: Signal; + + constructor( + scene: Phaser.Scene, + private gridOffsetX: number, + private gridOffsetY: number, + private callbacks: InventoryItemContainerCallbacks, + ) { + super(scene, gridOffsetX, gridOffsetY); + scene.add.existing(this); + this.setupInteractive(); + + const graphics = this.scene.add.graphics(); + const label = this.scene.add.text( + GRID_CONFIG.VIEWER_CELL_SIZE / 2, + GRID_CONFIG.VIEWER_CELL_SIZE / 2, + "", + GRID_CONFIG.ITEM_NAME_STYLE, + ); + this.add([graphics, label]); + + this.item = signal(); + + const disposables = new DisposableBag(this); + + const itemName = computed(() => { + const item = this.item.value; + return item?.meta?.itemData.name ?? item?.id ?? ""; + }); + disposables.addEffect(() => { + label.setText(itemName.value); + }); + + const shape = computed(() => { + return this.item.value?.shape; + }); + const color = computed(() => { + return this.getItemColor(this.item.value?.id ?? ""); + }); + disposables.addEffect(() => { + graphics.clear(); + if (!shape.value) return; + this.renderGraphics(graphics, shape.value, color.value); + }); + + const transform = computed(() => { + return this.item.value?.transform; + }); + disposables.addEffect(() => { + if (!transform.value) return; + this.snapBack(transform.value); + }); + } + + setItem(item: GameItem) { + this.item.value = item; + } + + renderGraphics( + graphics: Phaser.GameObjects.Graphics, + shape: ParsedShape, + itemColor: number, + ): void { + const cells = transformShape(shape, IDENTITY_TRANSFORM); + for (const cell of cells) { + const localX = (cell.x - cells[0].x) * GRID_CONFIG.VIEWER_CELL_SIZE; + const localY = (cell.y - cells[0].y) * GRID_CONFIG.VIEWER_CELL_SIZE; + + graphics.fillStyle(itemColor); + graphics.fillRect( + localX + 2, + localY + 2, + GRID_CONFIG.VIEWER_CELL_SIZE - 4, + GRID_CONFIG.VIEWER_CELL_SIZE - 4, + ); + } + + this.setSize( + GRID_CONFIG.VIEWER_CELL_SIZE * shape.width, + GRID_CONFIG.VIEWER_CELL_SIZE * shape.height, + ); + } + + private getItemColor(itemId: string): number { + const hash = itemId.split("").reduce((acc, c) => acc + c.charCodeAt(0), 0); + return ITEM_COLORS[hash % ITEM_COLORS.length]; + } + + private setupInteractive(): void { + this.setScrollFactor(0); + this.setInteractive({ useHandCursor: true }); + + let startX = 0; + let startY = 0; + dragDropEventEffect(this, (event) => { + if (event.type === DragDropEventType.DOWN) { + startX = this.x; + startY = this.y; + this.setAlpha(0.7); + } else if (event.type === DragDropEventType.MOVE) { + this.x = startX + event.deltaX; + this.y = startY + event.deltaY; + } else if (event.type === DragDropEventType.UP) { + this.setAlpha(1); + if (!this.handleDragEnd()) { + this.x = startX; + this.y = startY; + } + startX = startY = 0; + } + }); + } + + private handleDragEnd(): boolean { + const item = this.item.value; + if (!item) return false; + const itemId = item.id; + + const cellSize = GRID_CONFIG.VIEWER_CELL_SIZE; + const shapeWidth = item.shape?.width ?? 1; + const shapeHeight = item.shape?.height ?? 1; + + const x = this.x - this.gridOffsetX; + const y = this.y - this.gridOffsetY; + const targetX = Math.round((x - cellSize / 2) / cellSize); + const targetY = Math.round((y - cellSize / 2) / cellSize); + + const clampedX = Math.max(0, Math.min(targetX, 10 - shapeWidth)); + const clampedY = Math.max(0, Math.min(targetY, 10 - shapeHeight)); + + if ( + clampedX !== item.transform.offset.x || + clampedY !== item.transform.offset.y + ) { + return this.callbacks.onMoveItem(itemId, clampedX, clampedY); + } + return false; + } + + private snapBack(transform: Transform2D): void { + const { x, y } = transform.offset; + const targetX = this.gridOffsetX + x * GRID_CONFIG.VIEWER_CELL_SIZE; + const targetY = this.gridOffsetY + y * GRID_CONFIG.VIEWER_CELL_SIZE; + + this.scene.tweens.add({ + targets: this, + x: targetX, + y: targetY, + duration: 150, + ease: "Power2", + }); + } +} diff --git a/packages/sts-like-viewer/src/gameobjects/InventoryItemSpawner.ts b/packages/sts-like-viewer/src/gameobjects/InventoryItemSpawner.ts new file mode 100644 index 0000000..e32df2e --- /dev/null +++ b/packages/sts-like-viewer/src/gameobjects/InventoryItemSpawner.ts @@ -0,0 +1,86 @@ +import Phaser from "phaser"; +import type { Spawner } from "boardgame-phaser"; +import type { + GameItemMeta, + InventoryItem, +} from "boardgame-core/samples/slay-the-spire-like"; +import type { InventorySignal } from "@/state/inventory"; +import { spawnEffect } from "boardgame-phaser"; +import { InventoryItemContainer } from "./InventoryItemContainer"; +import type { InventoryItemContainerCallbacks } from "./InventoryItemContainer"; + +export interface InventoryItemSpawnerCallbacks extends InventoryItemContainerCallbacks {} + +export class InventoryItemSpawner implements Spawner< + [string, InventoryItem], + InventoryItemContainer +> { + constructor( + private scene: Phaser.Scene, + private inventorySignal: InventorySignal, + private gridOffsetX: number, + private gridOffsetY: number, + private callbacks: InventoryItemSpawnerCallbacks, + ) {} + + *getData(): Iterable<[string, InventoryItem]> { + const inventory = this.inventorySignal.value; + yield* inventory.items.entries(); + } + + getKey(entry: [string, InventoryItem]): string { + return entry[0]; + } + + onSpawn( + entry: [string, InventoryItem], + ): InventoryItemContainer | null { + const [itemId, item] = entry; + + const container = new InventoryItemContainer( + this.scene, + this.gridOffsetX, + this.gridOffsetY, + { + onMoveItem: (id, newX, newY) => { + return this.callbacks.onMoveItem(id, newX, newY); + }, + }, + ); + + container.setItem(item); + return container; + } + + onUpdate( + entry: [string, InventoryItem], + container: InventoryItemContainer, + ): void { + const [itemId, item] = entry; + + container.setItem(item); + } + + onDespawn(container: InventoryItemContainer): void { + // TODO: add tween + container.destroy(); + } +} + +export function createInventoryItemSpawner( + scene: Phaser.Scene, + inventorySignal: InventorySignal, + gridOffsetX: number, + gridOffsetY: number, + callbacks: InventoryItemSpawnerCallbacks, +) { + return spawnEffect( + new InventoryItemSpawner( + scene, + inventorySignal, + gridOffsetX, + gridOffsetY, + callbacks, + ), + ); +} diff --git a/packages/sts-like-viewer/src/scenes/InventoryItemSpawner.ts b/packages/sts-like-viewer/src/scenes/InventoryItemSpawner.ts deleted file mode 100644 index 57c2eb0..0000000 --- a/packages/sts-like-viewer/src/scenes/InventoryItemSpawner.ts +++ /dev/null @@ -1,270 +0,0 @@ -import Phaser from "phaser"; -import { - spawnEffect, - dragDropEventEffect, - DragDropEventType, - type Spawner, -} from "boardgame-phaser"; -import { GRID_CONFIG, ITEM_COLORS } from "@/config"; -import type { - GameItemMeta, - InventoryItem, -} from "boardgame-core/samples/slay-the-spire-like"; -import type { InventorySignal } from "@/state/inventory"; - -export interface InventoryItemSpawnerCallbacks { - onMoveItem: (itemId: string, newX: number, newY: number) => void; -} - -export class InventoryItemSpawner implements Spawner< - [string, InventoryItem], - Phaser.GameObjects.Container -> { - private dragState: { - itemId: string; - startX: number; - startY: number; - container: Phaser.GameObjects.Container; - } | null = null; - - constructor( - private scene: Phaser.Scene, - private inventorySignal: InventorySignal, - private gridOffsetX: number, - private gridOffsetY: number, - private callbacks: InventoryItemSpawnerCallbacks, - ) {} - - *getData(): Iterable<[string, InventoryItem]> { - const inventory = this.inventorySignal.value; - yield* inventory.items.entries(); - } - - getKey(entry: [string, InventoryItem]): string { - return entry[0]; - } - - private getCells( - item: InventoryItem, - ): { x: number; y: number }[] { - const cells: { x: number; y: number }[] = []; - const shape = item.shape; - const transform = item.transform; - - for (let y = 0; y < shape.height; y++) { - for (let x = 0; x < shape.width; x++) { - if (shape.grid[y]?.[x]) { - const finalX = x + transform.offset.x; - const finalY = y + transform.offset.y; - cells.push({ x: finalX, y: finalY }); - } - } - } - return cells; - } - - private getItemColor(itemId: string): number { - const hash = itemId.split("").reduce((acc, c) => acc + c.charCodeAt(0), 0); - return ITEM_COLORS[hash % ITEM_COLORS.length]; - } - - onSpawn( - entry: [string, InventoryItem], - ): Phaser.GameObjects.Container | null { - const [itemId, item] = entry; - const container = this.scene.add.container(0, 0); - const cells = this.getCells(item); - - const itemColor = this.getItemColor(itemId); - const graphics = this.scene.add.graphics(); - - // Draw graphics in container-local coordinates (container is at firstCell position) - for (const cell of cells) { - // Local coordinates relative to container position - const localX = (cell.x - cells[0].x) * GRID_CONFIG.VIEWER_CELL_SIZE; - const localY = (cell.y - cells[0].y) * GRID_CONFIG.VIEWER_CELL_SIZE; - - graphics.fillStyle(itemColor); - graphics.fillRect( - localX + 2, - localY + 2, - GRID_CONFIG.VIEWER_CELL_SIZE - 4, - GRID_CONFIG.VIEWER_CELL_SIZE - 4, - ); - } - - if (cells.length > 0) { - const itemName = item.meta?.itemData.name ?? itemId; - - // Text is centered in the first cell, relative to container - const textX = GRID_CONFIG.VIEWER_CELL_SIZE / 2; - const textY = GRID_CONFIG.VIEWER_CELL_SIZE / 2; - - const text = this.scene.add.text( - textX, - textY, - itemName, - GRID_CONFIG.ITEM_NAME_STYLE, - ); - text.setOrigin(0.5); - - container.add([graphics, text]); - - // Position container at the first cell's world position - const worldX = - this.gridOffsetX + cells[0].x * GRID_CONFIG.VIEWER_CELL_SIZE; - const worldY = - this.gridOffsetY + cells[0].y * GRID_CONFIG.VIEWER_CELL_SIZE; - container.setPosition(worldX, worldY); - } else { - container.add(graphics); - } - - // Make container interactive for drag-and-drop - container.setInteractive( - new Phaser.Geom.Rectangle( - 0, - 0, - GRID_CONFIG.VIEWER_CELL_SIZE, - GRID_CONFIG.VIEWER_CELL_SIZE, - ), - Phaser.Geom.Rectangle.Contains, - ); - container.setScrollFactor(0); - container.setSize( - GRID_CONFIG.VIEWER_CELL_SIZE * (item.shape?.width ?? 1), - GRID_CONFIG.VIEWER_CELL_SIZE * (item.shape?.height ?? 1), - ); - - // Setup drag handling - dragDropEventEffect(container, (event) => { - if (event.type === DragDropEventType.DOWN) { - // Start drag - this.dragState = { - itemId, - startX: container.x, - startY: container.y, - container, - }; - container.setAlpha(0.7); - } else if (event.type === DragDropEventType.MOVE) { - // Update drag position - if (this.dragState?.itemId === itemId) { - container.x = this.dragState.startX + event.deltaX; - container.y = this.dragState.startY + event.deltaY; - } - } else if (event.type === DragDropEventType.UP) { - // End drag - if (this.dragState?.itemId === itemId) { - container.setAlpha(1); - this.handleDragEnd(itemId, container); - this.dragState = null; - } - } - }); - - return container; - } - - private handleDragEnd( - itemId: string, - container: Phaser.GameObjects.Container, - ): void { - const inventory = this.inventorySignal.value; - const item = inventory.items.get(itemId); - if (!item) return; - - const cellSize = GRID_CONFIG.VIEWER_CELL_SIZE; - const shapeWidth = item.shape?.width ?? 1; - const shapeHeight = item.shape?.height ?? 1; - - // Calculate target grid position based on container center - const targetX = Math.round((container.x - cellSize / 2) / cellSize); - const targetY = Math.round((container.y - cellSize / 2) / cellSize); - - // Clamp to inventory bounds - const clampedX = Math.max( - 0, - Math.min(targetX, inventory.width - shapeWidth), - ); - const clampedY = Math.max( - 0, - Math.min(targetY, inventory.height - shapeHeight), - ); - - // If position changed, notify callback - if ( - clampedX !== item.transform.offset.x || - clampedY !== item.transform.offset.y - ) { - this.callbacks.onMoveItem(itemId, clampedX, clampedY); - } else { - // Snap back to original position - const originalX = this.gridOffsetX + item.transform.offset.x * cellSize; - const originalY = this.gridOffsetY + item.transform.offset.y * cellSize; - - this.scene.tweens.add({ - targets: container, - x: originalX, - y: originalY, - duration: 150, - ease: "Power2", - }); - } - } - - onUpdate( - entry: [string, InventoryItem], - obj: Phaser.GameObjects.Container, - ): void { - const [, item] = entry; - const cells = this.getCells(item); - - if (cells.length > 0) { - // Don't animate if currently dragging this item - if (this.dragState?.itemId !== entry[0]) { - const worldX = - this.gridOffsetX + cells[0].x * GRID_CONFIG.VIEWER_CELL_SIZE; - const worldY = - this.gridOffsetY + cells[0].y * GRID_CONFIG.VIEWER_CELL_SIZE; - - this.scene.tweens.add({ - targets: obj, - x: worldX, - y: worldY, - duration: 200, - ease: "Power2", - }); - } - } - } - - onDespawn(obj: Phaser.GameObjects.Container): void { - this.scene.tweens.add({ - targets: obj, - alpha: 0, - scale: 0.5, - duration: 200, - ease: "Back.easeIn", - onComplete: () => obj.destroy(), - }); - } -} - -export function createInventoryItemSpawner( - scene: Phaser.Scene, - inventorySignal: InventorySignal, - gridOffsetX: number, - gridOffsetY: number, - callbacks: InventoryItemSpawnerCallbacks, -) { - return spawnEffect( - new InventoryItemSpawner( - scene, - inventorySignal, - gridOffsetX, - gridOffsetY, - callbacks, - ), - ); -} diff --git a/packages/sts-like-viewer/src/scenes/InventoryTestScene.ts b/packages/sts-like-viewer/src/scenes/InventoryTestScene.ts index 408d045..53a88e2 100644 --- a/packages/sts-like-viewer/src/scenes/InventoryTestScene.ts +++ b/packages/sts-like-viewer/src/scenes/InventoryTestScene.ts @@ -3,7 +3,7 @@ import { createButton } from "@/utils/createButton"; import { GRID_CONFIG } from "@/config"; import { createInventorySignal, moveItem } from "@/state/inventory"; import { createItemIn, data } from "boardgame-core/samples/slay-the-spire-like"; -import { createInventoryItemSpawner } from "./InventoryItemSpawner"; +import { createInventoryItemSpawner } from "@/gameobjects/InventoryItemSpawner"; import { SceneKey } from "./types"; export class InventoryTestScene extends ReactiveScene { @@ -54,34 +54,36 @@ export class InventoryTestScene extends ReactiveScene { private drawGrid(invWidth: number, invHeight: number): void { const graphics = this.add.graphics(); - for (let y = 0; y < invHeight; y++) { - for (let x = 0; x < invWidth; x++) { - const px = this.gridOffsetX + x * GRID_CONFIG.VIEWER_CELL_SIZE; - const py = this.gridOffsetY + y * GRID_CONFIG.VIEWER_CELL_SIZE; + this.addEffect(() => { + for (let y = 0; y < invHeight; y++) { + for (let x = 0; x < invWidth; x++) { + const px = this.gridOffsetX + x * GRID_CONFIG.VIEWER_CELL_SIZE; + const py = this.gridOffsetY + y * GRID_CONFIG.VIEWER_CELL_SIZE; - const isOccupied = this.inventorySignal.value.occupiedCells.has( - `${x},${y}`, - ); - graphics.fillStyle( - isOccupied - ? GRID_CONFIG.CELL_OCCUPIED_COLOR - : GRID_CONFIG.CELL_EMPTY_COLOR, - ); - graphics.fillRect( - px + 1, - py + 1, - GRID_CONFIG.VIEWER_CELL_SIZE - 2, - GRID_CONFIG.VIEWER_CELL_SIZE - 2, - ); - graphics.lineStyle(1, GRID_CONFIG.GRID_LINE_COLOR); - graphics.strokeRect( - px, - py, - GRID_CONFIG.VIEWER_CELL_SIZE, - GRID_CONFIG.VIEWER_CELL_SIZE, - ); + const isOccupied = this.inventorySignal.value.occupiedCells.has( + `${x},${y}`, + ); + graphics.fillStyle( + isOccupied + ? GRID_CONFIG.CELL_OCCUPIED_COLOR + : GRID_CONFIG.CELL_EMPTY_COLOR, + ); + graphics.fillRect( + px + 1, + py + 1, + GRID_CONFIG.VIEWER_CELL_SIZE - 2, + GRID_CONFIG.VIEWER_CELL_SIZE - 2, + ); + graphics.lineStyle(1, GRID_CONFIG.GRID_LINE_COLOR); + graphics.strokeRect( + px, + py, + GRID_CONFIG.VIEWER_CELL_SIZE, + GRID_CONFIG.VIEWER_CELL_SIZE, + ); + } } - } + }); } private setupItemSpawner(): void { @@ -92,7 +94,7 @@ export class InventoryTestScene extends ReactiveScene { this.gridOffsetY, { onMoveItem: (itemId: string, newX: number, newY: number) => { - moveItem(this.inventorySignal, itemId, newX, newY); + return moveItem(this.inventorySignal, itemId, newX, newY); }, }, );