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

This commit is contained in:
2025-12-14 23:53:41 +01:00
parent 6dc69dd22a
commit 2eea9b860e
8 changed files with 577 additions and 101 deletions

View File

@@ -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' : ''}

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

View File

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

View File

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