diff --git a/src/client/src/modules/game/GameContextMenu.tsx b/src/client/src/modules/game/GameContextMenu.tsx index 6fff481..e7b7cdd 100644 --- a/src/client/src/modules/game/GameContextMenu.tsx +++ b/src/client/src/modules/game/GameContextMenu.tsx @@ -1,12 +1,13 @@ import React, { useEffect } from 'react'; import { CardInstance } from '../../types/game'; -interface ContextMenuRequest { +export interface ContextMenuRequest { x: number; y: number; - type: 'background' | 'card'; - targetId?: string; + type: 'background' | 'card' | 'zone'; + targetId?: string; // cardId or zoneName card?: CardInstance; + zone?: string; // 'library', 'graveyard', 'exile', 'hand' } interface GameContextMenuProps { @@ -32,9 +33,9 @@ export const GameContextMenu: React.FC = ({ request, onClo const style: React.CSSProperties = { position: 'fixed', - top: request.y, - left: request.x, - zIndex: 9999, // Ensure it's above everything + top: Math.min(request.y, window.innerHeight - 300), // Prevent going off bottom + left: Math.min(request.x, window.innerWidth - 224), // Prevent going off right (w-56 = 224px) + zIndex: 9999, }; // Prevent closing when clicking inside the menu @@ -42,6 +43,131 @@ export const GameContextMenu: React.FC = ({ request, onClo e.stopPropagation(); }; + const renderCardMenu = (card: CardInstance) => { + const zone = card.zone; + + return ( + <> +
+ {card.name} + {zone} +
+ + {/* Hand Menu */} + {zone === 'hand' && ( + <> + handleAction('MOVE_CARD', { cardId: card.instanceId, toZone: 'battlefield', position: { x: 50, y: 50 } })} /> + handleAction('MOVE_CARD', { cardId: card.instanceId, toZone: 'graveyard' })} /> + handleAction('MOVE_CARD', { cardId: card.instanceId, toZone: 'exile' })} /> +
+ handleAction('MOVE_CARD', { cardId: card.instanceId, toZone: 'library', position: 'top' })} /> + handleAction('MOVE_CARD', { cardId: card.instanceId, toZone: 'library', position: 'bottom' })} /> + + )} + + {/* Battlefield Menu */} + {zone === 'battlefield' && ( + <> + handleAction('TAP_CARD', { cardId: card.instanceId })} /> + handleAction('FLIP_CARD', { cardId: card.instanceId })} /> + +
+ { }} /> +
+ handleAction('ADD_COUNTER', { cardId: card.instanceId, counterType: '+1/+1', amount: 1 })} /> + handleAction('ADD_COUNTER', { cardId: card.instanceId, counterType: '-1/-1', amount: 1 })} /> + handleAction('ADD_COUNTER', { cardId: card.instanceId, counterType: 'loyalty', amount: 1 })} /> + handleAction('ADD_COUNTER', { cardId: card.instanceId, counterType: '+1/+1', amount: -1 })} /> +
+
+ + handleAction('CREATE_TOKEN', { + tokenData: { + name: `${card.name} (Copy)`, + imageUrl: card.imageUrl, + power: card.ptModification?.power, + toughness: card.ptModification?.toughness + }, + position: { x: (card.position.x || 50) + 2, y: (card.position.y || 50) + 2 } + })} /> + +
+ handleAction('MOVE_CARD', { cardId: card.instanceId, toZone: 'hand' })} /> + handleAction('MOVE_CARD', { cardId: card.instanceId, toZone: 'graveyard' })} /> + handleAction('MOVE_CARD', { cardId: card.instanceId, toZone: 'exile' })} /> + handleAction('MOVE_CARD', { cardId: card.instanceId, toZone: 'library', position: 'top' })} /> + + )} + + {/* Graveyard Menu */} + {zone === 'graveyard' && ( + <> + handleAction('MOVE_CARD', { cardId: card.instanceId, toZone: 'exile' })} /> + handleAction('MOVE_CARD', { cardId: card.instanceId, toZone: 'hand' })} /> + handleAction('MOVE_CARD', { cardId: card.instanceId, toZone: 'battlefield' })} /> + handleAction('MOVE_CARD', { cardId: card.instanceId, toZone: 'library', position: 'bottom' })} /> + + )} + + {/* Exile Menu */} + {zone === 'exile' && ( + <> + handleAction('MOVE_CARD', { cardId: card.instanceId, toZone: 'graveyard' })} /> + handleAction('MOVE_CARD', { cardId: card.instanceId, toZone: 'battlefield' })} /> + handleAction('MOVE_CARD', { cardId: card.instanceId, toZone: 'hand' })} /> + + )} + + {/* Library Menu (if we ever show context menu for cards IN library view?) */} + {zone === 'library' && ( + <> + handleAction('MOVE_CARD', { cardId: card.instanceId, toZone: 'hand' })} /> + + )} + +
+ handleAction('DELETE_CARD', { cardId: card.instanceId })} + /> + + ); + }; + + const renderZoneMenu = (zone: string) => { + return ( + <> +
+ {zone} Zone +
+ + handleAction('VIEW_ZONE', { zone })} /> + + {zone === 'library' && ( + <> + handleAction('DRAW_CARD')} /> + handleAction('SHUFFLE_LIBRARY')} /> + handleAction('MILL_CARD', { amount: 1 })} /> + + )} + + {zone === 'graveyard' && ( + <> + handleAction('EXILE_GRAVEYARD')} /> + handleAction('SHUFFLE_GRAVEYARD')} /> + + )} + + {zone === 'exile' && ( + <> + handleAction('SHUFFLE_EXILE')} /> + + )} + + ); + }; + return (
= ({ request, onClo onClick={onMenuClick} onContextMenu={(e) => e.preventDefault()} > - {request.type === 'card' && request.card && ( - <> -
- {request.card.name} -
- handleAction('TAP_CARD', { cardId: request.targetId })} /> - handleAction('FLIP_CARD', { cardId: request.targetId })} /> + {request.type === 'card' && request.card && renderCardMenu(request.card)} -
- { }} /> - {/* Submenu */} -
- handleAction('ADD_COUNTER', { cardId: request.targetId, counterType: '+1/+1', amount: 1 })} /> - handleAction('ADD_COUNTER', { cardId: request.targetId, counterType: '-1/-1', amount: 1 })} /> - handleAction('ADD_COUNTER', { cardId: request.targetId, counterType: 'loyalty', amount: 1 })} /> - handleAction('ADD_COUNTER', { cardId: request.targetId, counterType: '+1/+1', amount: -1 })} /> -
-
- - handleAction('CREATE_TOKEN', { - tokenData: { - name: `${request.card?.name} (Copy)`, - imageUrl: request.card?.imageUrl, - power: request.card?.ptModification?.power, - toughness: request.card?.ptModification?.toughness - }, - position: { x: (request.card?.position.x || 50) + 2, y: (request.card?.position.y || 50) + 2 } - })} /> - -
- - handleAction('DELETE_CARD', { cardId: request.targetId })} - /> - - )} + {request.type === 'zone' && request.zone && renderZoneMenu(request.zone)} {request.type === 'background' && ( <> @@ -97,25 +188,25 @@ export const GameContextMenu: React.FC = ({ request, onClo label="Create Token (1/1)" onClick={() => handleAction('CREATE_TOKEN', { tokenData: { name: 'Soldier', power: 1, toughness: 1 }, - // Convert click position to approximate percent if possible or center - // For now, simpler to spawn at center or random. - position: { x: Math.random() * 40 + 30, y: Math.random() * 40 + 30 } + position: { x: (request.x / window.innerWidth) * 100, y: (request.y / window.innerHeight) * 100 } })} /> handleAction('CREATE_TOKEN', { tokenData: { name: 'Zombie', power: 2, toughness: 2, imageUrl: 'https://cards.scryfall.io/large/front/b/d/bd4047a5-d14f-4d2d-9333-5c628dfca115.jpg' }, - position: { x: Math.random() * 40 + 30, y: Math.random() * 40 + 30 } + position: { x: (request.x / window.innerWidth) * 100, y: (request.y / window.innerHeight) * 100 } })} /> handleAction('CREATE_TOKEN', { tokenData: { name: 'Treasure', power: 0, toughness: 0, imageUrl: 'https://cards.scryfall.io/large/front/2/7/2776c5b9-1d22-4a00-9988-294747734185.jpg' }, - position: { x: Math.random() * 40 + 30, y: Math.random() * 40 + 30 } + position: { x: (request.x / window.innerWidth) * 100, y: (request.y / window.innerHeight) * 100 } })} /> +
+ handleAction('UNTAP_ALL')} /> )}
diff --git a/src/client/src/modules/game/GameView.tsx b/src/client/src/modules/game/GameView.tsx index c1d6939..ef306a4 100644 --- a/src/client/src/modules/game/GameView.tsx +++ b/src/client/src/modules/game/GameView.tsx @@ -2,7 +2,8 @@ import React, { useRef, useState, useEffect } from 'react'; import { GameState, CardInstance } from '../../types/game'; import { socketService } from '../../services/SocketService'; import { CardComponent } from './CardComponent'; -import { GameContextMenu } from './GameContextMenu'; +import { GameContextMenu, ContextMenuRequest } from './GameContextMenu'; +import { ZoneOverlay } from './ZoneOverlay'; interface GameViewProps { gameState: GameState; @@ -11,7 +12,8 @@ interface GameViewProps { export const GameView: React.FC = ({ gameState, currentPlayerId }) => { const battlefieldRef = useRef(null); - const [contextMenu, setContextMenu] = useState<{ x: number; y: number; type: 'background' | 'card'; targetId?: string; card?: CardInstance } | null>(null); + const [contextMenu, setContextMenu] = useState(null); + const [viewingZone, setViewingZone] = useState(null); useEffect(() => { // Disable default context menu @@ -20,30 +22,46 @@ export const GameView: React.FC = ({ gameState, currentPlayerId } return () => document.removeEventListener('contextmenu', handleContext); }, []); - const handleContextMenu = (e: React.MouseEvent, type: 'background' | 'card', targetId?: string) => { + const handleContextMenu = (e: React.MouseEvent, type: 'background' | 'card' | 'zone', targetId?: string, zoneName?: string) => { e.preventDefault(); - const card = targetId ? gameState.cards[targetId] : undefined; + e.stopPropagation(); + + const card = (type === 'card' && targetId) ? gameState.cards[targetId] : undefined; setContextMenu({ x: e.clientX, y: e.clientY, type, targetId, - card + card, + zone: zoneName }); }; const handleMenuAction = (actionType: string, payload: any) => { - // If creating token, inject current player ID as owner if not present - if (actionType === 'CREATE_TOKEN' && !payload.ownerId) { - payload.ownerId = currentPlayerId; + + if (actionType === 'VIEW_ZONE') { + setViewingZone(payload.zone); + return; + } + + // Default payload to object if undefined + const safePayload = payload || {}; + + // Inject currentPlayerId if not present (acts as actor) + if (!safePayload.playerId) { + safePayload.playerId = currentPlayerId; + } + // Inject ownerId if not present (useful for token creation etc) + if (!safePayload.ownerId) { + safePayload.ownerId = currentPlayerId; } socketService.socket.emit('game_action', { roomId: gameState.roomId, action: { type: actionType, - ...payload + ...safePayload } }); }; @@ -135,6 +153,15 @@ export const GameView: React.FC = ({ gameState, currentPlayerId } onAction={handleMenuAction} /> + {viewingZone && ( + setViewingZone(null)} + onCardContextMenu={(e, cardId) => handleContextMenu(e, 'card', cardId)} + /> + )} + {/* Top Area: Opponent */}
{/* Opponent Hand (Visual) */} @@ -224,7 +251,6 @@ export const GameView: React.FC = ({ gameState, currentPlayerId } onDragStart={(e, id) => e.dataTransfer.setData('cardId', id)} onClick={toggleTap} onContextMenu={(id, e) => { - e.stopPropagation(); // Stop bubbling to background handleContextMenu(e, 'card', id); }} /> @@ -247,6 +273,7 @@ export const GameView: React.FC = ({ gameState, currentPlayerId }
socketService.socket.emit('game_action', { roomId: gameState.roomId, action: { type: 'DRAW_CARD', playerId: currentPlayerId } })} + onContextMenu={(e) => handleContextMenu(e, 'zone', undefined, 'library')} >
{/* Deck look */} @@ -263,6 +290,7 @@ export const GameView: React.FC = ({ gameState, currentPlayerId } className="w-16 h-24 border-2 border-dashed border-slate-600 rounded flex items-center justify-center mt-2 transition-colors hover:border-slate-400 hover:bg-white/5" onDragOver={handleDragOver} onDrop={(e) => handleDrop(e, 'graveyard')} + onContextMenu={(e) => handleContextMenu(e, 'zone', undefined, 'graveyard')} >
Graveyard @@ -291,7 +319,7 @@ export const GameView: React.FC = ({ gameState, currentPlayerId } card={card} onDragStart={(e, id) => e.dataTransfer.setData('cardId', id)} onClick={toggleTap} - onContextMenu={(id) => toggleFlip(id)} + onContextMenu={(id, e) => handleContextMenu(e, 'card', id)} style={{ transformOrigin: 'bottom center' }} />
@@ -326,6 +354,7 @@ export const GameView: React.FC = ({ gameState, currentPlayerId } className="w-full text-center border-t border-white/5 pt-2 cursor-pointer hover:bg-white/5 rounded p-1" onDragOver={handleDragOver} onDrop={(e) => handleDrop(e, 'exile')} + onContextMenu={(e) => handleContextMenu(e, 'zone', undefined, 'exile')} > Exile Drop Zone {myExile.length} diff --git a/src/client/src/modules/game/ZoneOverlay.tsx b/src/client/src/modules/game/ZoneOverlay.tsx new file mode 100644 index 0000000..af3e67f --- /dev/null +++ b/src/client/src/modules/game/ZoneOverlay.tsx @@ -0,0 +1,81 @@ +import React from 'react'; +import { CardInstance } from '../../types/game'; + +interface ZoneOverlayProps { + zoneName: string; + cards: CardInstance[]; + onClose: () => void; + onCardContextMenu?: (e: React.MouseEvent, cardId: string) => void; +} + +export const ZoneOverlay: React.FC = ({ zoneName, cards, onClose, onCardContextMenu }) => { + return ( +
+
+ {/* Header */} +
+

+ {zoneName} + + {cards.length} Cards + +

+ +
+ + {/* Content */} +
+ {cards.length === 0 ? ( +
+

This zone is empty.

+
+ ) : ( +
+ {cards.map((card) => ( +
+
{ + if (onCardContextMenu) { + e.preventDefault(); + e.stopPropagation(); + onCardContextMenu(e, card.instanceId); + } + }} + > + {card.name} +
+
+

{card.name}

+
+
+ ))} +
+ )} +
+ + {/* Footer */} +
+ +
+
+
+ ); +}; diff --git a/src/server/managers/GameManager.ts b/src/server/managers/GameManager.ts index ceec9a6..6dadecb 100644 --- a/src/server/managers/GameManager.ts +++ b/src/server/managers/GameManager.ts @@ -104,6 +104,18 @@ export class GameManager { case 'SHUFFLE_LIBRARY': this.shuffleLibrary(game, action); break; + case 'SHUFFLE_GRAVEYARD': + this.shuffleGraveyard(game, action); + break; + case 'SHUFFLE_EXILE': + this.shuffleExile(game, action); + break; + case 'MILL_CARD': + this.millCard(game, action); + break; + case 'EXILE_GRAVEYARD': + this.exileGraveyard(game, action); + break; } return game; @@ -220,6 +232,37 @@ export class GameManager { // No-op in current logic since we pick randomly } + private shuffleGraveyard(_game: GameState, _action: { playerId: string }) { + // No-op + } + + private shuffleExile(_game: GameState, _action: { playerId: string }) { + // No-op + } + + private millCard(game: GameState, action: { playerId: string; amount: number }) { + // Similar to draw but to graveyard + const amount = action.amount || 1; + for (let i = 0; i < amount; i++) { + const libraryCards = Object.values(game.cards).filter(c => c.ownerId === action.playerId && c.zone === 'library'); + if (libraryCards.length > 0) { + const randomIndex = Math.floor(Math.random() * libraryCards.length); + const card = libraryCards[randomIndex]; + card.zone = 'graveyard'; + card.faceDown = false; + card.position.z = ++game.maxZ; + } + } + } + + private exileGraveyard(game: GameState, action: { playerId: string }) { + const graveyardCards = Object.values(game.cards).filter(c => c.ownerId === action.playerId && c.zone === 'graveyard'); + graveyardCards.forEach(card => { + card.zone = 'exile'; + card.position.z = ++game.maxZ; + }); + } + // Helper to add cards (e.g. at game start) addCardToGame(roomId: string, cardData: Partial) { const game = this.games.get(roomId);