69 lines
4.1 KiB
TypeScript
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(); }
|
|
}
|