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
Some checks failed
Build and Deploy / build (push) Failing after 2m32s
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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<CardComponentProps> = ({ card, onDragStart, onClick, style }) => {
|
||||
export const CardComponent: React.FC<CardComponentProps> = ({ card, onDragStart, onClick, onContextMenu, style }) => {
|
||||
return (
|
||||
<div
|
||||
draggable
|
||||
onDragStart={(e) => 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' : ''}
|
||||
|
||||
134
src/client/src/modules/game/GameContextMenu.tsx
Normal file
134
src/client/src/modules/game/GameContextMenu.tsx
Normal 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>
|
||||
);
|
||||
@@ -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<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']) => {
|
||||
e.preventDefault();
|
||||
const cardId = e.dataTransfer.getData('cardId');
|
||||
if (!cardId) return;
|
||||
|
||||
socketService.socket.emit('game_action', {
|
||||
roomId: gameState.roomId,
|
||||
action: {
|
||||
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
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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];
|
||||
// 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<GameViewProps> = ({ 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 (
|
||||
<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 */}
|
||||
<div className="flex-[2] bg-slate-900/50 border-b border-slate-800 flex flex-col relative p-4">
|
||||
<div className="absolute top-2 left-4 flex flex-col">
|
||||
<span className="font-bold text-slate-300">{opponent?.name || 'Waiting...'}</span>
|
||||
<span className="text-sm text-slate-500">Life: {opponent?.life}</span>
|
||||
<span className="text-xs text-slate-600">Hand: {oppHand.length} | Lib: {oppLibrary.length}</span>
|
||||
<div className="flex-[2] relative flex flex-col pointer-events-none">
|
||||
{/* Opponent Hand (Visual) */}
|
||||
<div className="absolute top-[-40px] left-0 right-0 flex justify-center -space-x-4 opacity-70">
|
||||
{oppHand.map((_, i) => (
|
||||
<div key={i} className="w-16 h-24 bg-slate-800 border border-slate-600 rounded shadow-lg transform rotate-180"></div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Opponent Battlefield - Just a flex container for now */}
|
||||
<div className="flex-1 flex flex-wrap items-center justify-center gap-2 p-8">
|
||||
{/* Opponent Info Bar */}
|
||||
<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">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-bold text-lg text-red-400">{opponent?.name || 'Waiting...'}</span>
|
||||
<div className="flex gap-2 text-xs text-slate-400">
|
||||
<span>Hand: {oppHand.length}</span>
|
||||
<span>Lib: {oppLibrary.length}</span>
|
||||
<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 => (
|
||||
<CardComponent
|
||||
<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}
|
||||
onDragStart={(e, id) => e.dataTransfer.setData('cardId', id)}
|
||||
onClick={toggleTap}
|
||||
// Opponent cards shouldn't necessarily be draggable by me, but depends on game rules.
|
||||
// Usually not.
|
||||
onDragStart={() => { }}
|
||||
onClick={() => { }} // Maybe inspect?
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Middle Area: My Battlefield */}
|
||||
{/* Middle Area: My Battlefield (The Table) */}
|
||||
<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}
|
||||
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 => (
|
||||
<CardComponent
|
||||
<div
|
||||
key={card.instanceId}
|
||||
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),
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
|
||||
{/* 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 */}
|
||||
<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
|
||||
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 } })}
|
||||
title="Click to Draw"
|
||||
>
|
||||
<div className="text-center">
|
||||
<span className="block font-bold text-slate-300">Library</span>
|
||||
<span className="text-xs text-slate-500">{myLibrary.length}</span>
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-slate-700 to-slate-800 rounded"></div>
|
||||
{/* Deck look */}
|
||||
<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
|
||||
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}
|
||||
onDrop={(e) => handleDrop(e, 'graveyard')}
|
||||
>
|
||||
<div className="text-center">
|
||||
<span className="block text-slate-400 text-sm">Grave</span>
|
||||
<span className="text-xs text-slate-500">{myGraveyard.length}</span>
|
||||
<span className="block text-slate-500 text-[10px] uppercase">Graveyard</span>
|
||||
<span className="text-sm font-bold text-slate-400">{myGraveyard.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hand Area */}
|
||||
<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}
|
||||
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">
|
||||
{myHand.map(card => (
|
||||
<CardComponent
|
||||
<div className="flex justify-center -space-x-12 w-full h-full items-end pb-4 perspective-500">
|
||||
{myHand.map((card, index) => (
|
||||
<div
|
||||
key={card.instanceId}
|
||||
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
|
||||
}}
|
||||
>
|
||||
<CardComponent
|
||||
card={card}
|
||||
onDragStart={(e, id) => e.dataTransfer.setData('cardId', id)}
|
||||
onClick={toggleTap}
|
||||
onContextMenu={(id) => toggleFlip(id)}
|
||||
style={{ transformOrigin: 'bottom center' }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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="text-center mt-4">
|
||||
<div className="text-xs text-slate-500 uppercase tracking-wider">Your Life</div>
|
||||
<div className="text-4xl font-bold text-emerald-500">{myPlayer?.life}</div>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<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>
|
||||
<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="w-40 p-2 flex flex-col gap-4 items-center justify-between border-l border-white/10 py-4">
|
||||
<div className="text-center">
|
||||
<div className="text-[10px] text-slate-400 uppercase tracking-wider mb-1">Your 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)]">
|
||||
{myPlayer?.life}
|
||||
</div>
|
||||
<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
|
||||
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}
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user