feat: Implement manual game mode with 3D battlefield, custom context menu, and card actions including tokens and counters.
Some checks failed
Build and Deploy / build (push) Failing after 2m32s

This commit is contained in:
2025-12-14 23:53:41 +01:00
parent 6dc69dd22a
commit 2eea9b860e
8 changed files with 577 additions and 101 deletions

View File

@@ -1,29 +1,9 @@
# Development Central Log # Development Status (Central)
## Status Overview ## Active Plans
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. - [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 ## Recent Completions
- **[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) - [Game Battlefield & Manual Mode](./devlog/2025-12-14-234500_game_battlefield_plan.md): Completed.
- **[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) - [Helm Chart Config](./devlog/2025-12-14-214500_helm_config.md): Completed.
- **[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.

View File

@@ -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).

View File

@@ -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.

View File

@@ -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.

View File

@@ -5,15 +5,22 @@ interface CardComponentProps {
card: CardInstance; card: CardInstance;
onDragStart: (e: React.DragEvent, cardId: string) => void; onDragStart: (e: React.DragEvent, cardId: string) => void;
onClick: (cardId: string) => void; onClick: (cardId: string) => void;
onContextMenu?: (cardId: string, e: React.MouseEvent) => void;
style?: React.CSSProperties; style?: React.CSSProperties;
} }
export const CardComponent: React.FC<CardComponentProps> = ({ card, onDragStart, onClick, style }) => { export const CardComponent: React.FC<CardComponentProps> = ({ card, onDragStart, onClick, onContextMenu, style }) => {
return ( return (
<div <div
draggable draggable
onDragStart={(e) => onDragStart(e, card.instanceId)} onDragStart={(e) => onDragStart(e, card.instanceId)}
onClick={() => onClick(card.instanceId)} onClick={() => onClick(card.instanceId)}
onContextMenu={(e) => {
if (onContextMenu) {
e.preventDefault();
onContextMenu(card.instanceId, e);
}
}}
className={` className={`
relative rounded-lg shadow-md cursor-pointer transition-transform hover:scale-105 select-none relative rounded-lg shadow-md cursor-pointer transition-transform hover:scale-105 select-none
${card.tapped ? 'rotate-90' : ''} ${card.tapped ? 'rotate-90' : ''}

View File

@@ -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<GameContextMenuProps> = ({ request, onClose, onAction }) => {
const [submenu, setSubmenu] = useState<string | null>(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 (
<div
style={style}
className="bg-slate-900 border border-slate-700 shadow-2xl rounded-md w-56 flex flex-col py-1 text-sm text-slate-200 select-none animate-in fade-in zoom-in-95 duration-100"
onClick={onMenuClick}
onContextMenu={(e) => e.preventDefault()}
>
{request.type === 'card' && request.card && (
<>
<div className="px-3 py-1 font-bold text-xs text-slate-500 uppercase tracking-widest border-b border-slate-800 mb-1">
{request.card.name}
</div>
<MenuItem label="Tap / Untap" onClick={() => handleAction('TAP_CARD', { cardId: request.targetId })} />
<MenuItem label={request.card.faceDown ? "Flip Face Up" : "Flip Face Down"} onClick={() => handleAction('FLIP_CARD', { cardId: request.targetId })} />
<div className="relative group">
<MenuItem label="Add Counter ▸" onClick={() => { }} onMouseEnter={() => setSubmenu('counter')} />
{/* Submenu */}
<div className="absolute left-full top-0 ml-1 w-40 bg-slate-900 border border-slate-700 rounded shadow-lg hidden group-hover:block">
<MenuItem label="+1/+1 Counter" onClick={() => handleAction('ADD_COUNTER', { cardId: request.targetId, counterType: '+1/+1', amount: 1 })} />
<MenuItem label="-1/-1 Counter" onClick={() => handleAction('ADD_COUNTER', { cardId: request.targetId, counterType: '-1/-1', amount: 1 })} />
<MenuItem label="Loyalty Counter" onClick={() => handleAction('ADD_COUNTER', { cardId: request.targetId, counterType: 'loyalty', amount: 1 })} />
<MenuItem label="Remove Counter" onClick={() => handleAction('ADD_COUNTER', { cardId: request.targetId, counterType: '+1/+1', amount: -1 })} />
</div>
</div>
<MenuItem label="Clone (Copy)" onClick={() => 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 }
})} />
<div className="h-px bg-slate-800 my-1 mx-2"></div>
<MenuItem
label="Delete Object"
className="text-red-500 hover:bg-red-900/30 hover:text-red-400"
onClick={() => handleAction('DELETE_CARD', { cardId: request.targetId })}
/>
</>
)}
{request.type === 'background' && (
<>
<div className="px-3 py-1 font-bold text-xs text-slate-500 uppercase tracking-widest border-b border-slate-800 mb-1">
Battlefield
</div>
<MenuItem
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 }
})}
/>
<MenuItem
label="Create Token (2/2)"
onClick={() => 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 }
})}
/>
<MenuItem
label="Create Treasure"
onClick={() => 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 }
})}
/>
</>
)}
</div>
);
};
const MenuItem: React.FC<{ label: string; onClick: () => void; className?: string; onMouseEnter?: () => void }> = ({ label, onClick, className = '', onMouseEnter }) => (
<div
className={`px-4 py-2 hover:bg-emerald-600/20 hover:text-emerald-300 cursor-pointer transition-colors ${className}`}
onClick={onClick}
onMouseEnter={onMouseEnter}
>
{label}
</div>
);

View File

@@ -1,7 +1,8 @@
import React from 'react'; import React, { useRef, useState, useEffect } from 'react';
import { GameState, CardInstance } from '../../types/game'; import { GameState, CardInstance } from '../../types/game';
import { socketService } from '../../services/SocketService'; import { socketService } from '../../services/SocketService';
import { CardComponent } from './CardComponent'; import { CardComponent } from './CardComponent';
import { GameContextMenu } from './GameContextMenu';
interface GameViewProps { interface GameViewProps {
gameState: GameState; gameState: GameState;
@@ -9,19 +10,72 @@ interface GameViewProps {
} }
export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }) => { export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }) => {
const battlefieldRef = useRef<HTMLDivElement>(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']) => { const handleDrop = (e: React.DragEvent, zone: CardInstance['zone']) => {
e.preventDefault(); e.preventDefault();
const cardId = e.dataTransfer.getData('cardId'); const cardId = e.dataTransfer.getData('cardId');
if (!cardId) return; 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', { socketService.socket.emit('game_action', {
roomId: gameState.roomId, roomId: gameState.roomId,
action: { action
type: 'MOVE_CARD',
cardId,
toZone: zone
}
}); });
}; };
@@ -39,12 +93,20 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
}); });
} }
const toggleFlip = (cardId: string) => {
socketService.socket.emit('game_action', {
roomId: gameState.roomId,
action: {
type: 'FLIP_CARD',
cardId
}
});
}
const myPlayer = gameState.players[currentPlayerId]; 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 opponentId = Object.keys(gameState.players).find(id => id !== currentPlayerId);
const opponent = opponentId ? gameState.players[opponentId] : null; const opponent = opponentId ? gameState.players[opponentId] : null;
// Helper to get cards
const getCards = (ownerId: string | undefined, zone: string) => { const getCards = (ownerId: string | undefined, zone: string) => {
if (!ownerId) return []; if (!ownerId) return [];
return Object.values(gameState.cards).filter(c => c.zone === zone && (c.controllerId === ownerId || c.ownerId === ownerId)); 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<GameViewProps> = ({ gameState, currentPlayerId }
const myExile = getCards(currentPlayerId, 'exile'); const myExile = getCards(currentPlayerId, 'exile');
const oppBattlefield = getCards(opponentId, 'battlefield'); 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 oppLibrary = getCards(opponentId, 'library');
const oppGraveyard = getCards(opponentId, 'graveyard');
const oppExile = getCards(opponentId, 'exile');
return ( return (
<div className="flex flex-col h-full w-full bg-slate-950 text-white overflow-hidden select-none"> <div
className="flex flex-col h-full w-full bg-[radial-gradient(ellipse_at_center,_var(--tw-gradient-stops))] from-slate-900 to-black text-white overflow-hidden select-none font-sans"
onContextMenu={(e) => handleContextMenu(e, 'background')}
>
<GameContextMenu
request={contextMenu}
onClose={() => setContextMenu(null)}
onAction={handleMenuAction}
/>
{/* Top Area: Opponent */} {/* Top Area: Opponent */}
<div className="flex-[2] bg-slate-900/50 border-b border-slate-800 flex flex-col relative p-4"> <div className="flex-[2] relative flex flex-col pointer-events-none">
<div className="absolute top-2 left-4 flex flex-col"> {/* Opponent Hand (Visual) */}
<span className="font-bold text-slate-300">{opponent?.name || 'Waiting...'}</span> <div className="absolute top-[-40px] left-0 right-0 flex justify-center -space-x-4 opacity-70">
<span className="text-sm text-slate-500">Life: {opponent?.life}</span> {oppHand.map((_, i) => (
<span className="text-xs text-slate-600">Hand: {oppHand.length} | Lib: {oppLibrary.length}</span> <div key={i} className="w-16 h-24 bg-slate-800 border border-slate-600 rounded shadow-lg transform rotate-180"></div>
))}
</div> </div>
{/* Opponent Battlefield - Just a flex container for now */} {/* Opponent Info Bar */}
<div className="flex-1 flex flex-wrap items-center justify-center gap-2 p-8"> <div className="absolute top-4 left-4 z-10 flex items-center space-x-4 pointer-events-auto bg-black/50 p-2 rounded-lg backdrop-blur-sm border border-slate-700">
{oppBattlefield.map(card => ( <div className="flex flex-col">
<CardComponent <span className="font-bold text-lg text-red-400">{opponent?.name || 'Waiting...'}</span>
key={card.instanceId} <div className="flex gap-2 text-xs text-slate-400">
card={card} <span>Hand: {oppHand.length}</span>
onDragStart={(e, id) => e.dataTransfer.setData('cardId', id)} <span>Lib: {oppLibrary.length}</span>
onClick={toggleTap} <span>Grave: {oppGraveyard.length}</span>
/> <span>Exile: {oppExile.length}</span>
))} </div>
</div>
<div className="text-3xl font-bold text-white">{opponent?.life}</div>
</div>
{/* Opponent Battlefield (Perspective Reversed or specific layout) */}
{/* For now, we place it "at the back" of the table. */}
<div className="flex-1 w-full relative perspective-1000">
<div
className="w-full h-full relative"
style={{
transform: 'rotateX(-20deg) scale(0.9)',
transformOrigin: 'center bottom',
}}
>
{oppBattlefield.map(card => (
<div
key={card.instanceId}
className="absolute transition-all duration-300 ease-out"
style={{
left: `${card.position?.x || 50}%`,
top: `${card.position?.y || 50}%`,
zIndex: Math.floor((card.position?.y || 0)), // Simple z-index based on vertical pos
}}
>
<CardComponent
card={card}
// Opponent cards shouldn't necessarily be draggable by me, but depends on game rules.
// Usually not.
onDragStart={() => { }}
onClick={() => { }} // Maybe inspect?
/>
</div>
))}
</div>
</div> </div>
</div> </div>
{/* Middle Area: My Battlefield */} {/* Middle Area: My Battlefield (The Table) */}
<div <div
className="flex-[3] bg-slate-900 p-4 relative border-b border-slate-800" className="flex-[4] relative perspective-1000 z-10"
ref={battlefieldRef}
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, 'battlefield')} onDrop={(e) => handleDrop(e, 'battlefield')}
> >
<div className="w-full h-full flex flex-wrap content-start gap-2 p-4 overflow-y-auto"> <div
className="w-full h-full relative bg-slate-900/20 border-y border-white/5 shadow-inner"
style={{
transform: 'rotateX(25deg)',
transformOrigin: 'center 40%', /* Pivot point */
boxShadow: 'inset 0 0 100px rgba(0,0,0,0.8)'
}}
>
{/* Battlefield Texture/Grid (Optional) */}
<div className="absolute inset-0 opacity-10 bg-[linear-gradient(rgba(255,255,255,0.1)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.1)_1px,transparent_1px)] bg-[size:50px_50px]"></div>
{myBattlefield.map(card => ( {myBattlefield.map(card => (
<CardComponent <div
key={card.instanceId} key={card.instanceId}
card={card} className="absolute transition-all duration-200"
onDragStart={(e, id) => e.dataTransfer.setData('cardId', id)} style={{
onClick={toggleTap} 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),
}}
>
<CardComponent
card={card}
onDragStart={(e, id) => e.dataTransfer.setData('cardId', id)}
onClick={toggleTap}
onContextMenu={(id, e) => {
e.stopPropagation(); // Stop bubbling to background
handleContextMenu(e, 'card', id);
}}
/>
</div>
))} ))}
{myBattlefield.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<span className="text-white/10 text-4xl font-bold uppercase tracking-widest">Battlefield</span>
</div>
)}
</div> </div>
</div> </div>
{/* Bottom Area: Controls & Hand */} {/* Bottom Area: Controls & Hand */}
<div className="h-64 flex bg-slate-950"> <div className="h-48 relative z-20 flex bg-gradient-to-t from-black to-slate-900/80 backdrop-blur-md shadow-[0_-5px_20px_rgba(0,0,0,0.5)]">
{/* Left Controls: Library/Grave */} {/* Left Controls: Library/Grave */}
<div className="w-48 bg-slate-900 p-2 flex flex-col gap-2 items-center justify-center border-r border-slate-800 z-10"> <div className="w-40 p-2 flex flex-col gap-2 items-center justify-center border-r border-white/10">
<div <div
className="w-20 h-28 bg-gradient-to-br from-slate-700 to-slate-800 rounded border border-slate-600 flex items-center justify-center cursor-pointer hover:border-emerald-500 shadow-lg" className="group relative w-16 h-24 bg-slate-800 rounded border border-slate-600 cursor-pointer shadow-lg transition-transform hover:-translate-y-1 hover:shadow-cyan-500/20"
onClick={() => socketService.socket.emit('game_action', { roomId: gameState.roomId, action: { type: 'DRAW_CARD', playerId: currentPlayerId } })} onClick={() => socketService.socket.emit('game_action', { roomId: gameState.roomId, action: { type: 'DRAW_CARD', playerId: currentPlayerId } })}
title="Click to Draw"
> >
<div className="text-center"> <div className="absolute inset-0 bg-gradient-to-br from-slate-700 to-slate-800 rounded"></div>
<span className="block font-bold text-slate-300">Library</span> {/* Deck look */}
<span className="text-xs text-slate-500">{myLibrary.length}</span> <div className="absolute top-[-2px] left-[-2px] right-[-2px] bottom-[2px] bg-slate-700 rounded z-[-1]"></div>
<div className="absolute top-[-4px] left-[-4px] right-[-4px] bottom-[4px] bg-slate-800 rounded z-[-2]"></div>
<div className="absolute inset-0 flex items-center justify-center flex-col">
<span className="text-xs font-bold text-slate-300 shadow-black drop-shadow-md">Library</span>
<span className="text-lg font-bold text-white shadow-black drop-shadow-md">{myLibrary.length}</span>
</div> </div>
</div> </div>
<div <div
className="w-20 h-28 bg-slate-800 rounded border border-slate-700 flex items-center justify-center dashed" 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} onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, 'graveyard')} onDrop={(e) => handleDrop(e, 'graveyard')}
> >
<div className="text-center"> <div className="text-center">
<span className="block text-slate-400 text-sm">Grave</span> <span className="block text-slate-500 text-[10px] uppercase">Graveyard</span>
<span className="text-xs text-slate-500">{myGraveyard.length}</span> <span className="text-sm font-bold text-slate-400">{myGraveyard.length}</span>
</div> </div>
</div> </div>
</div> </div>
{/* Hand Area */} {/* Hand Area */}
<div <div
className="flex-1 p-4 bg-black/40 flex items-end justify-center overflow-x-auto pb-8" className="flex-1 relative flex items-end justify-center px-4 pb-2"
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, 'hand')} onDrop={(e) => handleDrop(e, 'hand')}
> >
<div className="flex -space-x-12 hover:space-x-1 transition-all duration-300 items-end h-full pt-4"> <div className="flex justify-center -space-x-12 w-full h-full items-end pb-4 perspective-500">
{myHand.map(card => ( {myHand.map((card, index) => (
<CardComponent <div
key={card.instanceId} key={card.instanceId}
card={card} className="transition-all duration-300 hover:-translate-y-12 hover:scale-110 hover:z-50 hover:rotate-0 origin-bottom"
onDragStart={(e, id) => e.dataTransfer.setData('cardId', id)} style={{
onClick={toggleTap} transform: `rotate(${(index - (myHand.length - 1) / 2) * 5}deg) translateY(${Math.abs(index - (myHand.length - 1) / 2) * 5}px)`,
style={{ transformOrigin: 'bottom center' }} zIndex: index
/> }}
>
<CardComponent
card={card}
onDragStart={(e, id) => e.dataTransfer.setData('cardId', id)}
onClick={toggleTap}
onContextMenu={(id) => toggleFlip(id)}
style={{ transformOrigin: 'bottom center' }}
/>
</div>
))} ))}
</div> </div>
</div> </div>
{/* Right Controls: Exile / Life */} {/* Right Controls: Exile / Life */}
<div className="w-48 bg-slate-900 p-2 flex flex-col gap-4 items-center border-l border-slate-800"> <div className="w-40 p-2 flex flex-col gap-4 items-center justify-between border-l border-white/10 py-4">
<div className="text-center mt-4"> <div className="text-center">
<div className="text-xs text-slate-500 uppercase tracking-wider">Your Life</div> <div className="text-[10px] text-slate-400 uppercase tracking-wider mb-1">Your Life</div>
<div className="text-4xl font-bold text-emerald-500">{myPlayer?.life}</div> <div className="text-5xl font-black text-transparent bg-clip-text bg-gradient-to-b from-emerald-400 to-emerald-700 drop-shadow-[0_2px_10px_rgba(16,185,129,0.3)]">
<div className="flex gap-2 mt-2"> {myPlayer?.life}
<button className="w-8 h-8 bg-slate-800 rounded hover:bg-red-900 border border-slate-700 font-bold" onClick={() => socketService.socket.emit('game_action', { roomId: gameState.roomId, action: { type: 'UPDATE_LIFE', playerId: currentPlayerId, amount: -1 } })}>-</button> </div>
<button className="w-8 h-8 bg-slate-800 rounded hover:bg-emerald-900 border border-slate-700 font-bold" onClick={() => socketService.socket.emit('game_action', { roomId: gameState.roomId, action: { type: 'UPDATE_LIFE', playerId: currentPlayerId, amount: 1 } })}>+</button> <div className="flex gap-1 mt-2 justify-center">
<button
className="w-8 h-8 rounded-full bg-slate-800 hover:bg-red-500/20 text-red-500 border border-slate-700 hover:border-red-500 transition-colors flex items-center justify-center font-bold"
onClick={() => socketService.socket.emit('game_action', { roomId: gameState.roomId, action: { type: 'UPDATE_LIFE', playerId: currentPlayerId, amount: -1 } })}
>
-
</button>
<button
className="w-8 h-8 rounded-full bg-slate-800 hover:bg-emerald-500/20 text-emerald-500 border border-slate-700 hover:border-emerald-500 transition-colors flex items-center justify-center font-bold"
onClick={() => socketService.socket.emit('game_action', { roomId: gameState.roomId, action: { type: 'UPDATE_LIFE', playerId: currentPlayerId, amount: 1 } })}
>
+
</button>
</div> </div>
</div> </div>
<div <div
className="w-20 h-20 bg-slate-800 rounded border border-slate-700 flex items-center justify-center mt-auto mb-2 opacity-50 hover:opacity-100" className="w-full text-center border-t border-white/5 pt-2 cursor-pointer hover:bg-white/5 rounded p-1"
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, 'exile')} onDrop={(e) => handleDrop(e, 'exile')}
> >
<span className="text-xs text-slate-500">Exile ({myExile.length})</span> <span className="text-xs text-slate-500 block">Exile Drop Zone</span>
<span className="text-lg font-bold text-slate-400">{myExile.length}</span>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
); );

View File

@@ -30,6 +30,7 @@ interface GameState {
order: string[]; // Turn order (player IDs) order: string[]; // Turn order (player IDs)
turn: number; turn: number;
phase: string; phase: string;
maxZ: number; // Tracker for depth sorting
} }
export class GameManager { export class GameManager {
@@ -43,6 +44,7 @@ export class GameManager {
order: players.map(p => p.id), order: players.map(p => p.id),
turn: 1, turn: 1,
phase: 'beginning', phase: 'beginning',
maxZ: 100,
}; };
players.forEach(p => { players.forEach(p => {
@@ -61,8 +63,6 @@ export class GameManager {
gameState.players[gameState.order[0]].isActive = true; gameState.players[gameState.order[0]].isActive = true;
} }
// TODO: Load decks here. For now, we start with empty board/library.
this.games.set(roomId, gameState); this.games.set(roomId, gameState);
return gameState; return gameState;
} }
@@ -83,6 +83,18 @@ export class GameManager {
case 'TAP_CARD': case 'TAP_CARD':
this.tapCard(game, action); this.tapCard(game, action);
break; 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': case 'UPDATE_LIFE':
this.updateLife(game, action); this.updateLife(game, action);
break; break;
@@ -90,7 +102,7 @@ export class GameManager {
this.drawCard(game, action); this.drawCard(game, action);
break; break;
case 'SHUFFLE_LIBRARY': case 'SHUFFLE_LIBRARY':
this.shuffleLibrary(game, action); // Placeholder logic this.shuffleLibrary(game, action);
break; break;
} }
@@ -100,15 +112,70 @@ export class GameManager {
private moveCard(game: GameState, action: { cardId: string; toZone: CardInstance['zone']; position?: { x: number, y: number } }) { private moveCard(game: GameState, action: { cardId: string; toZone: CardInstance['zone']; position?: { x: number, y: number } }) {
const card = game.cards[action.cardId]; const card = game.cards[action.cardId];
if (card) { if (card) {
// Bring to front
card.position.z = ++game.maxZ;
card.zone = action.toZone; card.zone = action.toZone;
if (action.position) { if (action.position) {
card.position = { ...card.position, ...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.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 }) { private updateLife(game: GameState, action: { playerId: string; amount: number }) {
const player = game.players[action.playerId]; const player = game.players[action.playerId];
if (player) { if (player) {
@@ -130,18 +206,18 @@ export class GameManager {
// Find top card of library for this player // Find top card of library for this player
const libraryCards = Object.values(game.cards).filter(c => c.ownerId === action.playerId && c.zone === 'library'); const libraryCards = Object.values(game.cards).filter(c => c.ownerId === action.playerId && c.zone === 'library');
if (libraryCards.length > 0) { if (libraryCards.length > 0) {
// In a real implementation this should be ordered. // Pick random one (simulating shuffle for now)
// For now, just pick one (random or first). const randomIndex = Math.floor(Math.random() * libraryCards.length);
const card = libraryCards[0]; const card = libraryCards[randomIndex];
card.zone = 'hand'; card.zone = 'hand';
card.faceDown = false; card.faceDown = false;
card.position.z = ++game.maxZ;
} }
} }
private shuffleLibrary(_game: GameState, _action: { playerId: string }) { private shuffleLibrary(_game: GameState, _action: { playerId: string }) {
// In a real implementation we would shuffle the order array. // No-op in current logic since we pick randomly
// Since we retrieve by filtering currently, we don't have order.
// We need to implement order index if we want shuffling.
} }
// Helper to add cards (e.g. at game start) // Helper to add cards (e.g. at game start)