408 lines
21 KiB
TypeScript
408 lines
21 KiB
TypeScript
import { AreaPicker, bboxSizeKm } from './view/picker';
|
||
import { Viewer } from './view/viewer';
|
||
import { fetchOverpass, geocode, type BBox, type OsmElement } from './osm/overpass';
|
||
import { generateWorld } from './gen/pipeline';
|
||
import { downloadWorld, hasWorld, loadWorld, readJsonFile, saveWorld } from './store';
|
||
import type { World } from './types';
|
||
import { TileLoader } from '../client/tiles';
|
||
import type { Bounds } from './city/format';
|
||
import { RoomConnection } from '../client/connection';
|
||
import { create, fromBinary, toBinary } from '@bufbuild/protobuf';
|
||
import { CityJobSchema, CreateCitySchema } from '../client/generated/city_pb';
|
||
|
||
const MAX_KM2 = 25;
|
||
const WARN_KM2 = 10;
|
||
|
||
const app = document.getElementById('app')!;
|
||
let viewer: Viewer | null = null;
|
||
let picker: AreaPicker | null = null;
|
||
let tileLoader: TileLoader | null = null;
|
||
let cityLoad: AbortController | null = null;
|
||
let roomConnection: RoomConnection | null = null;
|
||
function stopRoom() { roomConnection?.destroy(); roomConnection = null; }
|
||
|
||
function stopTileLoading() {
|
||
cityLoad?.abort();
|
||
cityLoad = null;
|
||
tileLoader?.destroy();
|
||
tileLoader = null;
|
||
}
|
||
|
||
const esc = (s: string) => s.replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]!));
|
||
|
||
// ------------------------------------------------------------------ прогресс
|
||
|
||
const overlay = document.createElement('div');
|
||
overlay.className = 'overlay';
|
||
overlay.hidden = true;
|
||
overlay.innerHTML = `<div class="card progress-card"><div class="p-title">Генерация</div><div class="p-text"></div><div class="bar"><i></i></div><button class="btn" data-a="cancel">Отмена</button></div>`;
|
||
document.body.appendChild(overlay);
|
||
let abort: AbortController | null = null;
|
||
overlay.querySelector('[data-a=cancel]')!.addEventListener('click', () => abort?.abort());
|
||
function progress(text: string, frac: number) {
|
||
overlay.hidden = false;
|
||
overlay.querySelector('.p-text')!.textContent = text;
|
||
(overlay.querySelector('.bar i') as HTMLElement).style.width = `${Math.round(frac * 100)}%`;
|
||
}
|
||
const hideProgress = () => (overlay.hidden = true);
|
||
|
||
// ------------------------------------------------------------------ экраны
|
||
|
||
function showViewer(world: World) {
|
||
stopRoom();
|
||
stopTileLoading();
|
||
picker = null;
|
||
app.innerHTML = '';
|
||
viewer?.destroy();
|
||
viewer = new Viewer(app, world, {
|
||
onNew: () => showPicker(world.bbox),
|
||
onExport: () => downloadWorld(world),
|
||
});
|
||
(window as any).viewer = viewer;
|
||
}
|
||
|
||
async function showPicker(initial?: BBox) {
|
||
stopRoom();
|
||
stopTileLoading();
|
||
viewer?.destroy();
|
||
viewer = null;
|
||
app.innerHTML = '';
|
||
const stage = document.createElement('div');
|
||
stage.className = 'picker-stage';
|
||
app.appendChild(stage);
|
||
const panel = document.createElement('div');
|
||
panel.className = 'picker-panel card';
|
||
panel.innerHTML = `
|
||
<div class="brand">Город сверху</div>
|
||
<a class="btn" href="/?play">Сетевая игра — комнаты</a>
|
||
<p class="lead">Выделите на карте небольшой город или район — по данным OpenStreetMap будет построена подробная карта с планировками и мебелью во всех зданиях.</p>
|
||
<form class="search"><input type="search" placeholder="Найти место: город, район, улица…" autocomplete="off"><button class="btn">Найти</button></form>
|
||
<div class="results"></div>
|
||
<div class="row">
|
||
<button class="btn" data-a="select">▭ Выделить область</button>
|
||
<button class="btn ghost" data-a="quick">1×1 км в центре</button>
|
||
</div>
|
||
<div class="hint">Или перетаскивайте с зажатым Shift.</div>
|
||
<div class="sel-info">Область не выбрана</div>
|
||
<label class="field">Название <input type="text" data-f="name" placeholder="например, Балцерс"></label>
|
||
<button class="btn primary" data-a="go" disabled>Сгенерировать</button>
|
||
<details class="more"><summary>Другие способы</summary>
|
||
<div class="col">
|
||
<button class="btn ghost" data-a="last" hidden>Открыть последний мир</button>
|
||
<label class="btn ghost file">Открыть мир (.json / .json.gz)<input type="file" accept=".json,.gz" data-f="world" hidden></label>
|
||
<label class="btn ghost file">Сгенерировать из выгрузки Overpass (.json)<input type="file" accept=".json,.gz" data-f="osm" hidden></label>
|
||
<div class="hint">Выгрузка Overpass должна быть в формате <code>[out:json] … out geom;</code>. Область берётся из выделения или из данных.</div>
|
||
</div>
|
||
</details>
|
||
<div class="err" hidden></div>`;
|
||
stage.appendChild(panel);
|
||
const mapBox = document.createElement('div');
|
||
mapBox.className = 'map-box';
|
||
stage.appendChild(mapBox);
|
||
mapBox.insertAdjacentHTML('beforeend', '<div class="attribution">Карта © <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">OpenStreetMap</a> contributors</div>');
|
||
|
||
const $ = <T extends Element>(s: string) => panel.querySelector(s) as T;
|
||
const nameInput = $<HTMLInputElement>('[data-f=name]');
|
||
const goBtn = $<HTMLButtonElement>('[data-a=go]');
|
||
const selBtn = $<HTMLButtonElement>('[data-a=select]');
|
||
const err = $<HTMLDivElement>('.err');
|
||
const showErr = (m: string | null) => { err.hidden = !m; err.textContent = m ?? ''; };
|
||
|
||
let bbox: BBox | null = null;
|
||
picker = new AreaPicker(mapBox, (b) => {
|
||
bbox = b;
|
||
const info = $<HTMLDivElement>('.sel-info');
|
||
if (!b) {
|
||
info.textContent = 'Область не выбрана';
|
||
info.className = 'sel-info';
|
||
goBtn.disabled = true;
|
||
return;
|
||
}
|
||
const s = bboxSizeKm(b);
|
||
info.textContent = `${s.w.toFixed(2)} × ${s.h.toFixed(2)} км · ${s.km2.toFixed(2)} км²` + (s.km2 > MAX_KM2 ? ` — лимит ${MAX_KM2} км², уменьшите выделение` : s.km2 > WARN_KM2 ? ' — генерация плотной застройки может занять больше времени' : '');
|
||
info.className = 'sel-info' + (s.km2 > MAX_KM2 ? ' bad' : s.km2 > WARN_KM2 ? ' warn' : ' ok');
|
||
goBtn.disabled = s.km2 > MAX_KM2;
|
||
});
|
||
if (initial) picker.setSelection(initial);
|
||
else picker.fit([47.06, 9.49, 47.075, 9.52]);
|
||
|
||
selBtn.addEventListener('click', () => {
|
||
picker!.selectMode = !picker!.selectMode;
|
||
selBtn.classList.toggle('on', picker!.selectMode);
|
||
});
|
||
$<HTMLButtonElement>('[data-a=quick]').addEventListener('click', () => picker!.selectAroundCenter(1));
|
||
|
||
const form = $<HTMLFormElement>('.search');
|
||
form.addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
const q = (form.querySelector('input') as HTMLInputElement).value.trim();
|
||
if (!q) return;
|
||
const box = $<HTMLDivElement>('.results');
|
||
box.innerHTML = '<div class="hint">Ищу…</div>';
|
||
try {
|
||
const res = await geocode(q);
|
||
box.innerHTML = res.length ? '' : '<div class="hint">Ничего не найдено</div>';
|
||
for (const r of res) {
|
||
const b = document.createElement('button');
|
||
b.className = 'result';
|
||
b.textContent = r.name;
|
||
b.addEventListener('click', () => {
|
||
const s = bboxSizeKm(r.bbox);
|
||
picker!.fit(r.bbox);
|
||
if (s.km2 <= MAX_KM2 && s.km2 > 0.02) picker!.setSelection(r.bbox, false);
|
||
else {
|
||
picker!.fit([r.lat - 0.01, r.lon - 0.015, r.lat + 0.01, r.lon + 0.015]);
|
||
picker!.selectAroundCenter(1.2);
|
||
}
|
||
nameInput.value = r.name.split(',')[0];
|
||
box.innerHTML = '';
|
||
});
|
||
box.appendChild(b);
|
||
}
|
||
} catch (ex) {
|
||
box.innerHTML = `<div class="hint">${esc((ex as Error).message)}</div>`;
|
||
}
|
||
});
|
||
|
||
goBtn.addEventListener('click', async () => {
|
||
if (!bbox || goBtn.disabled) return;
|
||
goBtn.disabled = true;
|
||
showErr(null);
|
||
abort = new AbortController();
|
||
(overlay.querySelector('[data-a=cancel]') as HTMLButtonElement).textContent = new URLSearchParams(location.search).has('create-city') ? 'Закрыть ожидание' : 'Отмена';
|
||
try {
|
||
if (new URLSearchParams(location.search).has('create-city')) {
|
||
const response = await fetch('/api/cities/jobs',{method:'POST',headers:{'Content-Type':'application/x-protobuf'},body:toBinary(CreateCitySchema,create(CreateCitySchema,{bbox,name:nameInput.value.trim() || 'Новый город',seed:1})),signal:abort.signal});
|
||
if (!response.ok) throw new Error((await response.json()).error || `HTTP ${response.status}`);
|
||
const job = fromBinary(CityJobSchema,new Uint8Array(await response.arrayBuffer()));
|
||
for (;;) {
|
||
const response = await fetch(`/api/cities/jobs/${encodeURIComponent(job.id)}`,{signal:abort.signal});
|
||
if (!response.ok) throw new Error((await response.json()).error || `HTTP ${response.status}`);
|
||
const state = fromBinary(CityJobSchema,new Uint8Array(await response.arrayBuffer()));
|
||
if (state.status === 'failed') throw new Error(state.error || 'Не удалось создать город');
|
||
if (state.status === 'ready') { hideProgress(); history.replaceState(null,'',`/?play&room=${encodeURIComponent(state.roomId)}`); await showLobby(); return; }
|
||
progress(state.status === 'queued' ? 'Город в очереди…' : `${state.stage || 'Подготовка города'}…`,state.status === 'queued' ? 0.05 : 0.5);
|
||
await new Promise(resolve => setTimeout(resolve,1000));
|
||
abort.signal.throwIfAborted();
|
||
}
|
||
}
|
||
progress('Загрузка данных OpenStreetMap…', 0.02);
|
||
const els = await fetchOverpass(bbox, (s) => progress(s, 0.03), abort.signal);
|
||
await runGeneration(els, bbox, nameInput.value.trim() || 'Новая карта');
|
||
} catch (ex) {
|
||
hideProgress();
|
||
if ((ex as Error).name !== 'AbortError') showErr((ex as Error).message);
|
||
else if (new URLSearchParams(location.search).has('create-city')) showErr('Ожидание закрыто. Принятая сервером задача продолжится; готовый город появится в каталоге комнат.');
|
||
} finally {
|
||
goBtn.disabled = !bbox || bboxSizeKm(bbox).km2 > MAX_KM2;
|
||
}
|
||
});
|
||
|
||
const lastBtn = $<HTMLButtonElement>('[data-a=last]');
|
||
if (await hasWorld()) lastBtn.hidden = false;
|
||
lastBtn.addEventListener('click', async () => {
|
||
progress('Загрузка сохранённого мира…', 0.5);
|
||
const w = await loadWorld();
|
||
hideProgress();
|
||
if (w) showViewer(w);
|
||
});
|
||
$<HTMLInputElement>('[data-f=world]').addEventListener('change', async (e) => {
|
||
const f = (e.target as HTMLInputElement).files?.[0];
|
||
if (!f) return;
|
||
try {
|
||
progress('Чтение файла…', 0.4);
|
||
const w = (await readJsonFile(f)) as World;
|
||
if (!w.buildings || !w.bounds) throw new Error('Это не файл мира');
|
||
hideProgress();
|
||
showViewer(w);
|
||
} catch (ex) {
|
||
hideProgress();
|
||
showErr((ex as Error).message);
|
||
}
|
||
});
|
||
$<HTMLInputElement>('[data-f=osm]').addEventListener('change', async (e) => {
|
||
const f = (e.target as HTMLInputElement).files?.[0];
|
||
if (!f) return;
|
||
try {
|
||
progress('Чтение выгрузки…', 0.03);
|
||
const data = await readJsonFile(f);
|
||
const els: OsmElement[] = data.elements;
|
||
if (!Array.isArray(els)) throw new Error('В файле нет поля elements');
|
||
const b = bbox ?? bboxOfElements(els);
|
||
abort = new AbortController();
|
||
await runGeneration(els, b, nameInput.value.trim() || f.name.replace(/\.json.*$/, ''));
|
||
} catch (ex) {
|
||
hideProgress();
|
||
showErr((ex as Error).message);
|
||
}
|
||
});
|
||
}
|
||
|
||
function bboxOfElements(els: OsmElement[]): BBox {
|
||
let s = 90, w = 180, n = -90, e = -180;
|
||
for (const el of els)
|
||
if (el.type === 'node' && el.lat != null && el.lon != null) {
|
||
s = Math.min(s, el.lat); n = Math.max(n, el.lat); w = Math.min(w, el.lon); e = Math.max(e, el.lon);
|
||
} else if (el.geometry) for (const g of el.geometry) { s = Math.min(s, g.lat); n = Math.max(n, g.lat); w = Math.min(w, g.lon); e = Math.max(e, g.lon); }
|
||
return [s, w, n, e];
|
||
}
|
||
|
||
async function runGeneration(els: OsmElement[], bbox: BBox, name: string) {
|
||
// постоянный сид: один и тот же город всегда генерируется одинаково (сид здания = OSM-id ⊕ сид мира)
|
||
const seed = 1;
|
||
const t0 = performance.now();
|
||
const world = await generateWorld(els, bbox, name, seed, progress, abort?.signal);
|
||
console.info(`world generated in ${((performance.now() - t0) / 1000).toFixed(1)} s`);
|
||
progress('Сохранение…', 1);
|
||
try {
|
||
await saveWorld(world);
|
||
} catch (e) {
|
||
console.warn('save failed', e);
|
||
}
|
||
hideProgress();
|
||
showViewer(world);
|
||
}
|
||
|
||
// ------------------------------------------------------------------ старт
|
||
|
||
async function showTiledCity(url: string, connection?: RoomConnection) {
|
||
stopTileLoading();
|
||
const controller = cityLoad = new AbortController();
|
||
progress('Загрузка манифеста города…', 0.1);
|
||
abort = controller;
|
||
try {
|
||
const loader = await TileLoader.open(url, controller.signal);
|
||
if (controller.signal.aborted) return;
|
||
tileLoader = loader;
|
||
viewer?.destroy();
|
||
app.innerHTML = '';
|
||
let lastKey = '';
|
||
let timer: ReturnType<typeof setTimeout>;
|
||
const tiledViewer = new Viewer(app, loader.emptyWorld(), {
|
||
onNew: () => { clearTimeout(timer); if (connection) {history.replaceState(null,'','/?play');void showLobby();} else void showPicker(loader.manifest.city.bbox); },
|
||
newLabel:connection ? 'Комнаты' : undefined,
|
||
maxViewSpan: loader.manifest.tileSize * (connection ? 4 : 6),
|
||
maxLevels: loader.manifest.counts.maxLevels,
|
||
entities: connection ? () => connection.entities.positions() : undefined,
|
||
onViewport: (bounds: Bounds) => {
|
||
// Snap to tile boundaries: a paint after loading must not trigger a new request.
|
||
const size = loader.manifest.tileSize;
|
||
const x0 = Math.floor(bounds.minX / size) - 1, y0 = Math.floor(bounds.minY / size) - 1;
|
||
const x1 = Math.floor(bounds.maxX / size) + 1, y1 = Math.floor(bounds.maxY / size) + 1;
|
||
const key = `${x0},${y0},${x1},${y1}`;
|
||
if (key === lastKey) return;
|
||
lastKey = key;
|
||
connection?.subscribe({ minX: x0 * size, minY: y0 * size, maxX: x1 * size, maxY: y1 * size });
|
||
clearTimeout(timer);
|
||
timer = setTimeout(async () => {
|
||
if (tileLoader !== loader) return;
|
||
tiledViewer.setLoadStatus('Загрузка тайлов…');
|
||
try {
|
||
const world = await loader.load({ minX: x0 * size, minY: y0 * size, maxX: x1 * size, maxY: y1 * size });
|
||
if (tileLoader !== loader) return;
|
||
tiledViewer.replaceWorld(world);
|
||
tiledViewer.setLoadStatus(`${loader.manifest.counts.buildings} зданий в городе · ${world.buildings.length} в загруженной области`);
|
||
} catch (error) {
|
||
if ((error as Error).name !== 'AbortError' && tileLoader === loader) {
|
||
tiledViewer.setLoadStatus((error as Error).message);
|
||
}
|
||
}
|
||
}, 100);
|
||
},
|
||
});
|
||
viewer = tiledViewer;
|
||
if (connection) {
|
||
const controls = document.createElement('div'); controls.className = 'row';
|
||
const label = document.createElement('span'); label.className = 'room-clock'; label.setAttribute('role','status');
|
||
controls.appendChild(label);
|
||
for (const speed of [0, 1, 2, 4]) {
|
||
const button = document.createElement('button'); button.className = 'btn'; button.textContent = speed === 0 ? 'Пауза' : `×${speed}`;
|
||
button.onclick = () => connection.propose(speed); controls.appendChild(button);
|
||
}
|
||
for (const yes of [true,false]) {
|
||
const button = document.createElement('button'); button.className = 'btn'; button.textContent = yes ? 'За' : 'Против';
|
||
button.onclick = () => connection.vote(yes); controls.appendChild(button);
|
||
}
|
||
const vote = document.createElement('span'); vote.className = 'room-vote'; vote.setAttribute('role','status'); controls.appendChild(vote);
|
||
tiledViewer.root.querySelector('.viewer-top')!.appendChild(controls);
|
||
}
|
||
(window as any).viewer = viewer;
|
||
hideProgress();
|
||
} catch (error) {
|
||
hideProgress();
|
||
if ((error as Error).name === 'AbortError') return showPicker();
|
||
await showPicker();
|
||
const err = app.querySelector<HTMLElement>('.err')!;
|
||
err.hidden = false;
|
||
err.textContent = `Не удалось загрузить город: ${(error as Error).message}`;
|
||
}
|
||
}
|
||
|
||
async function showLobby() {
|
||
stopRoom(); stopTileLoading(); viewer?.destroy(); viewer = null; app.innerHTML = '';
|
||
const panel = document.createElement('div'); panel.className = 'card picker-panel'; panel.style.maxWidth = '700px';
|
||
panel.innerHTML = `<div class="brand">Комнаты города</div>
|
||
<label class="field">Ник <input name="nickname" maxlength="24" value="Наблюдатель"></label>
|
||
<div class="row"><select aria-label="Город"></select><button class="btn primary" data-create>Создать комнату</button><button class="btn" data-refresh>Обновить</button></div>
|
||
<div data-rooms></div><p role="status" data-status></p><a class="btn" href="/?create-city">Создать город на сервере</a><a class="btn" href="/">Генератор для отладки</a>`;
|
||
app.appendChild(panel);
|
||
const status = panel.querySelector<HTMLElement>('[data-status]')!;
|
||
const api = async (url: string, init?: RequestInit) => {
|
||
const response = await fetch(url,init); const body = await response.json();
|
||
if (!response.ok) throw new Error(body.error || `HTTP ${response.status}`); return body;
|
||
};
|
||
const join = (room: string) => {
|
||
const nickname = panel.querySelector<HTMLInputElement>('[name=nickname]')!.value.trim();
|
||
if (!nickname) { status.textContent = 'Введите ник'; return; }
|
||
status.textContent = 'Подключение…';
|
||
stopRoom();
|
||
const connection = new RoomConnection(room,nickname,{
|
||
welcome: w => { void showTiledCity(w.cityManifestUrl,connection); },
|
||
status: text => { status.textContent = text; viewer?.setLoadStatus(text); },
|
||
clock: c => { const clock = app.querySelector('.room-clock'); if (clock) clock.textContent = `День ${Math.floor(c.gameSeconds / 86400) + 1} · ${String(Math.floor(c.gameSeconds/3600)%24).padStart(2,'0')}:${String(Math.floor(c.gameSeconds/60)%60).padStart(2,'0')} · ×${c.speed}`; },
|
||
vote: v => { const vote = app.querySelector('.room-vote'); if (vote) vote.textContent = v.active ? `Голосование за ×${v.speed}: ${v.yes}/${v.players}, ${v.remainingSeconds} с` : `${v.players} игроков`; },
|
||
});
|
||
roomConnection = connection;
|
||
};
|
||
const refresh = async () => {
|
||
try {
|
||
const [cities, rooms] = await Promise.all([api('/api/cities'),api('/api/rooms')]);
|
||
const select = panel.querySelector('select')!; select.replaceChildren();
|
||
for (const city of cities) { const option = document.createElement('option'); option.value = city.id; option.textContent = city.name; select.appendChild(option); }
|
||
const list = panel.querySelector('[data-rooms]')!; list.replaceChildren();
|
||
for (const room of rooms) {
|
||
const button = document.createElement('button'); button.className = 'btn'; button.textContent = `${room.cityId} · ${room.players}/30 · ${room.active ? 'открыта' : 'сохранена'}`;
|
||
button.dataset.room=room.id;
|
||
button.onclick = () => join(room.id); list.appendChild(button);
|
||
}
|
||
status.textContent = cities.length ? (rooms.length ? '' : 'Создайте первую комнату') : 'Каталог пуст. Экспортируйте город: npm run export-city -- world.json public/cities/demo';
|
||
} catch (error) { status.textContent = `Сервер недоступен: ${(error as Error).message}`; }
|
||
};
|
||
panel.querySelector<HTMLButtonElement>('[data-refresh]')!.onclick = () => { void refresh(); };
|
||
panel.querySelector<HTMLButtonElement>('[data-create]')!.onclick = async () => {
|
||
const button = panel.querySelector<HTMLButtonElement>('[data-create]')!; button.disabled = true;
|
||
try {
|
||
const room = await api('/api/rooms',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({cityId:panel.querySelector('select')!.value,seed:1})});
|
||
join(room.id);
|
||
} catch (error) { status.textContent = (error as Error).message; }
|
||
finally { button.disabled = false; }
|
||
};
|
||
await refresh();
|
||
const requestedRoom = new URLSearchParams(location.search).get('room');
|
||
if (requestedRoom) join(requestedRoom);
|
||
}
|
||
|
||
(async () => {
|
||
const params = new URLSearchParams(location.search);
|
||
if (params.has('play')) return showLobby();
|
||
if (params.has('city')) return showTiledCity(params.get('city')!);
|
||
if (params.has('last') && (await hasWorld())) {
|
||
const w = await loadWorld();
|
||
if (w) return showViewer(w);
|
||
}
|
||
showPicker();
|
||
})();
|
||
|
||
// для отладки из консоли
|
||
(window as any).citygen = { runGeneration, showViewer };
|