Compare commits
2 Commits
2eea9b860e
...
dd9f19aff7
| Author | SHA1 | Date | |
|---|---|---|---|
| dd9f19aff7 | |||
| b13627363f |
@@ -2,6 +2,7 @@
|
||||
|
||||
## 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.
|
||||
- [Deck Tester Feature](./devlog/2025-12-15-002500_deck_tester_feature.md): Completed. Implemented a dedicated view to parse custom decks and instantly launch the 3D game sandbox.
|
||||
- [Game Context Menu & Immersion](./devlog/2025-12-14-235000_game_context_menu.md): Completed. Implemented custom right-click menus and game-feel enhancements.
|
||||
|
||||
## Recent Completions
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# Deck Tester Feature Implementation
|
||||
|
||||
## Objective
|
||||
Create a way to add a cards list to generate a deck and directly enter the game ui to test the imported deck, using the same exact game and battlefield of the draft.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Frontend
|
||||
1. **DeckTester Component (`src/client/src/modules/tester/DeckTester.tsx`)**:
|
||||
- Created a new component that allows users to input a deck list (text area or file upload).
|
||||
- Reused `CardParserService` and `ScryfallService` to parse the list and fetch card data.
|
||||
- Implemented image caching logic (sending to `/api/cards/cache`).
|
||||
- Connects to socket and emits `start_solo_test`.
|
||||
- Upon success, switches view to `GameRoom` with the received `room` and `game` state.
|
||||
|
||||
2. **App Integration (`src/client/src/App.tsx`)**:
|
||||
- Added a new "Deck Tester" tab to the main navigation.
|
||||
- Uses the `Play` icon from lucide-react.
|
||||
|
||||
3. **GameRoom Enhancement (`src/client/src/modules/lobby/GameRoom.tsx`)**:
|
||||
- Added `initialGameState` prop to allow initializing the `GameView` immediately without waiting for a socket update (handling potential race conditions or state sync delays).
|
||||
|
||||
### Backend
|
||||
1. **Socket Event (`src/server/index.ts`)**:
|
||||
- Added `start_solo_test` event handler.
|
||||
- Creates a room with status `playing`.
|
||||
- Initializes a game instance.
|
||||
- Adds cards from the provided deck list to the game (library zone).
|
||||
- Emits `room_update` and `game_update` to the client.
|
||||
|
||||
## Outcome
|
||||
The user can now navigate to "Deck Tester", paste a deck list, and immediately enter the 3D Game View to test interactions on the battlefield. This reuses the entire Draft Game infrastructure, ensuring consistency.
|
||||
@@ -1,12 +1,13 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Layers, Box, Trophy, Users } from 'lucide-react';
|
||||
import { Layers, Box, Trophy, Users, Play } from 'lucide-react';
|
||||
import { CubeManager } from './modules/cube/CubeManager';
|
||||
import { TournamentManager } from './modules/tournament/TournamentManager';
|
||||
import { LobbyManager } from './modules/lobby/LobbyManager';
|
||||
import { DeckTester } from './modules/tester/DeckTester';
|
||||
import { Pack } from './services/PackGeneratorService';
|
||||
|
||||
export const App: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState<'draft' | 'bracket' | 'lobby'>('draft');
|
||||
const [activeTab, setActiveTab] = useState<'draft' | 'bracket' | 'lobby' | 'tester'>('draft');
|
||||
const [generatedPacks, setGeneratedPacks] = useState<Pack[]>([]);
|
||||
|
||||
return (
|
||||
@@ -34,6 +35,12 @@ export const App: React.FC = () => {
|
||||
>
|
||||
<Users className="w-4 h-4" /> <span className="hidden md:inline">Online Lobby</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('tester')}
|
||||
className={`px-3 md:px-4 py-2 rounded-md text-sm font-bold flex items-center gap-2 transition-all ${activeTab === 'tester' ? 'bg-teal-600 text-white' : 'text-slate-400 hover:text-white'}`}
|
||||
>
|
||||
<Play className="w-4 h-4" /> <span className="hidden md:inline">Deck Tester</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('bracket')}
|
||||
className={`px-3 md:px-4 py-2 rounded-md text-sm font-bold flex items-center gap-2 transition-all ${activeTab === 'bracket' ? 'bg-blue-600 text-white' : 'text-slate-400 hover:text-white'}`}
|
||||
@@ -53,6 +60,7 @@ export const App: React.FC = () => {
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'lobby' && <LobbyManager generatedPacks={generatedPacks} />}
|
||||
{activeTab === 'tester' && <DeckTester />}
|
||||
{activeTab === 'bracket' && <TournamentManager />}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import { CardInstance } from '../../types/game';
|
||||
|
||||
interface ContextMenuRequest {
|
||||
export interface ContextMenuRequest {
|
||||
x: number;
|
||||
y: number;
|
||||
type: 'background' | 'card';
|
||||
targetId?: string;
|
||||
type: 'background' | 'card' | 'zone';
|
||||
targetId?: string; // cardId or zoneName
|
||||
card?: CardInstance;
|
||||
zone?: string; // 'library', 'graveyard', 'exile', 'hand'
|
||||
}
|
||||
|
||||
interface GameContextMenuProps {
|
||||
@@ -16,7 +17,6 @@ interface GameContextMenuProps {
|
||||
}
|
||||
|
||||
export const GameContextMenu: React.FC<GameContextMenuProps> = ({ request, onClose, onAction }) => {
|
||||
const [submenu, setSubmenu] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = () => onClose();
|
||||
@@ -33,9 +33,9 @@ export const GameContextMenu: React.FC<GameContextMenuProps> = ({ request, onClo
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
position: 'fixed',
|
||||
top: request.y,
|
||||
left: request.x,
|
||||
zIndex: 9999, // Ensure it's above everything
|
||||
top: Math.min(request.y, window.innerHeight - 300), // Prevent going off bottom
|
||||
left: Math.min(request.x, window.innerWidth - 224), // Prevent going off right (w-56 = 224px)
|
||||
zIndex: 9999,
|
||||
};
|
||||
|
||||
// Prevent closing when clicking inside the menu
|
||||
@@ -43,6 +43,131 @@ export const GameContextMenu: React.FC<GameContextMenuProps> = ({ request, onClo
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const renderCardMenu = (card: CardInstance) => {
|
||||
const zone = card.zone;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="px-3 py-1 font-bold text-xs text-slate-500 uppercase tracking-widest border-b border-slate-800 mb-1 flex justify-between items-center">
|
||||
<span className="truncate max-w-[120px]">{card.name}</span>
|
||||
<span className="text-[10px] bg-slate-800 px-1 rounded text-slate-400 capitalize">{zone}</span>
|
||||
</div>
|
||||
|
||||
{/* Hand Menu */}
|
||||
{zone === 'hand' && (
|
||||
<>
|
||||
<MenuItem label="Play (Battlefield)" onClick={() => handleAction('MOVE_CARD', { cardId: card.instanceId, toZone: 'battlefield', position: { x: 50, y: 50 } })} />
|
||||
<MenuItem label="Discard" onClick={() => handleAction('MOVE_CARD', { cardId: card.instanceId, toZone: 'graveyard' })} />
|
||||
<MenuItem label="Exile" onClick={() => handleAction('MOVE_CARD', { cardId: card.instanceId, toZone: 'exile' })} />
|
||||
<div className="h-px bg-slate-800 my-1 mx-2"></div>
|
||||
<MenuItem label="To Library (Top)" onClick={() => handleAction('MOVE_CARD', { cardId: card.instanceId, toZone: 'library', position: 'top' })} />
|
||||
<MenuItem label="To Library (Bottom)" onClick={() => handleAction('MOVE_CARD', { cardId: card.instanceId, toZone: 'library', position: 'bottom' })} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Battlefield Menu */}
|
||||
{zone === 'battlefield' && (
|
||||
<>
|
||||
<MenuItem label="Tap / Untap" onClick={() => handleAction('TAP_CARD', { cardId: card.instanceId })} />
|
||||
<MenuItem label={card.faceDown ? "Flip Face Up" : "Flip Face Down"} onClick={() => handleAction('FLIP_CARD', { cardId: card.instanceId })} />
|
||||
|
||||
<div className="relative group">
|
||||
<MenuItem label="Add Counter ▸" onClick={() => { }} />
|
||||
<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 z-50">
|
||||
<MenuItem label="+1/+1 Counter" onClick={() => handleAction('ADD_COUNTER', { cardId: card.instanceId, counterType: '+1/+1', amount: 1 })} />
|
||||
<MenuItem label="-1/-1 Counter" onClick={() => handleAction('ADD_COUNTER', { cardId: card.instanceId, counterType: '-1/-1', amount: 1 })} />
|
||||
<MenuItem label="Loyalty Counter" onClick={() => handleAction('ADD_COUNTER', { cardId: card.instanceId, counterType: 'loyalty', amount: 1 })} />
|
||||
<MenuItem label="Remove Counter" onClick={() => handleAction('ADD_COUNTER', { cardId: card.instanceId, counterType: '+1/+1', amount: -1 })} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MenuItem label="Clone (Copy)" onClick={() => handleAction('CREATE_TOKEN', {
|
||||
tokenData: {
|
||||
name: `${card.name} (Copy)`,
|
||||
imageUrl: card.imageUrl,
|
||||
power: card.ptModification?.power,
|
||||
toughness: card.ptModification?.toughness
|
||||
},
|
||||
position: { x: (card.position.x || 50) + 2, y: (card.position.y || 50) + 2 }
|
||||
})} />
|
||||
|
||||
<div className="h-px bg-slate-800 my-1 mx-2"></div>
|
||||
<MenuItem label="To Hand" onClick={() => handleAction('MOVE_CARD', { cardId: card.instanceId, toZone: 'hand' })} />
|
||||
<MenuItem label="Destroy (Grave)" onClick={() => handleAction('MOVE_CARD', { cardId: card.instanceId, toZone: 'graveyard' })} />
|
||||
<MenuItem label="Exile" onClick={() => handleAction('MOVE_CARD', { cardId: card.instanceId, toZone: 'exile' })} />
|
||||
<MenuItem label="To Library (Top)" onClick={() => handleAction('MOVE_CARD', { cardId: card.instanceId, toZone: 'library', position: 'top' })} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Graveyard Menu */}
|
||||
{zone === 'graveyard' && (
|
||||
<>
|
||||
<MenuItem label="Exile" onClick={() => handleAction('MOVE_CARD', { cardId: card.instanceId, toZone: 'exile' })} />
|
||||
<MenuItem label="Return to Hand" onClick={() => handleAction('MOVE_CARD', { cardId: card.instanceId, toZone: 'hand' })} />
|
||||
<MenuItem label="Return to Battlefield" onClick={() => handleAction('MOVE_CARD', { cardId: card.instanceId, toZone: 'battlefield' })} />
|
||||
<MenuItem label="To Library (Bottom)" onClick={() => handleAction('MOVE_CARD', { cardId: card.instanceId, toZone: 'library', position: 'bottom' })} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Exile Menu */}
|
||||
{zone === 'exile' && (
|
||||
<>
|
||||
<MenuItem label="Return to Graveyard" onClick={() => handleAction('MOVE_CARD', { cardId: card.instanceId, toZone: 'graveyard' })} />
|
||||
<MenuItem label="Return to Battlefield" onClick={() => handleAction('MOVE_CARD', { cardId: card.instanceId, toZone: 'battlefield' })} />
|
||||
<MenuItem label="Return to Hand" onClick={() => handleAction('MOVE_CARD', { cardId: card.instanceId, toZone: 'hand' })} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Library Menu (if we ever show context menu for cards IN library view?) */}
|
||||
{zone === 'library' && (
|
||||
<>
|
||||
<MenuItem label="Draw" onClick={() => handleAction('MOVE_CARD', { cardId: card.instanceId, toZone: 'hand' })} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<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: card.instanceId })}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const renderZoneMenu = (zone: string) => {
|
||||
return (
|
||||
<>
|
||||
<div className="px-3 py-1 font-bold text-xs text-slate-500 uppercase tracking-widest border-b border-slate-800 mb-1">
|
||||
{zone} Zone
|
||||
</div>
|
||||
|
||||
<MenuItem label={`View ${zone.charAt(0).toUpperCase() + zone.slice(1)}`} onClick={() => handleAction('VIEW_ZONE', { zone })} />
|
||||
|
||||
{zone === 'library' && (
|
||||
<>
|
||||
<MenuItem label="Draw Card" onClick={() => handleAction('DRAW_CARD')} />
|
||||
<MenuItem label="Shuffle Library" onClick={() => handleAction('SHUFFLE_LIBRARY')} />
|
||||
<MenuItem label="Mill 1 Card" onClick={() => handleAction('MILL_CARD', { amount: 1 })} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{zone === 'graveyard' && (
|
||||
<>
|
||||
<MenuItem label="Exile All" onClick={() => handleAction('EXILE_GRAVEYARD')} />
|
||||
<MenuItem label="Shuffle Graveyard" onClick={() => handleAction('SHUFFLE_GRAVEYARD')} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{zone === 'exile' && (
|
||||
<>
|
||||
<MenuItem label="Shuffle Exile" onClick={() => handleAction('SHUFFLE_EXILE')} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={style}
|
||||
@@ -50,44 +175,9 @@ export const GameContextMenu: React.FC<GameContextMenuProps> = ({ request, onClo
|
||||
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 })} />
|
||||
{request.type === 'card' && request.card && renderCardMenu(request.card)}
|
||||
|
||||
<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 === 'zone' && request.zone && renderZoneMenu(request.zone)}
|
||||
|
||||
{request.type === 'background' && (
|
||||
<>
|
||||
@@ -98,25 +188,25 @@ export const GameContextMenu: React.FC<GameContextMenuProps> = ({ request, onClo
|
||||
label="Create Token (1/1)"
|
||||
onClick={() => handleAction('CREATE_TOKEN', {
|
||||
tokenData: { name: 'Soldier', power: 1, toughness: 1 },
|
||||
// Convert click position to approximate percent if possible or center
|
||||
// For now, simpler to spawn at center or random.
|
||||
position: { x: Math.random() * 40 + 30, y: Math.random() * 40 + 30 }
|
||||
position: { x: (request.x / window.innerWidth) * 100, y: (request.y / window.innerHeight) * 100 }
|
||||
})}
|
||||
/>
|
||||
<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 }
|
||||
position: { x: (request.x / window.innerWidth) * 100, y: (request.y / window.innerHeight) * 100 }
|
||||
})}
|
||||
/>
|
||||
<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 }
|
||||
position: { x: (request.x / window.innerWidth) * 100, y: (request.y / window.innerHeight) * 100 }
|
||||
})}
|
||||
/>
|
||||
<div className="h-px bg-slate-800 my-1 mx-2"></div>
|
||||
<MenuItem label="Untap All My Permanents" onClick={() => handleAction('UNTAP_ALL')} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,8 @@ import React, { useRef, useState, useEffect } from 'react';
|
||||
import { GameState, CardInstance } from '../../types/game';
|
||||
import { socketService } from '../../services/SocketService';
|
||||
import { CardComponent } from './CardComponent';
|
||||
import { GameContextMenu } from './GameContextMenu';
|
||||
import { GameContextMenu, ContextMenuRequest } from './GameContextMenu';
|
||||
import { ZoneOverlay } from './ZoneOverlay';
|
||||
|
||||
interface GameViewProps {
|
||||
gameState: GameState;
|
||||
@@ -11,7 +12,8 @@ interface GameViewProps {
|
||||
|
||||
export const GameView: React.FC<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);
|
||||
const [contextMenu, setContextMenu] = useState<ContextMenuRequest | null>(null);
|
||||
const [viewingZone, setViewingZone] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Disable default context menu
|
||||
@@ -20,30 +22,46 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
|
||||
return () => document.removeEventListener('contextmenu', handleContext);
|
||||
}, []);
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent, type: 'background' | 'card', targetId?: string) => {
|
||||
const handleContextMenu = (e: React.MouseEvent, type: 'background' | 'card' | 'zone', targetId?: string, zoneName?: string) => {
|
||||
e.preventDefault();
|
||||
const card = targetId ? gameState.cards[targetId] : undefined;
|
||||
e.stopPropagation();
|
||||
|
||||
const card = (type === 'card' && targetId) ? gameState.cards[targetId] : undefined;
|
||||
|
||||
setContextMenu({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
type,
|
||||
targetId,
|
||||
card
|
||||
card,
|
||||
zone: zoneName
|
||||
});
|
||||
};
|
||||
|
||||
const handleMenuAction = (actionType: string, payload: any) => {
|
||||
// If creating token, inject current player ID as owner if not present
|
||||
if (actionType === 'CREATE_TOKEN' && !payload.ownerId) {
|
||||
payload.ownerId = currentPlayerId;
|
||||
|
||||
if (actionType === 'VIEW_ZONE') {
|
||||
setViewingZone(payload.zone);
|
||||
return;
|
||||
}
|
||||
|
||||
// Default payload to object if undefined
|
||||
const safePayload = payload || {};
|
||||
|
||||
// Inject currentPlayerId if not present (acts as actor)
|
||||
if (!safePayload.playerId) {
|
||||
safePayload.playerId = currentPlayerId;
|
||||
}
|
||||
// Inject ownerId if not present (useful for token creation etc)
|
||||
if (!safePayload.ownerId) {
|
||||
safePayload.ownerId = currentPlayerId;
|
||||
}
|
||||
|
||||
socketService.socket.emit('game_action', {
|
||||
roomId: gameState.roomId,
|
||||
action: {
|
||||
type: actionType,
|
||||
...payload
|
||||
...safePayload
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -135,6 +153,15 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
|
||||
onAction={handleMenuAction}
|
||||
/>
|
||||
|
||||
{viewingZone && (
|
||||
<ZoneOverlay
|
||||
zoneName={viewingZone}
|
||||
cards={getCards(currentPlayerId, viewingZone)}
|
||||
onClose={() => setViewingZone(null)}
|
||||
onCardContextMenu={(e, cardId) => handleContextMenu(e, 'card', cardId)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Top Area: Opponent */}
|
||||
<div className="flex-[2] relative flex flex-col pointer-events-none">
|
||||
{/* Opponent Hand (Visual) */}
|
||||
@@ -224,7 +251,6 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
|
||||
onDragStart={(e, id) => e.dataTransfer.setData('cardId', id)}
|
||||
onClick={toggleTap}
|
||||
onContextMenu={(id, e) => {
|
||||
e.stopPropagation(); // Stop bubbling to background
|
||||
handleContextMenu(e, 'card', id);
|
||||
}}
|
||||
/>
|
||||
@@ -247,6 +273,7 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
|
||||
<div
|
||||
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 } })}
|
||||
onContextMenu={(e) => handleContextMenu(e, 'zone', undefined, 'library')}
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-slate-700 to-slate-800 rounded"></div>
|
||||
{/* Deck look */}
|
||||
@@ -263,6 +290,7 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
|
||||
className="w-16 h-24 border-2 border-dashed border-slate-600 rounded flex items-center justify-center mt-2 transition-colors hover:border-slate-400 hover:bg-white/5"
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={(e) => handleDrop(e, 'graveyard')}
|
||||
onContextMenu={(e) => handleContextMenu(e, 'zone', undefined, 'graveyard')}
|
||||
>
|
||||
<div className="text-center">
|
||||
<span className="block text-slate-500 text-[10px] uppercase">Graveyard</span>
|
||||
@@ -291,7 +319,7 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
|
||||
card={card}
|
||||
onDragStart={(e, id) => e.dataTransfer.setData('cardId', id)}
|
||||
onClick={toggleTap}
|
||||
onContextMenu={(id) => toggleFlip(id)}
|
||||
onContextMenu={(id, e) => handleContextMenu(e, 'card', id)}
|
||||
style={{ transformOrigin: 'bottom center' }}
|
||||
/>
|
||||
</div>
|
||||
@@ -326,6 +354,7 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
|
||||
className="w-full text-center border-t border-white/5 pt-2 cursor-pointer hover:bg-white/5 rounded p-1"
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={(e) => handleDrop(e, 'exile')}
|
||||
onContextMenu={(e) => handleContextMenu(e, 'zone', undefined, 'exile')}
|
||||
>
|
||||
<span className="text-xs text-slate-500 block">Exile Drop Zone</span>
|
||||
<span className="text-lg font-bold text-slate-400">{myExile.length}</span>
|
||||
|
||||
81
src/client/src/modules/game/ZoneOverlay.tsx
Normal file
81
src/client/src/modules/game/ZoneOverlay.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import React from 'react';
|
||||
import { CardInstance } from '../../types/game';
|
||||
|
||||
interface ZoneOverlayProps {
|
||||
zoneName: string;
|
||||
cards: CardInstance[];
|
||||
onClose: () => void;
|
||||
onCardContextMenu?: (e: React.MouseEvent, cardId: string) => void;
|
||||
}
|
||||
|
||||
export const ZoneOverlay: React.FC<ZoneOverlayProps> = ({ zoneName, cards, onClose, onCardContextMenu }) => {
|
||||
return (
|
||||
<div className="fixed inset-0 z-[9990] flex items-center justify-center bg-black/80 backdrop-blur-sm animate-in fade-in duration-200">
|
||||
<div className="bg-slate-900 border border-slate-700 rounded-lg shadow-2xl w-3/4 h-3/4 flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-800 bg-slate-950">
|
||||
<h2 className="text-2xl font-bold text-slate-200 capitalize flex items-center gap-3">
|
||||
<span>{zoneName}</span>
|
||||
<span className="text-sm font-normal text-slate-500 bg-slate-900 px-2 py-1 rounded-full border border-slate-800">
|
||||
{cards.length} Cards
|
||||
</span>
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-slate-400 hover:text-white transition-colors p-2 hover:bg-white/10 rounded-full"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6 bg-[url('/bg-pattern.png')]">
|
||||
{cards.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-slate-500">
|
||||
<p className="text-lg">This zone is empty.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
|
||||
{cards.map((card) => (
|
||||
<div key={card.instanceId} className="relative group perspective-1000">
|
||||
<div
|
||||
className="relative aspect-[2.5/3.5] bg-slate-800 rounded-lg overflow-hidden shadow-lg border border-slate-700 transition-transform duration-200 hover:scale-105 hover:z-10 hover:shadow-xl hover:shadow-cyan-900/20 cursor-context-menu"
|
||||
onContextMenu={(e) => {
|
||||
if (onCardContextMenu) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onCardContextMenu(e, card.instanceId);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={card.imageUrl || 'https://via.placeholder.com/250x350'}
|
||||
alt={card.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 text-center">
|
||||
<p className="text-xs text-slate-400 truncate w-full">{card.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-4 border-t border-slate-800 bg-slate-950 flex justify-end">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-200 rounded text-sm font-medium transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -31,14 +31,15 @@ interface Room {
|
||||
interface GameRoomProps {
|
||||
room: Room;
|
||||
currentPlayerId: string;
|
||||
initialGameState?: any;
|
||||
}
|
||||
|
||||
export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPlayerId }) => {
|
||||
export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPlayerId, initialGameState }) => {
|
||||
const [room, setRoom] = useState<Room>(initialRoom);
|
||||
const [message, setMessage] = useState('');
|
||||
const [messages, setMessages] = useState<ChatMessage[]>(initialRoom.messages || []);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const [gameState, setGameState] = useState<any>(null);
|
||||
const [gameState, setGameState] = useState<any>(initialGameState || null);
|
||||
|
||||
useEffect(() => {
|
||||
setRoom(initialRoom);
|
||||
|
||||
180
src/client/src/modules/tester/DeckTester.tsx
Normal file
180
src/client/src/modules/tester/DeckTester.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Play, Upload, Loader2, AlertCircle } from 'lucide-react';
|
||||
import { CardParserService } from '../../services/CardParserService';
|
||||
import { ScryfallService, ScryfallCard } from '../../services/ScryfallService';
|
||||
import { socketService } from '../../services/SocketService';
|
||||
import { GameRoom } from '../lobby/GameRoom';
|
||||
|
||||
export const DeckTester: React.FC = () => {
|
||||
const parserService = useMemo(() => new CardParserService(), []);
|
||||
const scryfallService = useMemo(() => new ScryfallService(), []);
|
||||
|
||||
const [inputText, setInputText] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [progress, setProgress] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [activeRoom, setActiveRoom] = useState<any>(null);
|
||||
const [initialGame, setInitialGame] = useState<any>(null);
|
||||
const [playerId] = useState(() => Math.random().toString(36).substring(2) + Date.now().toString(36));
|
||||
const [playerName, setPlayerName] = useState('Tester');
|
||||
|
||||
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => setInputText(e.target?.result as string || '');
|
||||
reader.readAsText(file);
|
||||
event.target.value = '';
|
||||
};
|
||||
|
||||
const handleTestDeck = async () => {
|
||||
if (!inputText.trim()) {
|
||||
setError('Please enter a deck list');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setProgress('Parsing deck list...');
|
||||
|
||||
try {
|
||||
// 1. Parse
|
||||
const identifiers = parserService.parse(inputText);
|
||||
const fetchList = identifiers.map(id => id.type === 'id' ? { id: id.value } : { name: id.value });
|
||||
|
||||
// 2. Fetch from Scryfall
|
||||
const expandedCards: ScryfallCard[] = [];
|
||||
await scryfallService.fetchCollection(fetchList, (current, total) => {
|
||||
setProgress(`Fetching cards... (${current}/${total})`);
|
||||
});
|
||||
|
||||
// 3. Expand Quantities
|
||||
identifiers.forEach(id => {
|
||||
const card = scryfallService.getCachedCard(id.type === 'id' ? { id: id.value } : { name: id.value });
|
||||
if (card) {
|
||||
for (let i = 0; i < id.quantity; i++) expandedCards.push(card);
|
||||
} else {
|
||||
console.warn("Card not found:", id.value);
|
||||
}
|
||||
});
|
||||
|
||||
if (expandedCards.length === 0) {
|
||||
throw new Error("No valid cards found in list.");
|
||||
}
|
||||
|
||||
// 4. Cache Images on Server
|
||||
setProgress('Caching images...');
|
||||
const uniqueCards = Array.from(new Map(expandedCards.map(c => [c.id, c])).values());
|
||||
const cardsToCache = uniqueCards.map(c => ({
|
||||
id: c.id,
|
||||
image_uris: { normal: c.image_uris?.normal || c.card_faces?.[0]?.image_uris?.normal || "" }
|
||||
}));
|
||||
|
||||
const cacheResponse = await fetch('/api/cards/cache', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ cards: cardsToCache })
|
||||
});
|
||||
|
||||
if (!cacheResponse.ok) {
|
||||
console.warn("Failed to cache images, proceeding anyway...");
|
||||
}
|
||||
|
||||
// 5. Update cards with local image paths
|
||||
const baseUrl = `${window.location.protocol}//${window.location.host}/cards`;
|
||||
const deckToSend = expandedCards.map(c => ({
|
||||
...c,
|
||||
image: `${baseUrl}/${c.id}.jpg`
|
||||
}));
|
||||
|
||||
// 6. Connect & Start Solo Game
|
||||
setProgress('Starting game...');
|
||||
if (!socketService.socket.connected) {
|
||||
socketService.connect();
|
||||
}
|
||||
|
||||
const response = await socketService.emitPromise('start_solo_test', {
|
||||
playerId,
|
||||
playerName,
|
||||
deck: deckToSend
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
setInitialGame(response.game);
|
||||
setActiveRoom(response.room);
|
||||
} else {
|
||||
throw new Error(response.message || "Failed to start game");
|
||||
}
|
||||
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
setError(err.message || "An error occurred");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setProgress('');
|
||||
}
|
||||
};
|
||||
|
||||
if (activeRoom) {
|
||||
return <GameRoom room={activeRoom} currentPlayerId={playerId} initialGameState={initialGame} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-4 md:p-8">
|
||||
<div className="bg-slate-800 rounded-2xl p-8 border border-slate-700 shadow-2xl">
|
||||
<h2 className="text-3xl font-bold text-white mb-2 flex items-center gap-3">
|
||||
<Play className="w-8 h-8 text-emerald-500" /> Deck Tester
|
||||
</h2>
|
||||
<p className="text-slate-400 mb-8">Paste your deck list below to instantly test it on the battlefield.</p>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-900/50 border border-red-500 text-red-200 p-4 rounded-xl mb-6 flex items-center gap-3">
|
||||
<AlertCircle className="w-5 h-5" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-slate-300 mb-2">Player Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={playerName}
|
||||
onChange={(e) => setPlayerName(e.target.value)}
|
||||
className="w-full bg-slate-900 border border-slate-700 rounded-xl p-3 text-white focus:ring-2 focus:ring-emerald-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<label className="text-sm font-bold text-slate-300">Deck List</label>
|
||||
<label className="cursor-pointer text-xs text-blue-400 hover:text-blue-300 flex items-center gap-1 hover:underline">
|
||||
<Upload className="w-3 h-3" /> Upload .txt
|
||||
<input type="file" className="hidden" accept=".txt,.csv" onChange={handleFileUpload} />
|
||||
</label>
|
||||
</div>
|
||||
<textarea
|
||||
className="w-full h-64 bg-slate-900 border border-slate-700 rounded-xl p-4 font-mono text-sm text-slate-300 focus:ring-2 focus:ring-emerald-500 outline-none resize-none placeholder:text-slate-600"
|
||||
placeholder={"4 Lightning Bolt\n4 Mountain\n..."}
|
||||
value={inputText}
|
||||
onChange={(e) => setInputText(e.target.value)}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleTestDeck}
|
||||
disabled={loading || !inputText}
|
||||
className={`w-full py-4 rounded-xl font-bold text-lg shadow-lg flex justify-center items-center gap-2 transition-all ${loading
|
||||
? 'bg-slate-700 cursor-not-allowed text-slate-500'
|
||||
: 'bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-500 hover:to-teal-500 text-white transform hover:scale-[1.01]'
|
||||
}`}
|
||||
>
|
||||
{loading ? <Loader2 className="w-6 h-6 animate-spin" /> : <Play className="w-6 h-6 fill-current" />}
|
||||
{loading ? progress : 'Start Test Game'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -167,6 +167,39 @@ io.on('connection', (socket) => {
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('start_solo_test', ({ playerId, playerName, deck }, callback) => {
|
||||
// Create new room in 'playing' state (empty packs as not drafting)
|
||||
const room = roomManager.createRoom(playerId, playerName, []);
|
||||
room.status = 'playing';
|
||||
|
||||
// Join socket
|
||||
socket.join(room.id);
|
||||
console.log(`Solo Game started for ${room.id} by ${playerName}`);
|
||||
|
||||
// Init Game
|
||||
const game = gameManager.createGame(room.id, room.players);
|
||||
|
||||
// Load Deck (Expects expanded array of cards)
|
||||
if (Array.isArray(deck)) {
|
||||
deck.forEach((card: any) => {
|
||||
gameManager.addCardToGame(room.id, {
|
||||
ownerId: playerId,
|
||||
controllerId: playerId,
|
||||
oracleId: card.id,
|
||||
name: card.name,
|
||||
imageUrl: card.image || card.image_uris?.normal || card.card_faces?.[0]?.image_uris?.normal || "",
|
||||
zone: 'library'
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Send Init Updates
|
||||
callback({ success: true, room, game });
|
||||
// Emit updates to ensure client is in sync
|
||||
io.to(room.id).emit('room_update', room);
|
||||
io.to(room.id).emit('game_update', game);
|
||||
});
|
||||
|
||||
socket.on('start_game', ({ roomId, decks }) => {
|
||||
const room = roomManager.startGame(roomId);
|
||||
if (room) {
|
||||
|
||||
@@ -104,6 +104,18 @@ export class GameManager {
|
||||
case 'SHUFFLE_LIBRARY':
|
||||
this.shuffleLibrary(game, action);
|
||||
break;
|
||||
case 'SHUFFLE_GRAVEYARD':
|
||||
this.shuffleGraveyard(game, action);
|
||||
break;
|
||||
case 'SHUFFLE_EXILE':
|
||||
this.shuffleExile(game, action);
|
||||
break;
|
||||
case 'MILL_CARD':
|
||||
this.millCard(game, action);
|
||||
break;
|
||||
case 'EXILE_GRAVEYARD':
|
||||
this.exileGraveyard(game, action);
|
||||
break;
|
||||
}
|
||||
|
||||
return game;
|
||||
@@ -220,6 +232,37 @@ export class GameManager {
|
||||
// No-op in current logic since we pick randomly
|
||||
}
|
||||
|
||||
private shuffleGraveyard(_game: GameState, _action: { playerId: string }) {
|
||||
// No-op
|
||||
}
|
||||
|
||||
private shuffleExile(_game: GameState, _action: { playerId: string }) {
|
||||
// No-op
|
||||
}
|
||||
|
||||
private millCard(game: GameState, action: { playerId: string; amount: number }) {
|
||||
// Similar to draw but to graveyard
|
||||
const amount = action.amount || 1;
|
||||
for (let i = 0; i < amount; i++) {
|
||||
const libraryCards = Object.values(game.cards).filter(c => c.ownerId === action.playerId && c.zone === 'library');
|
||||
if (libraryCards.length > 0) {
|
||||
const randomIndex = Math.floor(Math.random() * libraryCards.length);
|
||||
const card = libraryCards[randomIndex];
|
||||
card.zone = 'graveyard';
|
||||
card.faceDown = false;
|
||||
card.position.z = ++game.maxZ;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private exileGraveyard(game: GameState, action: { playerId: string }) {
|
||||
const graveyardCards = Object.values(game.cards).filter(c => c.ownerId === action.playerId && c.zone === 'graveyard');
|
||||
graveyardCards.forEach(card => {
|
||||
card.zone = 'exile';
|
||||
card.position.z = ++game.maxZ;
|
||||
});
|
||||
}
|
||||
|
||||
// Helper to add cards (e.g. at game start)
|
||||
addCardToGame(roomId: string, cardData: Partial<CardInstance>) {
|
||||
const game = this.games.get(roomId);
|
||||
|
||||
Reference in New Issue
Block a user