Files
citygen/src/main.ts
T

408 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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 };