Files
sunrise/src/main.ts
T

258 lines
16 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 './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<HTMLAnchorElement>('[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<HTMLAnchorElement>(`[data-site="${siteId}"]`)!.setAttribute('aria-current', 'page');
if (site.coast) {
document.querySelectorAll<HTMLButtonElement>('[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<HTMLCanvasElement>('#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<HTMLButtonElement>('[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<HTMLInputElement>('#date')!.value = '2026-10-30';
document.querySelector<HTMLInputElement>('#time-slider')!.value = query.get('time') === 'day' ? '43200' : '25200';
document.querySelector<HTMLInputElement>('#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<HTMLFieldSetElement>('#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);
}
}