258 lines
16 KiB
TypeScript
258 lines
16 KiB
TypeScript
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);
|
||
}
|
||
}
|