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