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,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