Character System: - Inventory system with 5,482 equipment items - Feats tab with categories and details - Actions tab with 99 PF2e actions - Item detail modal with equipment info - Feat detail modal with descriptions - Edit character modal with image cropping Auth & UI: - Animated login screen with splash → form transition - Letter-by-letter "DIMENSION 47" animation - Starfield background with floating orbs - Logo tap glow effect - "Remember me" functionality (localStorage/sessionStorage) Real-time Sync: - WebSocket gateway for character updates - Live sync for HP, conditions, inventory, equipment status, money, level Database: - Added credits field to characters - Added custom fields for items - Added feat fields and relations - Included full database backup Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
146 lines
4.6 KiB
TypeScript
146 lines
4.6 KiB
TypeScript
import { useEffect, useRef, useCallback } from 'react';
|
|
import { io, Socket } from 'socket.io-client';
|
|
import { api } from '@/shared/lib/api';
|
|
import type { Character, CharacterItem, CharacterCondition } from '@/shared/types';
|
|
|
|
const SOCKET_URL = import.meta.env.VITE_API_URL?.replace('/api', '') || 'http://localhost:3001';
|
|
|
|
export type CharacterUpdateType = 'hp' | 'conditions' | 'item' | 'inventory' | 'money' | 'level' | 'equipment_status';
|
|
|
|
export interface CharacterUpdate {
|
|
characterId: string;
|
|
type: CharacterUpdateType;
|
|
data: any;
|
|
}
|
|
|
|
interface UseCharacterSocketOptions {
|
|
characterId: string;
|
|
onHpUpdate?: (data: { hpCurrent: number; hpTemp: number; hpMax: number }) => void;
|
|
onConditionsUpdate?: (data: { action: 'add' | 'update' | 'remove'; condition?: CharacterCondition; conditionId?: string }) => void;
|
|
onInventoryUpdate?: (data: { action: 'add' | 'remove' | 'update'; item?: CharacterItem; itemId?: string }) => void;
|
|
onEquipmentStatusUpdate?: (data: { action: 'update'; item: CharacterItem }) => void;
|
|
onMoneyUpdate?: (data: { credits: number }) => void;
|
|
onLevelUpdate?: (data: { level: number }) => void;
|
|
onFullUpdate?: (character: Character) => void;
|
|
}
|
|
|
|
export function useCharacterSocket({
|
|
characterId,
|
|
onHpUpdate,
|
|
onConditionsUpdate,
|
|
onInventoryUpdate,
|
|
onEquipmentStatusUpdate,
|
|
onMoneyUpdate,
|
|
onLevelUpdate,
|
|
onFullUpdate,
|
|
}: UseCharacterSocketOptions) {
|
|
const socketRef = useRef<Socket | null>(null);
|
|
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
const connect = useCallback(() => {
|
|
const token = api.getToken();
|
|
if (!token || !characterId) return;
|
|
|
|
// Disconnect existing socket if any
|
|
if (socketRef.current?.connected) {
|
|
socketRef.current.disconnect();
|
|
}
|
|
|
|
const socket = io(`${SOCKET_URL}/characters`, {
|
|
auth: { token },
|
|
transports: ['websocket', 'polling'],
|
|
reconnection: true,
|
|
reconnectionAttempts: 5,
|
|
reconnectionDelay: 1000,
|
|
});
|
|
|
|
socket.on('connect', () => {
|
|
console.log('[WebSocket] Connected to character namespace');
|
|
// Join the character room
|
|
socket.emit('join_character', { characterId }, (response: { success: boolean; error?: string }) => {
|
|
if (response.success) {
|
|
console.log(`[WebSocket] Joined character room: ${characterId}`);
|
|
} else {
|
|
console.error(`[WebSocket] Failed to join character room: ${response.error}`);
|
|
}
|
|
});
|
|
});
|
|
|
|
socket.on('disconnect', (reason) => {
|
|
console.log(`[WebSocket] Disconnected: ${reason}`);
|
|
});
|
|
|
|
socket.on('connect_error', (error) => {
|
|
console.error('[WebSocket] Connection error:', error.message);
|
|
});
|
|
|
|
// Handle character updates
|
|
socket.on('character_update', (update: CharacterUpdate) => {
|
|
console.log(`[WebSocket] Received update: ${update.type}`, update.data);
|
|
|
|
switch (update.type) {
|
|
case 'hp':
|
|
onHpUpdate?.(update.data);
|
|
break;
|
|
case 'conditions':
|
|
onConditionsUpdate?.(update.data);
|
|
break;
|
|
case 'inventory':
|
|
onInventoryUpdate?.(update.data);
|
|
break;
|
|
case 'equipment_status':
|
|
onEquipmentStatusUpdate?.(update.data);
|
|
break;
|
|
case 'money':
|
|
onMoneyUpdate?.(update.data);
|
|
break;
|
|
case 'level':
|
|
onLevelUpdate?.(update.data);
|
|
break;
|
|
case 'item':
|
|
// Item update that's not equipment status (e.g., quantity, notes)
|
|
onInventoryUpdate?.(update.data);
|
|
break;
|
|
}
|
|
});
|
|
|
|
// Handle full character refresh (e.g., after reconnect)
|
|
socket.on('character_refresh', (character: Character) => {
|
|
console.log('[WebSocket] Received full character refresh');
|
|
onFullUpdate?.(character);
|
|
});
|
|
|
|
socketRef.current = socket;
|
|
|
|
return socket;
|
|
}, [characterId, onHpUpdate, onConditionsUpdate, onInventoryUpdate, onEquipmentStatusUpdate, onMoneyUpdate, onLevelUpdate, onFullUpdate]);
|
|
|
|
const disconnect = useCallback(() => {
|
|
if (reconnectTimeoutRef.current) {
|
|
clearTimeout(reconnectTimeoutRef.current);
|
|
reconnectTimeoutRef.current = null;
|
|
}
|
|
|
|
if (socketRef.current) {
|
|
// Leave the character room before disconnecting
|
|
socketRef.current.emit('leave_character', { characterId });
|
|
socketRef.current.disconnect();
|
|
socketRef.current = null;
|
|
}
|
|
}, [characterId]);
|
|
|
|
useEffect(() => {
|
|
connect();
|
|
|
|
return () => {
|
|
disconnect();
|
|
};
|
|
}, [connect, disconnect]);
|
|
|
|
return {
|
|
socket: socketRef.current,
|
|
isConnected: socketRef.current?.connected ?? false,
|
|
reconnect: connect,
|
|
};
|
|
}
|