import './style.css'; import { createDialogs } from './ui/dialogs.ts'; import { createScreenshot } from './ui/screenshot.ts'; import { loadSummitAssets } from './scene/summitAssets.ts'; import { calculateSun, sunriseAzimuth } from './astronomy/sunCalculator.ts'; import { createWorld } from './scene/world.ts'; import { createInformationPanel } from './ui/informationPanel.ts'; import { TimeController } from './ui/timeController.ts'; import { loadTerrain } from './scene/terrain.ts'; import { createHeightController } from './ui/heightController.ts'; import type { TimeMode } from './ui/timeController.ts'; import { loadBuildingAssets } from './scene/buildingAssets.ts'; import { LOCATIONS, DEFAULT_LOCATION, LOCATION_ENABLED } from './constants/locations.ts'; import { loadCoast } from './scene/coast.ts'; import { loadStructures } from './scene/structures.ts'; import { WeatherController } from './weather/weather.ts'; import { loadSheep } from './scene/sheep.ts'; import type { WeatherStatus } from './weather/weather.ts'; import { createMultiplayerClient } from './multiplayer/client.ts'; import { MULTIPLAYER } from './constants/multiplayer.ts'; export interface StartOptions { roomId?: string; connection?: (message: string, connected: boolean) => void; ready?: () => void; error?: (message: string) => void; } export async function start(options: StartOptions = {}) { const dialogs = createDialogs(); const requested = new URLSearchParams(location.search).get('site'); const candidate = requested === 'hajodae' || requested === 'gisamun' || requested === 'gwanaksan' ? requested : DEFAULT_LOCATION; const siteId = LOCATION_ENABLED[candidate] ? candidate : DEFAULT_LOCATION; if (requested && requested !== siteId) { const url = new URL(location.href); url.searchParams.set('site', siteId); history.replaceState(null, '', url); } document.querySelectorAll('[data-site]').forEach(link => { if (!LOCATION_ENABLED[link.dataset.site as keyof typeof LOCATIONS]) { link.removeAttribute('href'); link.setAttribute('aria-disabled', 'true'); link.title = '준비 중'; link.textContent += ' · 준비 중'; } }); const site = LOCATIONS[siteId]; const coastal = site.coast !== null; const reconstructSummit = !coastal && new URLSearchParams(location.search).get('terrain') !== 'original'; const query = new URLSearchParams(location.search); const sharedEligible = siteId === MULTIPLAYER.locationId && reconstructSummit && !query.has('profile'); let sharedMode = sharedEligible && query.get('mode') !== 'solo'; const terrainComparison = document.getElementById('terrain-comparison') as HTMLAnchorElement; terrainComparison.hidden = coastal; const comparisonUrl = new URL(location.href); comparisonUrl.searchParams.set('mode', 'solo'); if (reconstructSummit) comparisonUrl.searchParams.set('terrain', 'original'); else comparisonUrl.searchParams.delete('terrain'); terrainComparison.href = comparisonUrl.pathname + comparisonUrl.search; terrainComparison.textContent = reconstructSummit ? '원본 지형 비교' : '사진 기반 보정 적용'; if (!coastal) { document.getElementById('reset-position')!.textContent = '시작 위치 복귀'; document.getElementById('terrain-source')!.textContent = '지형 보정 근거와 한계'; (document.getElementById('terrain-source') as HTMLAnchorElement).href = '/terrain/summit-reconstruction.html'; document.querySelector('#terrain-minimap canvas')!.setAttribute('aria-label', reconstructSummit ? '사진 기반 추정 지형 등고선 · 화살표는 현재 시점' : '원본 DSM 등고선 · 화살표는 현재 시점'); } const status = document.getElementById('terrain-status')!; document.title = `${site.name} · 해돋이 시뮬레이터`; document.getElementById('site-label')!.textContent = site.label; document.getElementById('observer-name')!.textContent = site.name; document.getElementById('current-location')!.textContent = site.name; document.querySelector('#world')!.setAttribute('aria-label', `${site.name} 태양 관측 3D 풍경`); status.textContent = `${site.name} 지형 불러오는 중…`; document.querySelector(`[data-site="${siteId}"]`)!.setAttribute('aria-current', 'page'); if (site.coast) { document.querySelectorAll('[data-look]').forEach(button => { button.hidden = true; }); document.getElementById('look-east')!.textContent = '동쪽 바다'; document.getElementById('look-city')!.textContent = site.coast.lookLabel; if (siteId === 'hajodae') document.getElementById('reset-position')!.textContent = '정자 위치 복귀'; if (siteId === 'gisamun') { document.getElementById('look-city')!.textContent = '조도 방향'; document.getElementById('reset-position')!.textContent = '조도 기준점 복귀'; } document.getElementById('city-overview')!.textContent = '해안 조감'; document.getElementById('movement-status')!.textContent = '시작점에서 0m · 해안 육지 안에서 이동'; for (const id of ['building-status', 'terrain-source', 'structure-source', 'osm-source']) { (document.getElementById(id) as HTMLAnchorElement).href = `${site.coast.directory}/source.html`; } document.getElementById('terrain-source')!.textContent = 'DSM 30m급 원본 보간 · 해안 높이 보정 없음 · 출처/제한'; document.getElementById('structure-source')!.textContent = '물결·반사는 시각적 표현 · 조석·실제 파고 미반영'; document.getElementById('osm-source')!.textContent = '해안선: © OpenStreetMap contributors · ODbL'; } try { const [terrain, buildings, coast, structures, summitGeometry] = await Promise.all([ loadTerrain(site.terrain, site.observer), coastal ? null : loadBuildingAssets(), site.coast ? loadCoast(site.coast.directory, site.observer, site.coast.lookTarget) : undefined, coastal ? undefined : loadStructures(reconstructSummit), reconstructSummit ? loadSummitAssets() : undefined, ]); const effectiveObserver = { ...site.observer, elevation: terrain.metadata.observerSurfaceElevation }; const information = createInformationPanel(effectiveObserver, site.name, coastal ? '근사 지면' : reconstructSummit ? '추정 지면' : '원본 DSM'); const sheep = await loadSheep(); const world = createWorld(document.querySelector('#world')!, terrain, buildings, (location) => { Object.assign(effectiveObserver, location); update(); }, coast, structures, reconstructSummit, summitGeometry, sheep); const viewEvents = new AbortController(); const screenshot = createScreenshot(() => world.captureFrame()); document.querySelectorAll('[data-look]').forEach(button => { button.addEventListener('click', () => world.lookAtStructure(button.dataset.look!), { signal: viewEvents.signal }); }); document.getElementById('look-east')!.addEventListener('click', () => world.lookEast(), { signal: viewEvents.signal }); document.getElementById('look-city')!.addEventListener('click', () => world.lookAtCity(), { signal: viewEvents.signal }); document.getElementById('city-overview')!.addEventListener('click', () => world.showCityOverview(), { signal: viewEvents.signal }); document.getElementById('reset-position')!.addEventListener('click', () => { if (sharedMode) multiplayer.reset(); else world.resetPosition(); }, { signal: viewEvents.signal }); const counts = world.buildingCounts; document.getElementById('building-status')!.textContent = buildings && counts ? `과천 건물 ${(counts.tagged + counts.levels + counts.fallback).toLocaleString()}개 · 높이 자료 ${counts.tagged} / 층수 환산 ${counts.levels} / 임시 높이 ${counts.fallback}` : siteId === 'gisamun' ? 'OSM 해안선 적용 · 모래 영역·관측점·해안 높이는 근사값' : 'OSM 해안선·모래사장 적용 · 관측점과 해안 높이는 근사값'; let selectedDate = new Date(); let selectedMode: TimeMode = 'LIVE'; const update = () => { const state = calculateSun(selectedDate, effectiveObserver); information.update(state, selectedMode); world.update(state); }; const weatherStatus = document.getElementById('weather-status')!; const applyWeather = ({ status, weather, source }: WeatherStatus) => { if (import.meta.env.DEV && new URLSearchParams(location.search).has('profile')) return; world.setWeather(weather); weatherStatus.textContent = status === 'ready' && weather ? `${source} · 운량 ${Math.round(weather.cloudCover)}% · ${weather.visibility === null ? '시정 미제공' : `시정 ${(weather.visibility / 1000).toFixed(1)}km`} · ${weather.rain === null ? '강우 미제공' : `비 ${weather.rain.toFixed(2)}mm/h`} · ${weather.snowfall === null ? '강설 미제공' : `눈 ${weather.snowfall.toFixed(2)}cm/h`}` : status === 'loading' ? '선택 시각의 기상자료 불러오는 중 · 기본 하늘' : status === 'error' ? '기상자료 연결 실패 · 기본 하늘' : '선택 시각의 기상자료 없음 · 기본 하늘'; }; const weather = new WeatherController(site.observer, state => { if (!sharedMode) applyWeather(state); }); const time = new TimeController((date, mode) => { selectedDate = date; selectedMode = mode; update(); if (!sharedMode) weather.update(date); }); const height = createHeightController(effectiveObserver.eyeHeight, (meters) => { effectiveObserver.eyeHeight = meters; world.setEyeHeight(meters); update(); }); world.resetPosition(); const faceSunrise = () => world.lookAtSunrise(sunriseAzimuth(selectedDate, effectiveObserver)); faceSunrise(); let firstJoin = true; const connectionStatus = document.getElementById('connection-status')!; const modeButton = document.getElementById('session-mode') as HTMLButtonElement; const retryButton = document.getElementById('retry-connection') as HTMLButtonElement; const nickname = document.getElementById('nickname') as HTMLInputElement; const newVisitorId = () => '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, c => (Number(c) ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> Number(c) / 4).toString(16)); let visitorId = newVisitorId(); try { const saved = localStorage.getItem('sunrise-visitor-id'); if (saved && /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(saved)) visitorId = saved; else localStorage.setItem('sunrise-visitor-id', visitorId); } catch { /* Storage-disabled browsers keep a per-page anonymous identity. */ } nickname.value = `방문자-${visitorId.slice(-4)}`; const multiplayer = createMultiplayerClient({ readPlayer: () => world.readPlayer(), joined: player => { world.applyPlayer(player); if (firstJoin) { faceSunrise(); firstJoin = false; } }, correction: player => world.applyPlayer(player), snapshot: (state, selfId) => world.synchronizePlayers(state.players, selfId), weather: state => { if (sharedMode) applyWeather(state); }, cleared: () => { world.clearPlayers(); world.setConnection(sharedMode, sharedMode ? () => multiplayer.now() : null); }, status: (message, connected) => { options.connection?.(message, connected); connectionStatus.textContent = message; retryButton.hidden = !sharedMode || connected; nickname.disabled = connected; world.setConnection(sharedMode && !connected, sharedMode ? () => multiplayer.now() : null); }, }); const join = () => multiplayer.connect({ locationId: siteId, roomId: options.roomId ?? MULTIPLAYER.roomId, visitorId, nickname: nickname.value.trim() || `방문자-${visitorId.slice(-4)}` }); const selectMode = (shared: boolean) => { sharedMode = shared; dialogs.setSharedMode(shared); modeButton.textContent = shared ? '혼자 보기로 전환' : '함께 보기로 전환'; document.getElementById('menu-current-mode')!.textContent = shared ? '현재 함께 보기 중' : '현재 혼자 보기 중'; terrainComparison.hidden = coastal || shared; retryButton.hidden = !shared; time.setSharedClock(shared ? () => new Date(multiplayer.now()) : null); const url = new URL(location.href); url.searchParams.set('mode', shared ? 'shared' : 'solo'); history.replaceState(null, '', url); if (shared) { applyWeather({ status: 'loading', weather: null }); join(); } else { multiplayer.disconnect(); world.setConnection(false, null); nickname.disabled = false; connectionStatus.textContent = '혼자 보기 · 날짜와 시간을 자유롭게 변경할 수 있습니다'; weather.update(selectedDate); } }; modeButton.addEventListener('click', () => { if (!sharedMode) { const url = new URL(location.href); url.searchParams.set('site', MULTIPLAYER.locationId); url.searchParams.set('mode', 'shared'); url.searchParams.delete('terrain'); url.searchParams.delete('profile'); location.assign(url.href); return; } selectMode(false); }, { signal: viewEvents.signal }); retryButton.addEventListener('click', join, { signal: viewEvents.signal }); selectMode(sharedMode); if (import.meta.env.DEV && new URLSearchParams(location.search).has('profile')) { // Reproducible developer benchmark: fixed date, time and clear atmosphere. const query = new URLSearchParams(location.search); document.querySelector('#date')!.value = '2026-10-30'; document.querySelector('#time-slider')!.value = query.get('time') === 'day' ? '43200' : '25200'; document.querySelector('#time-slider')!.dispatchEvent(new Event('input')); weatherStatus.textContent = 'DEV benchmark · fixed clear weather'; const { installRainGearPreview } = await import('./dev/rainGearPreview.ts'); installRainGearPreview(sample => world.setWeather(sample)); } status.textContent = coastal ? `${site.name} 3 × 3km · 30m급 DSM을 10m 간격으로 보간 · 영역 밖 육지 미반영` : reconstructSummit ? '사진 기반 추정 지형 · 반경 100m 보정 / 200m까지 연결 · 실측 아님' : '원본 30m DSM 비교 · 동일한 시작 위치 · 지역 보정 미적용'; document.querySelector('#time-controls')!.disabled = false; window.addEventListener('pagehide', (event) => { multiplayer.disconnect(); if (!event.persisted) { screenshot.dispose(); dialogs.dispose(); viewEvents.abort(); height.dispose(); time.dispose(); weather.dispose(); multiplayer.dispose(); world.dispose(); } }); window.addEventListener('pageshow', event => { if (event.persisted && sharedMode) join(); }); if (query.get('capture') === 'threads' && !sharedMode) { const { installThreadsCapture } = await import('./ui/threadsCapture.ts'); installThreadsCapture(effectiveObserver, world); } options.ready?.(); } catch (error) { status.textContent = '지형을 불러오지 못했습니다'; const message = document.getElementById('render-error')!; message.hidden = false; message.textContent = `시뮬레이터를 시작하지 못했습니다. ${error instanceof Error ? error.message : ''} 지형 파일과 WebGL 2 지원을 확인하고 새로고침해 주세요.`; options.error?.(message.textContent); console.error(error); } }