Files
sunrise/src/entry.ts
T

132 lines
5.6 KiB
TypeScript

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<HTMLElement>('.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<HTMLButtonElement>('.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<HTMLCanvasElement>('#lobby-sunrise')!);
background = scene;
return scene.ready;
}).catch(error => {
background?.dispose();
console.error('시작 화면 지형을 불러오지 못했습니다.', error);
});
}
window.addEventListener('pagehide', event => { if (!event.persisted) stopBackground(); });