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:
parent
a963eb70d3
commit
8ad7552ecc
270
frontend/src/game/interactions/HandManager.spec.ts
Normal file
270
frontend/src/game/interactions/HandManager.spec.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
602
frontend/src/game/interactions/HandManager.ts
Normal file
602
frontend/src/game/interactions/HandManager.ts
Normal 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.
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user