implemented game server sync

This commit is contained in:
2025-12-18 17:24:07 +01:00
parent e31323859f
commit a2a45a995c
15 changed files with 708 additions and 146 deletions

View File

@@ -1,4 +1,5 @@
import React, { useRef, useState, useEffect } from 'react';
import React, { useRef, useState, useEffect, useCallback } from 'react';
import { ChevronLeft, Eye } from 'lucide-react';
import { GameState, CardInstance } from '../../types/game';
import { socketService } from '../../services/SocketService';
import { CardComponent } from './CardComponent';
@@ -12,10 +13,79 @@ interface GameViewProps {
export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }) => {
const battlefieldRef = useRef<HTMLDivElement>(null);
const sidebarRef = useRef<HTMLDivElement>(null);
const [contextMenu, setContextMenu] = useState<ContextMenuRequest | null>(null);
const [viewingZone, setViewingZone] = useState<string | null>(null);
const [hoveredCard, setHoveredCard] = useState<CardInstance | null>(null);
// --- Sidebar State ---
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() => {
return localStorage.getItem('game_sidebarCollapsed') === 'true';
});
const [sidebarWidth, setSidebarWidth] = useState(() => {
const saved = localStorage.getItem('game_sidebarWidth');
return saved ? parseInt(saved, 10) : 320;
});
const resizingState = useRef<{
startX: number,
startWidth: number,
active: boolean
}>({ startX: 0, startWidth: 0, active: false });
// --- Persistence ---
useEffect(() => {
localStorage.setItem('game_sidebarCollapsed', isSidebarCollapsed.toString());
}, [isSidebarCollapsed]);
useEffect(() => {
localStorage.setItem('game_sidebarWidth', sidebarWidth.toString());
}, [sidebarWidth]);
useEffect(() => {
if (sidebarRef.current) sidebarRef.current.style.width = `${sidebarWidth}px`;
}, []);
// --- Resize Handlers ---
const handleResizeStart = (e: React.MouseEvent | React.TouchEvent) => {
if (e.cancelable) e.preventDefault();
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;
resizingState.current = {
startX: clientX,
startWidth: sidebarRef.current?.getBoundingClientRect().width || 320,
active: true
};
document.addEventListener('mousemove', onResizeMove);
document.addEventListener('touchmove', onResizeMove, { passive: false });
document.addEventListener('mouseup', onResizeEnd);
document.addEventListener('touchend', onResizeEnd);
document.body.style.cursor = 'col-resize';
};
const onResizeMove = useCallback((e: MouseEvent | TouchEvent) => {
if (!resizingState.current.active || !sidebarRef.current) return;
if (e.cancelable) e.preventDefault();
const clientX = (e as TouchEvent).touches ? (e as TouchEvent).touches[0].clientX : (e as MouseEvent).clientX;
const delta = clientX - resizingState.current.startX;
const newWidth = Math.max(200, Math.min(600, resizingState.current.startWidth + delta));
sidebarRef.current.style.width = `${newWidth}px`;
}, []);
const onResizeEnd = useCallback(() => {
if (resizingState.current.active && sidebarRef.current) {
setSidebarWidth(parseInt(sidebarRef.current.style.width));
}
resizingState.current.active = false;
document.removeEventListener('mousemove', onResizeMove);
document.removeEventListener('touchmove', onResizeMove);
document.removeEventListener('mouseup', onResizeEnd);
document.removeEventListener('touchend', onResizeEnd);
document.body.style.cursor = 'default';
}, []);
useEffect(() => {
// Disable default context menu
const handleContext = (e: MouseEvent) => e.preventDefault();
@@ -152,52 +222,111 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
)}
{/* Zoom Sidebar */}
<div className="hidden xl:flex w-80 shrink-0 flex-col items-center justify-start pt-8 border-r border-slate-800 bg-slate-950/50 z-30 p-4 relative shadow-2xl">
{hoveredCard ? (
<div className="animate-in fade-in slide-in-from-left-4 duration-200 sticky top-4 w-full h-[calc(100vh-2rem)] overflow-y-auto [&::-webkit-scrollbar]:hidden [-ms-overflow-style:'none'] [scrollbar-width:'none']">
<img
src={hoveredCard.imageUrl}
alt={hoveredCard.name}
className="w-full rounded-xl shadow-2xl shadow-black ring-1 ring-white/10"
/>
<div className="mt-4 text-center pb-4">
<h3 className="text-lg font-bold text-slate-200 leading-tight">{hoveredCard.name}</h3>
{isSidebarCollapsed ? (
<div key="collapsed" className="hidden xl:flex shrink-0 w-12 flex-col items-center py-4 bg-slate-900 border-r border-slate-800 z-30 gap-4 transition-all duration-300">
<button
onClick={() => setIsSidebarCollapsed(false)}
className="p-3 rounded-xl transition-all duration-200 group relative text-slate-500 hover:text-purple-400 hover:bg-slate-800"
title="Expand Preview"
>
<Eye className="w-6 h-6" />
<span className="absolute left-full ml-3 top-1/2 -translate-y-1/2 bg-slate-800 text-white text-xs font-bold px-2 py-1 rounded shadow-xl opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none ring-1 ring-white/10 z-50">
Card Preview
</span>
</button>
</div>
) : (
<div
key="expanded"
ref={sidebarRef}
className="hidden xl:flex shrink-0 flex-col items-center justify-start pt-4 border-r border-slate-800 bg-slate-900 z-30 p-4 relative group/sidebar shadow-2xl"
style={{ width: sidebarWidth }}
>
{/* Collapse Button */}
<button
onClick={() => setIsSidebarCollapsed(true)}
className="absolute top-2 right-2 p-1.5 bg-slate-800/80 hover:bg-slate-700 text-slate-400 hover:text-white rounded-lg transition-colors z-20 opacity-0 group-hover/sidebar:opacity-100"
title="Collapse Preview"
>
<ChevronLeft className="w-4 h-4" />
</button>
{hoveredCard.manaCost && (
<p className="text-sm text-slate-400 mt-1 font-mono tracking-widest">{hoveredCard.manaCost}</p>
)}
{hoveredCard.typeLine && (
<div className="text-xs text-emerald-400 uppercase tracking-wider font-bold mt-2 border-b border-white/10 pb-2 mb-3">
{hoveredCard.typeLine}
<div className="w-full relative sticky top-4 flex flex-col h-full overflow-hidden">
<div className="relative w-full aspect-[2.5/3.5] transition-all duration-300 ease-in-out shrink-0">
<div
className="relative w-full h-full"
style={{
transformStyle: 'preserve-3d',
transform: hoveredCard ? 'rotateY(0deg)' : 'rotateY(180deg)',
transition: 'transform 0.6s cubic-bezier(0.4, 0.0, 0.2, 1)'
}}
>
{/* Front Face (Hovered Card) */}
<div
className="absolute inset-0 w-full h-full bg-slate-900 rounded-xl"
style={{ backfaceVisibility: 'hidden' }}
>
{hoveredCard && (
<img
src={hoveredCard.imageUrl}
alt={hoveredCard.name}
className="w-full h-full object-cover rounded-xl shadow-2xl shadow-black ring-1 ring-white/10"
/>
)}
</div>
)}
{hoveredCard.oracleText && (
<div className="text-sm text-slate-300 text-left bg-slate-900/50 p-3 rounded-lg border border-slate-800 whitespace-pre-wrap leading-relaxed">
{hoveredCard.oracleText}
{/* Back Face (Card Back) */}
<div
className="absolute inset-0 w-full h-full rounded-xl shadow-2xl overflow-hidden bg-slate-900"
style={{
backfaceVisibility: 'hidden',
transform: 'rotateY(180deg)'
}}
>
<img
src="/images/back.jpg"
alt="Card Back"
className="w-full h-full object-cover"
draggable={false}
/>
</div>
)}
{/* Stats for Creatures */}
{hoveredCard.typeLine?.toLowerCase().includes('creature') && (
<div className="mt-3 bg-slate-800/80 rounded px-2 py-1 inline-block border border-slate-600 font-bold text-lg">
{/* Accessing raw PT might be hard if we don't have base PT, but we do have ptModification */}
{/* We don't strictly have base PT in CardInstance yet. Assuming UI mainly uses image. */}
{/* We'll skip P/T text for now as it needs base P/T to be passed from server. */}
</div>
)}
</div>
</div>
{/* Oracle Text & Details - Only when card is hovered */}
{hoveredCard && (
<div className="mt-4 flex-1 overflow-y-auto px-1 [&::-webkit-scrollbar]:hidden [-ms-overflow-style:'none'] [scrollbar-width:'none']">
<h3 className="text-lg font-bold text-slate-200 leading-tight">{hoveredCard.name}</h3>
{hoveredCard.manaCost && (
<p className="text-sm text-slate-400 mt-1 font-mono tracking-widest">{hoveredCard.manaCost}</p>
)}
{hoveredCard.typeLine && (
<div className="text-xs text-emerald-400 uppercase tracking-wider font-bold mt-2 border-b border-white/10 pb-2 mb-3">
{hoveredCard.typeLine}
</div>
)}
{hoveredCard.oracleText && (
<div className="text-sm text-slate-300 text-left bg-slate-900/50 p-3 rounded-lg border border-slate-800 whitespace-pre-wrap leading-relaxed shadow-inner">
{hoveredCard.oracleText}
</div>
)}
</div>
)}
</div>
) : (
<div className="flex flex-col items-center justify-center h-full text-slate-600 text-center opacity-50">
<div className="w-48 h-64 border-2 border-dashed border-slate-700 rounded-xl mb-4 flex items-center justify-center">
<span className="text-xs uppercase font-bold tracking-widest">Hover Card</span>
</div>
<p className="text-sm">Hover over a card to view clear details.</p>
{/* Resize Handle */}
<div
className="absolute right-0 top-0 bottom-0 w-1 bg-transparent hover:bg-emerald-500/50 cursor-col-resize z-50 flex flex-col justify-center items-center group transition-colors touch-none"
onMouseDown={handleResizeStart}
onTouchStart={handleResizeStart}
>
<div className="h-8 w-1 bg-slate-700/50 rounded-full group-hover:bg-emerald-400 transition-colors" />
</div>
)}
</div>
</div>
)}
{/* Main Game Area */}
<div className="flex-1 flex flex-col h-full relative">

View File

@@ -18,6 +18,7 @@ export const LobbyManager: React.FC<LobbyManagerProps> = ({ generatedPacks, avai
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const [initialDraftState, setInitialDraftState] = useState<any>(null);
const [initialGameState, setInitialGameState] = useState<any>(null);
const [playerId] = useState(() => {
const saved = localStorage.getItem('player_id');
@@ -195,6 +196,7 @@ export const LobbyManager: React.FC<LobbyManagerProps> = ({ generatedPacks, avai
if (response.success) {
setInitialDraftState(response.draftState || null);
setInitialGameState(response.gameState || null);
setActiveRoom(response.room);
} else {
setError(response.message || 'Failed to join room');
@@ -227,6 +229,9 @@ export const LobbyManager: React.FC<LobbyManagerProps> = ({ generatedPacks, avai
if (response.draftState) {
setInitialDraftState(response.draftState);
}
if (response.gameState) {
setInitialGameState(response.gameState);
}
} else {
console.warn("Rejoin failed by server: ", response.message);
localStorage.removeItem('active_room_id');
@@ -261,11 +266,12 @@ export const LobbyManager: React.FC<LobbyManagerProps> = ({ generatedPacks, avai
}
setActiveRoom(null);
setInitialDraftState(null);
setInitialGameState(null);
localStorage.removeItem('active_room_id');
};
if (activeRoom) {
return <GameRoom room={activeRoom} currentPlayerId={playerId} onExit={handleExitRoom} initialDraftState={initialDraftState} />;
return <GameRoom room={activeRoom} currentPlayerId={playerId} onExit={handleExitRoom} initialDraftState={initialDraftState} initialGameState={initialGameState} />;
}
return (