feat: introduce game log panel and context, integrating it into the side panel and logging game events.
Some checks failed
Build and Deploy / build (push) Failing after 11m56s
Some checks failed
Build and Deploy / build (push) Failing after 11m56s
This commit is contained in:
87
src/client/src/components/GameLogPanel.tsx
Normal file
87
src/client/src/components/GameLogPanel.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
import { useGameLog, GameLogEntry } from '../contexts/GameLogContext';
|
||||||
|
import { ScrollText, User, Bot, Info, AlertTriangle, ShieldAlert } from 'lucide-react';
|
||||||
|
|
||||||
|
interface GameLogPanelProps {
|
||||||
|
className?: string;
|
||||||
|
maxHeight?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GameLogPanel: React.FC<GameLogPanelProps> = ({ className, maxHeight = '200px' }) => {
|
||||||
|
const { logs } = useGameLog();
|
||||||
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Auto-scroll to bottom on new logs
|
||||||
|
useEffect(() => {
|
||||||
|
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}, [logs]);
|
||||||
|
|
||||||
|
const getIcon = (type: GameLogEntry['type'], source: string) => {
|
||||||
|
if (source === 'System') return <Info className="w-3 h-3 text-slate-500" />;
|
||||||
|
if (type === 'error') return <AlertTriangle className="w-3 h-3 text-red-500" />;
|
||||||
|
if (type === 'combat') return <ShieldAlert className="w-3 h-3 text-red-400" />;
|
||||||
|
if (source.includes('Bot')) return <Bot className="w-3 h-3 text-indigo-400" />;
|
||||||
|
return <User className="w-3 h-3 text-blue-400" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTypeStyle = (type: GameLogEntry['type']) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'error': return 'text-red-400 bg-red-900/10 border-red-900/30';
|
||||||
|
case 'warning': return 'text-amber-400 bg-amber-900/10 border-amber-900/30';
|
||||||
|
case 'success': return 'text-emerald-400 bg-emerald-900/10 border-emerald-900/30';
|
||||||
|
case 'combat': return 'text-red-300 bg-red-900/20 border-red-900/40 font-bold';
|
||||||
|
case 'action': return 'text-blue-300 bg-blue-900/10 border-blue-900/30';
|
||||||
|
default: return 'text-slate-300 border-transparent';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex flex-col bg-slate-900 border-t border-slate-800 ${className} overflow-hidden`} style={{ maxHeight }}>
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1 bg-slate-950 border-b border-slate-800 shrink-0">
|
||||||
|
<ScrollText className="w-3 h-3 text-slate-500" />
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-wider text-slate-500">Game Log</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-2 space-y-1 custom-scrollbar text-xs font-mono">
|
||||||
|
{logs.length === 0 && (
|
||||||
|
<div className="text-slate-600 italic px-2 py-4 text-center">
|
||||||
|
Game started. Actions will appear here.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{logs.map((log) => (
|
||||||
|
<div
|
||||||
|
key={log.id}
|
||||||
|
className={`
|
||||||
|
relative pl-2 pr-2 py-1.5 rounded border-l-2
|
||||||
|
${getTypeStyle(log.type)}
|
||||||
|
animate-in fade-in slide-in-from-left-2 duration-300
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<div className="mt-0.5 shrink-0 opacity-70">
|
||||||
|
{getIcon(log.type, log.source)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col min-w-0">
|
||||||
|
{/* Source Header */}
|
||||||
|
{log.source !== 'System' && (
|
||||||
|
<span className="text-[10px] font-bold opacity-70 mb-0.5 leading-none">
|
||||||
|
{log.source}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{/* Message Body */}
|
||||||
|
<span className="leading-tight break-words">
|
||||||
|
{log.message}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="ml-auto text-[9px] text-slate-600 whitespace-nowrap mt-0.5">
|
||||||
|
{new Date(log.timestamp).toLocaleTimeString([], { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div ref={bottomRef} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import React from 'react';
|
import React, { forwardRef } from 'react';
|
||||||
import { CardVisual, VisualCard } from './CardVisual';
|
import { CardVisual, VisualCard } from './CardVisual';
|
||||||
import { Eye, ChevronLeft } from 'lucide-react';
|
import { Eye, ChevronLeft } from 'lucide-react';
|
||||||
import { ManaIcon } from './ManaIcon';
|
import { ManaIcon } from './ManaIcon';
|
||||||
import { formatOracleText } from '../utils/textUtils';
|
import { formatOracleText } from '../utils/textUtils';
|
||||||
|
import { GameLogPanel } from './GameLogPanel';
|
||||||
|
|
||||||
interface SidePanelPreviewProps {
|
interface SidePanelPreviewProps {
|
||||||
card: VisualCard | null;
|
card: VisualCard | null;
|
||||||
@@ -10,23 +11,25 @@ interface SidePanelPreviewProps {
|
|||||||
isCollapsed: boolean;
|
isCollapsed: boolean;
|
||||||
onToggleCollapse: (collapsed: boolean) => void;
|
onToggleCollapse: (collapsed: boolean) => void;
|
||||||
onResizeStart?: (e: React.MouseEvent | React.TouchEvent) => void;
|
onResizeStart?: (e: React.MouseEvent | React.TouchEvent) => void;
|
||||||
className?: string; // For additional styling (positioning, z-index, etc)
|
className?: string;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
|
showLog?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SidePanelPreview: React.FC<SidePanelPreviewProps> = ({
|
export const SidePanelPreview = forwardRef<HTMLDivElement, SidePanelPreviewProps>(({
|
||||||
card,
|
card,
|
||||||
width,
|
width,
|
||||||
isCollapsed,
|
isCollapsed,
|
||||||
onToggleCollapse,
|
onToggleCollapse,
|
||||||
onResizeStart,
|
onResizeStart,
|
||||||
className,
|
className,
|
||||||
children
|
children,
|
||||||
}) => {
|
showLog = true,
|
||||||
|
}, ref) => {
|
||||||
// If collapsed, render the collapsed strip
|
// If collapsed, render the collapsed strip
|
||||||
if (isCollapsed) {
|
if (isCollapsed) {
|
||||||
return (
|
return (
|
||||||
<div className={`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 ${className || ''}`}>
|
<div ref={ref} className={`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 ${className || ''}`}>
|
||||||
<button
|
<button
|
||||||
onClick={() => onToggleCollapse(false)}
|
onClick={() => onToggleCollapse(false)}
|
||||||
className="p-3 rounded-xl transition-all duration-200 group relative text-slate-500 hover:text-purple-400 hover:bg-slate-800"
|
className="p-3 rounded-xl transition-all duration-200 group relative text-slate-500 hover:text-purple-400 hover:bg-slate-800"
|
||||||
@@ -44,6 +47,7 @@ export const SidePanelPreview: React.FC<SidePanelPreviewProps> = ({
|
|||||||
// Expanded View
|
// Expanded View
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
ref={ref}
|
||||||
className={`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 ${className || ''}`}
|
className={`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 ${className || ''}`}
|
||||||
style={{ width: width }}
|
style={{ width: width }}
|
||||||
>
|
>
|
||||||
@@ -144,6 +148,14 @@ export const SidePanelPreview: React.FC<SidePanelPreviewProps> = ({
|
|||||||
<div className="h-8 w-1 bg-slate-700/50 rounded-full group-hover:bg-emerald-400 transition-colors" />
|
<div className="h-8 w-1 bg-slate-700/50 rounded-full group-hover:bg-emerald-400 transition-colors" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
{/* Game Action Log - Fixed at bottom */}
|
||||||
|
{showLog && (
|
||||||
|
<GameLogPanel className="w-full shrink-0 border-t border-slate-800" maxHeight="30%" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
|
SidePanelPreview.displayName = 'SidePanelPreview';
|
||||||
|
|||||||
50
src/client/src/contexts/GameLogContext.tsx
Normal file
50
src/client/src/contexts/GameLogContext.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import React, { createContext, useContext, useState, useCallback } from 'react';
|
||||||
|
|
||||||
|
export interface GameLogEntry {
|
||||||
|
id: string;
|
||||||
|
timestamp: number;
|
||||||
|
message: string;
|
||||||
|
source: 'System' | 'Player' | 'Opponent' | string;
|
||||||
|
type: 'info' | 'action' | 'combat' | 'error' | 'success' | 'warning';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GameLogContextType {
|
||||||
|
logs: GameLogEntry[];
|
||||||
|
addLog: (message: string, type?: GameLogEntry['type'], source?: string) => void;
|
||||||
|
clearLogs: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GameLogContext = createContext<GameLogContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export const GameLogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
const [logs, setLogs] = useState<GameLogEntry[]>([]);
|
||||||
|
|
||||||
|
const addLog = useCallback((message: string, type: GameLogEntry['type'] = 'info', source: string = 'System') => {
|
||||||
|
const newLog: GameLogEntry = {
|
||||||
|
id: Math.random().toString(36).substr(2, 9),
|
||||||
|
timestamp: Date.now(),
|
||||||
|
message,
|
||||||
|
source,
|
||||||
|
type
|
||||||
|
};
|
||||||
|
setLogs(prev => [...prev, newLog]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearLogs = useCallback(() => {
|
||||||
|
setLogs([]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GameLogContext.Provider value={{ logs, addLog, clearLogs }}>
|
||||||
|
{children}
|
||||||
|
</GameLogContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useGameLog = () => {
|
||||||
|
const context = useContext(GameLogContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useGameLog must be used within a GameLogProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
@@ -608,11 +608,13 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
|
|||||||
|
|
||||||
{/* Zoom Sidebar */}
|
{/* Zoom Sidebar */}
|
||||||
<SidePanelPreview
|
<SidePanelPreview
|
||||||
|
ref={sidebarRef}
|
||||||
card={hoveredCard}
|
card={hoveredCard}
|
||||||
width={sidebarWidth}
|
width={sidebarWidth}
|
||||||
isCollapsed={isSidebarCollapsed}
|
isCollapsed={isSidebarCollapsed}
|
||||||
onToggleCollapse={setIsSidebarCollapsed}
|
onToggleCollapse={setIsSidebarCollapsed}
|
||||||
onResizeStart={handleResizeStart}
|
onResizeStart={handleResizeStart}
|
||||||
|
showLog={true}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Main Game Area */}
|
{/* Main Game Area */}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Users, LogOut, Copy, Check, MessageSquare, Send, Bell, BellOff, X, Bot,
|
|||||||
import { useConfirm } from '../../components/ConfirmDialog';
|
import { useConfirm } from '../../components/ConfirmDialog';
|
||||||
import { Modal } from '../../components/Modal';
|
import { Modal } from '../../components/Modal';
|
||||||
import { useGameToast, GameToastProvider } from '../../components/GameToast';
|
import { useGameToast, GameToastProvider } from '../../components/GameToast';
|
||||||
|
import { GameLogProvider, useGameLog } from '../../contexts/GameLogContext'; // Import Log Provider and Hook
|
||||||
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';
|
||||||
@@ -64,6 +65,7 @@ const GameRoomContent: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
|
|||||||
|
|
||||||
// Services
|
// Services
|
||||||
const { showGameToast } = useGameToast();
|
const { showGameToast } = useGameToast();
|
||||||
|
const { addLog } = useGameLog(); // Use Log Hook
|
||||||
const { confirm } = useConfirm();
|
const { confirm } = useConfirm();
|
||||||
|
|
||||||
// Restored States
|
// Restored States
|
||||||
@@ -222,13 +224,19 @@ const GameRoomContent: 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?
|
||||||
|
|
||||||
if (data.userId && data.userId !== currentPlayerId) return; // Don't spam others errors?
|
|
||||||
|
|
||||||
showGameToast(data.message, 'error');
|
showGameToast(data.message, 'error');
|
||||||
|
addLog(data.message, 'error', 'System'); // Add to log
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleGameNotification = (data: { message: string, type?: 'info' | 'success' | 'warning' | 'error' }) => {
|
const handleGameNotification = (data: { message: string, type?: 'info' | 'success' | 'warning' | 'error' }) => {
|
||||||
showGameToast(data.message, data.type || 'info');
|
showGameToast(data.message, data.type || 'info');
|
||||||
|
|
||||||
|
// Infer source from message content or default to System
|
||||||
|
// Ideally backend sends source, but for now we parse or default
|
||||||
|
let source = 'System';
|
||||||
|
if (data.message.includes('turn')) source = 'Game'; // Example heuristic
|
||||||
|
|
||||||
|
addLog(data.message, (data.type as any) || 'info', source);
|
||||||
};
|
};
|
||||||
|
|
||||||
socket.on('game_error', handleGameError);
|
socket.on('game_error', handleGameError);
|
||||||
@@ -675,7 +683,9 @@ const GameRoomContent: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
|
|||||||
export const GameRoom: React.FC<GameRoomProps> = (props) => {
|
export const GameRoom: React.FC<GameRoomProps> = (props) => {
|
||||||
return (
|
return (
|
||||||
<GameToastProvider>
|
<GameToastProvider>
|
||||||
<GameRoomContent {...props} />
|
<GameLogProvider>
|
||||||
|
<GameRoomContent {...props} />
|
||||||
|
</GameLogProvider>
|
||||||
</GameToastProvider>
|
</GameToastProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user