summaryrefslogtreecommitdiff
path: root/src/network/NetworkClient.ts
diff options
context:
space:
mode:
authorThomas Grothe <grothe.tr@gmail.com>2026-04-30 00:36:17 -0400
committerThomas Grothe <grothe.tr@gmail.com>2026-04-30 00:36:17 -0400
commitdd886585cb9a34af6e6dda24dcfabc8132fdebb1 (patch)
treeeb7be0922bf57fc3fe4a644f65cf212e0cdaa07b /src/network/NetworkClient.ts
parent60553f2103ca58405798be7f7d17153f49c2ac7a (diff)
Diffstat (limited to 'src/network/NetworkClient.ts')
-rw-r--r--src/network/NetworkClient.ts180
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
+ });
+ }
+ }
+}