feat: initialize sunrise with 100-player shared rooms

This commit is contained in:
2026-10-30 16:36:35 +09:00
commit 31d6eac4c2
257 changed files with 66063 additions and 0 deletions
+245
View File
@@ -0,0 +1,245 @@
import './style.css';
import { createDialogs } from './ui/dialogs.ts';
import { loadSummitAssets } from './scene/summitAssets.ts';
import { calculateSun } 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();
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 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),
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) { dialogs.dispose(); viewEvents.abort(); height.dispose(); time.dispose(); weather.dispose(); multiplayer.dispose(); world.dispose(); }
});
window.addEventListener('pageshow', event => { if (event.persisted && sharedMode) join(); });
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);
}
}