Implement tiled multiplayer city simulation and scaling stages
This commit is contained in:
1 parent
3d4b29fedf
commit
3b1fcd996a
53 files changed
+7354
-13
No files matched your search
@@ -0,0 +1,7 @@
|
||||
# Browser client
|
||||
|
||||
`tiles.ts` loads prepared city tiles over HTTP. The existing renderer and offline
|
||||
debug generator remain in `src/` to preserve the working prototype.
|
||||
|
||||
Open `/?city=/cities/demo/manifest.json`. Camera movement loads only intersecting
|
||||
tiles plus a margin, with six concurrent requests and a bounded tile cache.
|
||||
@@ -0,0 +1,68 @@
|
||||
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(); }
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Agent, Delta, Snapshot } from './generated/city_pb';
|
||||
|
||||
interface Sample { previous: Agent; current: Agent; fromTick: number; toTick: number }
|
||||
/** Keep only subscribed entities. Time is aligned to the latest server tick. */
|
||||
export class EntityState {
|
||||
private entities = new Map<number, Sample>();
|
||||
private tick = 0;
|
||||
private received = 0;
|
||||
snapshot(snapshot: Snapshot, now = performance.now()) {
|
||||
this.entities.clear(); this.tick = Number(snapshot.tick); this.received = now;
|
||||
for (const a of snapshot.entities) this.entities.set(a.id, { previous: a, current: a, fromTick: this.tick, toTick: this.tick });
|
||||
}
|
||||
delta(delta: Delta, now = performance.now()) {
|
||||
const tick = Number(delta.tick);
|
||||
if (tick < this.tick) return;
|
||||
this.tick = tick; this.received = now;
|
||||
for (const id of delta.despawned) this.entities.delete(id);
|
||||
for (const a of delta.spawned) this.entities.set(a.id, { previous: a, current: a, fromTick: tick, toTick: tick });
|
||||
for (const a of delta.changed) {
|
||||
const old = this.entities.get(a.id);
|
||||
if (old) this.entities.set(a.id, { previous: old.current, current: a, fromTick: old.toTick, toTick: tick });
|
||||
}
|
||||
}
|
||||
positions(now = performance.now()): { id: number; x: number; y: number }[] {
|
||||
const renderTick = this.tick + (now - this.received - 200) / 100;
|
||||
return [...this.entities].map(([id, s]) => {
|
||||
const t = s.toTick === s.fromTick ? 1 : Math.max(0, Math.min(1, (renderTick - s.fromTick) / (s.toTick - s.fromTick)));
|
||||
return { id, x: (s.previous.x + (s.current.x - s.previous.x) * t) / 100, y: (s.previous.y + (s.current.y - s.previous.y) * t) / 100 };
|
||||
});
|
||||
}
|
||||
clear() { this.entities.clear(); }
|
||||
get size() { return this.entities.size; }
|
||||
}
|
||||
@@ -0,0 +1,651 @@
|
||||
// @generated by protoc-gen-es v2.16.0 with parameter "target=ts"
|
||||
// @generated from file city.proto (package city, syntax proto3)
|
||||
/* eslint-disable */
|
||||
|
||||
import type { GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2";
|
||||
import { fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2";
|
||||
import type { Message } from "@bufbuild/protobuf";
|
||||
|
||||
/**
|
||||
* Describes the file city.proto.
|
||||
*/
|
||||
export const file_city: GenFile = /*@__PURE__*/
|
||||
fileDesc("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");
|
||||
|
||||
/**
|
||||
* @generated from message city.Rect
|
||||
*/
|
||||
export type Rect = Message<"city.Rect"> & {
|
||||
/**
|
||||
* @generated from field: sint32 min_x = 1;
|
||||
*/
|
||||
minX: number;
|
||||
|
||||
/**
|
||||
* @generated from field: sint32 min_y = 2;
|
||||
*/
|
||||
minY: number;
|
||||
|
||||
/**
|
||||
* @generated from field: sint32 max_x = 3;
|
||||
*/
|
||||
maxX: number;
|
||||
|
||||
/**
|
||||
* @generated from field: sint32 max_y = 4;
|
||||
*/
|
||||
maxY: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Describes the message city.Rect.
|
||||
* Use `create(RectSchema)` to create a new message.
|
||||
*/
|
||||
export const RectSchema: GenMessage<Rect> = /*@__PURE__*/
|
||||
messageDesc(file_city, 0);
|
||||
|
||||
/**
|
||||
* @generated from message city.Join
|
||||
*/
|
||||
export type Join = Message<"city.Join"> & {
|
||||
/**
|
||||
* @generated from field: string room = 1;
|
||||
*/
|
||||
room: string;
|
||||
|
||||
/**
|
||||
* @generated from field: uint32 client_version = 2;
|
||||
*/
|
||||
clientVersion: number;
|
||||
|
||||
/**
|
||||
* @generated from field: string nickname = 3;
|
||||
*/
|
||||
nickname: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Describes the message city.Join.
|
||||
* Use `create(JoinSchema)` to create a new message.
|
||||
*/
|
||||
export const JoinSchema: GenMessage<Join> = /*@__PURE__*/
|
||||
messageDesc(file_city, 1);
|
||||
|
||||
/**
|
||||
* @generated from message city.Subscribe
|
||||
*/
|
||||
export type Subscribe = Message<"city.Subscribe"> & {
|
||||
/**
|
||||
* @generated from field: city.Rect rect = 1;
|
||||
*/
|
||||
rect?: Rect | undefined;
|
||||
|
||||
/**
|
||||
* @generated from field: uint32 lod = 2;
|
||||
*/
|
||||
lod: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Describes the message city.Subscribe.
|
||||
* Use `create(SubscribeSchema)` to create a new message.
|
||||
*/
|
||||
export const SubscribeSchema: GenMessage<Subscribe> = /*@__PURE__*/
|
||||
messageDesc(file_city, 2);
|
||||
|
||||
/**
|
||||
* @generated from message city.Ping
|
||||
*/
|
||||
export type Ping = Message<"city.Ping"> & {
|
||||
/**
|
||||
* @generated from field: uint32 nonce = 1;
|
||||
*/
|
||||
nonce: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Describes the message city.Ping.
|
||||
* Use `create(PingSchema)` to create a new message.
|
||||
*/
|
||||
export const PingSchema: GenMessage<Ping> = /*@__PURE__*/
|
||||
messageDesc(file_city, 3);
|
||||
|
||||
/**
|
||||
* @generated from message city.Propose
|
||||
*/
|
||||
export type Propose = Message<"city.Propose"> & {
|
||||
/**
|
||||
* @generated from field: uint32 speed = 1;
|
||||
*/
|
||||
speed: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Describes the message city.Propose.
|
||||
* Use `create(ProposeSchema)` to create a new message.
|
||||
*/
|
||||
export const ProposeSchema: GenMessage<Propose> = /*@__PURE__*/
|
||||
messageDesc(file_city, 4);
|
||||
|
||||
/**
|
||||
* @generated from message city.Vote
|
||||
*/
|
||||
export type Vote = Message<"city.Vote"> & {
|
||||
/**
|
||||
* @generated from field: bool yes = 1;
|
||||
*/
|
||||
yes: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Describes the message city.Vote.
|
||||
* Use `create(VoteSchema)` to create a new message.
|
||||
*/
|
||||
export const VoteSchema: GenMessage<Vote> = /*@__PURE__*/
|
||||
messageDesc(file_city, 5);
|
||||
|
||||
/**
|
||||
* @generated from message city.CreateCity
|
||||
*/
|
||||
export type CreateCity = Message<"city.CreateCity"> & {
|
||||
/**
|
||||
* @generated from field: repeated double bbox = 1;
|
||||
*/
|
||||
bbox: number[];
|
||||
|
||||
/**
|
||||
* @generated from field: string name = 2;
|
||||
*/
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* @generated from field: uint32 seed = 3;
|
||||
*/
|
||||
seed: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Describes the message city.CreateCity.
|
||||
* Use `create(CreateCitySchema)` to create a new message.
|
||||
*/
|
||||
export const CreateCitySchema: GenMessage<CreateCity> = /*@__PURE__*/
|
||||
messageDesc(file_city, 6);
|
||||
|
||||
/**
|
||||
* @generated from message city.CityJob
|
||||
*/
|
||||
export type CityJob = Message<"city.CityJob"> & {
|
||||
/**
|
||||
* @generated from field: string id = 1;
|
||||
*/
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* @generated from field: string city_id = 2;
|
||||
*/
|
||||
cityId: string;
|
||||
|
||||
/**
|
||||
* @generated from field: string status = 3;
|
||||
*/
|
||||
status: string;
|
||||
|
||||
/**
|
||||
* @generated from field: string room_id = 4;
|
||||
*/
|
||||
roomId: string;
|
||||
|
||||
/**
|
||||
* @generated from field: string error = 5;
|
||||
*/
|
||||
error: string;
|
||||
|
||||
/**
|
||||
* @generated from field: string stage = 6;
|
||||
*/
|
||||
stage: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Describes the message city.CityJob.
|
||||
* Use `create(CityJobSchema)` to create a new message.
|
||||
*/
|
||||
export const CityJobSchema: GenMessage<CityJob> = /*@__PURE__*/
|
||||
messageDesc(file_city, 7);
|
||||
|
||||
/**
|
||||
* @generated from message city.ClientMessage
|
||||
*/
|
||||
export type ClientMessage = Message<"city.ClientMessage"> & {
|
||||
/**
|
||||
* @generated from oneof city.ClientMessage.body
|
||||
*/
|
||||
body: {
|
||||
/**
|
||||
* @generated from field: city.Join join = 1;
|
||||
*/
|
||||
value: Join;
|
||||
case: "join";
|
||||
} | {
|
||||
/**
|
||||
* @generated from field: city.Subscribe subscribe = 2;
|
||||
*/
|
||||
value: Subscribe;
|
||||
case: "subscribe";
|
||||
} | {
|
||||
/**
|
||||
* @generated from field: city.Ping ping = 3;
|
||||
*/
|
||||
value: Ping;
|
||||
case: "ping";
|
||||
} | {
|
||||
/**
|
||||
* @generated from field: city.Propose propose = 4;
|
||||
*/
|
||||
value: Propose;
|
||||
case: "propose";
|
||||
} | {
|
||||
/**
|
||||
* @generated from field: city.Vote vote = 5;
|
||||
*/
|
||||
value: Vote;
|
||||
case: "vote";
|
||||
} | { case: undefined; value?: undefined };
|
||||
};
|
||||
|
||||
/**
|
||||
* Describes the message city.ClientMessage.
|
||||
* Use `create(ClientMessageSchema)` to create a new message.
|
||||
*/
|
||||
export const ClientMessageSchema: GenMessage<ClientMessage> = /*@__PURE__*/
|
||||
messageDesc(file_city, 8);
|
||||
|
||||
/**
|
||||
* @generated from message city.Agent
|
||||
*/
|
||||
export type Agent = Message<"city.Agent"> & {
|
||||
/**
|
||||
* @generated from field: uint32 id = 1;
|
||||
*/
|
||||
id: number;
|
||||
|
||||
/**
|
||||
* @generated from field: sint32 x = 2;
|
||||
*/
|
||||
x: number;
|
||||
|
||||
/**
|
||||
* @generated from field: sint32 y = 3;
|
||||
*/
|
||||
y: number;
|
||||
|
||||
/**
|
||||
* @generated from field: uint32 kind = 4;
|
||||
*/
|
||||
kind: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Describes the message city.Agent.
|
||||
* Use `create(AgentSchema)` to create a new message.
|
||||
*/
|
||||
export const AgentSchema: GenMessage<Agent> = /*@__PURE__*/
|
||||
messageDesc(file_city, 9);
|
||||
|
||||
/**
|
||||
* @generated from message city.Welcome
|
||||
*/
|
||||
export type Welcome = Message<"city.Welcome"> & {
|
||||
/**
|
||||
* @generated from field: string room = 1;
|
||||
*/
|
||||
room: string;
|
||||
|
||||
/**
|
||||
* @generated from field: uint64 tick = 2;
|
||||
*/
|
||||
tick: bigint;
|
||||
|
||||
/**
|
||||
* @generated from field: string city_manifest_url = 3;
|
||||
*/
|
||||
cityManifestUrl: string;
|
||||
|
||||
/**
|
||||
* @generated from field: string session = 4;
|
||||
*/
|
||||
session: string;
|
||||
|
||||
/**
|
||||
* @generated from field: uint32 protocol_version = 5;
|
||||
*/
|
||||
protocolVersion: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Describes the message city.Welcome.
|
||||
* Use `create(WelcomeSchema)` to create a new message.
|
||||
*/
|
||||
export const WelcomeSchema: GenMessage<Welcome> = /*@__PURE__*/
|
||||
messageDesc(file_city, 10);
|
||||
|
||||
/**
|
||||
* @generated from message city.Snapshot
|
||||
*/
|
||||
export type Snapshot = Message<"city.Snapshot"> & {
|
||||
/**
|
||||
* @generated from field: uint64 tick = 1;
|
||||
*/
|
||||
tick: bigint;
|
||||
|
||||
/**
|
||||
* @generated from field: repeated city.Agent entities = 2;
|
||||
*/
|
||||
entities: Agent[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Describes the message city.Snapshot.
|
||||
* Use `create(SnapshotSchema)` to create a new message.
|
||||
*/
|
||||
export const SnapshotSchema: GenMessage<Snapshot> = /*@__PURE__*/
|
||||
messageDesc(file_city, 11);
|
||||
|
||||
/**
|
||||
* @generated from message city.Delta
|
||||
*/
|
||||
export type Delta = Message<"city.Delta"> & {
|
||||
/**
|
||||
* @generated from field: uint64 tick = 1;
|
||||
*/
|
||||
tick: bigint;
|
||||
|
||||
/**
|
||||
* @generated from field: repeated city.Agent spawned = 2;
|
||||
*/
|
||||
spawned: Agent[];
|
||||
|
||||
/**
|
||||
* @generated from field: repeated city.Agent changed = 3;
|
||||
*/
|
||||
changed: Agent[];
|
||||
|
||||
/**
|
||||
* @generated from field: repeated uint32 despawned = 4;
|
||||
*/
|
||||
despawned: number[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Describes the message city.Delta.
|
||||
* Use `create(DeltaSchema)` to create a new message.
|
||||
*/
|
||||
export const DeltaSchema: GenMessage<Delta> = /*@__PURE__*/
|
||||
messageDesc(file_city, 12);
|
||||
|
||||
/**
|
||||
* @generated from message city.Error
|
||||
*/
|
||||
export type Error = Message<"city.Error"> & {
|
||||
/**
|
||||
* @generated from field: string code = 1;
|
||||
*/
|
||||
code: string;
|
||||
|
||||
/**
|
||||
* @generated from field: string message = 2;
|
||||
*/
|
||||
message: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Describes the message city.Error.
|
||||
* Use `create(ErrorSchema)` to create a new message.
|
||||
*/
|
||||
export const ErrorSchema: GenMessage<Error> = /*@__PURE__*/
|
||||
messageDesc(file_city, 13);
|
||||
|
||||
/**
|
||||
* @generated from message city.VoteState
|
||||
*/
|
||||
export type VoteState = Message<"city.VoteState"> & {
|
||||
/**
|
||||
* @generated from field: bool active = 1;
|
||||
*/
|
||||
active: boolean;
|
||||
|
||||
/**
|
||||
* @generated from field: uint32 speed = 2;
|
||||
*/
|
||||
speed: number;
|
||||
|
||||
/**
|
||||
* @generated from field: uint32 yes = 3;
|
||||
*/
|
||||
yes: number;
|
||||
|
||||
/**
|
||||
* @generated from field: uint32 players = 4;
|
||||
*/
|
||||
players: number;
|
||||
|
||||
/**
|
||||
* @generated from field: uint32 remaining_seconds = 5;
|
||||
*/
|
||||
remainingSeconds: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Describes the message city.VoteState.
|
||||
* Use `create(VoteStateSchema)` to create a new message.
|
||||
*/
|
||||
export const VoteStateSchema: GenMessage<VoteState> = /*@__PURE__*/
|
||||
messageDesc(file_city, 14);
|
||||
|
||||
/**
|
||||
* @generated from message city.Clock
|
||||
*/
|
||||
export type Clock = Message<"city.Clock"> & {
|
||||
/**
|
||||
* @generated from field: uint64 tick = 1;
|
||||
*/
|
||||
tick: bigint;
|
||||
|
||||
/**
|
||||
* @generated from field: double game_seconds = 2;
|
||||
*/
|
||||
gameSeconds: number;
|
||||
|
||||
/**
|
||||
* @generated from field: uint32 speed = 3;
|
||||
*/
|
||||
speed: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Describes the message city.Clock.
|
||||
* Use `create(ClockSchema)` to create a new message.
|
||||
*/
|
||||
export const ClockSchema: GenMessage<Clock> = /*@__PURE__*/
|
||||
messageDesc(file_city, 15);
|
||||
|
||||
/**
|
||||
* @generated from message city.ServerMessage
|
||||
*/
|
||||
export type ServerMessage = Message<"city.ServerMessage"> & {
|
||||
/**
|
||||
* @generated from oneof city.ServerMessage.body
|
||||
*/
|
||||
body: {
|
||||
/**
|
||||
* @generated from field: city.Welcome welcome = 1;
|
||||
*/
|
||||
value: Welcome;
|
||||
case: "welcome";
|
||||
} | {
|
||||
/**
|
||||
* @generated from field: city.Snapshot snapshot = 2;
|
||||
*/
|
||||
value: Snapshot;
|
||||
case: "snapshot";
|
||||
} | {
|
||||
/**
|
||||
* @generated from field: city.Delta delta = 3;
|
||||
*/
|
||||
value: Delta;
|
||||
case: "delta";
|
||||
} | {
|
||||
/**
|
||||
* @generated from field: city.Error error = 4;
|
||||
*/
|
||||
value: Error;
|
||||
case: "error";
|
||||
} | {
|
||||
/**
|
||||
* @generated from field: city.Ping pong = 5;
|
||||
*/
|
||||
value: Ping;
|
||||
case: "pong";
|
||||
} | {
|
||||
/**
|
||||
* @generated from field: city.VoteState vote_state = 6;
|
||||
*/
|
||||
value: VoteState;
|
||||
case: "voteState";
|
||||
} | {
|
||||
/**
|
||||
* @generated from field: city.Clock clock = 7;
|
||||
*/
|
||||
value: Clock;
|
||||
case: "clock";
|
||||
} | { case: undefined; value?: undefined };
|
||||
};
|
||||
|
||||
/**
|
||||
* Describes the message city.ServerMessage.
|
||||
* Use `create(ServerMessageSchema)` to create a new message.
|
||||
*/
|
||||
export const ServerMessageSchema: GenMessage<ServerMessage> = /*@__PURE__*/
|
||||
messageDesc(file_city, 16);
|
||||
|
||||
/**
|
||||
* @generated from message city.SavedAgent
|
||||
*/
|
||||
export type SavedAgent = Message<"city.SavedAgent"> & {
|
||||
/**
|
||||
* @generated from field: uint32 id = 1;
|
||||
*/
|
||||
id: number;
|
||||
|
||||
/**
|
||||
* @generated from field: uint32 route = 2;
|
||||
*/
|
||||
route: number;
|
||||
|
||||
/**
|
||||
* @generated from field: double distance = 3;
|
||||
*/
|
||||
distance: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Describes the message city.SavedAgent.
|
||||
* Use `create(SavedAgentSchema)` to create a new message.
|
||||
*/
|
||||
export const SavedAgentSchema: GenMessage<SavedAgent> = /*@__PURE__*/
|
||||
messageDesc(file_city, 17);
|
||||
|
||||
/**
|
||||
* @generated from message city.Route
|
||||
*/
|
||||
export type Route = Message<"city.Route"> & {
|
||||
/**
|
||||
* @generated from field: repeated sint32 xy = 1;
|
||||
*/
|
||||
xy: number[];
|
||||
|
||||
/**
|
||||
* @generated from field: string home_id = 2;
|
||||
*/
|
||||
homeId: string;
|
||||
|
||||
/**
|
||||
* @generated from field: string workplace_id = 3;
|
||||
*/
|
||||
workplaceId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Describes the message city.Route.
|
||||
* Use `create(RouteSchema)` to create a new message.
|
||||
*/
|
||||
export const RouteSchema: GenMessage<Route> = /*@__PURE__*/
|
||||
messageDesc(file_city, 18);
|
||||
|
||||
/**
|
||||
* @generated from message city.RoomSave
|
||||
*/
|
||||
export type RoomSave = Message<"city.RoomSave"> & {
|
||||
/**
|
||||
* @generated from field: uint32 version = 1;
|
||||
*/
|
||||
version: number;
|
||||
|
||||
/**
|
||||
* @generated from field: string id = 2;
|
||||
*/
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* @generated from field: string city_id = 3;
|
||||
*/
|
||||
cityId: string;
|
||||
|
||||
/**
|
||||
* @generated from field: uint32 city_version = 4;
|
||||
*/
|
||||
cityVersion: number;
|
||||
|
||||
/**
|
||||
* @generated from field: uint64 tick = 5;
|
||||
*/
|
||||
tick: bigint;
|
||||
|
||||
/**
|
||||
* @generated from field: uint64 seed = 6;
|
||||
*/
|
||||
seed: bigint;
|
||||
|
||||
/**
|
||||
* @generated from field: double game_seconds = 7;
|
||||
*/
|
||||
gameSeconds: number;
|
||||
|
||||
/**
|
||||
* @generated from field: uint32 speed = 8;
|
||||
*/
|
||||
speed: number;
|
||||
|
||||
/**
|
||||
* @generated from field: repeated city.SavedAgent agents = 9;
|
||||
*/
|
||||
agents: SavedAgent[];
|
||||
|
||||
/**
|
||||
* @generated from field: repeated city.Route routes = 10;
|
||||
*/
|
||||
routes: Route[];
|
||||
|
||||
/**
|
||||
* @generated from field: string city_revision = 11;
|
||||
*/
|
||||
cityRevision: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Describes the message city.RoomSave.
|
||||
* Use `create(RoomSaveSchema)` to create a new message.
|
||||
*/
|
||||
export const RoomSaveSchema: GenMessage<RoomSave> = /*@__PURE__*/
|
||||
messageDesc(file_city, 19);
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { assembleWorld, intersects, parseManifest, parseTile, type Bounds, type CityManifest, type CityTile } from '../src/city/format';
|
||||
|
||||
/** Explicit gzip files work even on static hosts without Content-Encoding headers. */
|
||||
export async function fetchJson(url: string, signal?: AbortSignal, sha256?: string): Promise<unknown> {
|
||||
const response = await fetch(url, { signal });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}: ${url}`);
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
let blob = new Blob([bytes]);
|
||||
if (bytes[0] === 0x1f && bytes[1] === 0x8b) {
|
||||
blob = await new Response(blob.stream().pipeThrough(new DecompressionStream('gzip'))).blob();
|
||||
}
|
||||
const content = await blob.arrayBuffer();
|
||||
if (sha256) {
|
||||
const hash = globalThis.crypto?.subtle ? new Uint8Array(await crypto.subtle.digest('SHA-256', content)) :
|
||||
(await import('@noble/hashes/sha2.js')).sha256(new Uint8Array(content));
|
||||
const digest = [...hash].map(n=>n.toString(16).padStart(2,'0')).join('');
|
||||
if (digest !== sha256) throw new Error(`City tile checksum mismatch: ${url}`);
|
||||
}
|
||||
return JSON.parse(new TextDecoder().decode(content));
|
||||
}
|
||||
|
||||
export class TileLoader {
|
||||
private cache = new Map<string, CityTile>();
|
||||
private pending: AbortController | null = null;
|
||||
private revision = 0;
|
||||
constructor(readonly manifest: CityManifest, readonly manifestUrl: string, private maxTiles = 128) {}
|
||||
static async open(url: string, signal?: AbortSignal) {
|
||||
const absolute = new URL(url, location.href).href;
|
||||
return new TileLoader(parseManifest(await fetchJson(absolute, signal)), absolute);
|
||||
}
|
||||
async load(bounds: Bounds) {
|
||||
const revision = ++this.revision;
|
||||
this.pending?.abort();
|
||||
const controller = this.pending = new AbortController();
|
||||
const wanted = this.manifest.tiles.filter(t => intersects(t.bounds, bounds));
|
||||
if (wanted.length > this.maxTiles) throw new Error(`Слишком большой обзор: ${wanted.length} тайлов. Приблизьте карту (лимит ${this.maxTiles}).`);
|
||||
const loaded = new Map<string, CityTile>();
|
||||
let cursor = 0;
|
||||
// Bound HTTP concurrency and preserve old viewport until every new tile is available.
|
||||
try { await Promise.all(Array.from({ length: Math.min(6, wanted.length) }, async () => {
|
||||
while (cursor < wanted.length) {
|
||||
const ref = wanted[cursor++];
|
||||
let tile = this.cache.get(ref.id);
|
||||
if (!tile) tile = parseTile(await fetchJson(new URL(ref.url, this.manifestUrl).href, controller.signal, ref.sha256), this.manifest.city.id, ref.id);
|
||||
loaded.set(ref.id, tile);
|
||||
}
|
||||
})); } catch (error) { controller.abort(); throw error; }
|
||||
if (revision !== this.revision) throw new DOMException('Superseded viewport', 'AbortError');
|
||||
for (const [id, tile] of loaded) { this.cache.delete(id); this.cache.set(id, tile); }
|
||||
while (this.cache.size > this.maxTiles) this.cache.delete(this.cache.keys().next().value!);
|
||||
return assembleWorld(this.manifest, wanted.map(t => loaded.get(t.id)!));
|
||||
}
|
||||
emptyWorld() { return assembleWorld(this.manifest, []); }
|
||||
destroy() { ++this.revision; this.pending?.abort(); this.cache.clear(); }
|
||||
}
|
||||
Reference in new issue
Block a user