feat: Add hover border effect to cards when they are in the battlefield zone.
This commit is contained in:
@@ -82,7 +82,7 @@ define(['./workbox-5a5d9309'], (function (workbox) { 'use strict';
|
|||||||
"revision": "3ca0b8505b4bec776b69afdba2768812"
|
"revision": "3ca0b8505b4bec776b69afdba2768812"
|
||||||
}, {
|
}, {
|
||||||
"url": "index.html",
|
"url": "index.html",
|
||||||
"revision": "0.r2hp08ujhtk"
|
"revision": "0.sortnjvj4s8"
|
||||||
}], {});
|
}], {});
|
||||||
workbox.cleanupOutdatedCaches();
|
workbox.cleanupOutdatedCaches();
|
||||||
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
|
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
|
||||||
|
|||||||
93
src/client/src/components/GameToast.tsx
Normal file
93
src/client/src/components/GameToast.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import React, { createContext, useContext, useState, useCallback } from 'react';
|
||||||
|
import { AlertCircle, CheckCircle, Info, XCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
type GameToastType = 'success' | 'error' | 'info' | 'warning' | 'game-event';
|
||||||
|
|
||||||
|
interface GameToast {
|
||||||
|
id: string;
|
||||||
|
message: string;
|
||||||
|
type: GameToastType;
|
||||||
|
duration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GameToastContextType {
|
||||||
|
showGameToast: (message: string, type?: GameToastType, duration?: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GameToastContext = createContext<GameToastContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export const useGameToast = () => {
|
||||||
|
const context = useContext(GameToastContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useGameToast must be used within a GameToastProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GameToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
const [toasts, setToasts] = useState<GameToast[]>([]);
|
||||||
|
|
||||||
|
// Use a ref to keep track of timeouts so we can clear them (optional, but good practice)
|
||||||
|
// For simplicity here, we just use the timeout inside the callback.
|
||||||
|
|
||||||
|
const showGameToast = useCallback((message: string, type: GameToastType = 'info', duration = 3000) => {
|
||||||
|
const id = Math.random().toString(36).substring(2, 9);
|
||||||
|
setToasts((prev) => [...prev, { id, message, type, duration }]);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||||
|
}, duration);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GameToastContext.Provider value={{ showGameToast }}>
|
||||||
|
{children}
|
||||||
|
{/*
|
||||||
|
Positioning:
|
||||||
|
We want this to be distinct from the system toast (top-center).
|
||||||
|
Let's put it top-center but slightly lower, OR bottom-center?
|
||||||
|
User request: "dedicated toast".
|
||||||
|
Let's try "Center Top" but with a very distinct style, possibly overlaying the game board directly.
|
||||||
|
Actually, let's put it at the TOP CENTER, but occupying a dedicated space or just below the system header.
|
||||||
|
|
||||||
|
Using z-[1000] to ensure it's above everything in the game.
|
||||||
|
*/}
|
||||||
|
<div className="fixed top-20 left-1/2 -translate-x-1/2 z-[1000] flex flex-col gap-2 pointer-events-none w-full max-w-md px-4 items-center">
|
||||||
|
{toasts.map((toast) => (
|
||||||
|
<div
|
||||||
|
key={toast.id}
|
||||||
|
className={`
|
||||||
|
pointer-events-auto
|
||||||
|
flex items-center gap-3 px-6 py-3 rounded-full shadow-[0_0_15px_rgba(0,0,0,0.5)]
|
||||||
|
animate-in slide-in-from-top-4 fade-in zoom-in-95 duration-200
|
||||||
|
border backdrop-blur-md
|
||||||
|
${toastStyles[toast.type]}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{getIcon(toast.type)}
|
||||||
|
<span className="font-bold text-sm tracking-wide text-shadow-sm">{toast.message}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</GameToastContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toastStyles: Record<GameToastType, string> = {
|
||||||
|
success: 'bg-emerald-900/90 border-emerald-500/50 text-emerald-100',
|
||||||
|
error: 'bg-red-900/90 border-red-500/50 text-red-100',
|
||||||
|
warning: 'bg-amber-900/90 border-amber-500/50 text-amber-100',
|
||||||
|
info: 'bg-slate-900/90 border-slate-500/50 text-slate-100',
|
||||||
|
'game-event': 'bg-indigo-900/90 border-indigo-500/50 text-indigo-100',
|
||||||
|
};
|
||||||
|
|
||||||
|
const getIcon = (type: GameToastType) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'success': return <CheckCircle className="w-5 h-5 text-emerald-400" />;
|
||||||
|
case 'error': return <XCircle className="w-5 h-5 text-red-400" />;
|
||||||
|
case 'warning': return <AlertCircle className="w-5 h-5 text-amber-400" />;
|
||||||
|
case 'info': return <Info className="w-5 h-5 text-blue-400" />;
|
||||||
|
case 'game-event': return <Info className="w-5 h-5 text-indigo-400" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -71,7 +71,7 @@ export const CardComponent: React.FC<CardComponentProps> = ({ card, onDragStart,
|
|||||||
opacity: card.tapped ? 0.5 : style?.opacity ?? 1
|
opacity: card.tapped ? 0.5 : style?.opacity ?? 1
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="w-full h-full relative rounded-lg bg-slate-800 border-2 border-slate-700">
|
<div className={`w-full h-full relative rounded-lg bg-slate-800 border-2 border-slate-700 ${card.zone === 'battlefield' ? 'hover:border-slate-400' : ''}`}>
|
||||||
<CardVisual
|
<CardVisual
|
||||||
card={card}
|
card={card}
|
||||||
viewMode={viewMode}
|
viewMode={viewMode}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { socketService } from '../../services/SocketService';
|
|||||||
import { Users, LogOut, Copy, Check, MessageSquare, Send, Bell, BellOff, X, Bot, Layers } from 'lucide-react';
|
import { Users, LogOut, Copy, Check, MessageSquare, Send, Bell, BellOff, X, Bot, Layers } from 'lucide-react';
|
||||||
import { useConfirm } from '../../components/ConfirmDialog';
|
import { useConfirm } from '../../components/ConfirmDialog';
|
||||||
import { Modal } from '../../components/Modal';
|
import { Modal } from '../../components/Modal';
|
||||||
import { useToast } from '../../components/Toast';
|
import { useGameToast, GameToastProvider } from '../../components/GameToast';
|
||||||
import { GameView } from '../game/GameView';
|
import { GameView } from '../game/GameView';
|
||||||
import { DraftView } from '../draft/DraftView';
|
import { DraftView } from '../draft/DraftView';
|
||||||
import { TournamentManager as TournamentView } from '../tournament/TournamentManager';
|
import { TournamentManager as TournamentView } from '../tournament/TournamentManager';
|
||||||
@@ -42,7 +42,7 @@ interface GameRoomProps {
|
|||||||
onExit: () => void;
|
onExit: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPlayerId, initialGameState, initialDraftState, onExit }) => {
|
const GameRoomContent: React.FC<GameRoomProps> = ({ room: initialRoom, currentPlayerId, initialGameState, initialDraftState, onExit }) => {
|
||||||
// State
|
// State
|
||||||
const [room, setRoom] = useState<Room>(initialRoom);
|
const [room, setRoom] = useState<Room>(initialRoom);
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
@@ -63,7 +63,7 @@ export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
const { showToast } = useToast();
|
const { showGameToast } = useGameToast();
|
||||||
const { confirm } = useConfirm();
|
const { confirm } = useConfirm();
|
||||||
|
|
||||||
// Restored States
|
// Restored States
|
||||||
@@ -100,14 +100,14 @@ export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
|
|||||||
// 1. New Players
|
// 1. New Players
|
||||||
curr.forEach(p => {
|
curr.forEach(p => {
|
||||||
if (!prev.find(old => old.id === p.id)) {
|
if (!prev.find(old => old.id === p.id)) {
|
||||||
showToast(`${p.name} (${p.role}) joined the room.`, 'info');
|
showGameToast(`${p.name} (${p.role}) joined the room.`, 'info');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2. Left Players
|
// 2. Left Players
|
||||||
prev.forEach(p => {
|
prev.forEach(p => {
|
||||||
if (!curr.find(newP => newP.id === p.id)) {
|
if (!curr.find(newP => newP.id === p.id)) {
|
||||||
showToast(`${p.name} left the room.`, 'warning');
|
showGameToast(`${p.name} left the room.`, 'warning');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -116,16 +116,16 @@ export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
|
|||||||
const old = prev.find(o => o.id === p.id);
|
const old = prev.find(o => o.id === p.id);
|
||||||
if (old) {
|
if (old) {
|
||||||
if (!old.isOffline && p.isOffline) {
|
if (!old.isOffline && p.isOffline) {
|
||||||
showToast(`${p.name} lost connection.`, 'error');
|
showGameToast(`${p.name} lost connection.`, 'error');
|
||||||
}
|
}
|
||||||
if (old.isOffline && !p.isOffline) {
|
if (old.isOffline && !p.isOffline) {
|
||||||
showToast(`${p.name} reconnected!`, 'success');
|
showGameToast(`${p.name} reconnected!`, 'success');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
prevPlayersRef.current = curr;
|
prevPlayersRef.current = curr;
|
||||||
}, [room.players, notificationsEnabled, showToast]);
|
}, [room.players, notificationsEnabled, showGameToast]);
|
||||||
|
|
||||||
// Effects
|
// Effects
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -189,7 +189,7 @@ export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
|
|||||||
|
|
||||||
// Also handle finish
|
// Also handle finish
|
||||||
const handleTournamentFinished = (data: any) => {
|
const handleTournamentFinished = (data: any) => {
|
||||||
showToast(`Tournament Winner: ${data.winner.name}!`, 'success');
|
showGameToast(`Tournament Winner: ${data.winner.name}!`, 'success');
|
||||||
};
|
};
|
||||||
|
|
||||||
socket.on('draft_update', handleDraftUpdate);
|
socket.on('draft_update', handleDraftUpdate);
|
||||||
@@ -222,12 +222,22 @@ export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
|
|||||||
// Only show error if it's for me, or maybe generic "Action Failed"
|
// Only show error if it's for me, or maybe generic "Action Failed"
|
||||||
if (data.userId && data.userId !== currentPlayerId) return; // Don't spam others errors?
|
if (data.userId && data.userId !== currentPlayerId) return; // Don't spam others errors?
|
||||||
|
|
||||||
showToast(data.message, 'error');
|
if (data.userId && data.userId !== currentPlayerId) return; // Don't spam others errors?
|
||||||
|
|
||||||
|
showGameToast(data.message, 'error');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGameNotification = (data: { message: string, type?: 'info' | 'success' | 'warning' | 'error' }) => {
|
||||||
|
showGameToast(data.message, data.type || 'info');
|
||||||
};
|
};
|
||||||
|
|
||||||
socket.on('game_error', handleGameError);
|
socket.on('game_error', handleGameError);
|
||||||
return () => { socket.off('game_error', handleGameError); };
|
socket.on('game_notification', handleGameNotification);
|
||||||
}, [currentPlayerId, showToast]);
|
return () => {
|
||||||
|
socket.off('game_error', handleGameError);
|
||||||
|
socket.off('game_notification', handleGameNotification);
|
||||||
|
};
|
||||||
|
}, [currentPlayerId, showGameToast]);
|
||||||
|
|
||||||
const sendMessage = (e: React.FormEvent) => {
|
const sendMessage = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -324,7 +334,7 @@ export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
|
|||||||
onSubmit={(deck) => {
|
onSubmit={(deck) => {
|
||||||
socketService.socket.emit('match_ready', { matchId: preparingMatchId, deck });
|
socketService.socket.emit('match_ready', { matchId: preparingMatchId, deck });
|
||||||
setPreparingMatchId(null);
|
setPreparingMatchId(null);
|
||||||
showToast("Deck ready! Waiting for game to start...", 'success');
|
showGameToast("Deck ready! Waiting for game to start...", 'success');
|
||||||
}}
|
}}
|
||||||
submitLabel="Ready for Match"
|
submitLabel="Ready for Match"
|
||||||
/>;
|
/>;
|
||||||
@@ -661,3 +671,11 @@ export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const GameRoom: React.FC<GameRoomProps> = (props) => {
|
||||||
|
return (
|
||||||
|
<GameToastProvider>
|
||||||
|
<GameRoomContent {...props} />
|
||||||
|
</GameToastProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ import { EventEmitter } from 'events';
|
|||||||
export class GameManager extends EventEmitter {
|
export class GameManager extends EventEmitter {
|
||||||
public games: Map<string, StrictGameState> = new Map();
|
public games: Map<string, StrictGameState> = new Map();
|
||||||
|
|
||||||
|
// Helper to emit generic game notifications
|
||||||
|
public notify(roomId: string, message: string, type: 'info' | 'success' | 'warning' | 'error' = 'info', targetId?: string) {
|
||||||
|
this.emit('game_notification', roomId, { message, type, targetId });
|
||||||
|
}
|
||||||
|
|
||||||
createGame(gameId: string, players: { id: string; name: string; isBot?: boolean }[]): StrictGameState {
|
createGame(gameId: string, players: { id: string; name: string; isBot?: boolean }[]): StrictGameState {
|
||||||
|
|
||||||
// Convert array to map
|
// Convert array to map
|
||||||
@@ -199,6 +204,7 @@ export class GameManager extends EventEmitter {
|
|||||||
if (game.phase !== 'ending') {
|
if (game.phase !== 'ending') {
|
||||||
console.log(`[GameManager] Game Over. Winner: ${winner.name}`);
|
console.log(`[GameManager] Game Over. Winner: ${winner.name}`);
|
||||||
this.emit('game_over', { gameId, winnerId: winner.id });
|
this.emit('game_over', { gameId, winnerId: winner.id });
|
||||||
|
this.notify(gameId, `Game Over! ${winner.name} wins!`, 'success');
|
||||||
game.phase = 'ending'; // Mark as ending so we don't double emit
|
game.phase = 'ending'; // Mark as ending so we don't double emit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user