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:
@@ -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;
|
||||
|
||||
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: {
|
||||
type: 'MOVE_CARD',
|
||||
cardId,
|
||||
toZone: zone
|
||||
}
|
||||
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">
|
||||
{oppBattlefield.map(card => (
|
||||
<CardComponent
|
||||
key={card.instanceId}
|
||||
card={card}
|
||||
onDragStart={(e, id) => e.dataTransfer.setData('cardId', id)}
|
||||
onClick={toggleTap}
|
||||
/>
|
||||
))}
|
||||
{/* 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 => (
|
||||
<div
|
||||
key={card.instanceId}
|
||||
className="absolute transition-all duration-300 ease-out"
|
||||
style={{
|
||||
left: `${card.position?.x || 50}%`,
|
||||
top: `${card.position?.y || 50}%`,
|
||||
zIndex: Math.floor((card.position?.y || 0)), // Simple z-index based on vertical pos
|
||||
}}
|
||||
>
|
||||
<CardComponent
|
||||
card={card}
|
||||
// Opponent cards shouldn't necessarily be draggable by me, but depends on game rules.
|
||||
// Usually not.
|
||||
onDragStart={() => { }}
|
||||
onClick={() => { }} // Maybe inspect?
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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}
|
||||
card={card}
|
||||
onDragStart={(e, id) => e.dataTransfer.setData('cardId', id)}
|
||||
onClick={toggleTap}
|
||||
/>
|
||||
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}
|
||||
card={card}
|
||||
onDragStart={(e, id) => e.dataTransfer.setData('cardId', id)}
|
||||
onClick={toggleTap}
|
||||
style={{ transformOrigin: 'bottom center' }}
|
||||
/>
|
||||
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