feat: Implement initial multiplayer lobby and game room functionality with server-side room management.

This commit is contained in:
2025-12-14 21:42:13 +01:00
parent 1b8ae00da1
commit da643b787f
12 changed files with 713 additions and 26 deletions

View File

@@ -1,10 +1,13 @@
import React, { useState } from 'react';
import { Layers, Box, Trophy } from 'lucide-react';
import { Layers, Box, Trophy, Users } from 'lucide-react';
import { CubeManager } from './modules/cube/CubeManager';
import { TournamentManager } from './modules/tournament/TournamentManager';
import { LobbyManager } from './modules/lobby/LobbyManager';
import { Pack } from './services/PackGeneratorService';
export const App: React.FC = () => {
const [activeTab, setActiveTab] = useState<'draft' | 'bracket'>('draft');
const [activeTab, setActiveTab] = useState<'draft' | 'bracket' | 'lobby'>('draft');
const [generatedPacks, setGeneratedPacks] = useState<Pack[]>([]);
return (
<div className="min-h-screen bg-slate-900 text-slate-100 font-sans pb-20">
@@ -21,22 +24,35 @@ export const App: React.FC = () => {
<div className="flex bg-slate-900 rounded-lg p-1 border border-slate-700">
<button
onClick={() => setActiveTab('draft')}
className={`px-4 py-2 rounded-md text-sm font-bold flex items-center gap-2 transition-all ${activeTab === 'draft' ? 'bg-purple-600 text-white' : 'text-slate-400 hover:text-white'}`}
className={`px-3 md:px-4 py-2 rounded-md text-sm font-bold flex items-center gap-2 transition-all ${activeTab === 'draft' ? 'bg-purple-600 text-white' : 'text-slate-400 hover:text-white'}`}
>
<Box className="w-4 h-4" /> Draft Management
<Box className="w-4 h-4" /> <span className="hidden md:inline">Draft Management</span>
</button>
<button
onClick={() => setActiveTab('lobby')}
className={`px-3 md:px-4 py-2 rounded-md text-sm font-bold flex items-center gap-2 transition-all ${activeTab === 'lobby' ? 'bg-emerald-600 text-white' : 'text-slate-400 hover:text-white'}`}
>
<Users className="w-4 h-4" /> <span className="hidden md:inline">Online Lobby</span>
</button>
<button
onClick={() => setActiveTab('bracket')}
className={`px-4 py-2 rounded-md text-sm font-bold flex items-center gap-2 transition-all ${activeTab === 'bracket' ? 'bg-blue-600 text-white' : 'text-slate-400 hover:text-white'}`}
className={`px-3 md:px-4 py-2 rounded-md text-sm font-bold flex items-center gap-2 transition-all ${activeTab === 'bracket' ? 'bg-blue-600 text-white' : 'text-slate-400 hover:text-white'}`}
>
<Trophy className="w-4 h-4" /> Tournament / Bracket
<Trophy className="w-4 h-4" /> <span className="hidden md:inline">Tournament</span>
</button>
</div>
</div>
</header>
<main>
{activeTab === 'draft' && <CubeManager />}
{activeTab === 'draft' && (
<CubeManager
packs={generatedPacks}
setPacks={setGeneratedPacks}
onGoToLobby={() => setActiveTab('lobby')}
/>
)}
{activeTab === 'lobby' && <LobbyManager generatedPacks={generatedPacks} />}
{activeTab === 'bracket' && <TournamentManager />}
</main>
</div>

View File

@@ -1,11 +1,17 @@
import React, { useState, useRef, useEffect } from 'react';
import { Layers, RotateCcw, Box, Check, Loader2, Upload, LayoutGrid, List, Sliders, Settings } from 'lucide-react';
import { Layers, RotateCcw, Box, Check, Loader2, Upload, LayoutGrid, List, Sliders, Settings, Users } from 'lucide-react';
import { CardParserService } from '../../services/CardParserService';
import { ScryfallService, ScryfallCard, ScryfallSet } from '../../services/ScryfallService';
import { PackGeneratorService, ProcessedPools, SetsMap, Pack, PackGenerationSettings } from '../../services/PackGeneratorService';
import { PackCard } from '../../components/PackCard';
export const CubeManager: React.FC = () => {
interface CubeManagerProps {
packs: Pack[];
setPacks: React.Dispatch<React.SetStateAction<Pack[]>>;
onGoToLobby: () => void;
}
export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, onGoToLobby }) => {
// --- Services ---
// --- Services ---
// Memoize services to persist cache across renders
@@ -27,8 +33,6 @@ export const CubeManager: React.FC = () => {
ignoreTokens: true
});
const [packs, setPacks] = useState<Pack[]>([]);
// UI State
const [viewMode, setViewMode] = useState<'list' | 'grid' | 'stack'>('list');
@@ -378,10 +382,22 @@ export const CubeManager: React.FC = () => {
</h2>
</div>
<div className="flex bg-slate-800 rounded-lg p-1 border border-slate-700">
<button onClick={() => setViewMode('list')} className={`p-2 rounded ${viewMode === 'list' ? 'bg-slate-600 text-white' : 'text-slate-400'}`}><List className="w-4 h-4" /></button>
<button onClick={() => setViewMode('grid')} className={`p-2 rounded ${viewMode === 'grid' ? 'bg-slate-600 text-white' : 'text-slate-400'}`}><LayoutGrid className="w-4 h-4" /></button>
<button onClick={() => setViewMode('stack')} className={`p-2 rounded ${viewMode === 'stack' ? 'bg-slate-600 text-white' : 'text-slate-400'}`}><Layers className="w-4 h-4" /></button>
<div className="flex gap-2">
{/* Play Button */}
{packs.length > 0 && (
<button
onClick={onGoToLobby}
className="px-4 py-2 bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 text-white font-bold rounded-lg shadow-lg flex items-center gap-2 animate-in fade-in zoom-in"
>
<Users className="w-4 h-4" /> <span className="hidden sm:inline">Play Online</span>
</button>
)}
<div className="flex bg-slate-800 rounded-lg p-1 border border-slate-700">
<button onClick={() => setViewMode('list')} className={`p-2 rounded ${viewMode === 'list' ? 'bg-slate-600 text-white' : 'text-slate-400'}`}><List className="w-4 h-4" /></button>
<button onClick={() => setViewMode('grid')} className={`p-2 rounded ${viewMode === 'grid' ? 'bg-slate-600 text-white' : 'text-slate-400'}`}><LayoutGrid className="w-4 h-4" /></button>
<button onClick={() => setViewMode('stack')} className={`p-2 rounded ${viewMode === 'stack' ? 'bg-slate-600 text-white' : 'text-slate-400'}`}><Layers className="w-4 h-4" /></button>
</div>
</div>
</div>

View File

@@ -0,0 +1,179 @@
import React, { useState, useEffect, useRef } from 'react';
import { socketService } from '../../services/SocketService';
import { Users, MessageSquare, Send, Play, Copy, Check } from 'lucide-react';
interface Player {
id: string;
name: string;
isHost: boolean;
role: 'player' | 'spectator';
}
interface ChatMessage {
id: string;
sender: string;
text: string;
timestamp: string;
}
interface Room {
id: string;
hostId: string;
players: Player[];
status: string;
messages: ChatMessage[];
}
interface GameRoomProps {
room: Room;
currentPlayerId: string;
}
export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPlayerId }) => {
const [room, setRoom] = useState<Room>(initialRoom);
const [message, setMessage] = useState('');
const [messages, setMessages] = useState<ChatMessage[]>(initialRoom.messages || []);
const messagesEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setRoom(initialRoom);
setMessages(initialRoom.messages || []);
}, [initialRoom]);
useEffect(() => {
const socket = socketService.socket;
const handleRoomUpdate = (updatedRoom: Room) => {
console.log('Room updated:', updatedRoom);
setRoom(updatedRoom);
};
const handleNewMessage = (msg: ChatMessage) => {
setMessages(prev => [...prev, msg]);
};
socket.on('room_update', handleRoomUpdate);
socket.on('new_message', handleNewMessage);
return () => {
socket.off('room_update', handleRoomUpdate);
socket.off('new_message', handleNewMessage);
};
}, []);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
const sendMessage = (e: React.FormEvent) => {
e.preventDefault();
if (!message.trim()) return;
const me = room.players.find(p => p.id === currentPlayerId);
socketService.socket.emit('send_message', {
roomId: room.id,
sender: me?.name || 'Unknown',
text: message
});
setMessage('');
};
const copyRoomId = () => {
navigator.clipboard.writeText(room.id);
// Could show a toast here
};
return (
<div className="flex h-[calc(100vh-100px)] gap-4">
{/* Main Game Area (Placeholder for now) */}
<div className="flex-1 bg-slate-800 rounded-xl p-6 border border-slate-700 shadow-xl flex flex-col items-center justify-center">
<h2 className="text-3xl font-bold text-white mb-4">Waiting for Players...</h2>
<div className="flex items-center gap-4 bg-slate-900 px-6 py-3 rounded-xl border border-slate-700">
<span className="text-slate-400 uppercase text-xs font-bold tracking-wider">Room Code</span>
<code className="text-2xl font-mono text-emerald-400 font-bold tracking-widest">{room.id}</code>
<button onClick={copyRoomId} className="p-2 text-slate-400 hover:text-white transition-colors" title="Copy Code">
<Copy className="w-5 h-5" />
</button>
</div>
<div className="mt-8 text-center text-slate-400">
<p>Share the code with your friends to join.</p>
<p className="text-sm mt-2">
<span className="text-emerald-400 font-bold">{room.players.filter(p => p.role === 'player').length}</span> / 8 Players Joined
</p>
<p className="text-xs mt-1 text-slate-500">
{room.players.length} total connected (including spectators)
</p>
</div>
{room.players.find(p => p.id === currentPlayerId)?.isHost && (
<button
onClick={() => socketService.socket.emit('start_game', { roomId: room.id })}
disabled={room.status !== 'waiting'}
className="mt-8 px-8 py-3 bg-emerald-600 hover:bg-emerald-500 text-white font-bold rounded-lg flex items-center gap-2 shadow-lg shadow-emerald-900/20 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
<Play className="w-5 h-5" /> {room.status === 'waiting' ? 'Start Draft' : 'Draft in Progress'}
</button>
)}
</div>
{/* Sidebar: Players & Chat */}
<div className="w-80 flex flex-col gap-4">
{/* Players List */}
<div className="flex-1 bg-slate-800 rounded-xl p-4 border border-slate-700 shadow-xl overflow-hidden flex flex-col">
<h3 className="text-sm font-bold text-slate-400 uppercase mb-3 flex items-center gap-2">
<Users className="w-4 h-4" /> Lobby
</h3>
<div className="flex-1 overflow-y-auto space-y-2 pr-1">
{room.players.map(p => (
<div key={p.id} className="flex items-center justify-between bg-slate-900/50 p-2 rounded-lg border border-slate-700/50">
<div className="flex items-center gap-2">
<div className={`w-8 h-8 rounded-full flex items-center justify-center font-bold text-xs ${p.role === 'spectator' ? 'bg-slate-700 text-slate-300' : 'bg-gradient-to-br from-purple-500 to-blue-500 text-white'}`}>
{p.name.substring(0, 2).toUpperCase()}
</div>
<div className="flex flex-col">
<span className={`text-sm font-medium ${p.id === currentPlayerId ? 'text-white' : 'text-slate-300'}`}>
{p.name}
</span>
<span className="text-[10px] uppercase font-bold tracking-wider text-slate-500">
{p.role} {p.isHost && <span className="text-amber-500 ml-1"> Host</span>}
</span>
</div>
</div>
</div>
))}
</div>
</div>
{/* Chat */}
<div className="h-1/2 bg-slate-800 rounded-xl p-4 border border-slate-700 shadow-xl flex flex-col">
<h3 className="text-sm font-bold text-slate-400 uppercase mb-3 flex items-center gap-2">
<MessageSquare className="w-4 h-4" /> Chat
</h3>
<div className="flex-1 overflow-y-auto space-y-2 mb-3 pr-1 custom-scrollbar">
{messages.map(msg => (
<div key={msg.id} className="text-sm">
<span className="font-bold text-purple-400 text-xs">{msg.sender}: </span>
<span className="text-slate-300">{msg.text}</span>
</div>
))}
<div ref={messagesEndRef} />
</div>
<form onSubmit={sendMessage} className="flex gap-2">
<input
type="text"
value={message}
onChange={e => setMessage(e.target.value)}
className="flex-1 bg-slate-900 border border-slate-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-purple-500"
placeholder="Type..."
/>
<button type="submit" className="p-2 bg-purple-600 hover:bg-purple-500 rounded-lg text-white transition-colors">
<Send className="w-4 h-4" />
</button>
</form>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,166 @@
import React, { useState } from 'react';
import { socketService } from '../../services/SocketService';
import { GameRoom } from './GameRoom';
import { Pack } from '../../services/PackGeneratorService';
import { Users, PlusCircle, LogIn, AlertCircle } from 'lucide-react';
interface LobbyManagerProps {
generatedPacks: Pack[];
}
export const LobbyManager: React.FC<LobbyManagerProps> = ({ generatedPacks }) => {
const [activeRoom, setActiveRoom] = useState<any>(null);
const [playerName, setPlayerName] = useState('');
const [joinRoomId, setJoinRoomId] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const [playerId] = useState(() => Math.random().toString(36).substring(2) + Date.now().toString(36)); // Simple persistent ID
const connect = () => {
if (!socketService.socket.connected) {
socketService.connect();
}
};
const handleCreateRoom = async () => {
if (!playerName) {
setError('Please enter your name');
return;
}
if (generatedPacks.length === 0) {
setError('No packs generated! Please go to Draft Management and generate packs first.');
return;
}
setLoading(true);
setError('');
connect();
try {
const response = await socketService.emitPromise('create_room', {
hostId: playerId,
hostName: playerName,
packs: generatedPacks
});
if (response.success) {
setActiveRoom(response.room);
} else {
setError(response.message || 'Failed to create room');
}
} catch (err: any) {
setError(err.message || 'Connection error');
} finally {
setLoading(false);
}
};
const handleJoinRoom = async () => {
if (!playerName) {
setError('Please enter your name');
return;
}
if (!joinRoomId) {
setError('Please enter a Room ID');
return;
}
setLoading(true);
setError('');
connect();
try {
const response = await socketService.emitPromise('join_room', {
roomId: joinRoomId.toUpperCase(),
playerId,
playerName
});
if (response.success) {
setActiveRoom(response.room);
} else {
setError(response.message || 'Failed to join room');
}
} catch (err: any) {
setError(err.message || 'Connection error');
} finally {
setLoading(false);
}
};
if (activeRoom) {
return <GameRoom room={activeRoom} currentPlayerId={playerId} />;
}
return (
<div className="max-w-4xl mx-auto p-4 md:p-10">
<div className="bg-slate-800 rounded-2xl p-8 border border-slate-700 shadow-2xl">
<h2 className="text-3xl font-bold text-white mb-2 flex items-center gap-3">
<Users className="w-8 h-8 text-purple-500" /> Multiplayer Lobby
</h2>
<p className="text-slate-400 mb-8">Create a private room for your draft or join an existing one.</p>
{error && (
<div className="bg-red-900/50 border border-red-500 text-red-200 p-4 rounded-xl mb-6 flex items-center gap-3">
<AlertCircle className="w-5 h-5" />
{error}
</div>
)}
<div className="space-y-6">
<div>
<label className="block text-sm font-bold text-slate-300 mb-2">Your Name</label>
<input
type="text"
value={playerName}
onChange={(e) => setPlayerName(e.target.value)}
placeholder="Enter your nickname..."
className="w-full bg-slate-900 border border-slate-700 rounded-xl p-4 text-white focus:ring-2 focus:ring-purple-500 outline-none text-lg"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 pt-4 border-t border-slate-700">
{/* Create Room */}
<div className={`space-y-4 ${generatedPacks.length === 0 ? 'opacity-50' : ''}`}>
<h3 className="text-xl font-bold text-white">Create Room</h3>
<p className="text-sm text-slate-400">Start a new draft with your {generatedPacks.length} generated packs.</p>
<button
onClick={handleCreateRoom}
disabled={loading || generatedPacks.length === 0}
className="w-full py-4 bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 text-white font-bold rounded-xl shadow-lg transform transition hover:scale-[1.02] flex justify-center items-center gap-2 disabled:cursor-not-allowed disabled:grayscale"
>
<PlusCircle className="w-5 h-5" /> {loading ? 'Creating...' : 'Create Private Room'}
</button>
{generatedPacks.length === 0 && (
<p className="text-xs text-amber-500 text-center font-bold">Requires packs from Draft Management tab.</p>
)}
</div>
{/* Join Room */}
<div className="space-y-4">
<h3 className="text-xl font-bold text-white">Join Room</h3>
<p className="text-sm text-slate-400">Enter a code shared by your friend.</p>
<div className="flex gap-2">
<input
type="text"
value={joinRoomId}
onChange={(e) => setJoinRoomId(e.target.value)}
placeholder="ROOM CODE"
className="flex-1 bg-slate-900 border border-slate-700 rounded-xl p-4 text-white font-mono uppercase text-lg text-center tracking-widest focus:ring-2 focus:ring-blue-500 outline-none"
/>
</div>
<button
onClick={handleJoinRoom}
disabled={loading}
className="w-full py-4 bg-blue-600 hover:bg-blue-500 text-white font-bold rounded-xl shadow-lg transform transition hover:scale-[1.02] flex justify-center items-center gap-2"
>
<LogIn className="w-5 h-5" /> {loading ? 'Joining...' : 'Join Room'}
</button>
</div>
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,37 @@
import { io, Socket } from 'socket.io-client';
const URL = `http://${window.location.hostname}:3000`;
class SocketService {
public socket: Socket;
constructor() {
this.socket = io(URL, {
autoConnect: false
});
}
connect() {
this.socket.connect();
}
disconnect() {
this.socket.disconnect();
}
// Helper method to make requests with acknowledgements
emitPromise(event: string, data: any): Promise<any> {
return new Promise((resolve, reject) => {
this.socket.emit(event, data, (response: any) => {
if (response?.error) {
reject(response.error);
} else {
resolve(response);
}
});
});
}
}
export const socketService = new SocketService();