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.
Область не выбрана
Другие способы
Выгрузка Overpass должна быть в формате [out:json] … out geom;. Область берётся из выделения или из данных.
`; stage.appendChild(panel); const mapBox = document.createElement('div'); mapBox.className = 'map-box'; stage.appendChild(mapBox); mapBox.insertAdjacentHTML('beforeend', '
Карта © OpenStreetMap contributors
'); 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 };