import { create, fromBinary, toBinary } from '@bufbuild/protobuf'; import { ClientMessageSchema, ServerMessageSchema, type ClientMessage, type Welcome, type Clock, type VoteState, type Gameplay } 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; private closed = false; private pendingBounds: Bounds | null = null; private welcomed = false; private lod = 0; private retry:ReturnType | undefined; constructor(room: string, nickname: string, private events: { welcome: (welcome: Welcome) => void; status: (text: string) => void; clock: (clock: Clock) => void; vote: (vote: VoteState) => void; gameplay: (state: Gameplay) => 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 'gameplay': events.gameplay(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 } }); } intervene(action: string) { this.send({ case: 'intervene', value: { action } }); } destroy() { this.closed = true; clearTimeout(this.retry); clearInterval(this.ping); this.socket.close(); this.entities.clear(); } }