import { ACTIONS, ACTION_IDS, MATCH_COUNTDOWN_MS } from '../../constants/gameplay.js'; import type { ActionId } from '../shared/types.js'; import type { LocalMatchState } from '../shared/p2p-types.js'; import { appearanceKey, type FighterAppearance } from '../shared/cosmetics.js'; import { createActionRenderer } from './action-renderer.js'; import { actionIcon, ashPileArt, checkIcon, fighterArt } from './fighter-art.js'; import { createFinishCutscene } from './finish-cutscene.js'; import { matchIntroductionMessage } from '../shared/match-introduction.js'; import { EMOTE_COOLDOWN_MS, EMOTE_IDS, EMOTE_LABELS, EMOTE_LINES, type EmoteId } from '../../constants/emotes.js'; import { sound } from './sound.js'; const fighter = (i: number, appearance?: FighterAppearance) => `
${fighterArt(i, false, appearance)}
${ashPileArt()}
`; export function createMatchView(container: HTMLElement, onAction: (action: ActionId) => Promise, onRematch: () => void, onLeave: () => void, onResult: () => void = () => {}, onRules: () => void = () => {}, appearances?: [FighterAppearance, FighterAppearance], onEmote?: (id: EmoteId) => boolean | Promise) { container.innerHTML = `

메뉴
${fighter(0, appearances?.[0])}
${fighter(1, appearances?.[1])}
${actionIcon('charge')}
${ACTION_IDS.map(id => ``).join('')}
감정표현
${EMOTE_IDS.map(id => ``).join('')}
`; const arena = container.querySelector('.arena')!; const emoteMenu = container.querySelector('.emote-menu')!; let emoteReadyAt = 0, sendingEmote = false; emoteMenu.querySelectorAll('[data-emote]').forEach(button => { button.onclick = async () => { if (!current || sendingEmote || Date.now() < emoteReadyAt) return; const matchId = current.matchId; sendingEmote = true; emoteMenu.open = false; emoteMenu.querySelector('summary')?.focus(); try { if (await onEmote?.(button.dataset.emote as EmoteId) && current?.matchId === matchId) emoteReadyAt = Date.now() + EMOTE_COOLDOWN_MS; } finally { sendingEmote = false; } }; }); const renderer = createActionRenderer(arena); const cutscene = createFinishCutscene(() => { tick(); el('.result-banner h2').focus({ preventScroll: true }); }, appearances); const appearanceSignatures = [0, 1].map(i => appearanceKey(appearances?.[i])); let current: LocalMatchState | undefined, offset = 0, busy = false, lastId = '', resultReported = '', submission = 0; const el = (selector: string) => container.querySelector(selector)!; el('.result-banner h2').tabIndex = -1; const buttons = Array.from(container.querySelectorAll('[data-action]')); buttons.forEach(button => button.addEventListener('click', async () => { if (busy || !current || current.phase !== 'selecting' || current.ownAction || Date.now() + offset >= current.deadline) return; const action = button.dataset.action as ActionId; if (current.energy[current.selfIndex] < ACTIONS[action].cost) return; busy = true; tick(); const submittedMatch = current.matchId, submittedTurn = current.turnId, request = ++submission; try { const accepted = await onAction(action); if (request !== submission || !current) return; if (accepted && current.matchId === submittedMatch && current.turnId === submittedTurn && current.phase === 'selecting' && !current.ownAction) current.ownAction = action; } finally { if (request === submission) { busy = false; tick(); } } })); el('.leave').onclick = onLeave; el('.home-return').onclick = onLeave; el('.rematch').onclick = onRematch; el('.match-rules').onclick = () => { el('.match-menu').open = false; el('.match-menu summary').focus(); onRules(); }; function tick() { if (!current) return; const now = Date.now() + offset, s = current; const introduction = matchIntroductionMessage(s, now); el('.match-introduction').hidden = !introduction; el('.match-introduction h1').textContent = introduction ?? ''; arena.hidden = !!introduction; const revealing = resultReported !== s.matchId && !!s.reveal && now < s.reveal.revealEndsAt; const resultVisible = s.phase === 'finished' && !revealing; emoteMenu.hidden = !onEmote || !!introduction || s.phase === 'finished' || !s.opponentPresent; emoteMenu.querySelectorAll('button').forEach(button => { button.disabled = sendingEmote || Date.now() < emoteReadyAt; }); if (resultVisible && resultReported !== s.matchId) renderer.reset(); const recovering = !resultVisible && !s.opponentPresent; const phase = revealing ? 'revealing' : resultVisible ? 'finished' : s.phase; container.dataset.phase = phase; container.dataset.locked = String(!!s.ownAction); const finishing = arena.dataset.outcome === 'hit'; el('.match-top').hidden = resultVisible || !!introduction; el('.player-controls').hidden = finishing || resultVisible || !!introduction; el('.timing').hidden = finishing || resultVisible || !!introduction; el('.match-result').hidden = !resultVisible; el('.result-banner').hidden = !resultVisible; el('.mode-label').textContent = s.ranked ? '랭킹 대전' : s.mode === 'bot' ? '봇 대전' : '친구 대전'; el('.rematch').textContent = s.ranked ? '다시 매칭' : '다시 대전'; el('.turn-heading').textContent = s.phase === 'countdown' ? s.ranked ? '랭킹대전 준비 중…' : '대전 준비' : resultVisible ? '대전 종료' : `${s.turnId} 턴`; const remaining = s.phase === 'countdown' ? Math.max(0, s.startsAt - now) : Math.max(0, s.deadline - now); const count = Number.isFinite(remaining) ? String(Math.ceil(remaining / 1000)) : ''; const countdown = el('.countdown-number'); if (s.phase === 'countdown' && countdown.textContent !== count) { countdown.textContent = count; countdown.dataset.count = count; } el('.duel-cue').hidden = revealing || resultVisible || recovering; el('.selection-status').textContent = !resultVisible && !s.opponentPresent ? '연결 복구 중' : s.phase === 'countdown' ? Number.isFinite(remaining) ? '' : '상대 연결 중' : revealing ? '동시 공개' : resultVisible ? '' : busy ? '선택 중' : s.ownAction ? '선택 완료' : remaining > 0 ? '행동 선택' : '결과 대기'; el('.time-label').textContent = s.phase === 'selecting' && !revealing && !recovering ? `${(remaining / 1000).toFixed(1)}초` : ''; el('.time-track').hidden = recovering; const progress = s.phase === 'countdown' ? remaining / MATCH_COUNTDOWN_MS : s.phase === 'selecting' ? remaining / (s.deadline - s.startsAt) : 0; el('.time-track').style.setProperty('--time', String(Math.min(1, progress))); el('.time-track').setAttribute('aria-valuenow', String(Math.round(Math.min(1, progress) * 100))); el('.time-track').setAttribute('aria-label', s.phase === 'countdown' ? '대전 시작까지 남은 시간' : '남은 선택 시간'); el('.timing').dataset.urgent = String(!recovering && s.phase === 'selecting' && remaining <= 1000 && !s.ownAction); buttons.forEach(button => { const id = button.dataset.action as ActionId; const affordable = s.energy[s.selfIndex] >= ACTIONS[id].cost; button.disabled = busy || !s.opponentPresent || s.phase !== 'selecting' || !!s.ownAction || now < s.startsAt || now >= s.deadline || !affordable; button.dataset.unavailable = String(!affordable); button.classList.toggle('chosen', s.ownAction === id); button.setAttribute('aria-pressed', String(s.ownAction === id)); button.title = !affordable ? `기 ${ACTIONS[id].cost} 필요 · ${ACTIONS[id].effect}` : ACTIONS[id].effect; button.querySelector('small')!.textContent = !affordable ? `기 ${ACTIONS[id].cost} 필요` : id === 'charge' || id === 'guard' ? ACTIONS[id].effect : `기 ${ACTIONS[id].cost} 소모`; }); container.querySelectorAll('[data-fighter]').forEach((node, i) => { const self = i === s.selfIndex; const bubble = node.querySelector('.emote-bubble'); const emote = s.emotes?.[i]; if (bubble) { const line = emote && emote.expiresAt > now && !resultVisible ? EMOTE_LINES[(s.appearances ?? appearances)?.[i]?.characterId ?? i]?.[emote.id] : ''; bubble.hidden = !line; if (bubble.textContent !== (line ?? '')) bubble.textContent = line ?? ''; } node.dataset.locked = String(self && !!s.ownAction && phase === 'selecting'); if (resultVisible && !s.aborted && s.finished?.reason === 'hit' && s.reveal?.matchId === s.matchId && s.reveal.hitIndex === i && s.reveal.actions[1 - i] === 'beam') node.dataset.ash = 'true'; else delete node.dataset.ash; node.querySelector('.choice-status')!.textContent = phase === 'selecting' ? self && s.ownAction ? `${ACTIONS[s.ownAction].name} · 선택 완료` : !self && !s.opponentPresent ? '연결 끊김' : '' : ''; if (resultVisible && s.finished && !s.aborted && !finishing) node.dataset.reaction = s.finished.winnerIndex === null ? 'draw' : s.finished.winnerIndex === i ? 'win' : 'loss'; else delete node.dataset.reaction; }); if (resultVisible && s.finished) { if (resultReported !== s.matchId) { resultReported = s.matchId; onResult(); } const f = s.finished; el('.finish-reason').hidden = f.reason === 'hit'; const outcome = s.aborted ? 'aborted' : f.winnerIndex === null ? 'draw' : f.winnerIndex === s.selfIndex ? 'win' : 'loss'; if (outcome !== 'aborted' && !cutscene.active) sound.play(outcome, `${s.matchId}:result`); container.dataset.outcome = outcome; arena.dataset.result = outcome; el('.result-banner h2').textContent = ({ win: '승리!', loss: '패배', draw: '무승부', aborted: '대전 중단' })[outcome]; el('.match-result').dataset.result = outcome; el('.finish-reason').textContent = ({ hit: '명중', timeout: '선택 시간 초과', double_timeout: '양쪽 선택 시간 초과', forfeit: '기권', disconnect: '연결 종료', double_disconnect: '양쪽 연결 종료', connection_failed: '연결을 복구하지 못했습니다.', protocol_error: '대전 상태를 맞추지 못했습니다.' })[f.reason]; const stats = f.stats[s.mode]; el('.result-stats').textContent = f.saved ? `현재 연승 ${stats.current} · 최고 연승 ${stats.best}` : ''; el('.save-status').textContent = s.saveStatus === 'failed' ? '기록을 저장하지 못했습니다. 다시 시도하세요.' : f.saved ? '' : '기록 저장 중'; el('.rematch').disabled = !f.saved || (!s.ranked && (!!s.rematch[s.selfIndex] || s.opponentLeft)); el('.rematch-status').textContent = s.ranked ? '' : s.opponentLeft ? '상대방이 나갔습니다' : s.rematch[s.selfIndex] ? '상대의 재대전 동의 대기' : s.rematch[s.selfIndex === 0 ? 1 : 0] ? '상대가 재대전을 요청했습니다.' : ''; el('.rematch-status').setAttribute('role', s.opponentLeft ? 'alert' : 'status'); el('.rematch-status').classList.toggle('opponent-left', s.opponentLeft); } else { delete arena.dataset.result; if (arena.dataset.outcome) container.dataset.outcome = arena.dataset.outcome; else delete container.dataset.outcome; } } const interval = setInterval(tick, 100); return { update(state: LocalMatchState) { if (current?.matchId === state.matchId && current.version > state.version) return; if (lastId !== state.matchId) { emoteReadyAt = 0; emoteMenu.open = false; cutscene.reset(); renderer.reset(); lastId = state.matchId; busy = false; submission++; el('.match-introduction .queue-fighter').innerHTML = fighterArt(state.selfIndex, false, (state.appearances ?? appearances)?.[state.selfIndex]); } if (current && current.turnId !== state.turnId) { busy = false; submission++; } current = state; offset = state.clockNow - Date.now(); container.dataset.self = String(state.selfIndex); el('.energy-count').textContent = String(state.energy[state.selfIndex]); el('.energy').setAttribute('aria-label', `내 기 ${state.energy[state.selfIndex]}`); container.querySelectorAll('[data-fighter]').forEach((node, i) => { const appearance = (state.appearances ?? appearances)?.[i]; const signature = appearanceKey(appearance); if (appearanceSignatures[i] !== signature) { node.querySelector('.character-direction')!.innerHTML = fighterArt(i, false, appearance); appearanceSignatures[i] = signature; } node.classList.toggle('self', i === state.selfIndex); node.setAttribute('role', 'group'); node.setAttribute('aria-label', i === state.selfIndex ? '내 캐릭터' : state.mode === 'bot' ? '봇 캐릭터' : '상대 캐릭터'); }); // Rematch/presence updates must never restart a match's completed presentation. if (resultReported !== state.matchId) { if (state.reveal && !state.aborted && (!state.finished || state.finished.reason === 'hit')) renderer.play(state.reveal, Date.now() + offset); else renderer.reset(); } tick(); cutscene.update(state); }, destroy() { submission++; clearInterval(interval); cutscene.destroy(); renderer.destroy(); sound.stop(); container.innerHTML = ''; current = undefined; }, }; }