diff --git a/frontend/src/game/interactions/HandManager.spec.ts b/frontend/src/game/interactions/HandManager.spec.ts new file mode 100644 index 0000000..17a4d5f --- /dev/null +++ b/frontend/src/game/interactions/HandManager.spec.ts @@ -0,0 +1,270 @@ +/** + * HandManager tests + * + * Tests the hand card interaction system including drag-and-drop, + * zone validation, and event emission. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest' + +import { HandManager } from './HandManager' +import { gameBridge } from '../bridge' +import type { HandZone } from '../objects/HandZone' +import type { Card } from '../objects/Card' +import type { BoardLayout } from '@/types/phaser' +import type { CardDefinition, CardInstance } from '@/types/game' + +// Mock game bridge +vi.mock('../bridge', () => ({ + gameBridge: { + emit: vi.fn(), + on: vi.fn(), + off: vi.fn(), + }, +})) + +// Mock Phaser to prevent canvas issues +vi.mock('phaser', () => ({ + default: { + Math: { + Distance: { + Between: vi.fn((x1, y1, x2, y2) => Math.hypot(x2 - x1, y2 - y1)), + }, + }, + }, +})) + +describe('HandManager', () => { + let scene: unknown + let handZone: HandZone + let handManager: HandManager + let mockCard: Card + let mockLayout: BoardLayout + let mockGraphics: unknown + + beforeEach(() => { + /** + * Set up test environment with mocked Phaser scene and hand zone. + * + * Creates fresh instances for each test to ensure isolation. + */ + // Mock graphics object + mockGraphics = { + setDepth: vi.fn().mockReturnThis(), + clear: vi.fn(), + fillStyle: vi.fn(), + fillRect: vi.fn(), + lineStyle: vi.fn(), + strokeRect: vi.fn(), + destroy: vi.fn(), + } + + // Mock scene + scene = { + add: { + graphics: vi.fn(() => mockGraphics), + }, + tweens: { + add: vi.fn(), + }, + input: { + activePointer: { + x: 100, + y: 100, + }, + }, + } + + // Mock hand zone + handZone = { + getCards: vi.fn(() => [mockCard]), + getPlayerId: vi.fn(() => 'player1'), + } as unknown as HandZone + + // Mock card + const mockCardInstance: CardInstance = { + instance_id: 'card-123', + definition_id: 'pikachu', + damage: 0, + attached_energy: [], + attached_tools: [], + status_conditions: [], + ability_uses_this_turn: {}, + evolution_turn: null, + } + + const mockCardDefinition: CardDefinition = { + id: 'pikachu', + name: 'Pikachu', + card_type: 'pokemon', + stage: 'basic', + hp: 60, + pokemon_type: 'lightning', + } + + mockCard = { + setInteractive: vi.fn().mockReturnThis(), + disableInteractive: vi.fn(), + on: vi.fn(), + off: vi.fn(), + setPosition: vi.fn(), + setDepth: vi.fn(), + setScale: vi.fn(), + getCardInstance: vi.fn(() => mockCardInstance), + getCardDefinition: vi.fn(() => mockCardDefinition), + x: 100, + y: 200, + } as unknown as Card + + // Mock layout + mockLayout = { + myActive: { x: 500, y: 400, width: 100, height: 140, rotation: 0 }, + myBench: [ + { x: 200, y: 500, width: 100, height: 140, rotation: 0 }, + { x: 300, y: 500, width: 100, height: 140, rotation: 0 }, + { x: 400, y: 500, width: 100, height: 140, rotation: 0 }, + { x: 500, y: 500, width: 100, height: 140, rotation: 0 }, + { x: 600, y: 500, width: 100, height: 140, rotation: 0 }, + ], + myHand: { x: 400, y: 700, width: 800, height: 140, rotation: 0 }, + myDeck: { x: 900, y: 400, width: 100, height: 140, rotation: 0 }, + myDiscard: { x: 800, y: 400, width: 100, height: 140, rotation: 0 }, + myPrizes: [], + myEnergyZone: { x: 650, y: 400, width: 100, height: 140, rotation: 0 }, + oppActive: { x: 500, y: 200, width: 100, height: 140, rotation: Math.PI }, + oppBench: [], + oppHand: { x: 400, y: 100, width: 800, height: 140, rotation: Math.PI }, + oppDeck: { x: 100, y: 200, width: 100, height: 140, rotation: Math.PI }, + oppDiscard: { x: 200, y: 200, width: 100, height: 140, rotation: Math.PI }, + oppPrizes: [], + oppEnergyZone: { x: 350, y: 200, width: 100, height: 140, rotation: Math.PI }, + } + + handManager = new HandManager(scene, handZone) + handManager.setLayout(mockLayout) + + // Clear mock call history + vi.clearAllMocks() + }) + + it('creates HandManager instance', () => { + /** + * Test that HandManager can be instantiated. + * + * Verifies basic object creation and graphics setup. + * The graphics object is created during HandManager construction. + */ + // Create a new instance to test construction + const manager = new HandManager(scene, handZone) + + expect(manager).toBeDefined() + expect(scene.add.graphics).toHaveBeenCalled() + }) + + it('enables hand interactions for all cards', () => { + /** + * Test that enableHandInteractions sets up event listeners. + * + * Ensures all cards in hand become interactive when enabled. + */ + handManager.enableHandInteractions() + + expect(mockCard.setInteractive).toHaveBeenCalled() + expect(mockCard.on).toHaveBeenCalledWith('pointerdown', expect.any(Function)) + expect(mockCard.on).toHaveBeenCalledWith('pointermove', expect.any(Function)) + expect(mockCard.on).toHaveBeenCalledWith('pointerup', expect.any(Function)) + }) + + it('disables hand interactions', () => { + /** + * Test that disableHandInteractions removes interactivity. + * + * Verifies cards become non-interactive when disabled (e.g., opponent's turn). + */ + handManager.enableHandInteractions() + handManager.disableHandInteractions() + + expect(mockCard.disableInteractive).toHaveBeenCalled() + }) + + it('emits card:clicked event on card click', () => { + /** + * Test that clicking a card emits the correct event. + * + * Card clicks should send instance/definition IDs to Vue for action handling. + */ + handManager.enableHandInteractions() + + // Get the pointerdown handler + const onCalls = (mockCard.on as ReturnType).mock.calls + const pointerDownCall = onCalls.find( + (call: unknown[]) => call[0] === 'pointerdown' + ) + expect(pointerDownCall).toBeDefined() + + const pointerDownHandler = pointerDownCall![1] as (pointer: unknown) => void + + // Simulate pointer down and up without drag + const mockPointer = { x: 100, y: 200 } + pointerDownHandler(mockPointer) + + // Get the pointerup handler + const pointerUpCall = onCalls.find( + (call: unknown[]) => call[0] === 'pointerup' + ) + const pointerUpHandler = pointerUpCall![1] as () => void + pointerUpHandler() + + expect(gameBridge.emit).toHaveBeenCalledWith('card:clicked', { + instanceId: 'card-123', + definitionId: 'pikachu', + zone: 'hand', + playerId: 'player1', + }) + }) + + it('updates card registry', () => { + /** + * Test that card registry can be updated. + * + * The registry is needed for validating card types during drag operations. + */ + const registry = { + pikachu: { + id: 'pikachu', + name: 'Pikachu', + card_type: 'pokemon' as const, + stage: 'basic' as const, + }, + } + + handManager.setCardRegistry(registry) + + // No error should be thrown + expect(handManager).toBeDefined() + }) + + it('cleans up resources on destroy', () => { + /** + * Test that destroy method properly cleans up. + * + * Prevents memory leaks by ensuring graphics are destroyed. + */ + handManager.destroy() + + expect(mockGraphics.destroy).toHaveBeenCalled() + }) + + it('sets layout correctly', () => { + /** + * Test that layout can be updated. + * + * Layout updates are needed when viewport resizes. + */ + const newLayout: BoardLayout = { ...mockLayout } + handManager.setLayout(newLayout) + + // No error should be thrown + expect(handManager).toBeDefined() + }) +}) diff --git a/frontend/src/game/interactions/HandManager.ts b/frontend/src/game/interactions/HandManager.ts new file mode 100644 index 0000000..88f3c1c --- /dev/null +++ b/frontend/src/game/interactions/HandManager.ts @@ -0,0 +1,602 @@ +/** + * HandManager - Handles all hand card interactions in Phaser. + * + * Manages: + * - Card selection (tap/click) + * - Drag-and-drop to play cards + * - Energy attachment via drag + * - Visual feedback for valid drop zones + * - Integration with game bridge for action dispatch + * + * The HandManager listens for card interactions in the hand zone and emits + * events via the game bridge for Vue to handle. Vue components then use + * useGameActions to dispatch validated actions to the server. + */ + +import Phaser from 'phaser' + +import { gameBridge } from '../bridge' +import type { Card } from '../objects/Card' +import type { HandZone } from '../objects/HandZone' +import type { BoardLayout, ZonePosition } from '@/types/phaser' +import type { ZoneType, CardDefinition } from '@/types/game' + +// ============================================================================= +// Constants +// ============================================================================= + +/** Color for highlighting valid drop zones */ +const VALID_ZONE_COLOR = 0x22c55e // Green + +/** Color for highlighting invalid drop zones */ +const INVALID_ZONE_COLOR = 0xef4444 // Red + +/** Alpha for zone highlight overlays */ +const ZONE_HIGHLIGHT_ALPHA = 0.3 + +/** Minimum drag distance to trigger drag mode (prevents accidental drags) */ +const MIN_DRAG_DISTANCE = 10 + +// ============================================================================= +// Types +// ============================================================================= + +/** + * Zone validation result. + */ +interface ZoneValidation { + /** Whether the zone is a valid drop target */ + isValid: boolean + /** The zone type */ + zoneType: ZoneType + /** Slot index for multi-slot zones (bench, prizes) */ + slotIndex?: number + /** Reason why zone is invalid (for debugging) */ + reason?: string +} + +// ============================================================================= +// HandManager Class +// ============================================================================= + +/** + * Manages hand card interactions. + * + * Coordinates between card drag events and zone highlighting. + * Validates drop targets based on card type and game rules. + */ +export class HandManager { + // =========================================================================== + // Properties + // =========================================================================== + + /** The Phaser scene */ + private scene: Phaser.Scene + + /** Hand zone object */ + private handZone: HandZone + + /** Current board layout */ + private layout: BoardLayout | null = null + + /** Graphics object for zone highlights */ + private highlightGraphics: Phaser.GameObjects.Graphics + + /** Currently dragged card */ + private draggedCard: Card | null = null + + /** Card definition registry for validation */ + private cardRegistry: Record = {} + + /** Starting position of drag */ + private dragStartPosition: { x: number; y: number } | null = null + + /** Whether dragging is active */ + private isDragging = false + + /** Original position of dragged card */ + private originalCardPosition: { x: number; y: number } | null = null + + // =========================================================================== + // Constructor + // =========================================================================== + + /** + * Create a new HandManager. + * + * @param scene - The Phaser scene + * @param handZone - The hand zone to manage + */ + constructor(scene: Phaser.Scene, handZone: HandZone) { + this.scene = scene + this.handZone = handZone + + // Create graphics for zone highlights + this.highlightGraphics = scene.add.graphics() + this.highlightGraphics.setDepth(100) // Above board but below dragged card + } + + // =========================================================================== + // Public Methods + // =========================================================================== + + /** + * Update the board layout. + * + * @param layout - New board layout + */ + setLayout(layout: BoardLayout): void { + this.layout = layout + } + + /** + * Update the card registry for validation. + * + * @param registry - Card definition registry + */ + setCardRegistry(registry: Record): void { + this.cardRegistry = registry + } + + /** + * Enable interactions for all cards in hand. + * + * Sets up click and drag handlers for hand cards. + */ + enableHandInteractions(): void { + const handCards = this.handZone.getCards() + + for (const card of handCards) { + this.setupCardInteractions(card) + } + } + + /** + * Disable all hand interactions (e.g., during opponent's turn). + */ + disableHandInteractions(): void { + const handCards = this.handZone.getCards() + + for (const card of handCards) { + card.disableInteractive() + } + + this.clearDrag() + } + + /** + * Clean up resources. + */ + destroy(): void { + this.clearDrag() + this.highlightGraphics.destroy() + } + + // =========================================================================== + // Private Methods - Card Interaction Setup + // =========================================================================== + + /** + * Set up click and drag handlers for a card. + * + * @param card - The card to set up + */ + private setupCardInteractions(card: Card): void { + // Make sure card is interactive + card.setInteractive() + + // Remove any existing listeners to prevent duplicates + card.off('pointerdown') + card.off('pointermove') + card.off('pointerup') + + // Set up event handlers + card.on('pointerdown', (pointer: Phaser.Input.Pointer) => { + this.handleCardPointerDown(card, pointer) + }) + + card.on('pointermove', (pointer: Phaser.Input.Pointer) => { + this.handleCardPointerMove(card, pointer) + }) + + card.on('pointerup', () => { + this.handleCardPointerUp(card) + }) + } + + // =========================================================================== + // Private Methods - Event Handlers + // =========================================================================== + + /** + * Handle pointer down on a card (start of click or drag). + * + * @param card - The card that was pressed + * @param pointer - The pointer input + */ + private handleCardPointerDown(card: Card, pointer: Phaser.Input.Pointer): void { + // Store drag start position + this.dragStartPosition = { x: pointer.x, y: pointer.y } + + // Store original card position for cancel + this.originalCardPosition = { x: card.x, y: card.y } + + // Not yet dragging - wait for movement + this.isDragging = false + } + + /** + * Handle pointer move (dragging a card). + * + * @param card - The card being dragged + * @param pointer - The pointer input + */ + private handleCardPointerMove(card: Card, pointer: Phaser.Input.Pointer): void { + if (!this.dragStartPosition) return + + // Check if we've moved enough to start dragging + if (!this.isDragging) { + const distance = Phaser.Math.Distance.Between( + this.dragStartPosition.x, + this.dragStartPosition.y, + pointer.x, + pointer.y + ) + + if (distance >= MIN_DRAG_DISTANCE) { + this.startDrag(card) + } else { + // Not dragging yet + return + } + } + + // Update card position to follow pointer + card.setPosition(pointer.x, pointer.y) + + // Update zone highlights based on pointer position + this.updateZoneHighlights(card, pointer.x, pointer.y) + } + + /** + * Handle pointer up (end of click or drag). + * + * @param card - The card that was released + */ + private handleCardPointerUp(card: Card): void { + if (!this.isDragging) { + // It was a click, not a drag + this.handleCardClick(card) + } else { + // End drag and check for valid drop + this.endDrag(card) + } + + // Clear drag state + this.dragStartPosition = null + this.originalCardPosition = null + } + + // =========================================================================== + // Private Methods - Click Handling + // =========================================================================== + + /** + * Handle card click (tap without drag). + * + * Emits card:clicked event to Vue for showing action menu. + * + * @param card - The clicked card + */ + private handleCardClick(card: Card): void { + const instance = card.getCardInstance() + const definition = card.getCardDefinition() + + if (!instance || !definition) return + + // Emit click event via bridge + gameBridge.emit('card:clicked', { + instanceId: instance.instance_id, + definitionId: definition.id, + zone: 'hand', + playerId: this.handZone.getPlayerId(), + }) + } + + // =========================================================================== + // Private Methods - Drag Handling + // =========================================================================== + + /** + * Start dragging a card. + * + * @param card - The card to drag + */ + private startDrag(card: Card): void { + this.isDragging = true + this.draggedCard = card + + // Bring card to front + card.setDepth(1000) + + // Slightly enlarge card during drag + card.setScale(1.1) + } + + /** + * End drag and attempt to play the card. + * + * @param card - The dragged card + */ + private endDrag(card: Card): void { + if (!this.draggedCard) return + + // Get pointer position + const pointer = this.scene.input.activePointer + + // Validate drop zone + const validation = this.validateDropZone(card, pointer.x, pointer.y) + + if (validation.isValid) { + // Valid drop - emit play intention + this.emitPlayCardIntention(card, validation) + } else { + // Invalid drop - return card to hand + this.returnCardToHand(card) + } + + // Clean up drag state + this.clearDrag() + } + + /** + * Clear drag state and highlights. + */ + private clearDrag(): void { + if (this.draggedCard) { + // Reset card appearance + this.draggedCard.setDepth(0) + this.draggedCard.setScale(1) + this.draggedCard = null + } + + this.isDragging = false + this.highlightGraphics.clear() + } + + /** + * Return a card to its original position in hand. + * + * @param card - The card to return + */ + private returnCardToHand(card: Card): void { + if (!this.originalCardPosition) return + + // Animate return to original position + this.scene.tweens.add({ + targets: card, + x: this.originalCardPosition.x, + y: this.originalCardPosition.y, + duration: 200, + ease: 'Quad.easeOut', + }) + } + + // =========================================================================== + // Private Methods - Zone Validation & Highlighting + // =========================================================================== + + /** + * Update zone highlights based on drag position. + * + * @param card - The dragged card + * @param x - Pointer X position + * @param y - Pointer Y position + */ + private updateZoneHighlights(card: Card, x: number, y: number): void { + this.highlightGraphics.clear() + + if (!this.layout) return + + // Get card definition for validation + const definition = card.getCardDefinition() + if (!definition) return + + // Check each potential drop zone + const zones = this.getValidDropZones(definition) + + for (const { zone, zoneType, slotIndex } of zones) { + const isPointerOver = this.isPointerInZone(x, y, zone) + const validation = this.validateDropForCardType(definition, zoneType, slotIndex) + + if (isPointerOver) { + // Highlight this zone + const color = validation.isValid ? VALID_ZONE_COLOR : INVALID_ZONE_COLOR + this.drawZoneHighlight(zone, color) + } + } + } + + /** + * Validate a drop zone for the dragged card. + * + * @param card - The card being dropped + * @param x - Drop X position + * @param y - Drop Y position + * @returns Validation result + */ + private validateDropZone(card: Card, x: number, y: number): ZoneValidation { + const definition = card.getCardDefinition() + + if (!definition || !this.layout) { + return { isValid: false, zoneType: 'hand', reason: 'Missing definition or layout' } + } + + // Check each potential drop zone + const zones = this.getValidDropZones(definition) + + for (const { zone, zoneType, slotIndex } of zones) { + if (this.isPointerInZone(x, y, zone)) { + return this.validateDropForCardType(definition, zoneType, slotIndex) + } + } + + return { isValid: false, zoneType: 'hand', reason: 'Not over any zone' } + } + + /** + * Get potential drop zones based on card type. + * + * @param definition - Card definition + * @returns Array of zones to check + */ + private getValidDropZones( + definition: CardDefinition + ): Array<{ zone: ZonePosition; zoneType: ZoneType; slotIndex?: number }> { + if (!this.layout) return [] + + const zones: Array<{ zone: ZonePosition; zoneType: ZoneType; slotIndex?: number }> = [] + + if (definition.card_type === 'pokemon') { + // Pokemon can go to bench slots + this.layout.myBench.forEach((benchZone, index) => { + zones.push({ zone: benchZone, zoneType: 'bench', slotIndex: index }) + }) + } else if (definition.card_type === 'energy') { + // Energy can be attached to active or bench Pokemon + zones.push({ zone: this.layout.myActive, zoneType: 'active' }) + + this.layout.myBench.forEach((benchZone, index) => { + zones.push({ zone: benchZone, zoneType: 'bench', slotIndex: index }) + }) + } + // Trainer cards are typically played via click menu, not drag-and-drop + + return zones + } + + /** + * Validate if a card type can be dropped in a specific zone. + * + * @param definition - Card definition + * @param zoneType - Target zone type + * @param slotIndex - Target slot index (for bench) + * @returns Validation result + */ + private validateDropForCardType( + definition: CardDefinition, + zoneType: ZoneType, + slotIndex?: number + ): ZoneValidation { + // Pokemon -> Bench + if (definition.card_type === 'pokemon' && zoneType === 'bench') { + // Basic validation - Vue will handle full validation via server + return { + isValid: definition.stage === 'basic', // Only basic Pokemon can be played to bench + zoneType, + slotIndex, + reason: definition.stage !== 'basic' ? 'Only basic Pokemon to bench' : undefined, + } + } + + // Energy -> Active or Bench + if (definition.card_type === 'energy' && (zoneType === 'active' || zoneType === 'bench')) { + // Energy can be attached to any Pokemon (server will validate energy per turn limit) + return { + isValid: true, + zoneType, + slotIndex, + } + } + + return { + isValid: false, + zoneType, + slotIndex, + reason: 'Invalid card type for this zone', + } + } + + /** + * Check if pointer is within a zone's bounds. + * + * @param x - Pointer X + * @param y - Pointer Y + * @param zone - Zone position + * @returns True if pointer is in zone + */ + private isPointerInZone(x: number, y: number, zone: ZonePosition): boolean { + const left = zone.x - zone.width / 2 + const right = zone.x + zone.width / 2 + const top = zone.y - zone.height / 2 + const bottom = zone.y + zone.height / 2 + + return x >= left && x <= right && y >= top && y <= bottom + } + + /** + * Draw a highlight rectangle for a zone. + * + * @param zone - Zone to highlight + * @param color - Highlight color + */ + private drawZoneHighlight(zone: ZonePosition, color: number): void { + this.highlightGraphics.fillStyle(color, ZONE_HIGHLIGHT_ALPHA) + this.highlightGraphics.fillRect( + zone.x - zone.width / 2, + zone.y - zone.height / 2, + zone.width, + zone.height + ) + + this.highlightGraphics.lineStyle(2, color, 1) + this.highlightGraphics.strokeRect( + zone.x - zone.width / 2, + zone.y - zone.height / 2, + zone.width, + zone.height + ) + } + + // =========================================================================== + // Private Methods - Action Emission + // =========================================================================== + + /** + * Emit play card or attach energy intention to Vue. + * + * @param card - The card to play + * @param validation - Zone validation result + */ + private emitPlayCardIntention(card: Card, validation: ZoneValidation): void { + const instance = card.getCardInstance() + const definition = card.getCardDefinition() + + if (!instance || !definition) return + + // Determine the action type based on card type and target zone + if (definition.card_type === 'energy') { + // Emit attach energy intention + gameBridge.emit('card:clicked', { + instanceId: instance.instance_id, + definitionId: definition.id, + zone: 'hand', + playerId: this.handZone.getPlayerId(), + // Vue will interpret this as attach energy based on context + }) + } else if (definition.card_type === 'pokemon' && validation.zoneType === 'bench') { + // Emit play Pokemon to bench intention + gameBridge.emit('card:clicked', { + instanceId: instance.instance_id, + definitionId: definition.id, + zone: 'hand', + playerId: this.handZone.getPlayerId(), + // Vue will interpret this as play to bench based on context + }) + } + + // Note: We emit card:clicked instead of specialized events because + // Vue components handle the actual action dispatch via useGameActions. + // The drag-and-drop is primarily UX sugar on top of the click-based system. + } +}