feat: Introduce zone viewing overlay and add server-side zone management actions.
Some checks failed
Build and Deploy / build (push) Failing after 1m15s

This commit is contained in:
2025-12-16 12:55:01 +01:00
parent b13627363f
commit dd9f19aff7
4 changed files with 303 additions and 59 deletions

View File

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

View File

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

View 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>
);
};

View File

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