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 = `
`;
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 = `
Город сверху
Сетевая игра — комнаты
Выделите на карте небольшой город или район — по данным OpenStreetMap будет построена подробная карта с планировками и мебелью во всех зданиях.
Или перетаскивайте с зажатым Shift.
Область не выбрана
Другие способы
`;
stage.appendChild(panel);
const mapBox = document.createElement('div');
mapBox.className = 'map-box';
stage.appendChild(mapBox);
mapBox.insertAdjacentHTML('beforeend', '');
const $ = (s: string) => panel.querySelector(s) as T;
const nameInput = $('[data-f=name]');
const goBtn = $('[data-a=go]');
const selBtn = $('[data-a=select]');
const err = $('.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 = $('.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);
});
$('[data-a=quick]').addEventListener('click', () => picker!.selectAroundCenter(1));
const form = $('.search');
form.addEventListener('submit', async (e) => {
e.preventDefault();
const q = (form.querySelector('input') as HTMLInputElement).value.trim();
if (!q) return;
const box = $('.results');
box.innerHTML = 'Ищу…
';
try {
const res = await geocode(q);
box.innerHTML = res.length ? '' : 'Ничего не найдено
';
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 = `${esc((ex as Error).message)}
`;
}
});
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 = $('[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);
});
$('[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);
}
});
$('[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;
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('.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 = `Комнаты города
Создать город на сервереГенератор для отладки`;
app.appendChild(panel);
const status = panel.querySelector('[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('[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('[data-refresh]')!.onclick = () => { void refresh(); };
panel.querySelector('[data-create]')!.onclick = async () => {
const button = panel.querySelector('[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 };