export interface GhostState { id: string; x: number; y: number; vx: number; vy: number; height: number; layer: string; cycle: number; updatedAt: number; } interface WelcomeMessage { type: 'welcome'; id: string; } interface SnapshotMessage { type: 'snapshot'; players: GhostState[]; } interface StateMessage { type: 'state'; x: number; y: number; vx: number; vy: number; height: number; layer: string; cycle: number; } interface DiscoveryResponse { servers?: Array<{ url: string; region?: string; }>; } export interface NetworkStatus { label: string; detail: string; peers: number; } const DEFAULT_DISCOVERY = 'http://localhost:8080/servers'; const DEFAULT_SOCKET = 'ws://localhost:8080/ws'; export class NetworkClient { private socket?: WebSocket; private discoveryUrl: string; private fallbackUrl: string; private reconnectHandle?: number; private stateBuffer?: StateMessage; private flushHandle?: number; private selfId?: string; private snapshotHandler: (players: GhostState[]) => void; private statusHandler: (status: NetworkStatus) => void; constructor( snapshotHandler: (players: GhostState[]) => void, statusHandler: (status: NetworkStatus) => void ) { this.snapshotHandler = snapshotHandler; this.statusHandler = statusHandler; this.discoveryUrl = import.meta.env.VITE_DISCOVERY_URL ?? DEFAULT_DISCOVERY; this.fallbackUrl = import.meta.env.VITE_WS_URL ?? DEFAULT_SOCKET; } start(): void { void this.connect(); } stop(): void { if (this.reconnectHandle) { window.clearTimeout(this.reconnectHandle); this.reconnectHandle = undefined; } if (this.flushHandle) { window.clearInterval(this.flushHandle); this.flushHandle = undefined; } this.socket?.close(); this.socket = undefined; } updateState(state: Omit): void { this.stateBuffer = { type: 'state', ...state }; } private async connect(): Promise { this.statusHandler({ label: 'network', detail: 'discovering ghost relay…', peers: 0 }); const target = await this.resolveSocketUrl(); this.socket = new WebSocket(target); this.socket.addEventListener('open', () => { this.statusHandler({ label: 'network', detail: `ghost relay online · ${target}`, peers: 0 }); this.startFlusher(); }); this.socket.addEventListener('message', (event) => { this.handleMessage(event.data); }); this.socket.addEventListener('close', () => { this.statusHandler({ label: 'network', detail: 'ghost relay offline · retrying…', peers: 0 }); this.snapshotHandler([]); this.socket = undefined; this.selfId = undefined; this.stopFlusher(); this.reconnectHandle = window.setTimeout(() => { void this.connect(); }, 2000); }); this.socket.addEventListener('error', () => { this.statusHandler({ label: 'network', detail: 'ghost relay unreachable', peers: 0 }); }); } private async resolveSocketUrl(): Promise { try { const response = await fetch(this.discoveryUrl, { headers: { Accept: 'application/json' } }); if (!response.ok) { return this.fallbackUrl; } const payload = (await response.json()) as DiscoveryResponse; const first = payload.servers?.[0]?.url; return first ?? this.fallbackUrl; } catch { return this.fallbackUrl; } } private startFlusher(): void { this.stopFlusher(); this.flushHandle = window.setInterval(() => { if (!this.socket || this.socket.readyState !== WebSocket.OPEN || !this.stateBuffer) { return; } this.socket.send(JSON.stringify(this.stateBuffer)); }, 80); } private stopFlusher(): void { if (!this.flushHandle) { return; } window.clearInterval(this.flushHandle); this.flushHandle = undefined; } private handleMessage(raw: string): void { let parsed: WelcomeMessage | SnapshotMessage | undefined; try { parsed = JSON.parse(raw) as WelcomeMessage | SnapshotMessage; } catch { return; } if (parsed.type === 'welcome') { this.selfId = parsed.id; return; } if (parsed.type === 'snapshot') { const others = parsed.players.filter((player) => player.id !== this.selfId); this.snapshotHandler(others); this.statusHandler({ label: 'network', detail: this.socket?.url ? `ghost relay online · ${this.socket.url}` : 'ghost relay online', peers: others.length }); } } }