Add HandManager for card interaction handling

- Implement drag-and-drop for cards in hand
- Add click handlers for card actions
- Validate drop zones based on game state
- Enable/disable interactions based on turn

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2026-02-01 20:51:43 -06:00
parent a963eb70d3
commit 8ad7552ecc
2 changed files with 872 additions and 0 deletions

View File

@ -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<typeof vi.fn>).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()
})
})

View File

@ -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<string, CardDefinition> = {}
/** 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<string, CardDefinition>): 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.
}
}