diff --git a/packages/sts-like-viewer/src/config.ts b/packages/sts-like-viewer/src/config.ts new file mode 100644 index 0000000..5902a1d --- /dev/null +++ b/packages/sts-like-viewer/src/config.ts @@ -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']); diff --git a/packages/sts-like-viewer/src/scenes/GameFlowScene.ts b/packages/sts-like-viewer/src/scenes/GameFlowScene.ts index 34307b2..d395d42 100644 --- a/packages/sts-like-viewer/src/scenes/GameFlowScene.ts +++ b/packages/sts-like-viewer/src/scenes/GameFlowScene.ts @@ -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 = { - start: 0x44aa44, - end: 0xcc8844, - minion: 0xcc4444, - elite: 0xcc44cc, - event: 0xaaaa44, - camp: 0x44cccc, - shop: 0x4488cc, - curio: 0x8844cc, -}; - -const NODE_LABELS: Record = { - 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; - // 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 = new Map(); constructor(gameState: MutableSignal) { - 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 { @@ -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); - } } diff --git a/packages/sts-like-viewer/src/scenes/GridViewerScene.ts b/packages/sts-like-viewer/src/scenes/GridViewerScene.ts index 0e3e906..11e3548 100644 --- a/packages/sts-like-viewer/src/scenes/GridViewerScene.ts +++ b/packages/sts-like-viewer/src/scenes/GridViewerScene.ts @@ -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; - 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(this.GRID_WIDTH, this.GRID_HEIGHT); + super("GridViewerScene"); + this.inventory = createGridInventory( + 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 = { 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(this.GRID_WIDTH, this.GRID_HEIGHT); - await this.sceneController.restart(); + createButton({ + scene: this, + label: "清空", + x: width - 260, + y: 40, + onClick: async () => { + this.inventory = createGridInventory( + GRID_CONFIG.WIDTH, + GRID_CONFIG.HEIGHT, + ); + await this.sceneController.restart(); + }, }); - this.createButton('随机填充', width - 130, 40, async () => { - this.randomFill(); - await this.sceneController.restart(); + createButton({ + scene: this, + label: "随机填充", + x: width - 130, + y: 40, + onClick: async () => { + this.randomFill(); + await this.sceneController.restart(); + }, }); } private randomFill(): void { - this.inventory = createGridInventory(this.GRID_WIDTH, this.GRID_HEIGHT); + this.inventory = createGridInventory( + 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 = { 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); - } } diff --git a/packages/sts-like-viewer/src/scenes/IndexScene.ts b/packages/sts-like-viewer/src/scenes/IndexScene.ts index fb6adfe..48e41cc 100644 --- a/packages/sts-like-viewer/src/scenes/IndexScene.ts +++ b/packages/sts-like-viewer/src/scenes/IndexScene.ts @@ -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 () => { - await this.sceneController.launch(targetScene); + 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); + }, }); } } diff --git a/packages/sts-like-viewer/src/scenes/MapViewerScene.ts b/packages/sts-like-viewer/src/scenes/MapViewerScene.ts index d7113fc..91943af 100644 --- a/packages/sts-like-viewer/src/scenes/MapViewerScene.ts +++ b/packages/sts-like-viewer/src/scenes/MapViewerScene.ts @@ -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.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.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', () => { - this.seed = Date.now(); - this.mapContainer.destroy(); - this.drawMap(); + 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; } } diff --git a/packages/sts-like-viewer/src/scenes/PlaceholderEncounterScene.ts b/packages/sts-like-viewer/src/scenes/PlaceholderEncounterScene.ts index 5775755..af5aeb5 100644 --- a/packages/sts-like-viewer/src/scenes/PlaceholderEncounterScene.ts +++ b/packages/sts-like-viewer/src/scenes/PlaceholderEncounterScene.ts @@ -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) { - 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 () => { - await this.completeEncounter(); + 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 = { - 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 = { - 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 { @@ -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 {}; } } } diff --git a/packages/sts-like-viewer/src/scenes/ShapeViewerScene.ts b/packages/sts-like-viewer/src/scenes/ShapeViewerScene.ts index 2ac9b72..b063b18 100644 --- a/packages/sts-like-viewer/src/scenes/ShapeViewerScene.ts +++ b/packages/sts-like-viewer/src/scenes/ShapeViewerScene.ts @@ -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', - wordWrap: { width: shapeWidth }, - }).setOrigin(0.5); + this.add + .text(startX + shapeWidth / 2, startY + shapeHeight + 46, itemData.desc, { + fontSize: "10px", + color: "#888888", + wordWrap: { width: shapeWidth }, + }) + .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); } } diff --git a/packages/sts-like-viewer/src/utils/createButton.ts b/packages/sts-like-viewer/src/utils/createButton.ts new file mode 100644 index 0000000..ca1e653 --- /dev/null +++ b/packages/sts-like-viewer/src/utils/createButton.ts @@ -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 }; +} diff --git a/packages/sts-like-viewer/src/widgets/CombatUnitWidget.ts b/packages/sts-like-viewer/src/widgets/CombatUnitWidget.ts deleted file mode 100644 index 5fc16b0..0000000 --- a/packages/sts-like-viewer/src/widgets/CombatUnitWidget.ts +++ /dev/null @@ -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 = { - 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/DragController.ts b/packages/sts-like-viewer/src/widgets/DragController.ts new file mode 100644 index 0000000..4a81b0e --- /dev/null +++ b/packages/sts-like-viewer/src/widgets/DragController.ts @@ -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["shape"]; + itemTransform: InventoryItem["transform"]; + itemMeta: InventoryItem["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; + getItemColor: (itemId: string) => number; + getItemCells: ( + item: InventoryItem, + ) => { x: number; y: number }[]; + onPlaceItem: (item: InventoryItem) => void; + onCreateLostItem: ( + itemId: string, + shape: InventoryItem["shape"], + transform: InventoryItem["transform"], + meta: InventoryItem["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; + private getItemColor: (itemId: string) => number; + private getItemCells: ( + item: InventoryItem, + ) => { x: number; y: number }[]; + private onPlaceItem: (item: InventoryItem) => void; + private onCreateLostItem: ( + itemId: string, + shape: InventoryItem["shape"], + transform: InventoryItem["transform"], + meta: InventoryItem["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["shape"], + transform: InventoryItem["transform"], + meta: InventoryItem["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 = { + 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; + } + } +} diff --git a/packages/sts-like-viewer/src/widgets/InventoryWidget.ts b/packages/sts-like-viewer/src/widgets/InventoryWidget.ts index 962745e..cd15da6 100644 --- a/packages/sts-like-viewer/src/widgets/InventoryWidget.ts +++ b/packages/sts-like-viewer/src/widgets/InventoryWidget.ts @@ -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['shape']; - itemTransform: InventoryItem['transform']; - itemMeta: InventoryItem['meta']; - ghostContainer: Phaser.GameObjects.Container; - previewGraphics: Phaser.GameObjects.Graphics; - dragOffsetX: number; - dragOffsetY: number; -} - -interface LostItem { - id: string; - container: Phaser.GameObjects.Container; - shape: InventoryItem['shape']; - transform: InventoryItem['transform']; - meta: InventoryItem['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; @@ -55,18 +36,13 @@ export class InventoryWidget { 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 renderer: ItemRenderer; + private dragController: DragController; + private lostItemManager: LostItemManager; - 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; + 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 { - 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); + return this.gameState.value + .inventory as unknown as GridInventory; } 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): 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, + ): 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): { 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 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); - } + this.pointerDownHandler = (pointer: Phaser.Input.Pointer) => { + if (!this.dragController.isDragging()) return; + if (pointer.button === 1) { + this.dragController.rotateDraggedItem(); } + }; + + 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): void { + this.gameState.produce((state) => { + placeItem(state.inventory, item); + }); + const inventory = this.getInventory(); + const placedItem = inventory.items.get(item.id); + if (placedItem) { + const visuals = this.renderer.createItemVisuals(item.id, placedItem); + this.setupItemInteraction(item.id, visuals); } - ghostContainer.add(ghostGraphics); + } - const previewGraphics = this.scene.add.graphics().setDepth(999).setAlpha(0.5); - - this.dragState = { + private handleCreateLostItem( + itemId: string, + shape: InventoryItem["shape"], + transform: InventoryItem["transform"], + meta: InventoryItem["meta"], + ): void { + this.lostItemManager.createLostItem( itemId, - itemShape: item.shape, - itemTransform: { ...item.transform, offset: { ...item.transform.offset } }, - itemMeta: item.meta, - ghostContainer, - previewGraphics, - dragOffsetX, - dragOffsetY, - }; + shape, + transform, + meta, + this.dragController.getDraggedItemPosition().x, + this.dragController.getDraggedItemPosition().y, + ); } - 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 getLostItemShape(itemId: string) { + return this.lostItemManager.getLostItem(itemId)?.shape!; } - 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 getLostItemTransform(itemId: string) { + return this.lostItemManager.getLostItem(itemId)?.transform!; } - 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 = { - 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)); - - if (cellX < 0 || cellY < 0 || cellX >= this.getInventory().width || cellY >= this.getInventory().height) { - return null; - } - - 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); - - 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 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(); } } diff --git a/packages/sts-like-viewer/src/widgets/ItemRenderer.ts b/packages/sts-like-viewer/src/widgets/ItemRenderer.ts new file mode 100644 index 0000000..157118b --- /dev/null +++ b/packages/sts-like-viewer/src/widgets/ItemRenderer.ts @@ -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(); + private colorMap = new Map(); + 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, + ): 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): { 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(); + } +} diff --git a/packages/sts-like-viewer/src/widgets/LostItemManager.ts b/packages/sts-like-viewer/src/widgets/LostItemManager.ts new file mode 100644 index 0000000..e085e13 --- /dev/null +++ b/packages/sts-like-viewer/src/widgets/LostItemManager.ts @@ -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["shape"]; + transform: InventoryItem["transform"]; + meta: InventoryItem["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(); + + 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["shape"], + transform: InventoryItem["transform"], + meta: InventoryItem["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(); + } +}