From 2eea9b860ec2acbda56483e27e90b22d254cf18d Mon Sep 17 00:00:00 2001 From: dnviti Date: Sun, 14 Dec 2025 23:53:41 +0100 Subject: [PATCH] feat: Implement manual game mode with 3D battlefield, custom context menu, and card actions including tokens and counters. --- docs/development/CENTRAL.md | 34 +-- ...2025-12-14-234500_game_battlefield_plan.md | 32 ++ .../2025-12-14-235000_game_context_menu.md | 39 +++ .../2025-12-14-235500_enhance_3d_game_view.md | 41 +++ src/client/src/modules/game/CardComponent.tsx | 9 +- .../src/modules/game/GameContextMenu.tsx | 134 ++++++++ src/client/src/modules/game/GameView.tsx | 289 ++++++++++++++---- src/server/managers/GameManager.ts | 100 +++++- 8 files changed, 577 insertions(+), 101 deletions(-) create mode 100644 docs/development/devlog/2025-12-14-234500_game_battlefield_plan.md create mode 100644 docs/development/devlog/2025-12-14-235000_game_context_menu.md create mode 100644 docs/development/devlog/2025-12-14-235500_enhance_3d_game_view.md create mode 100644 src/client/src/modules/game/GameContextMenu.tsx diff --git a/docs/development/CENTRAL.md b/docs/development/CENTRAL.md index c1b2114..bec3d7c 100644 --- a/docs/development/CENTRAL.md +++ b/docs/development/CENTRAL.md @@ -1,29 +1,9 @@ -# Development Central Log +# Development Status (Central) -## Status Overview -The project has successfully migrated from a .NET backend to a Node.js Modular Monolith. The core "Draft Preparation" and "Tournament Bracket" functionalities have been implemented in the frontend using React, adhering to the reference design. +## Active Plans +- [Enhance 3D Game View](./devlog/2025-12-14-235500_enhance_3d_game_view.md): Active. Transforming the battlefield into a fully immersive 3D environment. +- [Game Context Menu & Immersion](./devlog/2025-12-14-235000_game_context_menu.md): Completed. Implemented custom right-click menus and game-feel enhancements. -## Recent Updates -- **[2025-12-14] Core Implementation**: Refactored `gemini-generated.js` into modular services and components. Implemented Cube Manager and Tournament Manager. [Link](./devlog/2025-12-14-194558_core_implementation.md) -- **[2025-12-14] Parser Robustness**: Improving `CardParserService` to handle formats without Scryfall IDs (e.g., Arena exports). [Link](./devlog/2025-12-14-210000_fix_parser_robustness.md) -- **[2025-12-14] Set Generation**: Implemented full set fetching and booster box generation (Completed). [Link](./devlog/2025-12-14-211000_set_based_generation.md) -- **[2025-12-14] Cleanup**: Removed Tournament Mode and simplified pack display as requested. [Link](./devlog/2025-12-14-211500_remove_tournament_mode.md) -- **[2025-12-14] UI Tweak**: Auto-configured generation mode based on source selection. [Link](./devlog/2025-12-14-212000_ui_simplification.md) -- **[2025-12-14] Multiplayer Game Plan**: Plan for Real Game & Online Multiplayer. [Link](./devlog/2025-12-14-212500_multiplayer_game_plan.md) -- **[2025-12-14] Bug Fix**: Fixed `crypto.randomUUID` error for non-secure contexts. [Link](./devlog/2025-12-14-214400_fix_uuid_error.md) -- **[2025-12-14] Game Interactions**: Implemented basic game loop, zone management, and drag-and-drop gameplay. [Link](./devlog/2025-12-14-220000_game_interactions.md) -- **[2025-12-14] Draft & Deck Builder**: Implemented full draft simulation (Pick/Pass) and Deck Construction with land station. [Link](./devlog/2025-12-14-223000_draft_and_deckbuilder.md) -- **[2025-12-14] Image Caching**: Implemented server-side image caching to ensure reliable card rendering. [Link](./devlog/2025-12-14-224500_image_caching.md) -- **[2025-12-14] Fix Draft Images**: Fixed image loading in Draft UI by adding proxy configuration and correcting property access. [Link](./devlog/2025-12-14-230000_fix_draft_images.md) -- **[2025-12-14] Fix Submit Deck**: Implemented `player_ready` handler and state transition to auto-start game when deck is submitted. [Link](./devlog/2025-12-14-233000_fix_submit_deck.md) -- **[2025-12-14] Fix Hooks & Waiting State**: Resolved React hook violation crash and added proper waiting screen for ready players. [Link](./devlog/2025-12-14-234500_fix_hooks_and_waiting_state.md) -- **[2025-12-14] Docker Containerization**: Created Dockerfile, fixed build errors, and verified monolithic build. [Link](./devlog/2025-12-14-235700_docker_containerization.md) - -## Active Modules -1. **Cube Manager**: Fully functional (Parsing, Fetching, Pack Generation). -2. **Tournament Manager**: Basic Bracket generation implemented. - -## Roadmap -1. **Backend Integration**: Connect frontend generation to backend via Socket.IO. -2. **Live Draft**: Implement the multiplayer drafting interface. -3. **User Session**: Handle host/player sessions. +## Recent Completions +- [Game Battlefield & Manual Mode](./devlog/2025-12-14-234500_game_battlefield_plan.md): Completed. +- [Helm Chart Config](./devlog/2025-12-14-214500_helm_config.md): Completed. diff --git a/docs/development/devlog/2025-12-14-234500_game_battlefield_plan.md b/docs/development/devlog/2025-12-14-234500_game_battlefield_plan.md new file mode 100644 index 0000000..5d6ac18 --- /dev/null +++ b/docs/development/devlog/2025-12-14-234500_game_battlefield_plan.md @@ -0,0 +1,32 @@ +# Game Battlefield & Manual Mode Implementation Plan + +## Goal +Implement a 3D-style battlefield and manual game logic for the MTG Draft Maker. The system should allow players to drag and drop cards freely onto the battlefield, tap cards, and manage zones (Hand, Library, Graveyard, Exile) in a manual fashion typical of virtual tabletops. + +## Status: Completed + +## Implemented Features +- **3D Battlefield UI**: + - Used CSS `perspective: 1000px` and `rotateX` to create a depth effect. + - Cards are absolutely positioned on the battlefield based on percentage coordinates (0-100%). + - Shadows and gradients enhance the "tabletop" feel. +- **Manual Game Logic**: + - **Free Drag and Drop**: Players can move cards anywhere on the battlefield. Coordinates are calculated relative to the drop target. + - **Z-Index Management**: Backend tracks a `maxZ` counter. Every move or flip brings the card to the front (`z-index` increment). + - **Actions**: + - **Tap/Untap**: Click to toggle (rotate 90 degrees). + - **Flip**: Right-click to toggle face-up/face-down status. + - **Draw**: Click library to draw. + - **Life**: Buttons to increment/decrement life. +- **Multiplayer Synchronization**: + - All actions (`MOVE_CARD`, `TAP_CARD`, `FLIP_CARD`, `UPDATE_LIFE`) are broadcast via Socket.IO. + - Opponent's battlefield is rendered in a mirrored 3D perspective. + +## Files Modified +- `src/client/src/modules/game/GameView.tsx`: Main UI logic. +- `src/client/src/modules/game/CardComponent.tsx`: Added context menu support. +- `src/server/managers/GameManager.ts`: Logic for actions and state management. + +## Next Steps +- Test with real players to fine-tune the "feel" of dragging (maybe add grid snapping option later). +- Implement "Search Library" feature (currently just Draw). diff --git a/docs/development/devlog/2025-12-14-235000_game_context_menu.md b/docs/development/devlog/2025-12-14-235000_game_context_menu.md new file mode 100644 index 0000000..6786e6e --- /dev/null +++ b/docs/development/devlog/2025-12-14-235000_game_context_menu.md @@ -0,0 +1,39 @@ +# Game Context Menu & Immersion Update Plan + +## Goal +Implement a robust, video-game-style context menu for the battlefield and cards. This menu will allow players to perform advanced manual actions required for MTG, such as creating tokens and managing counters, while eliminating "browser-like" feel. + +## Status: Completed + +## Implemented Features +- **Custom Game Context Menu**: + - Replaces default browser context menu. + - Dark, video-game themed UI with glassmorphism. + - Animated entrance (fade/zoom). +- **Functionality**: + - **Global (Background)**: + - "Create Token" (Default 1/1, 2/2, Treasure). + - **Card Specific**: + - "Tap / Untap" + - "Flip Face Up / Down" + - "Add Counter" (Submenu: +1/+1, -1/-1, Loyalty) + - "Clone (Copy)" (Creates an exact token copy of the card) + - "Delete Object" (Removing tokens or cards) +- **Backend Logic**: + - `GameManager` now handles: + - `ADD_COUNTER`: Adds/removes counters logic. + - `CREATE_TOKEN`: Generates new token instances with specific stats/art. + - `DELETE_CARD`: Removes objects from the game. +- **Frontend Integration**: + - `GameView` manages menu state (position, target). + - `CardComponent` triggers menu only on itself, bubbling prevented. + - Hand cards also support right-click menus. + +## Files Modified +- `src/client/src/modules/game/GameContextMenu.tsx`: New component. +- `src/client/src/modules/game/GameView.tsx`: Integrated menu. +- `src/server/managers/GameManager.ts`: Added token/counter handlers. + +## Next Steps +- Add sounds for menu open/click. +- Add more token types or a token editor. diff --git a/docs/development/devlog/2025-12-14-235500_enhance_3d_game_view.md b/docs/development/devlog/2025-12-14-235500_enhance_3d_game_view.md new file mode 100644 index 0000000..0ad08de --- /dev/null +++ b/docs/development/devlog/2025-12-14-235500_enhance_3d_game_view.md @@ -0,0 +1,41 @@ +# Enhancement Plan: True 3D Game Area + +The goal is to transform the game area into a "really 3D game" experience using CSS 3D transforms. + +## Objectives +1. **Immersive 3D Table**: Create a convincing 3D perspective of a table where cards are placed. +2. **Card Physics Simulation**: Visuals should simulate cards having weight, thickness, and position in 3D space. +3. **Dynamic Camera/View**: Fix the viewing angle to be consistent with a player sitting at a table. + +## Implementation Steps + +### 1. Scene Setup (GameView.tsx) +- Create a "Scene" container with high `perspective` (e.g., `1200px` to `2000px`). +- Create a "World" container that holds the table and other elements, allowing for global rotation if needed. +- Implement a "TableSurface" div that is rotated `rotateX(40-60deg)` to simulate a flat surface viewed from an angle. + +### 2. Battlefield Enchancement +- The player's battlefield should be the bottom half of the table. +- The opponent's battlefield should be the top half. +- Use `transform-style: preserve-3d` extensively. +- Add a grid/mat texture to the table surface to enhance the depth perception. + +### 3. Card 3D Component (CardComponent.tsx) +- Refactor `CardComponent` to use a 3D structure. +- Add a container for 3D positioning (`translate3d`). +- Add a visual "lift" when dragging or hovering (`translateZ`). +- Enhance the shadow to be on the "table" surface, separating from the card when lifting. + - *Implementation Note*: The shadow might need to be a separate element `after` the card or a separate div to stay on the table plane while the card lifts. + +### 4. Lighting and Atmosphere +- Add a "Light Source" effect (radial gradient overlay). +- Adjust colors to be darker/moodier, fitting the "Dark Gaming UI" aesthetic. + +## Tech Stack +- CSS via Tailwind + Inline Styles for dynamic coordinates. +- React for state/rendering. + +## Execution Order +1. Refactor `GameView.tsx` layout to standard CSS 3D Scene structure. +2. Update `CardComponent.tsx` to handle 3D props (tilt, lift). +3. Fine-tune values for perspective and rotation. diff --git a/src/client/src/modules/game/CardComponent.tsx b/src/client/src/modules/game/CardComponent.tsx index f7818fd..05c06fe 100644 --- a/src/client/src/modules/game/CardComponent.tsx +++ b/src/client/src/modules/game/CardComponent.tsx @@ -5,15 +5,22 @@ interface CardComponentProps { card: CardInstance; onDragStart: (e: React.DragEvent, cardId: string) => void; onClick: (cardId: string) => void; + onContextMenu?: (cardId: string, e: React.MouseEvent) => void; style?: React.CSSProperties; } -export const CardComponent: React.FC = ({ card, onDragStart, onClick, style }) => { +export const CardComponent: React.FC = ({ card, onDragStart, onClick, onContextMenu, style }) => { return (
onDragStart(e, card.instanceId)} onClick={() => onClick(card.instanceId)} + onContextMenu={(e) => { + if (onContextMenu) { + e.preventDefault(); + onContextMenu(card.instanceId, e); + } + }} className={` relative rounded-lg shadow-md cursor-pointer transition-transform hover:scale-105 select-none ${card.tapped ? 'rotate-90' : ''} diff --git a/src/client/src/modules/game/GameContextMenu.tsx b/src/client/src/modules/game/GameContextMenu.tsx new file mode 100644 index 0000000..8f560f0 --- /dev/null +++ b/src/client/src/modules/game/GameContextMenu.tsx @@ -0,0 +1,134 @@ +import React, { useEffect, useState } from 'react'; +import { CardInstance } from '../../types/game'; + +interface ContextMenuRequest { + x: number; + y: number; + type: 'background' | 'card'; + targetId?: string; + card?: CardInstance; +} + +interface GameContextMenuProps { + request: ContextMenuRequest | null; + onClose: () => void; + onAction: (action: string, payload?: any) => void; +} + +export const GameContextMenu: React.FC = ({ request, onClose, onAction }) => { + const [submenu, setSubmenu] = useState(null); + + useEffect(() => { + const handleClickOutside = () => onClose(); + window.addEventListener('click', handleClickOutside); + return () => window.removeEventListener('click', handleClickOutside); + }, [onClose]); + + if (!request) return null; + + const handleAction = (action: string, payload?: any) => { + onAction(action, payload); + onClose(); + }; + + const style: React.CSSProperties = { + position: 'fixed', + top: request.y, + left: request.x, + zIndex: 9999, // Ensure it's above everything + }; + + // Prevent closing when clicking inside the menu + const onMenuClick = (e: React.MouseEvent) => { + e.stopPropagation(); + }; + + return ( +
e.preventDefault()} + > + {request.type === 'card' && request.card && ( + <> +
+ {request.card.name} +
+ handleAction('TAP_CARD', { cardId: request.targetId })} /> + handleAction('FLIP_CARD', { cardId: request.targetId })} /> + +
+ { }} onMouseEnter={() => setSubmenu('counter')} /> + {/* 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 === 'background' && ( + <> +
+ Battlefield +
+ 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 } + })} + /> + 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 } + })} + /> + 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 } + })} + /> + + )} +
+ ); +}; + +const MenuItem: React.FC<{ label: string; onClick: () => void; className?: string; onMouseEnter?: () => void }> = ({ label, onClick, className = '', onMouseEnter }) => ( +
+ {label} +
+); diff --git a/src/client/src/modules/game/GameView.tsx b/src/client/src/modules/game/GameView.tsx index 113ff8a..c1d6939 100644 --- a/src/client/src/modules/game/GameView.tsx +++ b/src/client/src/modules/game/GameView.tsx @@ -1,7 +1,8 @@ -import React from 'react'; +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'; interface GameViewProps { gameState: GameState; @@ -9,19 +10,72 @@ 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); + + useEffect(() => { + // Disable default context menu + const handleContext = (e: MouseEvent) => e.preventDefault(); + document.addEventListener('contextmenu', handleContext); + return () => document.removeEventListener('contextmenu', handleContext); + }, []); + + const handleContextMenu = (e: React.MouseEvent, type: 'background' | 'card', targetId?: string) => { + e.preventDefault(); + const card = targetId ? gameState.cards[targetId] : undefined; + + setContextMenu({ + x: e.clientX, + y: e.clientY, + type, + targetId, + card + }); + }; + + 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; + } + + socketService.socket.emit('game_action', { + roomId: gameState.roomId, + action: { + type: actionType, + ...payload + } + }); + }; const handleDrop = (e: React.DragEvent, zone: CardInstance['zone']) => { e.preventDefault(); const cardId = e.dataTransfer.getData('cardId'); if (!cardId) return; + const action: any = { + type: 'MOVE_CARD', + cardId, + toZone: zone + }; + + // Calculate position if dropped on battlefield + if (zone === 'battlefield' && battlefieldRef.current) { + const rect = battlefieldRef.current.getBoundingClientRect(); + // Calculate relative position (0-100%) + // We clamp values to keep cards somewhat within bounds (0-90 to account for card width) + const rawX = ((e.clientX - rect.left) / rect.width) * 100; + const rawY = ((e.clientY - rect.top) / rect.height) * 100; + + const x = Math.max(0, Math.min(90, rawX)); + const y = Math.max(0, Math.min(85, rawY)); // 85 to ensure bottom of card isn't cut off too much + + action.position = { x, y }; + } + socketService.socket.emit('game_action', { roomId: gameState.roomId, - action: { - type: 'MOVE_CARD', - cardId, - toZone: zone - } + action }); }; @@ -39,12 +93,20 @@ export const GameView: React.FC = ({ gameState, currentPlayerId } }); } + const toggleFlip = (cardId: string) => { + socketService.socket.emit('game_action', { + roomId: gameState.roomId, + action: { + type: 'FLIP_CARD', + cardId + } + }); + } + const myPlayer = gameState.players[currentPlayerId]; - // Simple 1v1 assumption for now, or just taking the first other player const opponentId = Object.keys(gameState.players).find(id => id !== currentPlayerId); const opponent = opponentId ? gameState.players[opponentId] : null; - // Helper to get cards const getCards = (ownerId: string | undefined, zone: string) => { if (!ownerId) return []; return Object.values(gameState.cards).filter(c => c.zone === zone && (c.controllerId === ownerId || c.ownerId === ownerId)); @@ -57,114 +119,219 @@ export const GameView: React.FC = ({ gameState, currentPlayerId } const myExile = getCards(currentPlayerId, 'exile'); const oppBattlefield = getCards(opponentId, 'battlefield'); - const oppHand = getCards(opponentId, 'hand'); // Should be hidden/count only + const oppHand = getCards(opponentId, 'hand'); const oppLibrary = getCards(opponentId, 'library'); + const oppGraveyard = getCards(opponentId, 'graveyard'); + const oppExile = getCards(opponentId, 'exile'); return ( -
+
handleContextMenu(e, 'background')} + > + setContextMenu(null)} + onAction={handleMenuAction} + /> + {/* Top Area: Opponent */} -
-
- {opponent?.name || 'Waiting...'} - Life: {opponent?.life} - Hand: {oppHand.length} | Lib: {oppLibrary.length} +
+ {/* Opponent Hand (Visual) */} +
+ {oppHand.map((_, i) => ( +
+ ))}
- {/* Opponent Battlefield - Just a flex container for now */} -
- {oppBattlefield.map(card => ( - e.dataTransfer.setData('cardId', id)} - onClick={toggleTap} - /> - ))} + {/* Opponent Info Bar */} +
+
+ {opponent?.name || 'Waiting...'} +
+ Hand: {oppHand.length} + Lib: {oppLibrary.length} + Grave: {oppGraveyard.length} + Exile: {oppExile.length} +
+
+
{opponent?.life}
+
+ + {/* Opponent Battlefield (Perspective Reversed or specific layout) */} + {/* For now, we place it "at the back" of the table. */} +
+
+ {oppBattlefield.map(card => ( +
+ { }} + onClick={() => { }} // Maybe inspect? + /> +
+ ))} +
- {/* Middle Area: My Battlefield */} + {/* Middle Area: My Battlefield (The Table) */}
handleDrop(e, 'battlefield')} > -
+
+ {/* Battlefield Texture/Grid (Optional) */} +
+ {myBattlefield.map(card => ( - e.dataTransfer.setData('cardId', id)} - onClick={toggleTap} - /> + className="absolute transition-all duration-200" + style={{ + left: `${card.position?.x || Math.random() * 80}%`, + top: `${card.position?.y || Math.random() * 80}%`, + zIndex: card.position?.z ?? (Math.floor((card.position?.y || 0)) + 10), + }} + > + e.dataTransfer.setData('cardId', id)} + onClick={toggleTap} + onContextMenu={(id, e) => { + e.stopPropagation(); // Stop bubbling to background + handleContextMenu(e, 'card', id); + }} + /> +
))} + + {myBattlefield.length === 0 && ( +
+ Battlefield +
+ )}
{/* Bottom Area: Controls & Hand */} -
+
+ {/* Left Controls: Library/Grave */} -
+
socketService.socket.emit('game_action', { roomId: gameState.roomId, action: { type: 'DRAW_CARD', playerId: currentPlayerId } })} - title="Click to Draw" > -
- Library - {myLibrary.length} +
+ {/* Deck look */} +
+
+ +
+ Library + {myLibrary.length}
+
handleDrop(e, 'graveyard')} >
- Grave - {myGraveyard.length} + Graveyard + {myGraveyard.length}
{/* Hand Area */}
handleDrop(e, 'hand')} > -
- {myHand.map(card => ( - + {myHand.map((card, index) => ( +
e.dataTransfer.setData('cardId', id)} - onClick={toggleTap} - style={{ transformOrigin: 'bottom center' }} - /> + className="transition-all duration-300 hover:-translate-y-12 hover:scale-110 hover:z-50 hover:rotate-0 origin-bottom" + style={{ + transform: `rotate(${(index - (myHand.length - 1) / 2) * 5}deg) translateY(${Math.abs(index - (myHand.length - 1) / 2) * 5}px)`, + zIndex: index + }} + > + e.dataTransfer.setData('cardId', id)} + onClick={toggleTap} + onContextMenu={(id) => toggleFlip(id)} + style={{ transformOrigin: 'bottom center' }} + /> +
))}
{/* Right Controls: Exile / Life */} -
-
-
Your Life
-
{myPlayer?.life}
-
- - +
+
+
Your Life
+
+ {myPlayer?.life} +
+
+ +
handleDrop(e, 'exile')} > - Exile ({myExile.length}) + Exile Drop Zone + {myExile.length}
+
); diff --git a/src/server/managers/GameManager.ts b/src/server/managers/GameManager.ts index 192f57c..ceec9a6 100644 --- a/src/server/managers/GameManager.ts +++ b/src/server/managers/GameManager.ts @@ -30,6 +30,7 @@ interface GameState { order: string[]; // Turn order (player IDs) turn: number; phase: string; + maxZ: number; // Tracker for depth sorting } export class GameManager { @@ -43,6 +44,7 @@ export class GameManager { order: players.map(p => p.id), turn: 1, phase: 'beginning', + maxZ: 100, }; players.forEach(p => { @@ -61,8 +63,6 @@ export class GameManager { gameState.players[gameState.order[0]].isActive = true; } - // TODO: Load decks here. For now, we start with empty board/library. - this.games.set(roomId, gameState); return gameState; } @@ -83,6 +83,18 @@ export class GameManager { case 'TAP_CARD': this.tapCard(game, action); break; + case 'FLIP_CARD': + this.flipCard(game, action); + break; + case 'ADD_COUNTER': + this.addCounter(game, action); + break; + case 'CREATE_TOKEN': + this.createToken(game, action); + break; + case 'DELETE_CARD': + this.deleteCard(game, action); + break; case 'UPDATE_LIFE': this.updateLife(game, action); break; @@ -90,7 +102,7 @@ export class GameManager { this.drawCard(game, action); break; case 'SHUFFLE_LIBRARY': - this.shuffleLibrary(game, action); // Placeholder logic + this.shuffleLibrary(game, action); break; } @@ -100,15 +112,70 @@ export class GameManager { private moveCard(game: GameState, action: { cardId: string; toZone: CardInstance['zone']; position?: { x: number, y: number } }) { const card = game.cards[action.cardId]; if (card) { + // Bring to front + card.position.z = ++game.maxZ; + card.zone = action.toZone; if (action.position) { card.position = { ...card.position, ...action.position }; } - // Reset tapped state if moving to hand/library/graveyard? - if (['hand', 'library', 'graveyard', 'exile'].includes(action.toZone)) { + + // Auto-untap and reveal if moving to public zones (optional, but helpful default) + if (['hand', 'graveyard', 'exile'].includes(action.toZone)) { card.tapped = false; - card.faceDown = action.toZone === 'library'; + card.faceDown = false; } + // Library is usually face down + if (action.toZone === 'library') { + card.faceDown = true; + card.tapped = false; + } + } + } + + private addCounter(game: GameState, action: { cardId: string; counterType: string; amount: number }) { + const card = game.cards[action.cardId]; + if (card) { + const existing = card.counters.find(c => c.type === action.counterType); + if (existing) { + existing.count += action.amount; + // Remove if 0 or less? Usually yes for counters like +1/+1 but let's just keep logic simple + if (existing.count <= 0) { + card.counters = card.counters.filter(c => c.type !== action.counterType); + } + } else if (action.amount > 0) { + card.counters.push({ type: action.counterType, count: action.amount }); + } + } + } + + private createToken(game: GameState, action: { ownerId: string; tokenData: any; position?: { x: number, y: number } }) { + const tokenId = `token-${Math.random().toString(36).substring(7)}`; + // @ts-ignore + const token: CardInstance = { + instanceId: tokenId, + oracleId: 'token', + name: action.tokenData.name || 'Token', + imageUrl: action.tokenData.imageUrl || 'https://cards.scryfall.io/large/front/5/f/5f75e883-2574-4b9e-8fcb-5db3d9579fae.jpg?1692233606', // Generic token image + controllerId: action.ownerId, + ownerId: action.ownerId, + zone: 'battlefield', + tapped: false, + faceDown: false, + position: { + x: action.position?.x || 50, + y: action.position?.y || 50, + z: ++game.maxZ + }, + counters: [], + ptModification: { power: action.tokenData.power || 0, toughness: action.tokenData.toughness || 0 } + }; + game.cards[tokenId] = token; + } + + private deleteCard(game: GameState, action: { cardId: string }) { + if (game.cards[action.cardId]) { + delete game.cards[action.cardId]; } } @@ -119,6 +186,15 @@ export class GameManager { } } + private flipCard(game: GameState, action: { cardId: string }) { + const card = game.cards[action.cardId]; + if (card) { + // Bring to front on flip too + card.position.z = ++game.maxZ; + card.faceDown = !card.faceDown; + } + } + private updateLife(game: GameState, action: { playerId: string; amount: number }) { const player = game.players[action.playerId]; if (player) { @@ -130,18 +206,18 @@ export class GameManager { // Find top card of library for this player const libraryCards = Object.values(game.cards).filter(c => c.ownerId === action.playerId && c.zone === 'library'); if (libraryCards.length > 0) { - // In a real implementation this should be ordered. - // For now, just pick one (random or first). - const card = libraryCards[0]; + // Pick random one (simulating shuffle for now) + const randomIndex = Math.floor(Math.random() * libraryCards.length); + const card = libraryCards[randomIndex]; + card.zone = 'hand'; card.faceDown = false; + card.position.z = ++game.maxZ; } } private shuffleLibrary(_game: GameState, _action: { playerId: string }) { - // In a real implementation we would shuffle the order array. - // Since we retrieve by filtering currently, we don't have order. - // We need to implement order index if we want shuffling. + // No-op in current logic since we pick randomly } // Helper to add cards (e.g. at game start)