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:
2025-12-18 17:40:36 +01:00
parent a2a45a995c
commit 842beae419
16 changed files with 10436 additions and 300 deletions

View File

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

View File

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

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

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

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

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

View File

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