diff options
Diffstat (limited to 'src/network/NetworkClient.ts')
| -rw-r--r-- | src/network/NetworkClient.ts | 180 |
1 files changed, 180 insertions, 0 deletions
diff --git a/src/network/NetworkClient.ts b/src/network/NetworkClient.ts new file mode 100644 index 0000000..a2d3ef4 --- /dev/null +++ b/src/network/NetworkClient.ts @@ -0,0 +1,180 @@ +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<StateMessage, 'type'>): void { + this.stateBuffer = { type: 'state', ...state }; + } + + private async connect(): Promise<void> { + 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<string> { + 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 + }); + } + } +} |
