feat: Add hover border effect to cards when they are in the battlefield zone.

This commit is contained in:
2025-12-23 00:19:55 +01:00
parent 35407a5cd4
commit 6edfb8b9e4
5 changed files with 132 additions and 15 deletions

View File

@@ -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"), {

View 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" />;
}
};

View File

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

View File

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

View File

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