feat: Introduce zone viewing overlay and add server-side zone management actions.
Some checks failed
Build and Deploy / build (push) Failing after 1m15s
Some checks failed
Build and Deploy / build (push) Failing after 1m15s
This commit is contained in:
@@ -1,12 +1,13 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { CardInstance } from '../../types/game';
|
||||
|
||||
interface ContextMenuRequest {
|
||||
export interface ContextMenuRequest {
|
||||
x: number;
|
||||
y: number;
|
||||
type: 'background' | 'card';
|
||||
targetId?: string;
|
||||
type: 'background' | 'card' | 'zone';
|
||||
targetId?: string; // cardId or zoneName
|
||||
card?: CardInstance;
|
||||
zone?: string; // 'library', 'graveyard', 'exile', 'hand'
|
||||
}
|
||||
|
||||
interface GameContextMenuProps {
|
||||
@@ -32,9 +33,9 @@ export const GameContextMenu: React.FC<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
|
||||
@@ -42,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}
|
||||
@@ -49,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={() => { }} />
|
||||
{/* 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' && (
|
||||
<>
|
||||
@@ -97,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>
|
||||
);
|
||||
};
|
||||
@@ -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