Files
citygen/client/connection.ts
T

69 lines
4.1 KiB
TypeScript

import { create, fromBinary, toBinary } from '@bufbuild/protobuf';
import { ClientMessageSchema, ServerMessageSchema, type ClientMessage, type Welcome, type Clock, type VoteState } from './generated/city_pb';
import type { Bounds } from '../src/city/format';
import { EntityState } from './entities';
export class RoomConnection {
readonly entities = new EntityState();
private socket: WebSocket;
private ping: ReturnType<typeof setInterval>;
private closed = false;
private pendingBounds: Bounds | null = null;
private welcomed = false;
private lod = 0;
private retry:ReturnType<typeof setTimeout> | undefined;
constructor(room: string, nickname: string, private events: {
welcome: (welcome: Welcome) => void; status: (text: string) => void;
clock: (clock: Clock) => void; vote: (vote: VoteState) => void;
}) {
const url = new URL(`/api/rooms/${encodeURIComponent(room)}/ws`, location.href);
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
this.socket = new WebSocket(url); this.socket.binaryType = 'arraybuffer';
this.socket.onopen = () => this.send({ case: 'join', value: { room, nickname, clientVersion: 1 } });
this.socket.onmessage = ev => {
try {
const message = fromBinary(ServerMessageSchema, new Uint8Array(ev.data));
const b = message.body;
switch (b.case) {
case 'welcome':
if (b.value.protocolVersion !== 1) throw new Error('Несовместимая версия сервера');
this.welcomed = true; events.welcome(b.value);
if (this.pendingBounds) this.subscribe(this.pendingBounds);
break;
case 'snapshot': this.entities.snapshot(b.value); break;
case 'delta': this.entities.delta(b.value); break;
case 'error':
if (b.value.code === 'ENTITY_LIMIT' && this.pendingBounds && this.lod < 10) {
this.lod++; this.subscribe(this.pendingBounds); events.status(`Плотная область: отображается 1 из ${2 ** this.lod} жителей`);
} else if (b.value.code==='TRAFFIC_LIMIT' && this.pendingBounds) {
const bounds=this.pendingBounds;clearTimeout(this.retry);
this.retry=setTimeout(()=>{if (this.pendingBounds===bounds) this.subscribe(bounds);},1000);
events.status('Подождите, обновляем область…');
} else events.status(b.value.code === 'VOTE_LIMIT' ? 'Дождитесь завершения голосования или 30-секундного интервала между предложениями' : `${b.value.code}: ${b.value.message}`);
break;
case 'clock': events.clock(b.value); break;
case 'voteState': events.vote(b.value); break;
}
} catch (error) { events.status((error as Error).message); this.destroy(); }
};
this.socket.onclose = () => { clearInterval(this.ping); this.entities.clear(); if (!this.closed) events.status('Соединение закрыто. Вернитесь в список комнат, чтобы войти снова.'); };
this.socket.onerror = () => events.status('Не удалось подключиться к серверу');
this.ping = setInterval(() => this.send({ case: 'ping', value: { nonce: 1 } }), 20_000);
}
private send(body: any) {
if (this.socket.readyState === WebSocket.OPEN) this.socket.send(toBinary(ClientMessageSchema, create(ClientMessageSchema, { body })));
}
subscribe(bounds: Bounds) {
clearTimeout(this.retry);
this.pendingBounds = bounds;
if (!this.welcomed) return;
this.send({ case: 'subscribe', value: { rect: {
minX: Math.round(bounds.minX * 100), minY: Math.round(bounds.minY * 100),
maxX: Math.round(bounds.maxX * 100), maxY: Math.round(bounds.maxY * 100),
}, lod: this.lod } });
}
propose(speed: number) { this.send({ case: 'propose', value: { speed } }); }
vote(yes: boolean) { this.send({ case: 'vote', value: { yes } }); }
destroy() { this.closed = true; clearTimeout(this.retry); clearInterval(this.ping); this.socket.close(); this.entities.clear(); }
}