import './style.css'; import './lobby.css'; import { APP_NAME, LOBBY, type LobbyData } from './constants/lobby.ts'; const lobby = document.getElementById('lobby')!; const title = document.getElementById('lobby-title')!; const status = document.getElementById('lobby-status')!; const options = document.getElementById('lobby-options')!; const back = document.getElementById('lobby-back') as HTMLButtonElement; const retry = document.getElementById('lobby-retry') as HTMLButtonElement; const hud = document.querySelector('.hud')!; hud.inert = true; document.title = APP_NAME; let channel: LobbyData['channel']; let channelRequest: AbortController | undefined; let background: { dispose(): void } | undefined; let backgroundCancelled = false; function stopBackground() { backgroundCancelled = true; background?.dispose(); background = undefined; } function message(text: string) { status.textContent = text; status.hidden = !text; } function screen(heading = '', text = '') { title.textContent = heading; title.hidden = !heading; message(text); options.replaceChildren(); back.hidden = retry.hidden = true; if (heading) title.focus({ preventScroll: true }); } function choice(name: string, detail: string, disabled: boolean, action: () => void) { const button = document.createElement('button'); button.type = 'button'; button.className = 'lobby-choice'; button.disabled = disabled; const label = document.createElement('span'); label.textContent = name; button.append(label); if (detail) { const description = document.createElement('small'); description.textContent = detail; button.append(description); } button.addEventListener('click', action); options.append(button); } function home(focus = false) { channelRequest?.abort(); screen(); choice('혼자보기', '', false, () => { void enter(false); }); choice('같이 보기', '', false, () => { void showChannels(); }); if (focus) options.querySelector('button')!.focus(); } async function showChannels() { channelRequest?.abort(); const request = new AbortController(); channelRequest = request; screen('채널 선택', '불러오는 중…'); back.hidden = false; back.onclick = () => home(true); try { const response = await fetch(LOBBY.endpoint, { cache: 'no-store', signal: AbortSignal.any([request.signal, AbortSignal.timeout(LOBBY.timeoutMs)]) }); if (!response.ok) throw new Error('채널 조회 실패'); const data = await response.json() as LobbyData; if (request.signal.aborted) return; channel = data.channel; message(channel.available ? '' : '지금은 입장할 수 없습니다.'); choice(channel.name, `${channel.players} / ${channel.capacity}명`, !channel.available, () => { void enter(true); }); if (!channel.available) { retry.hidden = false; retry.onclick = () => { void showChannels(); }; } } catch { if (request.signal.aborted) return; message('채널에 연결하지 못했습니다.'); retry.hidden = false; retry.onclick = () => { void showChannels(); }; } } function reveal() { lobby.hidden = true; document.body.classList.remove('in-lobby'); hud.inert = false; document.querySelector('.scene-toolbar button')?.focus(); } async function enter(shared: boolean) { stopBackground(); screen('', '불러오는 중…'); const url = new URL(location.href); url.searchParams.set('site', 'gwanaksan'); url.searchParams.set('mode', shared ? 'shared' : 'solo'); url.searchParams.delete('terrain'); url.searchParams.delete('profile'); history.replaceState(null, '', url); let ready = false, connected = !shared, entered = false; const finish = () => { if (!ready || !connected || entered) return; entered = true; requestAnimationFrame(() => requestAnimationFrame(reveal)); }; const fail = () => { message('입장하지 못했습니다.'); retry.textContent = '다시 시도'; retry.hidden = false; retry.onclick = () => location.reload(); }; try { const scene = await import('./main.ts'); await scene.start({ roomId: shared ? channel.id : undefined, connection(text, joined) { connected = !shared || joined; if (entered || !shared) return; message(joined ? '불러오는 중…' : text); retry.hidden = joined; retry.textContent = '다시 입장'; retry.onclick = () => document.getElementById('retry-connection')!.click(); finish(); }, ready() { ready = true; finish(); }, error: fail, }); } catch { fail(); } } // Explicit solo/comparison and developer links retain direct entry. const query = new URLSearchParams(location.search); if (query.get('mode') === 'solo' || (import.meta.env.DEV && query.has('profile'))) { void import('./main.ts').then(module => module.start({ ready: reveal, error() { message('장면을 불러오지 못했습니다.'); retry.hidden = false; retry.onclick = () => location.reload(); } })).catch(() => { message('장면을 불러오지 못했습니다.'); retry.hidden = false; retry.onclick = () => location.reload(); }); } else { if (query.get('mode') === 'shared') void showChannels(); else home(); void import('./scene/lobbySunrise.ts').then(module => { if (backgroundCancelled) return; const scene = module.createLobbySunrise(document.querySelector('#lobby-sunrise')!); background = scene; return scene.ready; }).catch(error => { background?.dispose(); console.error('시작 화면 지형을 불러오지 못했습니다.', error); }); } window.addEventListener('pagehide', event => { if (!event.persisted) stopBackground(); });