feat: Add battle screen with real-time sync (Phase 1 MVP)
All checks were successful
Deploy Dimension47 / deploy (push) Successful in 35s
All checks were successful
Deploy Dimension47 / deploy (push) Successful in 35s
- Add battle module with sessions, maps, tokens, and combatants - Implement WebSocket gateway for real-time battle updates - Add map upload with configurable grid system - Create battle canvas with token rendering and drag support - Support PC tokens from characters and NPC tokens from templates - Add initiative tracking and round management - GM-only controls for token manipulation Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { LoginPage, RegisterPage, useAuthStore } from '@/features/auth';
|
||||
import { CampaignsPage, CampaignDetailPage } from '@/features/campaigns';
|
||||
import { CharacterSheetPage } from '@/features/characters';
|
||||
import { BattlePage } from '@/features/battle';
|
||||
import { ProtectedRoute } from '@/shared/components/protected-route';
|
||||
import { Layout } from '@/shared/components/layout';
|
||||
|
||||
@@ -46,6 +47,7 @@ function AppContent() {
|
||||
<Route path="/" element={<CampaignsPage />} />
|
||||
<Route path="/campaigns/:id" element={<CampaignDetailPage />} />
|
||||
<Route path="/campaigns/:id/characters/:characterId" element={<CharacterSheetPage />} />
|
||||
<Route path="/campaigns/:id/battle" element={<BattlePage />} />
|
||||
<Route path="/library" element={<div>Library (TODO)</div>} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
238
client/src/features/battle/components/battle-canvas.tsx
Normal file
238
client/src/features/battle/components/battle-canvas.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
import React, { useRef, useState, useCallback, useMemo, useEffect } from 'react';
|
||||
import type { BattleSession, BattleMap } from '@/shared/types';
|
||||
import { Token } from './token';
|
||||
import { cn } from '@/shared/lib/utils';
|
||||
|
||||
interface BattleCanvasProps {
|
||||
session: BattleSession;
|
||||
map: BattleMap | null;
|
||||
isGM: boolean;
|
||||
selectedTokenId: string | null;
|
||||
onSelectToken: (tokenId: string | null) => void;
|
||||
onMoveToken: (tokenId: string, positionX: number, positionY: number) => void;
|
||||
}
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL?.replace('/api', '') || 'http://localhost:5000';
|
||||
|
||||
export function BattleCanvas({
|
||||
session,
|
||||
map,
|
||||
isGM,
|
||||
selectedTokenId,
|
||||
onSelectToken,
|
||||
onMoveToken,
|
||||
}: BattleCanvasProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
|
||||
const [draggedToken, setDraggedToken] = useState<string | null>(null);
|
||||
const [showGrid, setShowGrid] = useState(true);
|
||||
|
||||
// Calculate cell size based on container and grid dimensions
|
||||
const cellSize = useMemo(() => {
|
||||
if (!map || containerSize.width === 0 || containerSize.height === 0) return 40;
|
||||
|
||||
const cellWidth = containerSize.width / map.gridSizeX;
|
||||
const cellHeight = containerSize.height / map.gridSizeY;
|
||||
|
||||
// Use the smaller dimension to ensure grid fits
|
||||
return Math.floor(Math.min(cellWidth, cellHeight));
|
||||
}, [map, containerSize]);
|
||||
|
||||
// Calculate canvas dimensions
|
||||
const canvasSize = useMemo(() => {
|
||||
if (!map) return { width: 800, height: 600 };
|
||||
return {
|
||||
width: cellSize * map.gridSizeX,
|
||||
height: cellSize * map.gridSizeY,
|
||||
};
|
||||
}, [map, cellSize]);
|
||||
|
||||
// Update container size on resize
|
||||
useEffect(() => {
|
||||
const updateSize = () => {
|
||||
if (containerRef.current) {
|
||||
setContainerSize({
|
||||
width: containerRef.current.clientWidth,
|
||||
height: containerRef.current.clientHeight,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
updateSize();
|
||||
window.addEventListener('resize', updateSize);
|
||||
return () => window.removeEventListener('resize', updateSize);
|
||||
}, []);
|
||||
|
||||
// Handle drag start
|
||||
const handleDragStart = useCallback((tokenId: string) => (e: React.DragEvent | React.TouchEvent) => {
|
||||
if (!isGM) return;
|
||||
|
||||
setDraggedToken(tokenId);
|
||||
onSelectToken(tokenId);
|
||||
|
||||
if ('dataTransfer' in e) {
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', tokenId);
|
||||
}
|
||||
}, [isGM, onSelectToken]);
|
||||
|
||||
// Handle drop on canvas
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
if (!draggedToken || !isGM || !map) return;
|
||||
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left - map.gridOffsetX;
|
||||
const y = e.clientY - rect.top - map.gridOffsetY;
|
||||
|
||||
// Calculate grid position
|
||||
const gridX = Math.floor(x / cellSize);
|
||||
const gridY = Math.floor(y / cellSize);
|
||||
|
||||
// Clamp to grid bounds
|
||||
const clampedX = Math.max(0, Math.min(gridX, map.gridSizeX - 1));
|
||||
const clampedY = Math.max(0, Math.min(gridY, map.gridSizeY - 1));
|
||||
|
||||
onMoveToken(draggedToken, clampedX, clampedY);
|
||||
setDraggedToken(null);
|
||||
}, [draggedToken, isGM, map, cellSize, onMoveToken]);
|
||||
|
||||
// Handle drag over
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
}, []);
|
||||
|
||||
// Handle canvas click (deselect token)
|
||||
const handleCanvasClick = useCallback((e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onSelectToken(null);
|
||||
}
|
||||
}, [onSelectToken]);
|
||||
|
||||
// Render grid lines
|
||||
const gridLines = useMemo(() => {
|
||||
if (!map || !showGrid) return null;
|
||||
|
||||
const lines: React.ReactElement[] = [];
|
||||
|
||||
// Vertical lines
|
||||
for (let x = 0; x <= map.gridSizeX; x++) {
|
||||
lines.push(
|
||||
<line
|
||||
key={`v-${x}`}
|
||||
x1={x * cellSize + map.gridOffsetX}
|
||||
y1={map.gridOffsetY}
|
||||
x2={x * cellSize + map.gridOffsetX}
|
||||
y2={canvasSize.height + map.gridOffsetY}
|
||||
stroke="rgba(255, 255, 255, 0.3)"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Horizontal lines
|
||||
for (let y = 0; y <= map.gridSizeY; y++) {
|
||||
lines.push(
|
||||
<line
|
||||
key={`h-${y}`}
|
||||
x1={map.gridOffsetX}
|
||||
y1={y * cellSize + map.gridOffsetY}
|
||||
x2={canvasSize.width + map.gridOffsetX}
|
||||
y2={y * cellSize + map.gridOffsetY}
|
||||
stroke="rgba(255, 255, 255, 0.3)"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return lines;
|
||||
}, [map, showGrid, cellSize, canvasSize]);
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col bg-black/50 rounded-lg overflow-hidden">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-2 p-2 bg-black/30 border-b border-white/10">
|
||||
<button
|
||||
onClick={() => setShowGrid(!showGrid)}
|
||||
className={cn(
|
||||
'px-3 py-1 text-sm rounded transition-colors',
|
||||
showGrid ? 'bg-primary text-white' : 'bg-white/10 text-white/70 hover:bg-white/20',
|
||||
)}
|
||||
>
|
||||
Grid
|
||||
</button>
|
||||
{map && (
|
||||
<span className="text-sm text-white/50">
|
||||
{map.gridSizeX} x {map.gridSizeY} ({cellSize}px)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Canvas container */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex-1 overflow-auto flex items-center justify-center p-4"
|
||||
>
|
||||
<div
|
||||
className="relative bg-gray-900 shadow-2xl"
|
||||
style={{
|
||||
width: canvasSize.width + (map?.gridOffsetX ?? 0),
|
||||
height: canvasSize.height + (map?.gridOffsetY ?? 0),
|
||||
}}
|
||||
onClick={handleCanvasClick}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
>
|
||||
{/* Map background */}
|
||||
{map && (
|
||||
<img
|
||||
src={`${API_URL}${map.imageUrl}`}
|
||||
alt={map.name}
|
||||
className="absolute inset-0 w-full h-full object-cover pointer-events-none"
|
||||
draggable={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Grid overlay */}
|
||||
<svg
|
||||
className="absolute inset-0 w-full h-full pointer-events-none"
|
||||
style={{ zIndex: 5 }}
|
||||
>
|
||||
{gridLines}
|
||||
</svg>
|
||||
|
||||
{/* Tokens */}
|
||||
<div
|
||||
className="absolute"
|
||||
style={{
|
||||
left: map?.gridOffsetX ?? 0,
|
||||
top: map?.gridOffsetY ?? 0,
|
||||
width: canvasSize.width,
|
||||
height: canvasSize.height,
|
||||
}}
|
||||
>
|
||||
{session.tokens.map((token) => (
|
||||
<Token
|
||||
key={token.id}
|
||||
token={token}
|
||||
cellSize={cellSize}
|
||||
isSelected={token.id === selectedTokenId}
|
||||
isGM={isGM}
|
||||
onSelect={() => onSelectToken(token.id)}
|
||||
onDragStart={handleDragStart(token.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* No map placeholder */}
|
||||
{!map && (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-white/50">
|
||||
<p>Keine Karte ausgewählt</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
450
client/src/features/battle/components/battle-page.tsx
Normal file
450
client/src/features/battle/components/battle-page.tsx
Normal file
@@ -0,0 +1,450 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { ArrowLeft, Plus, Users, Swords, ChevronRight } from 'lucide-react';
|
||||
import { api } from '@/shared/lib/api';
|
||||
import { useAuthStore } from '@/features/auth';
|
||||
import { BattleCanvas } from './battle-canvas';
|
||||
import { BattleSessionList } from './battle-session-list';
|
||||
import { useBattleSocket } from '../hooks/use-battle-socket';
|
||||
import type { BattleSession, BattleToken, Character, Combatant } from '@/shared/types';
|
||||
import { cn } from '@/shared/lib/utils';
|
||||
|
||||
export function BattlePage() {
|
||||
const { id: campaignId } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { user } = useAuthStore();
|
||||
|
||||
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null);
|
||||
const [selectedTokenId, setSelectedTokenId] = useState<string | null>(null);
|
||||
const [showAddPanel, setShowAddPanel] = useState(false);
|
||||
|
||||
// Fetch campaign to check GM status
|
||||
const { data: campaign } = useQuery({
|
||||
queryKey: ['campaign', campaignId],
|
||||
queryFn: () => api.getCampaign(campaignId!),
|
||||
enabled: !!campaignId,
|
||||
});
|
||||
|
||||
const isGM = campaign?.gmId === user?.id;
|
||||
|
||||
// Fetch battle sessions
|
||||
const { data: sessions = [] } = useQuery({
|
||||
queryKey: ['battleSessions', campaignId],
|
||||
queryFn: () => api.getBattleSessions(campaignId!),
|
||||
enabled: !!campaignId,
|
||||
});
|
||||
|
||||
// Fetch battle maps
|
||||
const { data: maps = [] } = useQuery({
|
||||
queryKey: ['battleMaps', campaignId],
|
||||
queryFn: () => api.getBattleMaps(campaignId!),
|
||||
enabled: !!campaignId,
|
||||
});
|
||||
|
||||
// Fetch characters for adding PCs
|
||||
const { data: characters = [] } = useQuery({
|
||||
queryKey: ['characters', campaignId],
|
||||
queryFn: () => api.getCharacters(campaignId!),
|
||||
enabled: !!campaignId && isGM,
|
||||
});
|
||||
|
||||
// Fetch combatants for adding NPCs
|
||||
const { data: combatants = [] } = useQuery({
|
||||
queryKey: ['combatants', campaignId],
|
||||
queryFn: () => api.getCombatants(campaignId!),
|
||||
enabled: !!campaignId && isGM,
|
||||
});
|
||||
|
||||
// Get current session
|
||||
const currentSession = sessions.find((s: BattleSession) => s.id === selectedSessionId);
|
||||
|
||||
// WebSocket connection
|
||||
const {
|
||||
isConnected,
|
||||
moveToken,
|
||||
updateTokenHp,
|
||||
setInitiative,
|
||||
advanceRound,
|
||||
} = useBattleSocket({
|
||||
sessionId: selectedSessionId || '',
|
||||
onTokenMoved: useCallback((data: { tokenId: string; positionX: number; positionY: number }) => {
|
||||
queryClient.setQueryData(['battleSessions', campaignId], (old: BattleSession[]) =>
|
||||
old?.map(session =>
|
||||
session.id === selectedSessionId
|
||||
? {
|
||||
...session,
|
||||
tokens: session.tokens.map(token =>
|
||||
token.id === data.tokenId
|
||||
? { ...token, positionX: data.positionX, positionY: data.positionY }
|
||||
: token
|
||||
),
|
||||
}
|
||||
: session
|
||||
)
|
||||
);
|
||||
}, [queryClient, campaignId, selectedSessionId]),
|
||||
onTokenHpChanged: useCallback((data: { tokenId: string; hpCurrent: number; hpMax: number }) => {
|
||||
queryClient.setQueryData(['battleSessions', campaignId], (old: BattleSession[]) =>
|
||||
old?.map(session =>
|
||||
session.id === selectedSessionId
|
||||
? {
|
||||
...session,
|
||||
tokens: session.tokens.map(token =>
|
||||
token.id === data.tokenId
|
||||
? { ...token, hpCurrent: data.hpCurrent, hpMax: data.hpMax }
|
||||
: token
|
||||
),
|
||||
}
|
||||
: session
|
||||
)
|
||||
);
|
||||
}, [queryClient, campaignId, selectedSessionId]),
|
||||
onInitiativeSet: useCallback((data: { tokenId: string; initiative: number }) => {
|
||||
queryClient.setQueryData(['battleSessions', campaignId], (old: BattleSession[]) =>
|
||||
old?.map(session =>
|
||||
session.id === selectedSessionId
|
||||
? {
|
||||
...session,
|
||||
tokens: session.tokens.map(token =>
|
||||
token.id === data.tokenId
|
||||
? { ...token, initiative: data.initiative }
|
||||
: token
|
||||
),
|
||||
}
|
||||
: session
|
||||
)
|
||||
);
|
||||
}, [queryClient, campaignId, selectedSessionId]),
|
||||
onRoundAdvanced: useCallback((data: { roundNumber: number }) => {
|
||||
queryClient.setQueryData(['battleSessions', campaignId], (old: BattleSession[]) =>
|
||||
old?.map(session =>
|
||||
session.id === selectedSessionId
|
||||
? { ...session, roundNumber: data.roundNumber }
|
||||
: session
|
||||
)
|
||||
);
|
||||
}, [queryClient, campaignId, selectedSessionId]),
|
||||
onTokenAdded: useCallback(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['battleSessions', campaignId] });
|
||||
}, [queryClient, campaignId]),
|
||||
onTokenRemoved: useCallback(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['battleSessions', campaignId] });
|
||||
}, [queryClient, campaignId]),
|
||||
});
|
||||
|
||||
// Mutations
|
||||
const createSessionMutation = useMutation({
|
||||
mutationFn: (data: { name: string; mapId?: string }) =>
|
||||
api.createBattleSession(campaignId!, data),
|
||||
onSuccess: (newSession) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['battleSessions', campaignId] });
|
||||
setSelectedSessionId(newSession.id);
|
||||
},
|
||||
});
|
||||
|
||||
const updateSessionMutation = useMutation({
|
||||
mutationFn: ({ sessionId, data }: { sessionId: string; data: any }) =>
|
||||
api.updateBattleSession(campaignId!, sessionId, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['battleSessions', campaignId] });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteSessionMutation = useMutation({
|
||||
mutationFn: (sessionId: string) =>
|
||||
api.deleteBattleSession(campaignId!, sessionId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['battleSessions', campaignId] });
|
||||
setSelectedSessionId(null);
|
||||
},
|
||||
});
|
||||
|
||||
const addTokenMutation = useMutation({
|
||||
mutationFn: (data: any) =>
|
||||
api.addBattleToken(campaignId!, selectedSessionId!, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['battleSessions', campaignId] });
|
||||
},
|
||||
});
|
||||
|
||||
const removeTokenMutation = useMutation({
|
||||
mutationFn: (tokenId: string) =>
|
||||
api.removeBattleToken(campaignId!, selectedSessionId!, tokenId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['battleSessions', campaignId] });
|
||||
setSelectedTokenId(null);
|
||||
},
|
||||
});
|
||||
|
||||
// Auto-select first active session or first session
|
||||
useEffect(() => {
|
||||
if (sessions.length > 0 && !selectedSessionId) {
|
||||
const activeSession = sessions.find((s: BattleSession) => s.isActive);
|
||||
setSelectedSessionId(activeSession?.id || sessions[0].id);
|
||||
}
|
||||
}, [sessions, selectedSessionId]);
|
||||
|
||||
// Handle token move
|
||||
const handleMoveToken = useCallback(async (tokenId: string, positionX: number, positionY: number) => {
|
||||
try {
|
||||
await moveToken(tokenId, positionX, positionY);
|
||||
} catch (error) {
|
||||
console.error('Failed to move token:', error);
|
||||
}
|
||||
}, [moveToken]);
|
||||
|
||||
// Add PC token
|
||||
const handleAddPC = useCallback((character: Character) => {
|
||||
addTokenMutation.mutate({
|
||||
characterId: character.id,
|
||||
name: character.name,
|
||||
positionX: 0,
|
||||
positionY: 0,
|
||||
hpCurrent: character.hpCurrent,
|
||||
hpMax: character.hpMax,
|
||||
size: 1,
|
||||
});
|
||||
setShowAddPanel(false);
|
||||
}, [addTokenMutation]);
|
||||
|
||||
// Add NPC token
|
||||
const handleAddNPC = useCallback((combatant: Combatant) => {
|
||||
addTokenMutation.mutate({
|
||||
combatantId: combatant.id,
|
||||
name: combatant.name,
|
||||
positionX: 0,
|
||||
positionY: 0,
|
||||
hpCurrent: combatant.hpMax,
|
||||
hpMax: combatant.hpMax,
|
||||
size: 1,
|
||||
});
|
||||
setShowAddPanel(false);
|
||||
}, [addTokenMutation]);
|
||||
|
||||
// Get selected token
|
||||
const selectedToken = currentSession?.tokens.find((t: BattleToken) => t.id === selectedTokenId);
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col bg-gray-900">
|
||||
{/* Header */}
|
||||
<header className="flex items-center justify-between px-4 py-3 bg-black/50 border-b border-white/10">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => navigate(`/campaigns/${campaignId}`)}
|
||||
className="p-2 rounded-lg bg-white/5 hover:bg-white/10 text-white/70 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Swords className="w-5 h-5 text-primary" />
|
||||
<h1 className="text-lg font-semibold text-white">Kampfbildschirm</h1>
|
||||
</div>
|
||||
{isConnected && (
|
||||
<span className="px-2 py-0.5 text-[10px] font-medium bg-green-500/20 text-green-400 rounded">
|
||||
Verbunden
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{currentSession && (
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-sm text-white/70">
|
||||
Runde: <span className="font-bold text-white">{currentSession.roundNumber}</span>
|
||||
</div>
|
||||
{isGM && (
|
||||
<button
|
||||
onClick={() => advanceRound()}
|
||||
className="px-3 py-1.5 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary/80 transition-colors"
|
||||
>
|
||||
Nächste Runde
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Session list sidebar */}
|
||||
<BattleSessionList
|
||||
sessions={sessions}
|
||||
maps={maps}
|
||||
selectedSessionId={selectedSessionId}
|
||||
isGM={isGM}
|
||||
onSelectSession={setSelectedSessionId}
|
||||
onCreateSession={(name, mapId) => createSessionMutation.mutate({ name, mapId })}
|
||||
onDeleteSession={(sessionId) => deleteSessionMutation.mutate(sessionId)}
|
||||
onToggleActive={(sessionId, isActive) =>
|
||||
updateSessionMutation.mutate({ sessionId, data: { isActive } })
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Battle canvas */}
|
||||
{currentSession ? (
|
||||
<BattleCanvas
|
||||
session={currentSession}
|
||||
map={currentSession.map || null}
|
||||
isGM={isGM}
|
||||
selectedTokenId={selectedTokenId}
|
||||
onSelectToken={setSelectedTokenId}
|
||||
onMoveToken={handleMoveToken}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center text-white/50">
|
||||
<p>Wähle einen Kampf aus oder erstelle einen neuen</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Token detail panel / Add panel */}
|
||||
{currentSession && isGM && (
|
||||
<div className="w-72 bg-black/30 border-l border-white/10 flex flex-col">
|
||||
{/* Panel header */}
|
||||
<div className="p-3 border-b border-white/10 flex items-center justify-between">
|
||||
<h3 className="font-semibold text-white">
|
||||
{showAddPanel ? 'Teilnehmer hinzufügen' : 'Token-Details'}
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setShowAddPanel(!showAddPanel)}
|
||||
className={cn(
|
||||
'p-1.5 rounded-lg transition-colors',
|
||||
showAddPanel
|
||||
? 'bg-white/10 text-white/70'
|
||||
: 'bg-primary/20 text-primary hover:bg-primary/30',
|
||||
)}
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Panel content */}
|
||||
<div className="flex-1 overflow-y-auto p-3">
|
||||
{showAddPanel ? (
|
||||
<div className="space-y-4">
|
||||
{/* PCs */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-white/70 mb-2">Spielercharaktere</h4>
|
||||
<div className="space-y-1">
|
||||
{characters.filter((c: Character) => c.type === 'PC').map((character: Character) => (
|
||||
<button
|
||||
key={character.id}
|
||||
onClick={() => handleAddPC(character)}
|
||||
className="w-full flex items-center gap-2 p-2 rounded-lg bg-blue-500/10 hover:bg-blue-500/20 text-blue-400 transition-colors"
|
||||
>
|
||||
<Users className="w-4 h-4" />
|
||||
<span className="text-sm truncate">{character.name}</span>
|
||||
<ChevronRight className="w-4 h-4 ml-auto opacity-50" />
|
||||
</button>
|
||||
))}
|
||||
{characters.filter((c: Character) => c.type === 'PC').length === 0 && (
|
||||
<p className="text-xs text-white/40">Keine Charaktere verfügbar</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* NPCs/Monsters */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-white/70 mb-2">NPCs & Monster</h4>
|
||||
<div className="space-y-1">
|
||||
{combatants.map((combatant: Combatant) => (
|
||||
<button
|
||||
key={combatant.id}
|
||||
onClick={() => handleAddNPC(combatant)}
|
||||
className="w-full flex items-center gap-2 p-2 rounded-lg bg-red-500/10 hover:bg-red-500/20 text-red-400 transition-colors"
|
||||
>
|
||||
<Swords className="w-4 h-4" />
|
||||
<span className="text-sm truncate">{combatant.name}</span>
|
||||
<span className="text-xs opacity-50">Lvl {combatant.level}</span>
|
||||
<ChevronRight className="w-4 h-4 ml-auto opacity-50" />
|
||||
</button>
|
||||
))}
|
||||
{combatants.length === 0 && (
|
||||
<p className="text-xs text-white/40">Keine NPCs verfügbar</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : selectedToken ? (
|
||||
<div className="space-y-4">
|
||||
{/* Token info */}
|
||||
<div>
|
||||
<h4 className="text-lg font-semibold text-white">{selectedToken.name}</h4>
|
||||
<p className="text-sm text-white/50">
|
||||
{selectedToken.characterId ? 'Spielercharakter' : 'NPC/Monster'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* HP */}
|
||||
<div>
|
||||
<label className="text-sm text-white/70">HP</label>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<input
|
||||
type="number"
|
||||
value={selectedToken.hpCurrent}
|
||||
onChange={(e) => updateTokenHp(selectedToken.id, parseInt(e.target.value) || 0)}
|
||||
className="w-20 px-2 py-1 bg-white/5 border border-white/10 rounded text-white text-center"
|
||||
/>
|
||||
<span className="text-white/50">/</span>
|
||||
<span className="text-white">{selectedToken.hpMax}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Initiative */}
|
||||
<div>
|
||||
<label className="text-sm text-white/70">Initiative</label>
|
||||
<input
|
||||
type="number"
|
||||
value={selectedToken.initiative ?? ''}
|
||||
onChange={(e) => setInitiative(selectedToken.id, parseInt(e.target.value) || 0)}
|
||||
className="w-full mt-1 px-2 py-1 bg-white/5 border border-white/10 rounded text-white"
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Position */}
|
||||
<div>
|
||||
<label className="text-sm text-white/70">Position</label>
|
||||
<p className="text-white">
|
||||
X: {selectedToken.positionX}, Y: {selectedToken.positionY}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Conditions */}
|
||||
{selectedToken.conditions.length > 0 && (
|
||||
<div>
|
||||
<label className="text-sm text-white/70">Zustände</label>
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{selectedToken.conditions.map((condition: string, i: number) => (
|
||||
<span
|
||||
key={i}
|
||||
className="px-2 py-0.5 text-xs bg-yellow-500/20 text-yellow-400 rounded"
|
||||
>
|
||||
{condition}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Remove token */}
|
||||
<button
|
||||
onClick={() => removeTokenMutation.mutate(selectedToken.id)}
|
||||
className="w-full px-3 py-2 bg-red-500/20 text-red-400 rounded-lg text-sm hover:bg-red-500/30 transition-colors"
|
||||
>
|
||||
Vom Kampf entfernen
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-white/40 py-8">
|
||||
<p>Kein Token ausgewählt</p>
|
||||
<p className="text-xs mt-1">Klicke auf ein Token oder füge neue hinzu</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
179
client/src/features/battle/components/battle-session-list.tsx
Normal file
179
client/src/features/battle/components/battle-session-list.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import { useState } from 'react';
|
||||
import { Plus, Trash2, Play, Pause, Users } from 'lucide-react';
|
||||
import type { BattleSession, BattleMap } from '@/shared/types';
|
||||
import { cn } from '@/shared/lib/utils';
|
||||
|
||||
interface BattleSessionListProps {
|
||||
sessions: BattleSession[];
|
||||
maps: BattleMap[];
|
||||
selectedSessionId: string | null;
|
||||
isGM: boolean;
|
||||
onSelectSession: (sessionId: string) => void;
|
||||
onCreateSession: (name: string, mapId?: string) => void;
|
||||
onDeleteSession: (sessionId: string) => void;
|
||||
onToggleActive: (sessionId: string, isActive: boolean) => void;
|
||||
}
|
||||
|
||||
export function BattleSessionList({
|
||||
sessions,
|
||||
maps,
|
||||
selectedSessionId,
|
||||
isGM,
|
||||
onSelectSession,
|
||||
onCreateSession,
|
||||
onDeleteSession,
|
||||
onToggleActive,
|
||||
}: BattleSessionListProps) {
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [newSessionName, setNewSessionName] = useState('');
|
||||
const [selectedMapId, setSelectedMapId] = useState<string>('');
|
||||
|
||||
const handleCreate = () => {
|
||||
if (!newSessionName.trim()) return;
|
||||
onCreateSession(newSessionName.trim(), selectedMapId || undefined);
|
||||
setNewSessionName('');
|
||||
setSelectedMapId('');
|
||||
setShowCreateForm(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-64 bg-black/30 border-r border-white/10 flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="p-3 border-b border-white/10 flex items-center justify-between">
|
||||
<h2 className="font-semibold text-white">Kämpfe</h2>
|
||||
{isGM && (
|
||||
<button
|
||||
onClick={() => setShowCreateForm(!showCreateForm)}
|
||||
className="p-1.5 rounded-lg bg-primary/20 hover:bg-primary/30 text-primary transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create form */}
|
||||
{showCreateForm && isGM && (
|
||||
<div className="p-3 border-b border-white/10 space-y-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Kampfname..."
|
||||
value={newSessionName}
|
||||
onChange={(e) => setNewSessionName(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white placeholder:text-white/40 focus:outline-none focus:border-primary"
|
||||
autoFocus
|
||||
/>
|
||||
<select
|
||||
value={selectedMapId}
|
||||
onChange={(e) => setSelectedMapId(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white focus:outline-none focus:border-primary"
|
||||
>
|
||||
<option value="">Keine Karte</option>
|
||||
{maps.map((map) => (
|
||||
<option key={map.id} value={map.id}>
|
||||
{map.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={!newSessionName.trim()}
|
||||
className="flex-1 px-3 py-1.5 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary/80 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Erstellen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowCreateForm(false)}
|
||||
className="px-3 py-1.5 bg-white/10 text-white rounded-lg text-sm hover:bg-white/20 transition-colors"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Session list */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{sessions.length === 0 ? (
|
||||
<div className="p-4 text-center text-white/50 text-sm">
|
||||
Keine Kämpfe vorhanden
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-2 space-y-1">
|
||||
{sessions.map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
className={cn(
|
||||
'p-3 rounded-lg cursor-pointer transition-colors',
|
||||
selectedSessionId === session.id
|
||||
? 'bg-primary/30 border border-primary'
|
||||
: 'bg-white/5 border border-transparent hover:bg-white/10',
|
||||
)}
|
||||
onClick={() => onSelectSession(session.id)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-white truncate">
|
||||
{session.name || 'Unbenannter Kampf'}
|
||||
</span>
|
||||
{session.isActive && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-bold bg-green-500 text-white rounded">
|
||||
LIVE
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-1 text-xs text-white/50">
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="w-3 h-3" />
|
||||
{session.tokens.length}
|
||||
</span>
|
||||
{session.map && (
|
||||
<span className="truncate">{session.map.name}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions for GM */}
|
||||
{isGM && selectedSessionId === session.id && (
|
||||
<div className="flex gap-1 mt-2">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleActive(session.id, !session.isActive);
|
||||
}}
|
||||
className={cn(
|
||||
'flex-1 flex items-center justify-center gap-1 px-2 py-1 rounded text-xs transition-colors',
|
||||
session.isActive
|
||||
? 'bg-yellow-500/20 text-yellow-400 hover:bg-yellow-500/30'
|
||||
: 'bg-green-500/20 text-green-400 hover:bg-green-500/30',
|
||||
)}
|
||||
>
|
||||
{session.isActive ? (
|
||||
<>
|
||||
<Pause className="w-3 h-3" /> Pausieren
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="w-3 h-3" /> Starten
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (confirm('Kampf wirklich löschen?')) {
|
||||
onDeleteSession(session.id);
|
||||
}
|
||||
}}
|
||||
className="px-2 py-1 rounded bg-red-500/20 text-red-400 hover:bg-red-500/30 transition-colors"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
124
client/src/features/battle/components/token.tsx
Normal file
124
client/src/features/battle/components/token.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { memo, useMemo } from 'react';
|
||||
import type { BattleToken } from '@/shared/types';
|
||||
import { cn } from '@/shared/lib/utils';
|
||||
|
||||
interface TokenProps {
|
||||
token: BattleToken;
|
||||
cellSize: number;
|
||||
isSelected?: boolean;
|
||||
isGM?: boolean;
|
||||
onSelect?: () => void;
|
||||
onDragStart?: (e: React.DragEvent | React.TouchEvent) => void;
|
||||
}
|
||||
|
||||
// Token size in grid cells based on PF2e creature sizes
|
||||
const SIZE_MULTIPLIER: Record<number, number> = {
|
||||
1: 1, // Medium
|
||||
2: 2, // Large
|
||||
3: 3, // Huge
|
||||
4: 4, // Gargantuan
|
||||
};
|
||||
|
||||
// HP percentage thresholds for visual indicators
|
||||
function getHealthClass(hpCurrent: number, hpMax: number): string {
|
||||
const percentage = (hpCurrent / hpMax) * 100;
|
||||
if (percentage <= 0) return 'bg-gray-600';
|
||||
if (percentage <= 20) return 'bg-red-600';
|
||||
if (percentage <= 50) return 'bg-yellow-600';
|
||||
return 'bg-green-600';
|
||||
}
|
||||
|
||||
export const Token = memo(function Token({
|
||||
token,
|
||||
cellSize,
|
||||
isSelected,
|
||||
isGM,
|
||||
onSelect,
|
||||
onDragStart,
|
||||
}: TokenProps) {
|
||||
const sizeMultiplier = SIZE_MULTIPLIER[token.size] ?? 1;
|
||||
const tokenSize = cellSize * sizeMultiplier;
|
||||
const padding = 2;
|
||||
|
||||
// Calculate position
|
||||
const style = useMemo(() => ({
|
||||
left: `${token.positionX * cellSize + padding}px`,
|
||||
top: `${token.positionY * cellSize + padding}px`,
|
||||
width: `${tokenSize - padding * 2}px`,
|
||||
height: `${tokenSize - padding * 2}px`,
|
||||
}), [token.positionX, token.positionY, cellSize, tokenSize, padding]);
|
||||
|
||||
// Get initials for token
|
||||
const initials = useMemo(() => {
|
||||
const words = token.name.split(' ');
|
||||
if (words.length >= 2) {
|
||||
return (words[0][0] + words[1][0]).toUpperCase();
|
||||
}
|
||||
return token.name.substring(0, 2).toUpperCase();
|
||||
}, [token.name]);
|
||||
|
||||
// Determine if this is a PC or NPC/Monster
|
||||
const isPC = !!token.characterId;
|
||||
const healthClass = getHealthClass(token.hpCurrent, token.hpMax);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute rounded-full cursor-pointer transition-all duration-100',
|
||||
'flex items-center justify-center',
|
||||
'border-2 shadow-lg',
|
||||
isSelected ? 'ring-2 ring-primary ring-offset-2 ring-offset-black z-20' : 'z-10',
|
||||
isPC ? 'border-blue-400' : 'border-red-400',
|
||||
'hover:scale-110',
|
||||
)}
|
||||
style={style}
|
||||
onClick={onSelect}
|
||||
draggable={isGM}
|
||||
onDragStart={onDragStart}
|
||||
onTouchStart={onDragStart}
|
||||
title={`${token.name}${token.initiative !== null ? ` (Init: ${token.initiative})` : ''}`}
|
||||
>
|
||||
{/* Background with health indicator */}
|
||||
<div className={cn(
|
||||
'absolute inset-0 rounded-full',
|
||||
isPC ? 'bg-blue-900' : 'bg-red-900',
|
||||
)} />
|
||||
|
||||
{/* HP bar */}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute bottom-0 left-0 right-0 h-1 rounded-b-full transition-all',
|
||||
healthClass,
|
||||
)}
|
||||
style={{ width: `${Math.max(0, Math.min(100, (token.hpCurrent / token.hpMax) * 100))}%` }}
|
||||
/>
|
||||
|
||||
{/* Token content */}
|
||||
<div className="relative z-10 flex flex-col items-center justify-center">
|
||||
<span className="text-xs font-bold text-white drop-shadow-lg">
|
||||
{initials}
|
||||
</span>
|
||||
{/* Show HP for GM, or for PCs */}
|
||||
{(isGM || isPC) && (
|
||||
<span className="text-[10px] text-white/80">
|
||||
{token.hpCurrent}/{token.hpMax}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Initiative badge */}
|
||||
{token.initiative !== null && token.initiative !== undefined && (
|
||||
<div className="absolute -top-1 -right-1 w-4 h-4 rounded-full bg-primary text-[10px] font-bold text-white flex items-center justify-center">
|
||||
{token.initiative}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Conditions indicator */}
|
||||
{token.conditions.length > 0 && (
|
||||
<div className="absolute -bottom-1 -right-1 w-4 h-4 rounded-full bg-yellow-500 text-[10px] font-bold text-black flex items-center justify-center">
|
||||
{token.conditions.length}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
308
client/src/features/battle/hooks/use-battle-socket.ts
Normal file
308
client/src/features/battle/hooks/use-battle-socket.ts
Normal file
@@ -0,0 +1,308 @@
|
||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import { io, Socket } from 'socket.io-client';
|
||||
import { api } from '@/shared/lib/api';
|
||||
import type { BattleSession, BattleToken } from '@/shared/types';
|
||||
|
||||
// Derive WebSocket URL from API URL (remove /api suffix)
|
||||
const SOCKET_URL = import.meta.env.VITE_API_URL?.replace('/api', '') || 'http://localhost:5000';
|
||||
|
||||
// Singleton socket manager to prevent multiple connections
|
||||
let globalSocket: Socket | null = null;
|
||||
let globalSocketRefCount = 0;
|
||||
let currentSessionId: string | null = null;
|
||||
let connectionAttempted = false;
|
||||
|
||||
export type BattleUpdateType =
|
||||
| 'token_added'
|
||||
| 'token_removed'
|
||||
| 'token_moved'
|
||||
| 'token_updated'
|
||||
| 'token_hp_changed'
|
||||
| 'initiative_set'
|
||||
| 'round_advanced'
|
||||
| 'session_updated'
|
||||
| 'session_deleted';
|
||||
|
||||
export interface BattleUpdate {
|
||||
sessionId: string;
|
||||
type: BattleUpdateType;
|
||||
data: any;
|
||||
}
|
||||
|
||||
interface UseBattleSocketOptions {
|
||||
sessionId: string;
|
||||
onTokenAdded?: (data: { token: BattleToken }) => void;
|
||||
onTokenRemoved?: (data: { tokenId: string }) => void;
|
||||
onTokenMoved?: (data: { tokenId: string; positionX: number; positionY: number }) => void;
|
||||
onTokenUpdated?: (data: { token: BattleToken }) => void;
|
||||
onTokenHpChanged?: (data: { tokenId: string; hpCurrent: number; hpMax: number }) => void;
|
||||
onInitiativeSet?: (data: { tokenId: string; initiative: number }) => void;
|
||||
onRoundAdvanced?: (data: { roundNumber: number }) => void;
|
||||
onSessionUpdated?: (data: { session: BattleSession }) => void;
|
||||
onSessionDeleted?: () => void;
|
||||
}
|
||||
|
||||
export function useBattleSocket({
|
||||
sessionId,
|
||||
onTokenAdded,
|
||||
onTokenRemoved,
|
||||
onTokenMoved,
|
||||
onTokenUpdated,
|
||||
onTokenHpChanged,
|
||||
onInitiativeSet,
|
||||
onRoundAdvanced,
|
||||
onSessionUpdated,
|
||||
onSessionDeleted,
|
||||
}: UseBattleSocketOptions) {
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [isGM, setIsGM] = useState(false);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
// Use refs for callbacks to avoid reconnection on callback changes
|
||||
const callbacksRef = useRef({
|
||||
onTokenAdded,
|
||||
onTokenRemoved,
|
||||
onTokenMoved,
|
||||
onTokenUpdated,
|
||||
onTokenHpChanged,
|
||||
onInitiativeSet,
|
||||
onRoundAdvanced,
|
||||
onSessionUpdated,
|
||||
onSessionDeleted,
|
||||
});
|
||||
|
||||
// Update refs when callbacks change (without causing reconnection)
|
||||
useEffect(() => {
|
||||
callbacksRef.current = {
|
||||
onTokenAdded,
|
||||
onTokenRemoved,
|
||||
onTokenMoved,
|
||||
onTokenUpdated,
|
||||
onTokenHpChanged,
|
||||
onInitiativeSet,
|
||||
onRoundAdvanced,
|
||||
onSessionUpdated,
|
||||
onSessionDeleted,
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
const token = api.getToken();
|
||||
if (!token || !sessionId) return;
|
||||
|
||||
// Increment ref count
|
||||
globalSocketRefCount++;
|
||||
|
||||
// If we already have a socket for a different session, leave that room first
|
||||
if (globalSocket?.connected && currentSessionId && currentSessionId !== sessionId) {
|
||||
globalSocket.emit('leave_battle', { sessionId: currentSessionId });
|
||||
}
|
||||
|
||||
// Update current session
|
||||
currentSessionId = sessionId;
|
||||
|
||||
// Create socket if it doesn't exist and we haven't already tried
|
||||
if (!globalSocket && !connectionAttempted) {
|
||||
connectionAttempted = true;
|
||||
|
||||
globalSocket = io(`${SOCKET_URL}/battles`, {
|
||||
auth: { token },
|
||||
transports: ['polling', 'websocket'],
|
||||
upgrade: true,
|
||||
reconnection: true,
|
||||
reconnectionAttempts: 3,
|
||||
reconnectionDelay: 3000,
|
||||
reconnectionDelayMax: 10000,
|
||||
timeout: 10000,
|
||||
autoConnect: true,
|
||||
});
|
||||
|
||||
globalSocket.on('connect', () => {
|
||||
if (!mountedRef.current) return;
|
||||
console.log('[Battle WebSocket] Connected');
|
||||
setIsConnected(true);
|
||||
|
||||
// Join battle room on connect/reconnect
|
||||
if (currentSessionId && globalSocket) {
|
||||
globalSocket.emit('join_battle', { sessionId: currentSessionId }, (response: { success: boolean; isGM?: boolean; error?: string }) => {
|
||||
if (response.success) {
|
||||
console.log(`[Battle WebSocket] Joined room: ${currentSessionId}, isGM: ${response.isGM}`);
|
||||
if (mountedRef.current) {
|
||||
setIsGM(response.isGM ?? false);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
globalSocket.on('disconnect', (reason) => {
|
||||
console.log(`[Battle WebSocket] Disconnected: ${reason}`);
|
||||
if (mountedRef.current) {
|
||||
setIsConnected(false);
|
||||
}
|
||||
});
|
||||
|
||||
globalSocket.on('connect_error', () => {
|
||||
if (mountedRef.current) {
|
||||
setIsConnected(false);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle battle updates
|
||||
globalSocket.on('battle_update', (update: BattleUpdate) => {
|
||||
if (!mountedRef.current) return;
|
||||
const callbacks = callbacksRef.current;
|
||||
|
||||
switch (update.type) {
|
||||
case 'token_added':
|
||||
callbacks.onTokenAdded?.(update.data);
|
||||
break;
|
||||
case 'token_removed':
|
||||
callbacks.onTokenRemoved?.(update.data);
|
||||
break;
|
||||
case 'token_moved':
|
||||
callbacks.onTokenMoved?.(update.data);
|
||||
break;
|
||||
case 'token_updated':
|
||||
callbacks.onTokenUpdated?.(update.data);
|
||||
break;
|
||||
case 'token_hp_changed':
|
||||
callbacks.onTokenHpChanged?.(update.data);
|
||||
break;
|
||||
case 'initiative_set':
|
||||
callbacks.onInitiativeSet?.(update.data);
|
||||
break;
|
||||
case 'round_advanced':
|
||||
callbacks.onRoundAdvanced?.(update.data);
|
||||
break;
|
||||
case 'session_updated':
|
||||
callbacks.onSessionUpdated?.(update.data);
|
||||
break;
|
||||
case 'session_deleted':
|
||||
callbacks.onSessionDeleted?.();
|
||||
break;
|
||||
}
|
||||
});
|
||||
} else if (globalSocket?.connected) {
|
||||
// Socket already exists and connected, just join the room
|
||||
globalSocket.emit('join_battle', { sessionId }, (response: { success: boolean; isGM?: boolean; error?: string }) => {
|
||||
if (response.success) {
|
||||
console.log(`[Battle WebSocket] Joined room: ${sessionId}, isGM: ${response.isGM}`);
|
||||
if (mountedRef.current) {
|
||||
setIsGM(response.isGM ?? false);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
globalSocketRefCount--;
|
||||
|
||||
if (globalSocket && currentSessionId === sessionId) {
|
||||
globalSocket.emit('leave_battle', { sessionId });
|
||||
}
|
||||
|
||||
// Only disconnect socket if no more refs
|
||||
if (globalSocketRefCount <= 0 && globalSocket) {
|
||||
globalSocket.disconnect();
|
||||
globalSocket = null;
|
||||
currentSessionId = null;
|
||||
connectionAttempted = false;
|
||||
}
|
||||
};
|
||||
}, [sessionId]);
|
||||
|
||||
// Socket actions
|
||||
const moveToken = useCallback((tokenId: string, positionX: number, positionY: number) => {
|
||||
if (!globalSocket?.connected || !currentSessionId) return Promise.reject('Not connected');
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
globalSocket!.emit('move_token', {
|
||||
sessionId: currentSessionId,
|
||||
tokenId,
|
||||
positionX,
|
||||
positionY,
|
||||
}, (response: { success: boolean; error?: string }) => {
|
||||
if (response.success) {
|
||||
resolve(response);
|
||||
} else {
|
||||
reject(response.error);
|
||||
}
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateTokenHp = useCallback((tokenId: string, hpCurrent: number) => {
|
||||
if (!globalSocket?.connected || !currentSessionId) return Promise.reject('Not connected');
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
globalSocket!.emit('update_token_hp', {
|
||||
sessionId: currentSessionId,
|
||||
tokenId,
|
||||
hpCurrent,
|
||||
}, (response: { success: boolean; error?: string }) => {
|
||||
if (response.success) {
|
||||
resolve(response);
|
||||
} else {
|
||||
reject(response.error);
|
||||
}
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setInitiative = useCallback((tokenId: string, initiative: number) => {
|
||||
if (!globalSocket?.connected || !currentSessionId) return Promise.reject('Not connected');
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
globalSocket!.emit('set_initiative', {
|
||||
sessionId: currentSessionId,
|
||||
tokenId,
|
||||
initiative,
|
||||
}, (response: { success: boolean; error?: string }) => {
|
||||
if (response.success) {
|
||||
resolve(response);
|
||||
} else {
|
||||
reject(response.error);
|
||||
}
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
const advanceRound = useCallback(() => {
|
||||
if (!globalSocket?.connected || !currentSessionId) return Promise.reject('Not connected');
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
globalSocket!.emit('advance_round', {
|
||||
sessionId: currentSessionId,
|
||||
}, (response: { success: boolean; error?: string }) => {
|
||||
if (response.success) {
|
||||
resolve(response);
|
||||
} else {
|
||||
reject(response.error);
|
||||
}
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
const reconnect = useCallback(() => {
|
||||
if (globalSocket) {
|
||||
globalSocket.connect();
|
||||
} else {
|
||||
connectionAttempted = false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
socket: globalSocket,
|
||||
isConnected,
|
||||
isGM,
|
||||
reconnect,
|
||||
moveToken,
|
||||
updateTokenHp,
|
||||
setInitiative,
|
||||
advanceRound,
|
||||
};
|
||||
}
|
||||
5
client/src/features/battle/index.ts
Normal file
5
client/src/features/battle/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { BattlePage } from './components/battle-page';
|
||||
export { BattleCanvas } from './components/battle-canvas';
|
||||
export { BattleSessionList } from './components/battle-session-list';
|
||||
export { Token } from './components/token';
|
||||
export { useBattleSocket } from './hooks/use-battle-socket';
|
||||
@@ -474,6 +474,218 @@ class ApiClient {
|
||||
const response = await this.client.delete(`/campaigns/${campaignId}/characters/${characterId}/alchemy/prepared/${itemId}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// BATTLE SYSTEM
|
||||
// ==========================================
|
||||
|
||||
// Battle Sessions
|
||||
async getBattleSessions(campaignId: string) {
|
||||
const response = await this.client.get(`/campaigns/${campaignId}/battles`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getBattleSession(campaignId: string, sessionId: string) {
|
||||
const response = await this.client.get(`/campaigns/${campaignId}/battles/${sessionId}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async createBattleSession(campaignId: string, data: {
|
||||
name?: string;
|
||||
mapId?: string;
|
||||
isActive?: boolean;
|
||||
}) {
|
||||
const response = await this.client.post(`/campaigns/${campaignId}/battles`, data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async updateBattleSession(campaignId: string, sessionId: string, data: {
|
||||
name?: string;
|
||||
mapId?: string;
|
||||
isActive?: boolean;
|
||||
roundNumber?: number;
|
||||
}) {
|
||||
const response = await this.client.put(`/campaigns/${campaignId}/battles/${sessionId}`, data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async deleteBattleSession(campaignId: string, sessionId: string) {
|
||||
const response = await this.client.delete(`/campaigns/${campaignId}/battles/${sessionId}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// Battle Tokens
|
||||
async addBattleToken(campaignId: string, sessionId: string, data: {
|
||||
combatantId?: string;
|
||||
characterId?: string;
|
||||
name: string;
|
||||
positionX: number;
|
||||
positionY: number;
|
||||
hpCurrent: number;
|
||||
hpMax: number;
|
||||
initiative?: number;
|
||||
conditions?: string[];
|
||||
size?: number;
|
||||
}) {
|
||||
const response = await this.client.post(`/campaigns/${campaignId}/battles/${sessionId}/tokens`, data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async updateBattleToken(campaignId: string, sessionId: string, tokenId: string, data: {
|
||||
name?: string;
|
||||
positionX?: number;
|
||||
positionY?: number;
|
||||
hpCurrent?: number;
|
||||
hpMax?: number;
|
||||
initiative?: number;
|
||||
conditions?: string[];
|
||||
size?: number;
|
||||
}) {
|
||||
const response = await this.client.patch(`/campaigns/${campaignId}/battles/${sessionId}/tokens/${tokenId}`, data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async removeBattleToken(campaignId: string, sessionId: string, tokenId: string) {
|
||||
const response = await this.client.delete(`/campaigns/${campaignId}/battles/${sessionId}/tokens/${tokenId}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async moveBattleToken(campaignId: string, sessionId: string, tokenId: string, positionX: number, positionY: number) {
|
||||
const response = await this.client.patch(`/campaigns/${campaignId}/battles/${sessionId}/tokens/${tokenId}/move`, {
|
||||
positionX,
|
||||
positionY,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async setTokenInitiative(campaignId: string, sessionId: string, tokenId: string, initiative: number) {
|
||||
const response = await this.client.patch(`/campaigns/${campaignId}/battles/${sessionId}/tokens/${tokenId}/initiative`, {
|
||||
initiative,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async advanceBattleRound(campaignId: string, sessionId: string) {
|
||||
const response = await this.client.post(`/campaigns/${campaignId}/battles/${sessionId}/advance-round`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// Battle Maps
|
||||
async getBattleMaps(campaignId: string) {
|
||||
const response = await this.client.get(`/campaigns/${campaignId}/battle-maps`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getBattleMap(campaignId: string, mapId: string) {
|
||||
const response = await this.client.get(`/campaigns/${campaignId}/battle-maps/${mapId}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async uploadBattleMap(campaignId: string, data: {
|
||||
name: string;
|
||||
gridSizeX?: number;
|
||||
gridSizeY?: number;
|
||||
gridOffsetX?: number;
|
||||
gridOffsetY?: number;
|
||||
image: File;
|
||||
}) {
|
||||
const formData = new FormData();
|
||||
formData.append('name', data.name);
|
||||
if (data.gridSizeX !== undefined) formData.append('gridSizeX', data.gridSizeX.toString());
|
||||
if (data.gridSizeY !== undefined) formData.append('gridSizeY', data.gridSizeY.toString());
|
||||
if (data.gridOffsetX !== undefined) formData.append('gridOffsetX', data.gridOffsetX.toString());
|
||||
if (data.gridOffsetY !== undefined) formData.append('gridOffsetY', data.gridOffsetY.toString());
|
||||
formData.append('image', data.image);
|
||||
|
||||
const response = await this.client.post(`/campaigns/${campaignId}/battle-maps`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async updateBattleMap(campaignId: string, mapId: string, data: {
|
||||
name?: string;
|
||||
gridSizeX?: number;
|
||||
gridSizeY?: number;
|
||||
gridOffsetX?: number;
|
||||
gridOffsetY?: number;
|
||||
}) {
|
||||
const response = await this.client.put(`/campaigns/${campaignId}/battle-maps/${mapId}`, data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async deleteBattleMap(campaignId: string, mapId: string) {
|
||||
const response = await this.client.delete(`/campaigns/${campaignId}/battle-maps/${mapId}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// Combatants (NPC/Monster Templates)
|
||||
async getCombatants(campaignId: string) {
|
||||
const response = await this.client.get(`/campaigns/${campaignId}/combatants`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getCombatant(campaignId: string, combatantId: string) {
|
||||
const response = await this.client.get(`/campaigns/${campaignId}/combatants/${combatantId}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async createCombatant(campaignId: string, data: {
|
||||
name: string;
|
||||
type: 'PC' | 'NPC' | 'MONSTER';
|
||||
level: number;
|
||||
hpMax: number;
|
||||
ac: number;
|
||||
fortitude: number;
|
||||
reflex: number;
|
||||
will: number;
|
||||
perception: number;
|
||||
speed?: number;
|
||||
avatarUrl?: string;
|
||||
description?: string;
|
||||
abilities?: Array<{
|
||||
name: string;
|
||||
actionCost: number;
|
||||
actionType: 'ACTION' | 'REACTION' | 'FREE';
|
||||
description: string;
|
||||
damage?: string;
|
||||
traits?: string[];
|
||||
}>;
|
||||
}) {
|
||||
const response = await this.client.post(`/campaigns/${campaignId}/combatants`, data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async updateCombatant(campaignId: string, combatantId: string, data: {
|
||||
name?: string;
|
||||
type?: 'PC' | 'NPC' | 'MONSTER';
|
||||
level?: number;
|
||||
hpMax?: number;
|
||||
ac?: number;
|
||||
fortitude?: number;
|
||||
reflex?: number;
|
||||
will?: number;
|
||||
perception?: number;
|
||||
speed?: number;
|
||||
avatarUrl?: string;
|
||||
description?: string;
|
||||
abilities?: Array<{
|
||||
name: string;
|
||||
actionCost: number;
|
||||
actionType: 'ACTION' | 'REACTION' | 'FREE';
|
||||
description: string;
|
||||
damage?: string;
|
||||
traits?: string[];
|
||||
}>;
|
||||
}) {
|
||||
const response = await this.client.put(`/campaigns/${campaignId}/combatants/${combatantId}`, data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async deleteCombatant(campaignId: string, combatantId: string) {
|
||||
const response = await this.client.delete(`/campaigns/${campaignId}/combatants/${combatantId}`);
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
|
||||
export const api = new ApiClient();
|
||||
|
||||
Reference in New Issue
Block a user