feat: Implement game and server persistence using Redis and file storage, and add a collapsible, resizable card preview sidebar to the game view.
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
import React from 'react';
|
||||
import { CardInstance } from '../../types/game';
|
||||
import { useGesture } from './GestureManager';
|
||||
import { useRef, useEffect } from 'react';
|
||||
|
||||
interface CardComponentProps {
|
||||
card: CardInstance;
|
||||
@@ -12,8 +14,19 @@ interface CardComponentProps {
|
||||
}
|
||||
|
||||
export const CardComponent: React.FC<CardComponentProps> = ({ card, onDragStart, onClick, onContextMenu, onMouseEnter, onMouseLeave, style }) => {
|
||||
const { registerCard, unregisterCard } = useGesture();
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (cardRef.current) {
|
||||
registerCard(card.instanceId, cardRef.current);
|
||||
}
|
||||
return () => unregisterCard(card.instanceId);
|
||||
}, [card.instanceId]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={cardRef}
|
||||
draggable
|
||||
onDragStart={(e) => onDragStart(e, card.instanceId)}
|
||||
onClick={() => onClick(card.instanceId)}
|
||||
|
||||
@@ -5,6 +5,10 @@ import { socketService } from '../../services/SocketService';
|
||||
import { CardComponent } from './CardComponent';
|
||||
import { GameContextMenu, ContextMenuRequest } from './GameContextMenu';
|
||||
import { ZoneOverlay } from './ZoneOverlay';
|
||||
import { PhaseStrip } from './PhaseStrip';
|
||||
import { SmartButton } from './SmartButton';
|
||||
import { StackVisualizer } from './StackVisualizer';
|
||||
import { GestureManager } from './GestureManager';
|
||||
|
||||
interface GameViewProps {
|
||||
gameState: GameState;
|
||||
@@ -330,6 +334,7 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
|
||||
|
||||
{/* Main Game Area */}
|
||||
<div className="flex-1 flex flex-col h-full relative">
|
||||
<StackVisualizer gameState={gameState} />
|
||||
|
||||
{/* Top Area: Opponent */}
|
||||
<div className="flex-[2] relative flex flex-col pointer-events-none">
|
||||
@@ -393,46 +398,48 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={(e) => handleDrop(e, 'battlefield')}
|
||||
>
|
||||
<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%',
|
||||
boxShadow: 'inset 0 0 100px rgba(0,0,0,0.8)'
|
||||
}}
|
||||
>
|
||||
{/* Battlefield Texture/Grid */}
|
||||
<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>
|
||||
<GestureManager>
|
||||
<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%',
|
||||
boxShadow: 'inset 0 0 100px rgba(0,0,0,0.8)'
|
||||
}}
|
||||
>
|
||||
{/* Battlefield Texture/Grid */}
|
||||
<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 => (
|
||||
<div
|
||||
key={card.instanceId}
|
||||
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) => {
|
||||
handleContextMenu(e, 'card', id);
|
||||
{myBattlefield.map(card => (
|
||||
<div
|
||||
key={card.instanceId}
|
||||
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),
|
||||
}}
|
||||
onMouseEnter={() => setHoveredCard(card)}
|
||||
onMouseLeave={() => setHoveredCard(null)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
>
|
||||
<CardComponent
|
||||
card={card}
|
||||
onDragStart={(e, id) => e.dataTransfer.setData('cardId', id)}
|
||||
onClick={toggleTap}
|
||||
onContextMenu={(id, e) => {
|
||||
handleContextMenu(e, 'card', id);
|
||||
}}
|
||||
onMouseEnter={() => setHoveredCard(card)}
|
||||
onMouseLeave={() => setHoveredCard(null)}
|
||||
/>
|
||||
</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>
|
||||
{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>
|
||||
</GestureManager>
|
||||
</div>
|
||||
|
||||
{/* Bottom Area: Controls & Hand */}
|
||||
@@ -440,46 +447,58 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
|
||||
|
||||
{/* Left Controls: Library/Grave */}
|
||||
<div className="w-40 p-2 flex flex-col gap-2 items-center justify-center border-r border-white/10">
|
||||
<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', { action: { type: 'DRAW_CARD' } })}
|
||||
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 */}
|
||||
<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>
|
||||
{/* Phase Strip Integration */}
|
||||
<div className="mb-2 scale-75 origin-center">
|
||||
<PhaseStrip gameState={gameState} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
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>
|
||||
<span className="text-sm font-bold text-slate-400">{myGraveyard.length}</span>
|
||||
<div className="flex gap-2">
|
||||
<div
|
||||
className="group relative w-12 h-16 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', { action: { type: 'DRAW_CARD' } })}
|
||||
onContextMenu={(e) => handleContextMenu(e, 'zone', undefined, 'library')}
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-slate-700 to-slate-800 rounded"></div>
|
||||
<div className="absolute inset-0 flex items-center justify-center flex-col">
|
||||
<span className="text-[8px] font-bold text-slate-300">Lib</span>
|
||||
<span className="text-sm font-bold text-white">{myLibrary.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="w-12 h-16 border-2 border-dashed border-slate-600 rounded flex items-center justify-center 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-[8px] uppercase">GY</span>
|
||||
<span className="text-sm font-bold text-slate-400">{myGraveyard.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hand Area */}
|
||||
{/* Hand Area & Smart Button */}
|
||||
<div
|
||||
className="flex-1 relative flex items-end justify-center px-4 pb-2"
|
||||
className="flex-1 relative flex flex-col items-center justify-end px-4 pb-2"
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={(e) => handleDrop(e, 'hand')}
|
||||
>
|
||||
{/* Smart Button Floating above Hand */}
|
||||
<div className="mb-4 z-40">
|
||||
<SmartButton
|
||||
gameState={gameState}
|
||||
playerId={currentPlayerId}
|
||||
onAction={(type, payload) => socketService.socket.emit(type, { action: payload })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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}
|
||||
className="transition-all duration-300 hover:-translate-y-12 hover:scale-110 hover:z-50 hover:rotate-0 origin-bottom"
|
||||
className="transition-all duration-300 hover:-translate-y-16 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
|
||||
|
||||
136
src/client/src/modules/game/GestureManager.tsx
Normal file
136
src/client/src/modules/game/GestureManager.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
|
||||
import React, { createContext, useContext, useRef, useState, useEffect } from 'react';
|
||||
import { socketService } from '../../services/SocketService';
|
||||
|
||||
interface GestureContextType {
|
||||
registerCard: (id: string, element: HTMLElement) => void;
|
||||
unregisterCard: (id: string) => void;
|
||||
}
|
||||
|
||||
const GestureContext = createContext<GestureContextType>({
|
||||
registerCard: () => { },
|
||||
unregisterCard: () => { },
|
||||
});
|
||||
|
||||
export const useGesture = () => useContext(GestureContext);
|
||||
|
||||
interface GestureManagerProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const GestureManager: React.FC<GestureManagerProps> = ({ children }) => {
|
||||
const cardRefs = useRef<Map<string, HTMLElement>>(new Map());
|
||||
const [gesturePath, setGesturePath] = useState<{ x: number, y: number }[]>([]);
|
||||
const isGesturing = useRef(false);
|
||||
const startPoint = useRef<{ x: number, y: number } | null>(null);
|
||||
|
||||
const registerCard = (id: string, element: HTMLElement) => {
|
||||
cardRefs.current.set(id, element);
|
||||
};
|
||||
|
||||
const unregisterCard = (id: string) => {
|
||||
cardRefs.current.delete(id);
|
||||
};
|
||||
|
||||
const onPointerDown = (e: React.PointerEvent) => {
|
||||
// Only start gesture if clicking on background or specific handle?
|
||||
// For now, let's assume Right Click or Middle Drag is Gesture Mode?
|
||||
// Or just "Drag on Background".
|
||||
// If e.target is a card, usually DnD handles it.
|
||||
// We check if event target is NOT a card.
|
||||
|
||||
// Simplification: Check if Shift Key is held for Gesture Mode?
|
||||
// Or just native touch swipe.
|
||||
|
||||
// Let's rely on event propagation. If card didn't stopPropagation, maybe background catches it.
|
||||
// Assuming GameView wrapper catches this.
|
||||
|
||||
isGesturing.current = true;
|
||||
startPoint.current = { x: e.clientX, y: e.clientY };
|
||||
setGesturePath([{ x: e.clientX, y: e.clientY }]);
|
||||
|
||||
// Capture pointer
|
||||
(e.target as Element).setPointerCapture(e.pointerId);
|
||||
};
|
||||
|
||||
const onPointerMove = (e: React.PointerEvent) => {
|
||||
if (!isGesturing.current) return;
|
||||
|
||||
setGesturePath(prev => [...prev, { x: e.clientX, y: e.clientY }]);
|
||||
};
|
||||
|
||||
const onPointerUp = (e: React.PointerEvent) => {
|
||||
if (!isGesturing.current) return;
|
||||
isGesturing.current = false;
|
||||
|
||||
// Analyze Path for "Slash" (Swipe to Tap)
|
||||
// Check intersection with cards
|
||||
handleSwipeToTap();
|
||||
|
||||
setGesturePath([]);
|
||||
(e.target as Element).releasePointerCapture(e.pointerId);
|
||||
};
|
||||
|
||||
const handleSwipeToTap = () => {
|
||||
// Bounding box of path?
|
||||
// Simple: Check which cards intersect with the path line segments.
|
||||
// Optimization: Just check if path points are inside card rects.
|
||||
|
||||
const intersectedCards = new Set<string>();
|
||||
|
||||
const path = gesturePath;
|
||||
if (path.length < 2) return; // Too short
|
||||
|
||||
// Check every card
|
||||
cardRefs.current.forEach((el, id) => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
|
||||
// Simple hit test: Does any point in path fall in rect?
|
||||
// Better: Line intersection.
|
||||
// For MVP: Check points.
|
||||
for (const p of path) {
|
||||
if (p.x >= rect.left && p.x <= rect.right && p.y >= rect.top && p.y <= rect.bottom) {
|
||||
intersectedCards.add(id);
|
||||
break; // Found hit
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// If we hit cards, toggle tap
|
||||
if (intersectedCards.size > 0) {
|
||||
intersectedCards.forEach(id => {
|
||||
socketService.socket.emit('game_action', {
|
||||
action: { type: 'TAP_CARD', cardId: id }
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<GestureContext.Provider value={{ registerCard, unregisterCard }}>
|
||||
<div
|
||||
className="relative w-full h-full touch-none"
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
>
|
||||
{children}
|
||||
|
||||
{/* SVG Overlay for Path */}
|
||||
{gesturePath.length > 0 && (
|
||||
<svg className="absolute inset-0 pointer-events-none z-50 overflow-visible">
|
||||
<polyline
|
||||
points={gesturePath.map(p => `${p.x},${p.y}`).join(' ')}
|
||||
fill="none"
|
||||
stroke="cyan"
|
||||
strokeWidth="4"
|
||||
strokeLinecap="round"
|
||||
strokeOpacity="0.6"
|
||||
className="drop-shadow-[0_0_10px_rgba(0,255,255,0.8)]"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</GestureContext.Provider>
|
||||
);
|
||||
};
|
||||
50
src/client/src/modules/game/PhaseStrip.tsx
Normal file
50
src/client/src/modules/game/PhaseStrip.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
|
||||
import React from 'react';
|
||||
import { GameState, Phase, Step } from '../../types/game';
|
||||
import { Sun, Shield, Swords, ArrowRightToLine, Hourglass } from 'lucide-react';
|
||||
|
||||
interface PhaseStripProps {
|
||||
gameState: GameState;
|
||||
}
|
||||
|
||||
export const PhaseStrip: React.FC<PhaseStripProps> = ({ gameState }) => {
|
||||
const currentPhase = gameState.phase as Phase;
|
||||
const currentStep = gameState.step as Step;
|
||||
|
||||
// Phase Definitions
|
||||
const phases: { id: Phase; icon: React.ElementType; label: string }[] = [
|
||||
{ id: 'beginning', icon: Sun, label: 'Beginning' },
|
||||
{ id: 'main1', icon: Shield, label: 'Main 1' },
|
||||
{ id: 'combat', icon: Swords, label: 'Combat' },
|
||||
{ id: 'main2', icon: Shield, label: 'Main 2' },
|
||||
{ id: 'ending', icon: Hourglass, label: 'End' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex bg-black/40 backdrop-blur-md rounded-full p-1 border border-white/10 gap-1">
|
||||
{phases.map((p) => {
|
||||
const isActive = p.id === currentPhase;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={p.id}
|
||||
className={`
|
||||
relative flex items-center justify-center w-8 h-8 rounded-full transition-all duration-300
|
||||
${isActive ? 'bg-emerald-500 text-white shadow-[0_0_10px_rgba(16,185,129,0.5)] scale-110 z-10' : 'text-slate-500 bg-transparent hover:bg-white/5'}
|
||||
`}
|
||||
title={p.label}
|
||||
>
|
||||
<p.icon size={16} />
|
||||
|
||||
{/* Active Step Indicator (Text below or Tooltip) */}
|
||||
{isActive && (
|
||||
<span className="absolute -bottom-6 left-1/2 -translate-x-1/2 text-[10px] font-bold text-white uppercase tracking-wider whitespace-nowrap bg-black/80 px-2 py-0.5 rounded border border-white/10">
|
||||
{currentStep}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
61
src/client/src/modules/game/SmartButton.tsx
Normal file
61
src/client/src/modules/game/SmartButton.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
|
||||
import React from 'react';
|
||||
import { GameState } from '../../types/game';
|
||||
|
||||
interface SmartButtonProps {
|
||||
gameState: GameState;
|
||||
playerId: string;
|
||||
onAction: (type: string, payload?: any) => void;
|
||||
}
|
||||
|
||||
export const SmartButton: React.FC<SmartButtonProps> = ({ gameState, playerId, onAction }) => {
|
||||
const isMyPriority = gameState.priorityPlayerId === playerId;
|
||||
const isStackEmpty = !gameState.stack || gameState.stack.length === 0;
|
||||
|
||||
let label = "Wait";
|
||||
let colorClass = "bg-slate-700 text-slate-400 cursor-not-allowed";
|
||||
let actionType: string | null = null;
|
||||
|
||||
if (isMyPriority) {
|
||||
if (isStackEmpty) {
|
||||
// Pass Priority / Advance Step
|
||||
// If Main Phase, could technically play land/cast, but button defaults to Pass
|
||||
label = "Pass Turn/Phase";
|
||||
// If we want more granular: "Move to Combat" vs "End Turn" based on phase
|
||||
if (gameState.phase === 'main1') label = "Pass to Combat";
|
||||
else if (gameState.phase === 'main2') label = "End Turn";
|
||||
else label = "Pass";
|
||||
|
||||
colorClass = "bg-emerald-600 hover:bg-emerald-500 text-white shadow-[0_0_15px_rgba(16,185,129,0.5)] animate-pulse";
|
||||
actionType = 'PASS_PRIORITY';
|
||||
} else {
|
||||
// Resolve Top Item
|
||||
const topItem = gameState.stack![gameState.stack!.length - 1];
|
||||
label = `Resolve ${topItem?.name || 'Item'}`;
|
||||
colorClass = "bg-amber-600 hover:bg-amber-500 text-white shadow-[0_0_15px_rgba(245,158,11,0.5)]";
|
||||
actionType = 'PASS_PRIORITY'; // Resolving is just passing priority when stack not empty
|
||||
}
|
||||
}
|
||||
|
||||
const handleClick = () => {
|
||||
if (actionType) {
|
||||
onAction('game_strict_action', { type: actionType });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
disabled={!isMyPriority}
|
||||
className={`
|
||||
px-6 py-3 rounded-xl font-bold text-lg uppercase tracking-wider transition-all duration-300
|
||||
${colorClass}
|
||||
border border-white/10
|
||||
flex items-center justify-center
|
||||
min-w-[200px]
|
||||
`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
76
src/client/src/modules/game/StackVisualizer.tsx
Normal file
76
src/client/src/modules/game/StackVisualizer.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
|
||||
import React from 'react';
|
||||
import { StackObject, GameState } from '../../types/game';
|
||||
import { ArrowLeft, Sparkles } from 'lucide-react';
|
||||
|
||||
interface StackVisualizerProps {
|
||||
gameState: GameState;
|
||||
onResolve?: () => void; // Optional fast-action helper
|
||||
}
|
||||
|
||||
export const StackVisualizer: React.FC<StackVisualizerProps> = ({ gameState, onResolve }) => {
|
||||
const stack = gameState.stack || [];
|
||||
|
||||
if (stack.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute right-4 top-1/2 -translate-y-1/2 flex flex-col-reverse gap-2 z-50 pointer-events-none">
|
||||
|
||||
{/* Stack Container */}
|
||||
<div className="flex flex-col-reverse gap-2 items-end">
|
||||
{stack.map((item, index) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`
|
||||
relative group pointer-events-auto
|
||||
w-64 bg-slate-900/90 backdrop-blur-md
|
||||
border-l-4 border-amber-500
|
||||
rounded-r-lg shadow-xl
|
||||
p-3 transform transition-all duration-300
|
||||
hover:scale-105 hover:-translate-x-2
|
||||
flex flex-col gap-1
|
||||
animate-in slide-in-from-right fade-in duration-300
|
||||
`}
|
||||
style={{
|
||||
// Stagger visual for depth
|
||||
marginRight: `${index * 4}px`
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between text-xs text-amber-500 font-bold uppercase tracking-wider">
|
||||
<span>{item.type}</span>
|
||||
<Sparkles size={12} />
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<div className="text-white font-bold leading-tight">
|
||||
{item.name}
|
||||
</div>
|
||||
|
||||
{/* Targets (if any) */}
|
||||
{item.targets && item.targets.length > 0 && (
|
||||
<div className="text-xs text-slate-400 mt-1 flex items-center gap-1">
|
||||
<ArrowLeft size={10} />
|
||||
<span>Targets {item.targets.length} item(s)</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Index Indicator */}
|
||||
<div className="absolute -left-3 top-1/2 -translate-y-1/2 w-6 h-6 bg-amber-600 rounded-full flex items-center justify-center text-xs font-bold text-white border-2 border-slate-900 shadow-lg">
|
||||
{index + 1}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Label */}
|
||||
<div className="text-right pr-2">
|
||||
<span className="text-amber-500/50 text-[10px] font-bold uppercase tracking-[0.2em] [writing-mode:vertical-rl] rotate-180">
|
||||
The Stack
|
||||
</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,3 +1,22 @@
|
||||
|
||||
export type Phase = 'beginning' | 'main1' | 'combat' | 'main2' | 'ending';
|
||||
|
||||
export type Step =
|
||||
| 'untap' | 'upkeep' | 'draw'
|
||||
| 'main'
|
||||
| 'beginning_combat' | 'declare_attackers' | 'declare_blockers' | 'combat_damage' | 'end_combat'
|
||||
| 'end' | 'cleanup';
|
||||
|
||||
export interface StackObject {
|
||||
id: string;
|
||||
sourceId: string;
|
||||
controllerId: string;
|
||||
type: 'spell' | 'ability' | 'trigger';
|
||||
name: string;
|
||||
text: string;
|
||||
targets: string[];
|
||||
}
|
||||
|
||||
export interface CardInstance {
|
||||
instanceId: string;
|
||||
oracleId: string; // Scryfall ID
|
||||
@@ -5,7 +24,7 @@ export interface CardInstance {
|
||||
imageUrl: string;
|
||||
controllerId: string;
|
||||
ownerId: string;
|
||||
zone: 'library' | 'hand' | 'battlefield' | 'graveyard' | 'exile' | 'command';
|
||||
zone: 'library' | 'hand' | 'battlefield' | 'graveyard' | 'exile' | 'command' | 'stack';
|
||||
tapped: boolean;
|
||||
faceDown: boolean;
|
||||
position: { x: number; y: number; z: number }; // For freeform placement
|
||||
@@ -23,6 +42,7 @@ export interface PlayerState {
|
||||
poison: number;
|
||||
energy: number;
|
||||
isActive: boolean;
|
||||
hasPassed?: boolean;
|
||||
}
|
||||
|
||||
export interface GameState {
|
||||
@@ -31,5 +51,10 @@ export interface GameState {
|
||||
cards: Record<string, CardInstance>; // Keyed by instanceId
|
||||
order: string[]; // Turn order (player IDs)
|
||||
turn: number;
|
||||
phase: string;
|
||||
// Strict Mode Extension
|
||||
phase: string | Phase;
|
||||
step?: Step;
|
||||
stack?: StackObject[];
|
||||
activePlayerId?: string; // Explicitly tracked in strict
|
||||
priorityPlayerId?: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user