import { io, type Socket } from 'socket.io-client'; import { ACTIONS, ACTION_IDS } from '../../constants/gameplay.js'; import { GUEST_TOKEN_KEY } from '../../constants/session.js'; import { GAME_CHECKPOINT_KEY, GAME_ROOM_KEY } from '../../constants/recording.js'; import { ROOM_POLL_MS } from '../../constants/webrtc.js'; import { GAME_NAME } from '../../constants/branding.js'; import { HOME_TELEPORT_APPEARANCE } from '../../constants/home.js'; import type { Mode, PlayerStats } from '../shared/types.js'; import type { ActiveMatch, IceConfiguration, LocalMatchState, PeerRoom } from '../shared/p2p-types.js'; import { createClientAnalytics } from './analytics.js'; import { ApiClientError, createGameApi } from './api.js'; import { createMatchController, type ControllerCheckpoint, type MatchController } from './match-controller.js'; import { createPeer } from './peer.js'; import { createResultReporter, type SessionStorage } from './result-reporter.js'; import { createMatchView } from './match-view.js'; import { fighterArt } from './fighter-art.js'; import { createLobbyFeatures } from './lobby-features.js'; import { createGlobalSettingsCache } from './global-settings.js'; import { createAppFooter } from './app-footer.js'; import { installSoundControls } from './sound.js'; import { createPresence } from './presence.js'; import './game.css'; import './match.css'; import './home.css'; import './ui-theme.css'; import './lobby-features.css'; import './app-footer.css'; const el = (id: string) => document.getElementById(id)! as T; const footer = createAppFooter(el('app-footer'), { rules: el('rules-dialog'), privacy: el('privacy-dialog'), contact: el('contact-dialog'), }); installSoundControls(); const presence = createPresence(el('online-count')); const globalSettings = createGlobalSettingsCache({ getItem: key => localStorage.getItem(key), setItem: (key, value) => localStorage.setItem(key, value), }); window.addEventListener('focus', () => { void globalSettings.refresh(); }); document.addEventListener('visibilitychange', () => { if (!document.hidden) void globalSettings.refresh(); }); document.title = GAME_NAME; const homeFighters = el('home-fighters'); homeFighters.innerHTML = '
' + fighterArt(0) + '
' + fighterArt(0, false, HOME_TELEPORT_APPEARANCE) + '
' + fighterArt(1) + '
'; homeFighters.classList.add('home-entering'); homeFighters.addEventListener('animationend', event => { if (event.animationName === 'home-teleport-in') homeFighters.classList.remove('home-entering'); }); el('waiting-fighter').innerHTML = fighterArt(0); const memory = new Map(); let storageAvailable = true, ready = false, pending = false, booting = false; let lobby: ReturnType | undefined; const storage: SessionStorage = { getItem(key) { try { return sessionStorage.getItem(key) ?? memory.get(key) ?? null; } catch { storageAvailable = false; return memory.get(key) ?? null; } }, setItem(key, value) { memory.set(key, value); try { sessionStorage.setItem(key, value); } catch { storageAvailable = false; } }, removeItem(key) { memory.delete(key); try { sessionStorage.removeItem(key); } catch { storageAvailable = false; } }, }; const api = createGameApi(storage); const analytics = createClientAnalytics({ events: events => api.request('/api/analytics/events', 'POST', { events }), activity: visible => api.request('/api/analytics/activity', 'POST', { visible }), }); let reporter: ReturnType; let controller: MatchController | undefined, view: ReturnType | undefined; let currentMatch: LocalMatchState | undefined, currentRoom: PeerRoom | undefined, currentScreen = ''; let matchReceivedAt = 0; let waitingSocket: Socket | undefined, roomPoll: ReturnType | undefined; let startingMatch: string | undefined, startedEvent: string | undefined, finishedEvent: string | undefined; function notice(message = '') { el('notice').textContent = message; } const connection = footer.setConnection; function controls() { ['bot-start', 'room-create', 'join-open'].forEach(id => { el(id).disabled = !ready || pending || !!lobby?.busy || !!lobby?.active; }); lobby?.setReady(ready && !pending); } function errorNotice(error: unknown) { notice(error instanceof ApiClientError ? error.message : '연결을 확인하고 다시 시도하세요.'); el('reconnect').hidden = false; } async function perform(action: () => Promise, event?: string) { if (pending || !ready) return; pending = true; controls(); notice(); if (event) analytics.click(event); try { await action(); } catch (error) { errorNotice(error); } finally { pending = false; controls(); } } function setScreen(screen: 'home' | 'join' | 'waiting' | 'match' | 'result' | 'ranked-queue') { if (currentScreen !== screen) { analytics.screen(screen); currentScreen = screen; notice(); } el('home').hidden = !['home', 'join'].includes(screen); el('waiting').hidden = screen !== 'waiting'; el('match').hidden = !['match', 'result'].includes(screen); el('join-form').hidden = screen !== 'join'; document.body.dataset.screen = screen; el('ranked-queue').hidden = screen !== 'ranked-queue'; if (!['match', 'result'].includes(screen)) { view?.destroy(); view = undefined; currentMatch = undefined; } } function stats(data: PlayerStats) { el('stats').innerHTML = (['bot', 'friend'] as const).map(mode => { const s = data[mode]; return '
' + (mode === 'bot' ? '봇 대전' : '친구 대전') + '
현재 ' + s.current + '최고 ' + s.best + '
'; }).join(''); } function checkpoint(): ControllerCheckpoint | undefined { try { const saved = JSON.parse(storage.getItem(GAME_CHECKPOINT_KEY) ?? 'null'); return saved?.gameSessionId === api.session.gameSessionId ? saved : undefined; } catch { return undefined; } } function rememberRoom(room?: PeerRoom) { currentRoom = room; if (room) storage.setItem(GAME_ROOM_KEY, JSON.stringify({ gameSessionId: api.session.gameSessionId, room })); else storage.removeItem(GAME_ROOM_KEY); } function stopWaiting() { waitingSocket?.removeAllListeners(); waitingSocket?.disconnect(); waitingSocket = undefined; clearTimeout(roomPoll); } function signaling(room: PeerRoom) { return io('/signal', { autoConnect: false, reconnection: false, forceNew: true, auth: { gameSessionId: api.session.gameSessionId, token: api.session.token, roomId: room.id } }); } function showMatch(state: LocalMatchState) { if (state.phase === 'finished' && state.mode === 'friend' && currentRoom?.currentMatchId === state.matchId) { state = { ...state, rematch: [...currentRoom.rematch], opponentLeft: currentRoom.status === 'closed' }; } currentMatch = state; matchReceivedAt = Date.now(); if (currentScreen !== 'result' || state.phase !== 'finished') setScreen('match'); if (!view) view = createMatchView(el('match'), action => currentMatch?.ranked ? lobby!.submitAction(action) : controller?.submitAction(action) ?? Promise.resolve(false), () => { void perform(() => currentMatch?.ranked ? lobby!.rematch() : controller ? controller.rematch() : rematch(), 'rematch'); }, () => { void perform(leave); }, () => setScreen('result'), footer.openRules, undefined, id => currentMatch?.ranked ? lobby!.sendEmote(id) : controller?.sendEmote(id) ?? false); view.update(state); if (state.finished?.saved && !state.ranked) stats(state.finished.stats); if (state.saveStatus === 'failed') el('reconnect').hidden = false; if (state.phase === 'finished') { connection('대전 종료', true); if (finishedEvent !== state.matchId && state.finished) { finishedEvent = state.matchId; if (!state.ranked && state.mode === 'friend' && currentRoom?.status === 'paired') watchRematch(currentRoom); analytics.gameFinished(state.matchId, state.mode, state.aborted ? 'aborted' : state.finished.winnerIndex === null ? 'draw' : state.finished.winnerIndex === state.selfIndex ? 'win' : 'loss', state.finished.reason); } } else connection(state.mode === 'friend' && !state.opponentPresent ? '연결 복구 중' : Number.isFinite(state.startsAt) ? '대전 중' : '연결 중', state.opponentPresent && Number.isFinite(state.startsAt), state.mode === 'friend' && !state.opponentPresent); } async function launch(mode: Mode, room?: PeerRoom, existing?: ActiveMatch) { const matchId = existing?.matchId ?? room?.currentMatchId ?? crypto.randomUUID(); if (startingMatch === matchId || controller?.view().matchId === matchId) return; startingMatch = matchId; stopWaiting(); let active = existing; try { const [session] = await Promise.all([api.sync(), globalSettings.refresh()]); if (!active) { if (session.activeMatch) { if (session.activeMatch.matchId !== matchId) throw new ApiClientError(409, 'MATCH_PENDING', '이전 대전 기록을 저장한 뒤 다시 시도하세요.'); active = session.activeMatch; } else { const input = { requestId: crypto.randomUUID(), matchId, mode, ...(room ? { roomId: room.id } : {}), expectedPreviousSeq: session.lastAppliedSeq }; try { active = await api.registerMatch(input); } catch (error) { const recovered = await api.sync(); if (recovered.activeMatch?.matchId !== matchId) throw error; active = recovered.activeMatch; } } } controller?.destroy(); controller = undefined; view?.destroy(); view = undefined; const saved = checkpoint(); let peer: ReturnType | undefined; const savedFinal = saved?.activeMatch.matchId === matchId && saved.checkpoint.state.phase === 'finished' && (saved.confirmed?.state.phase === 'finished' || saved.checkpoint.state.aborted); if (mode === 'friend' && !savedFinal) { if (!room) throw new Error('ROOM_REQUIRED'); const peerRoom = room; const ice = () => api.request('/api/rtc-config?roomId=' + encodeURIComponent(peerRoom.id)); peer = createPeer({ matchId, roomId: peerRoom.id, slot: peerRoom.selfIndex, epoch: saved?.activeMatch.matchId === matchId ? saved.epoch : 0, iceServers: (await ice()).iceServers, refreshIce: async () => (await ice()).iceServers, createSignaling: () => signaling(peerRoom) }); } controller = createMatchController({ activeMatch: active!, gameSessionId: api.session.gameSessionId, selfIndex: room?.selfIndex ?? 0, reporter, storage, peer, onRematch: rematch }); controller.subscribe(showMatch); await controller.start(); if (startedEvent !== matchId && !controller.view().aborted) { startedEvent = matchId; analytics.gameStarted(matchId, mode); } el('reconnect').hidden = true; } catch (error) { if (active && !controller) await reporter.submit({ ...active, outcome: 'aborted', reason: 'connection_failed' }); throw error; } finally { startingMatch = undefined; } } async function acceptRoom(room: PeerRoom) { rememberRoom(room); if (room.status === 'closed') { stopWaiting(); rememberRoom(); setScreen('home'); notice('방이 종료되었습니다.'); return; } if (room.status === 'paired') { await launch('friend', room); return; } setScreen('waiting'); el('waiting-code').textContent = room.code; el('room-expiry').textContent = new Date(room.expiresAt).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' }) + ' 만료'; stopWaiting(); waitingSocket = signaling(room); waitingSocket.on('room:state', (next: PeerRoom) => { if (next.status !== 'waiting') void acceptRoom(next).catch(errorNotice); }); waitingSocket.on('connect_error', errorNotice); waitingSocket.on('disconnect', () => { connection('연결 끊김', false, true); el('reconnect').hidden = false; }); waitingSocket.connect(); } async function rematch() { if (!currentMatch?.finished?.saved) return; if (currentMatch.mode === 'bot') { storage.removeItem(GAME_CHECKPOINT_KEY); await launch('bot'); return; } if (!currentRoom) return; const room = currentRoom; let next: PeerRoom; try { next = await api.request('/api/rooms/' + room.id + '/rematch', 'POST', { round: room.round, requestId: crypto.randomUUID() }); } catch (error) { if (error instanceof ApiClientError && [400, 409, 404, 410].includes(error.status)) { let updated: PeerRoom; try { updated = await api.request('/api/rooms/' + room.id); } catch (readError) { if (!(readError instanceof ApiClientError) || ![404, 410].includes(readError.status)) throw readError; updated = { ...room, status: 'closed' }; } if (updated.status === 'closed') { rememberRoom(updated); refreshRoomMatch(); return; } } throw error; } rememberRoom(next); refreshRoomMatch(); if (next.round > room.round) { storage.removeItem(GAME_CHECKPOINT_KEY); await launch('friend', next); return; } watchRematch(room); } function refreshRoomMatch() { if (currentMatch) showMatch({ ...currentMatch, clockNow: currentMatch.clockNow + Date.now() - matchReceivedAt }); } function watchRematch(room: PeerRoom) { clearTimeout(roomPoll); const poll = async () => { if (currentRoom?.id !== room.id) return; try { const updated = await api.request('/api/rooms/' + room.id); if (currentRoom?.id !== room.id) return; rememberRoom(updated); refreshRoomMatch(); if (updated.status === 'closed') return; if (updated.round > room.round) { storage.removeItem(GAME_CHECKPOINT_KEY); await launch('friend', updated); return; } } catch (error) { if (currentRoom?.id !== room.id) return; if (error instanceof ApiClientError && [404, 410].includes(error.status)) { rememberRoom({ ...room, status: 'closed' }); refreshRoomMatch(); return; } errorNotice(error); if (error instanceof ApiClientError && error.status < 500) return; } roomPoll = setTimeout(() => { void poll(); }, ROOM_POLL_MS); }; roomPoll = setTimeout(() => { void poll(); }, ROOM_POLL_MS); } async function leave() { if (currentMatch?.ranked || lobby?.active) { await lobby!.leave(); return; } stopWaiting(); if (controller && controller.view().phase !== 'finished') await controller.leave(); if (currentRoom) await api.leaveRoom(currentRoom.id); controller?.destroy(); controller = undefined; rememberRoom(); storage.removeItem(GAME_CHECKPOINT_KEY); stats((await api.sync()).stats); setScreen('home'); connection('접속됨', true); } async function boot() { if (booting) return; booting = true; ready = false; controls(); connection('접속 중', false); try { let token: string | undefined; try { token = localStorage.getItem(GUEST_TOKEN_KEY) ?? undefined; } catch { storageAvailable = false; } const [session] = await Promise.all([api.ensureSession(token), globalSettings.refresh()]); presence.connect(session); if (session.guestToken) try { localStorage.setItem(GUEST_TOKEN_KEY, session.guestToken); } catch { storageAvailable = false; } reporter?.destroy(); reporter = createResultReporter({ gameSessionId: session.gameSessionId, record: value => api.record(value) }, storage); analytics.ready(); stats(session.stats); connection('접속됨', true); el('reconnect').hidden = true; await reporter.resumePending(); await api.sync(); if (await api.resumeRoomLeave()) { rememberRoom(); storage.removeItem(GAME_CHECKPOINT_KEY); } const saved = checkpoint(); try { const stored = JSON.parse(storage.getItem(GAME_ROOM_KEY) ?? 'null'); if (stored?.gameSessionId === session.gameSessionId) currentRoom = stored.room; } catch { rememberRoom(); } const active = api.session.activeMatch; try { if (await lobby?.initialize(!active && !currentRoom)) { ready = true; return; } } catch (error) { errorNotice(error); } if (active) { if (active.mode === 'friend' && saved?.activeMatch.matchId !== active.matchId) { await reporter.submit({ ...active, outcome: 'aborted', reason: 'connection_failed' }); if (active.roomId) await api.leaveRoom(active.roomId); rememberRoom(); } else if (active.mode === 'friend') { try { rememberRoom(await api.request('/api/rooms/' + active.roomId)); } catch (error) { if (!(error instanceof ApiClientError) || ![404, 410].includes(error.status)) throw error; await reporter.submit({ ...active, outcome: 'aborted', reason: 'connection_failed' }); rememberRoom(); } } if (api.session.activeMatch) await launch(active.mode, currentRoom, active); else { stats(api.session.stats); setScreen('home'); } } else if (saved?.checkpoint.state.phase === 'finished') { const state = saved.checkpoint.state; state.appearances = saved.activeMatch.appearances; state.saveStatus = 'saved'; state.finished!.saved = true; state.finished!.stats = api.session.stats; state.clockNow = Date.now(); state.reveal = undefined; state.finished!.revealEndsAt = state.clockNow; state.opponentPresent = state.mode === 'bot' || !!currentRoom; showMatch(state); if (state.mode === 'friend' && currentRoom) { try { const room = await api.request('/api/rooms/' + currentRoom.id); rememberRoom(room); if (room.status === 'paired' && room.currentMatchId !== state.matchId) { storage.removeItem(GAME_CHECKPOINT_KEY); await launch('friend', room); } else { showMatch(state); if (room.status === 'paired' && room.rematch[room.selfIndex]) watchRematch(room); } } catch (error) { if (!(error instanceof ApiClientError) || ![404, 410].includes(error.status)) throw error; rememberRoom(); state.opponentLeft = true; showMatch(state); } } } else if (currentRoom) { try { await acceptRoom(await api.request('/api/rooms/' + currentRoom.id)); } catch (error) { rememberRoom(); setScreen('home'); errorNotice(error); } } else setScreen(new URLSearchParams(location.search).has('room') ? 'join' : 'home'); ready = true; if (!storageAvailable) { notice('저장소를 사용할 수 없어 새로고침 시 기록을 복원할 수 없습니다.'); analytics.error('storage_unavailable', currentScreen || 'home'); } } catch (error) { errorNotice(error); } finally { booting = false; controls(); } } el('bot-start').onclick = () => { void perform(() => launch('bot'), 'bot_start'); }; el('room-create').onclick = () => { void perform(async () => acceptRoom(await api.createRoom()), 'room_create'); }; el('join-open').onclick = () => { setScreen('join'); el('room-code').focus(); }; el('join-close').onclick = () => setScreen('home'); el('join-form').onsubmit = e => { e.preventDefault(); void perform(async () => acceptRoom(await api.request('/api/rooms/join', 'POST', { code: el('room-code').value.trim().toUpperCase(), requestId: crypto.randomUUID() })), 'room_join'); }; el('waiting-leave').onclick = () => { void perform(leave); }; el('reconnect').onclick = () => { if (controller?.view().phase === 'finished') { void controller.retrySave(); return; } if (controller) return; void boot(); }; async function copy(link: boolean) { if (!currentRoom) return; const url = new URL(location.origin); url.searchParams.set('room', currentRoom.code); try { await navigator.clipboard.writeText(link ? url.toString() : currentRoom.code); notice('복사했습니다.'); } catch { notice('복사하지 못했습니다. 표시된 코드를 전달하세요.'); } } el('copy-code').onclick = () => { void copy(false); }; el('copy-link').onclick = () => { void copy(true); }; el('rules-list').innerHTML = ACTION_IDS.map(id => '
' + ACTIONS[id].name + '기 ' + ACTIONS[id].cost + ' 소모 · ' + ACTIONS[id].effect + '
').join(''); const invitation = new URLSearchParams(location.search).get('room'); if (invitation && /^[A-Z0-9]{6}$/i.test(invitation)) el('room-code').value = invitation.toUpperCase(); lobby = createLobbyFeatures({ api, onQueue: message => { setScreen('ranked-queue'); connection(message, true); }, onMatch: state => showMatch(state), onHome: () => { setScreen('home'); connection('접속됨', true); notice(); el('reconnect').hidden = true; }, onAppearance: appearance => { el('waiting-fighter').innerHTML = fighterArt(0, false, appearance); }, onError: errorNotice, onAvailabilityChange: controls, }); void boot();