From 6edfb8b9e431845e46faea0ebce443ba0b65a793 Mon Sep 17 00:00:00 2001 From: dnviti Date: Tue, 23 Dec 2025 00:19:55 +0100 Subject: [PATCH] feat: Add hover border effect to cards when they are in the battlefield zone. --- src/client/dev-dist/sw.js | 2 +- src/client/src/components/GameToast.tsx | 93 +++++++++++++++++++ src/client/src/modules/game/CardComponent.tsx | 2 +- src/client/src/modules/lobby/GameRoom.tsx | 44 ++++++--- src/server/managers/GameManager.ts | 6 ++ 5 files changed, 132 insertions(+), 15 deletions(-) create mode 100644 src/client/src/components/GameToast.tsx diff --git a/src/client/dev-dist/sw.js b/src/client/dev-dist/sw.js index 9686a5c..f6cbf17 100644 --- a/src/client/dev-dist/sw.js +++ b/src/client/dev-dist/sw.js @@ -82,7 +82,7 @@ define(['./workbox-5a5d9309'], (function (workbox) { 'use strict'; "revision": "3ca0b8505b4bec776b69afdba2768812" }, { "url": "index.html", - "revision": "0.r2hp08ujhtk" + "revision": "0.sortnjvj4s8" }], {}); workbox.cleanupOutdatedCaches(); workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), { diff --git a/src/client/src/components/GameToast.tsx b/src/client/src/components/GameToast.tsx new file mode 100644 index 0000000..96265af --- /dev/null +++ b/src/client/src/components/GameToast.tsx @@ -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(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([]); + + // 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 ( + + {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. + */} +
+ {toasts.map((toast) => ( +
+ {getIcon(toast.type)} + {toast.message} +
+ ))} +
+
+ ); +}; + +const toastStyles: Record = { + 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 ; + case 'error': return ; + case 'warning': return ; + case 'info': return ; + case 'game-event': return ; + } +}; diff --git a/src/client/src/modules/game/CardComponent.tsx b/src/client/src/modules/game/CardComponent.tsx index 15248a4..93353e9 100644 --- a/src/client/src/modules/game/CardComponent.tsx +++ b/src/client/src/modules/game/CardComponent.tsx @@ -71,7 +71,7 @@ export const CardComponent: React.FC = ({ card, onDragStart, opacity: card.tapped ? 0.5 : style?.opacity ?? 1 }} > -
+
void; } -export const GameRoom: React.FC = ({ room: initialRoom, currentPlayerId, initialGameState, initialDraftState, onExit }) => { +const GameRoomContent: React.FC = ({ room: initialRoom, currentPlayerId, initialGameState, initialDraftState, onExit }) => { // State const [room, setRoom] = useState(initialRoom); const [modalOpen, setModalOpen] = useState(false); @@ -63,7 +63,7 @@ export const GameRoom: React.FC = ({ room: initialRoom, currentPl }); // Services - const { showToast } = useToast(); + const { showGameToast } = useGameToast(); const { confirm } = useConfirm(); // Restored States @@ -100,14 +100,14 @@ export const GameRoom: React.FC = ({ room: initialRoom, currentPl // 1. New Players curr.forEach(p => { 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 prev.forEach(p => { 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 = ({ room: initialRoom, currentPl const old = prev.find(o => o.id === p.id); if (old) { if (!old.isOffline && p.isOffline) { - showToast(`${p.name} lost connection.`, 'error'); + showGameToast(`${p.name} lost connection.`, 'error'); } if (old.isOffline && !p.isOffline) { - showToast(`${p.name} reconnected!`, 'success'); + showGameToast(`${p.name} reconnected!`, 'success'); } } }); prevPlayersRef.current = curr; - }, [room.players, notificationsEnabled, showToast]); + }, [room.players, notificationsEnabled, showGameToast]); // Effects useEffect(() => { @@ -189,7 +189,7 @@ export const GameRoom: React.FC = ({ room: initialRoom, currentPl // Also handle finish const handleTournamentFinished = (data: any) => { - showToast(`Tournament Winner: ${data.winner.name}!`, 'success'); + showGameToast(`Tournament Winner: ${data.winner.name}!`, 'success'); }; socket.on('draft_update', handleDraftUpdate); @@ -222,12 +222,22 @@ export const GameRoom: React.FC = ({ room: initialRoom, currentPl // 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? - 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); - return () => { socket.off('game_error', handleGameError); }; - }, [currentPlayerId, showToast]); + socket.on('game_notification', handleGameNotification); + return () => { + socket.off('game_error', handleGameError); + socket.off('game_notification', handleGameNotification); + }; + }, [currentPlayerId, showGameToast]); const sendMessage = (e: React.FormEvent) => { e.preventDefault(); @@ -324,7 +334,7 @@ export const GameRoom: React.FC = ({ room: initialRoom, currentPl onSubmit={(deck) => { socketService.socket.emit('match_ready', { matchId: preparingMatchId, deck }); setPreparingMatchId(null); - showToast("Deck ready! Waiting for game to start...", 'success'); + showGameToast("Deck ready! Waiting for game to start...", 'success'); }} submitLabel="Ready for Match" />; @@ -661,3 +671,11 @@ export const GameRoom: React.FC = ({ room: initialRoom, currentPl
); }; + +export const GameRoom: React.FC = (props) => { + return ( + + + + ); +}; diff --git a/src/server/managers/GameManager.ts b/src/server/managers/GameManager.ts index 7bbd73d..382e9a2 100644 --- a/src/server/managers/GameManager.ts +++ b/src/server/managers/GameManager.ts @@ -10,6 +10,11 @@ import { EventEmitter } from 'events'; export class GameManager extends EventEmitter { public games: Map = 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 { // Convert array to map @@ -199,6 +204,7 @@ export class GameManager extends EventEmitter { if (game.phase !== 'ending') { console.log(`[GameManager] Game Over. Winner: ${winner.name}`); 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 } }