Prepare game updates and single-process PM2 deployment

This commit is contained in:
2026-10-14 19:13:36 +09:00
parent 4f24129e29
commit 22792bc2b4
50 changed files with 1546 additions and 197 deletions
+41
View File
@@ -0,0 +1,41 @@
/* Keep the footer in layout during combat so it never covers action buttons. */
body.player-app .app > footer.app-footer {
display: flex;
flex: 0 0 auto;
width: 100vw;
align-self: center;
flex-direction: row;
align-items: center;
justify-content: flex-end;
gap: 4px;
padding: 8px max(24px, env(safe-area-inset-right)) max(8px, env(safe-area-inset-bottom)) max(16px, env(safe-area-inset-left));
background: transparent;
color: var(--muted);
}
body.player-app .app-footer nav { display: flex; align-items: center; gap: 4px; }
body.player-app .app-footer :is(.footer-divider, nav > span) { font-size: 12px; }
body.player-app .app-footer #connection { min-height: 16px; font-size: 10px; color: var(--muted); }
body.player-app .app-footer #connection::before { background: #9ca3af; }
body.player-app .app-footer #connection.connected { color: #15803d; }
body.player-app .app-footer #connection.connected::before { background: #15803d; }
body.player-app .app-footer #connection.disconnected { color: #dc2626; }
body.player-app .app-footer #connection.disconnected::before { background: #dc2626; }
body.player-app .app-footer button.quiet {
min-height: 44px;
padding: 8px;
border: 0;
background: transparent;
color: #454545;
box-shadow: none;
font-size: 12px;
transform: none;
}
body.player-app .app-footer button.quiet:hover { background: #fff0df; color: var(--ink); }
body.player-app .app-footer button.quiet:active { background: #ffe2bd; transform: none; }
body.player-app:is([data-screen=home], [data-screen=join]) .app-footer { position: absolute; right: 0; bottom: 0; background: transparent; }
body.player-app:is([data-screen=match], [data-screen=result]) .app > footer.app-footer { display: flex; }
@media (max-width: 760px) {
body.player-app:is([data-screen=home], [data-screen=join]) .app > footer.app-footer { position: static; margin-top: auto; }
}
+23
View File
@@ -0,0 +1,23 @@
import { FOOTER_LINKS } from '../../constants/footer.js';
type FooterDialog = typeof FOOTER_LINKS[number]['id'];
export function createAppFooter(container: HTMLElement, dialogs: Record<FooterDialog, HTMLDialogElement>) {
container.classList.add('app-footer');
container.innerHTML = `<span id="connection" role="status">연결 중</span><span class="footer-divider" aria-hidden="true">|</span><nav aria-label="안내">${FOOTER_LINKS.map(({ id, label }) =>
`<button type="button" class="quiet" id="${id}-open" aria-haspopup="dialog" aria-controls="${id}-dialog">${label}</button>`).join('<span aria-hidden="true">|</span>')}</nav>`;
const status = container.querySelector<HTMLElement>('#connection')!;
for (const { id } of FOOTER_LINKS) {
container.querySelector<HTMLButtonElement>(`#${id}-open`)!.onclick = () => dialogs[id].showModal();
dialogs[id].querySelectorAll<HTMLButtonElement>('[data-close]').forEach(button => {
button.onclick = () => dialogs[id].close();
});
}
return {
setConnection(message: string, connected: boolean, disconnected = false) {
status.textContent = message; status.classList.toggle('connected', connected);
status.classList.toggle('disconnected', !connected && disconnected);
},
openRules() { dialogs.rules.showModal(); },
};
}
+15 -12
View File
@@ -3,6 +3,7 @@ import { ACTIONS } from '../../constants/gameplay.js';
import type { LocalMatchState } from '../shared/p2p-types.js';
import { appearanceKey, type FighterAppearance } from '../shared/cosmetics.js';
import { finisherArt } from './fighter-art.js';
import { DEFAULT_GLOBAL_SETTINGS } from '../../constants/global-settings.js';
// Survives view replacement on reconnect, without persisting anything to game state.
let presentation: { matchId: string; startedAt: number; dismissed: boolean } | undefined;
@@ -11,6 +12,7 @@ export function createFinishCutscene(onDismiss: () => void, appearances?: [Fight
let dialog: HTMLDialogElement | undefined, timer: ReturnType<typeof setTimeout> | undefined;
let returnFocus: HTMLElement | undefined, previousBodyState: string | undefined;
let activeMatch = '', resultAt = 0, artSignature = '';
let timing: Record<keyof typeof CUTSCENE_TIMING, number> = { ...CUTSCENE_TIMING };
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
function close(restoreFocus: boolean) {
@@ -36,20 +38,18 @@ export function createFinishCutscene(onDismiss: () => void, appearances?: [Fight
if (!dialog || presentation?.matchId !== activeMatch) return;
clearTimeout(timer);
const now = performance.now(), elapsed = now - presentation.startedAt;
const complete = (reducedMotion.matches || elapsed >= CUTSCENE_TIMING.complete) && now >= resultAt;
const reactionEnds = dialog.dataset.attack === 'beam' ? CUTSCENE_TIMING.beamDissolve : CUTSCENE_TIMING.fallen;
const complete = (reducedMotion.matches || elapsed >= timing.complete) && now >= resultAt;
const reactionEnds = dialog.dataset.attack === 'beam' ? timing.beamDissolve : timing.fallen;
const stage = complete ? 'complete' : reducedMotion.matches || elapsed >= reactionEnds ? 'fallen'
: elapsed >= CUTSCENE_TIMING.fallen ? 'reaction'
: elapsed >= CUTSCENE_TIMING.impact ? 'impact' : 'attack';
: elapsed >= timing.fallen ? 'reaction'
: elapsed >= timing.impact ? 'impact' : 'attack';
dialog.dataset.stage = stage;
const button = dialog.querySelector<HTMLButtonElement>('.cutscene-result')!;
button.hidden = !complete;
if (complete) {
if (document.activeElement === dialog) button.focus({ preventScroll: true });
dismiss();
} else {
const next = reducedMotion.matches ? resultAt : Math.max(resultAt, presentation.startedAt + CUTSCENE_TIMING.complete);
const boundary = !reducedMotion.matches && elapsed < CUTSCENE_TIMING.impact ? presentation.startedAt + CUTSCENE_TIMING.impact
: !reducedMotion.matches && elapsed < CUTSCENE_TIMING.fallen ? presentation.startedAt + CUTSCENE_TIMING.fallen
const next = reducedMotion.matches ? resultAt : Math.max(resultAt, presentation.startedAt + timing.complete);
const boundary = !reducedMotion.matches && elapsed < timing.impact ? presentation.startedAt + timing.impact
: !reducedMotion.matches && elapsed < timing.fallen ? presentation.startedAt + timing.fallen
: !reducedMotion.matches && elapsed < reactionEnds ? presentation.startedAt + reactionEnds : next;
timer = setTimeout(advance, Math.max(0, boundary - now));
}
@@ -75,6 +75,9 @@ export function createFinishCutscene(onDismiss: () => void, appearances?: [Fight
return;
}
const now = performance.now();
const speed = (state.settings ?? DEFAULT_GLOBAL_SETTINGS).finishCutsceneSpeed;
timing = { impact: CUTSCENE_TIMING.impact / speed, fallen: CUTSCENE_TIMING.fallen / speed,
beamDissolve: CUTSCENE_TIMING.beamDissolve / speed, complete: CUTSCENE_TIMING.complete / speed };
if (presentation?.matchId !== state.matchId) {
presentation = { matchId: state.matchId, startedAt: now - Math.max(0, state.clockNow - reveal.revealedAt), dismissed: false };
}
@@ -84,14 +87,14 @@ export function createFinishCutscene(onDismiss: () => void, appearances?: [Fight
previousBodyState = document.body.dataset.cutscene;
const name = (index: number) => index === state.selfIndex ? '나' : state.mode === 'bot' ? '봇' : '상대';
dialog = document.createElement('dialog');
dialog.style.setProperty('--cutscene-time-scale', String(1 / speed));
dialog.className = 'finish-cutscene'; dialog.tabIndex = -1;
dialog.dataset.hitIndex = String(hit);
dialog.dataset.attack = reveal.actions[attacker];
dialog.dataset.stage = 'attack';
dialog.setAttribute('aria-label', `${name(attacker)}의 ${ACTIONS[reveal.actions[attacker]].name} 명중. ${hit === state.selfIndex ? '패배' : '승리'}.`);
dialog.innerHTML = `${finisherArt(hit, reveal.actions[attacker], chosenAppearances)}<button class="cutscene-result" hidden>결과 보기</button>`;
dialog.innerHTML = finisherArt(hit, reveal.actions[attacker], chosenAppearances);
artSignature = signature;
dialog.querySelector<HTMLButtonElement>('.cutscene-result')!.onclick = dismiss;
dialog.addEventListener('cancel', event => { event.preventDefault(); dismiss(); });
document.body.append(dialog);
document.body.dataset.cutscene = 'active';
+33 -11
View File
@@ -1,4 +1,4 @@
import { MATCH_COUNTDOWN_MS, RULES_VERSION } from '../../constants/gameplay.js';
import { RULES_VERSION } from '../../constants/gameplay.js';
import { RECONNECT_GRACE_MS } from '../../constants/session.js';
import { PEER_PING_MS, PEER_SILENCE_MS, PROTOCOL_TIMEOUT_MS, PROTOCOL_VERSION, RESUME_START_DELAY_MS } from '../../constants/webrtc.js';
import type { MatchEngine } from '../shared/match-engine.js';
@@ -7,6 +7,7 @@ import type { MatchCheckpoint, PeerMessage, PublicCheckpoint } from '../shared/p
import type { ActionId, Pair } from '../shared/types.js';
import type { GameClock } from './match-controller.js';
import type { PeerConnection } from './peer.js';
import { matchIntroductionDuration, matchPreparationDuration } from '../shared/match-introduction.js';
export type GamePeer = Pick<PeerConnection, 'connect' | 'connected' | 'epoch' | 'send' | 'onMessage' | 'onState' | 'restart' | 'close'>;
interface FriendConfig {
@@ -19,6 +20,7 @@ export function createFriendMatch(config: FriendConfig) {
let stopped = false, recovering = false, offset = 0, localReady = false, peerReady = false, begun = false;
let confirmed = config.saved?.confirmed ?? engine.checkpoint();
let exchange: TurnExchange | undefined, ownDigest = '', remoteDigest = '', remoteAck = '', sentAck = false, presented = false;
let pendingTurnId: number | undefined, pendingCommit: Extract<PeerMessage, { type: 'turn:commit' }> | undefined;
let presentAt: number | undefined, privateChoice: { action: ActionId | null; nonce: string } | undefined;
let resumedPeer = false, resumedAck = false, lastPeerAt = clock.now(), queue = Promise.resolve();
let leaveResolve: (() => void) | undefined;
@@ -29,7 +31,12 @@ export function createFriendMatch(config: FriendConfig) {
function clear(name: string) { clock.clearTimeout(timers.get(name)); timers.delete(name); }
function later(name: string, fn: () => void, ms: number) { clear(name); timers.set(name, clock.setTimeout(() => { timers.delete(name); if (!stopped) fn(); }, Math.max(0, ms))); }
function clearAll() { for (const name of [...timers.keys()]) clear(name); }
function run(fn: () => unknown | Promise<unknown>) { void Promise.resolve().then(fn).catch(() => { if (!stopped) abort('protocol_error'); }); }
function handleError() {
if (stopped) return;
if (!peer.connected) { void recover(); return; }
abort('protocol_error');
}
function run(fn: () => unknown | Promise<unknown>) { void Promise.resolve().then(fn).catch(handleError); }
function send(type: PeerMessage['type'], fields: Record<string, unknown> = {}) {
peer.send({ protocolVersion: PROTOCOL_VERSION, rulesVersion: RULES_VERSION, matchId: config.matchId,
connectionEpoch: peer.epoch, turnId: engine.view().turnId, type, ...fields } as PeerMessage);
@@ -42,7 +49,7 @@ export function createFriendMatch(config: FriendConfig) {
engine.abort(reason); config.changed(); config.finished();
stopped = true; peer.close(); leaveResolve?.();
}
function armProtocol() { later('protocol', () => abort('protocol_error'), PROTOCOL_TIMEOUT_MS); }
function armProtocol() { later('protocol', () => { void recover(); }, PROTOCOL_TIMEOUT_MS); }
function publicCheckpoint(): PublicCheckpoint {
const state = confirmed.state;
return { turnId: state.phase === 'revealing' ? state.turnId + 1 : Math.max(1, state.turnId), energy: [...state.energy],
@@ -90,6 +97,7 @@ export function createFriendMatch(config: FriendConfig) {
else if (presentAt !== undefined) present(presentAt);
}
async function resolved(actions: Pair<ActionId | null>) {
clear('deadline');
engine.resolve(actions);
const state = engine.view();
ownDigest = await digestValue([config.matchId, peer.epoch, state.turnId, state.energy, actions, state.finished?.winnerIndex ?? null, state.finished?.reason ?? null]);
@@ -97,20 +105,24 @@ export function createFriendMatch(config: FriendConfig) {
send('turn:resolved', { digest: ownDigest }); agree();
}
function selection() {
const earlyCommit = pendingCommit; pendingTurnId = undefined; pendingCommit = undefined;
privateChoice = undefined; ownDigest = remoteDigest = remoteAck = ''; sentAck = false; presented = false; presentAt = undefined;
const state = engine.view();
exchange = createTurnExchange({ matchId: config.matchId, epoch: peer.epoch, turnId: state.turnId, slot: config.slot, energy: state.energy,
send: message => peer.send(message), onResolved: actions => run(() => resolved(actions)),
onPrivateState: value => { privateChoice = value; config.changed(); }, onPhase: () => armProtocol() });
onPrivateState: value => { privateChoice = value; config.changed(); },
onPhase: phase => { if (phase !== 'commit' || now() >= state.deadline) armProtocol(); } });
if (earlyCommit) void exchange.receive(earlyCommit).catch(handleError);
config.changed();
later('deadline', () => run(() => exchange!.commit(engine.view().ownAction)), state.deadline - now());
later('deadline', () => { armProtocol(); run(() => exchange!.commit(engine.view().ownAction)); }, state.deadline - now());
}
function applyStart(startsAt: number, resume = false) {
if (!Number.isFinite(startsAt)) { abort('protocol_error'); return; }
if (resume) {
engine.resumeTurn(confirmed, startsAt); recovering = false; clear('recovery'); engine.presence(true); selection(); heartbeat(); return;
}
if (!begun) { begun = true; engine.start(startsAt - MATCH_COUNTDOWN_MS); confirmed = engine.checkpoint(); config.changed(); }
pendingTurnId = engine.view().turnId + 1; pendingCommit = undefined;
if (!begun) { begun = true; engine.start(startsAt - matchPreparationDuration(engine.view().settings)); confirmed = engine.checkpoint(); config.changed(); }
later('start', () => { if (engine.beginTurn(startsAt)) selection(); }, startsAt - now());
}
function startTurn(startsAt: number, resume = false) {
@@ -118,7 +130,7 @@ export function createFriendMatch(config: FriendConfig) {
send('turn:start', { startsAt, resume, turnId }); applyStart(startsAt, resume);
}
function maybeStart() {
if (localReady && peerReady && config.slot === 0 && !begun && !recovering) startTurn(now() + MATCH_COUNTDOWN_MS);
if (localReady && peerReady && config.slot === 0 && !begun && !recovering) startTurn(now() + matchPreparationDuration(engine.view().settings) + matchIntroductionDuration(engine.view().settings, false));
}
function maybeResume() {
if (!recovering || !resumedPeer || !resumedAck) return;
@@ -129,9 +141,11 @@ export function createFriendMatch(config: FriendConfig) {
if (stopped || recovering) return;
if (confirmed.state.phase === 'finished') {
clearAll(); if (engine.view().phase !== 'finished') engine.restore(confirmed);
engine.present(now()); engine.presence(false); config.changed(); config.finished(); return;
// A confirmed result keeps its original reveal window when the peer leaves.
engine.presence(false); config.changed(); config.finished(); return;
}
recovering = true; resumedPeer = resumedAck = false; clearAll(); exchange = undefined;
recovering = true; resumedPeer = resumedAck = false; clearAll(); exchange = undefined; pendingTurnId = undefined; pendingCommit = undefined;
engine.restore(confirmed);
engine.presence(false); config.changed();
later('recovery', () => abort('connection_failed'), RECONNECT_GRACE_MS - (clock.now() - disconnectedAt));
try {
@@ -165,13 +179,20 @@ export function createFriendMatch(config: FriendConfig) {
}
if (message.type === 'match:leave-ack') { leaveResolve?.(); return; }
if (message.type === 'match:rematch') { engine.requestRematch(config.slot === 0 ? 1 : 0); config.changed(); return; }
if (recovering || message.turnId !== engine.view().turnId) return;
if (recovering) return;
// A peer may select before this browser's scheduled turn-start callback runs.
if (message.type === 'turn:commit' && message.turnId === pendingTurnId) {
if (message.slot === config.slot) throw new Error('INVALID_SLOT');
if (pendingCommit && pendingCommit.digest !== message.digest) throw new Error('COMMITMENT_CHANGED');
pendingCommit = message; return;
}
if (message.turnId !== engine.view().turnId) return;
if (message.type === 'turn:commit' || message.type === 'turn:reveal') { await exchange?.receive(message); return; }
if (message.type === 'turn:resolved') { if (remoteDigest && remoteDigest !== message.digest) throw new Error('RESULT_CHANGED'); remoteDigest = message.digest; agree(); }
else if (message.type === 'turn:ack') { remoteAck = message.digest; agree(); }
else if (message.type === 'turn:present') { if (config.slot !== 1 || !Number.isFinite(message.revealedAt)) throw new Error('INVALID_PRESENT'); presentAt = message.revealedAt; agree(); }
}
const offMessage = peer.onMessage(message => { queue = queue.then(() => receive(message)).catch(() => abort('protocol_error')); });
const offMessage = peer.onMessage(message => { queue = queue.then(() => receive(message)).catch(handleError); });
const offState = peer.onState(state => {
if (state === 'disconnected' || state === 'failed') { const disconnectedAt = clock.now(); void queue.then(() => recover(disconnectedAt)); }
});
@@ -184,6 +205,7 @@ export function createFriendMatch(config: FriendConfig) {
catch { abort('connection_failed'); }
},
get recovering() { return recovering; },
submitAction() { if (!recovering && !stopped) run(() => exchange!.commit(engine.view().ownAction)); },
requestRematch() { engine.requestRematch(config.slot); if (peer.connected) send('match:rematch'); config.changed(); },
snapshot: () => ({ confirmed: structuredClone(confirmed), epoch: peer.epoch, privateChoice }),
async leave() {
+9 -12
View File
@@ -95,13 +95,10 @@ dialog.finish-cutscene::backdrop{background:#080a0a;backdrop-filter:none}
.rock-fallen-art{overflow:visible}
/* Stage changes reveal additional panels; previously revealed panels stay on the page. */
.finish-cutscene[data-stage=attack] .cutscene-impact,.finish-cutscene[data-stage=attack] .cutscene-fallen,.finish-cutscene[data-stage=impact] .cutscene-fallen{visibility:hidden}
.finish-cutscene[data-stage=attack] .cutscene-attack{animation:cutscene-enter .28s ease-out both}
.finish-cutscene[data-stage=impact] .cutscene-impact{animation:cutscene-enter .22s ease-out both}
.finish-cutscene[data-stage=fallen] .cutscene-fallen{animation:cutscene-enter .4s ease-out both}
.finish-cutscene[data-stage=impact] .cutscene-combatant[data-hit=true] .character{animation:cutscene-hit .35s ease-out both}
.finish-cutscene .cutscene-result{position:absolute;right:max(16px,env(safe-area-inset-right));bottom:max(14px,env(safe-area-inset-bottom));z-index:10;min-height:44px;padding:9px 17px;border:1px solid #d5cfbb;border-radius:3px;background:#141a18;color:#fff7e4;font-size:13px;line-height:1.2;box-shadow:2px 3px 0 #05070780}
.finish-cutscene .cutscene-result:hover{background:#303c35;border-color:#fff5db;transform:none}
.finish-cutscene .cutscene-result:focus-visible{outline:3px solid #fff6cd;outline-offset:4px}
.finish-cutscene[data-stage=attack] .cutscene-attack{animation:cutscene-enter calc(.28s * var(--cutscene-time-scale, 1)) ease-out both}
.finish-cutscene[data-stage=impact] .cutscene-impact{animation:cutscene-enter calc(.22s * var(--cutscene-time-scale, 1)) ease-out both}
.finish-cutscene[data-stage=fallen] .cutscene-fallen{animation:cutscene-enter calc(.4s * var(--cutscene-time-scale, 1)) ease-out both}
.finish-cutscene[data-stage=impact] .cutscene-combatant[data-hit=true] .character{animation:cutscene-hit calc(.35s * var(--cutscene-time-scale, 1)) ease-out both}
@keyframes cutscene-enter{from{opacity:0;translate:0 14px}to{opacity:1;translate:0 0}}
@keyframes cutscene-hit{from{transform:translate(-6px,-3px) rotate(-12deg)}to{transform:translate(-23px,-10px) rotate(-35deg)}}
@media(max-width:520px){
@@ -125,7 +122,7 @@ dialog.finish-cutscene::backdrop{background:#080a0a;backdrop-filter:none}
.finish-cutscene[data-attack=beam] .beam-impact-victim{position:absolute;right:2%;top:54%;width:var(--beam-caster-size);aspect-ratio:260/300;translate:0 -50%;z-index:2;visibility:hidden}
.finish-cutscene[data-attack=beam] .beam-impact-victim[data-fighter="0"]{right:auto;left:2%}
.finish-cutscene[data-attack=beam] .beam-impact-victim .character{transform:translate(-10px,-8px) rotate(-25deg);transform-origin:130px 150px}
.finish-cutscene[data-attack=beam][data-stage=impact] .beam-ray{animation:beam-cutscene-fire .3s ease-out both}
.finish-cutscene[data-attack=beam][data-stage=impact] .beam-ray{animation:beam-cutscene-fire calc(.3s * var(--cutscene-time-scale, 1)) ease-out both}
.finish-cutscene[data-attack=beam] .beam-aftermath{background:#080a0a;animation:none;translate:none;opacity:1}
.finish-cutscene[data-attack=beam] .beam-aftermath:after{opacity:.16}
.finish-cutscene[data-attack=beam] .beam-reaction{position:absolute;inset:0 0 0 42%;overflow:hidden;clip-path:polygon(24% 0,100% 0,100% 100%,0 100%);background:#fcffff;visibility:hidden}
@@ -145,10 +142,10 @@ dialog.finish-cutscene::backdrop{background:#080a0a;backdrop-filter:none}
.finish-cutscene[data-attack=beam] .beam-fragments{opacity:.6;transform:translateX(0)}
.finish-cutscene[data-attack=beam]:is([data-stage=reaction],[data-stage=fallen],[data-stage=complete]) .beam-reaction{visibility:visible}
.finish-cutscene[data-attack=beam]:is([data-stage=fallen],[data-stage=complete]) .beam-dissolve{visibility:visible}
.finish-cutscene[data-attack=beam][data-stage=reaction] .beam-reaction-erosion{animation:beam-cutscene-erode .55s ease-out both}
.finish-cutscene[data-attack=beam][data-stage=reaction] .beam-reaction-light{animation:beam-cutscene-irradiate .55s ease-out both}
.finish-cutscene[data-attack=beam][data-stage=fallen] .beam-remnant{animation:beam-cutscene-fade .55s ease-out both}
.finish-cutscene[data-attack=beam][data-stage=fallen] .beam-fragments{animation:beam-cutscene-scatter .55s ease-out both}
.finish-cutscene[data-attack=beam][data-stage=reaction] .beam-reaction-erosion{animation:beam-cutscene-erode calc(.55s * var(--cutscene-time-scale, 1)) ease-out both}
.finish-cutscene[data-attack=beam][data-stage=reaction] .beam-reaction-light{animation:beam-cutscene-irradiate calc(.55s * var(--cutscene-time-scale, 1)) ease-out both}
.finish-cutscene[data-attack=beam][data-stage=fallen] .beam-remnant{animation:beam-cutscene-fade calc(.55s * var(--cutscene-time-scale, 1)) ease-out both}
.finish-cutscene[data-attack=beam][data-stage=fallen] .beam-fragments{animation:beam-cutscene-scatter calc(.55s * var(--cutscene-time-scale, 1)) ease-out both}
@keyframes beam-cutscene-fire{from{opacity:.35;transform:scaleX(.15)}to{opacity:1;transform:scaleX(1)}}
@keyframes beam-cutscene-erode{from{transform:translateX(22px)}to{transform:translateX(0)}}
@keyframes beam-cutscene-irradiate{from{opacity:.2}to{opacity:.85}}
+26
View File
@@ -0,0 +1,26 @@
import { DEFAULT_GLOBAL_SETTINGS, GLOBAL_SETTINGS_CACHE_KEY, GLOBAL_SETTINGS_REQUEST_TIMEOUT_MS } from '../../constants/global-settings.js';
import { isGlobalSettings, type GlobalSettings } from '../shared/global-settings.js';
export function createGlobalSettingsCache(storage: Pick<Storage, 'getItem' | 'setItem'>, send: typeof fetch = globalThis.fetch.bind(globalThis)) {
let current: GlobalSettings | undefined, pending: Promise<GlobalSettings> | undefined;
try { const cached: unknown = JSON.parse(storage.getItem(GLOBAL_SETTINGS_CACHE_KEY) ?? 'null'); if (isGlobalSettings(cached)) current = cached; } catch { /* Storage can be unavailable. */ }
return {
refresh(): Promise<GlobalSettings> {
if (pending) return pending;
pending = (async () => {
try {
const response = await send('/api/global-settings' + (current ? '?revision=' + current.revision : ''), { cache: 'no-store', signal: AbortSignal.timeout(GLOBAL_SETTINGS_REQUEST_TIMEOUT_MS) });
if (!response.ok) throw new Error('SETTINGS_UNAVAILABLE');
const result = await response.json();
if (!result.ok) throw new Error('SETTINGS_UNAVAILABLE');
if (isGlobalSettings(result.data.settings)) {
current = result.data.settings;
try { storage.setItem(GLOBAL_SETTINGS_CACHE_KEY, JSON.stringify(current)); } catch { /* Keep the in-memory copy. */ }
} else if (!current || result.data.revision !== current.revision) throw new Error('INVALID_SETTINGS');
} catch { /* Recheck on the next load, focus or match; retain the last valid cache. */ }
return { ...(current ?? DEFAULT_GLOBAL_SETTINGS) };
})().finally(() => { pending = undefined; });
return pending;
},
};
}
+2 -2
View File
@@ -17,9 +17,9 @@
<p id="notice" class="notice" role="status" aria-live="polite"></p>
<button id="reconnect" class="primary" hidden>다시 시도</button>
</main>
<footer class="app-footer"><span id="connection" role="status">연결 중</span><nav aria-label="안내"><button type="button" class="quiet" id="rules-open" aria-haspopup="dialog" aria-controls="rules-dialog">rule</button><span aria-hidden="true">|</span><button type="button" class="quiet" id="privacy-open" aria-haspopup="dialog" aria-controls="privacy-dialog">privacy</button><span aria-hidden="true">|</span><button type="button" class="quiet" id="contact-open" aria-haspopup="dialog" aria-controls="contact-dialog">contact</button></nav></footer>
<footer id="app-footer" class="app-footer"></footer>
</div>
<dialog id="rules-dialog"><div class="dialog-top"><h2>규칙</h2><button class="quiet" data-close aria-label="규칙 닫기">닫기</button></div><p>기 0으로 시작합니다. 공격에 한 번 맞으면 종료됩니다.</p><div id="rules-list"></div><p>선택 시간 4초. 선택은 바꿀 수 없고 마감 때 함께 공개됩니다.</p><p>한쪽 미입력은 패배, 양쪽 미입력은 무승부입니다. 상대에게 나가기가 전달되면 기권 패배입니다.</p><p>친구 대전 연결이 끊기면 최대 10초간 복구합니다. 확정되지 않은 선택은 취소됩니다. 복구하지 못하면 전적 변경 없이 중단합니다.</p><p>같은 공격끼리는 상쇄됩니다. 공격과 순간이동은 실패해도 기를 소모합니다.</p><p>봇·친구 연승은 탭의 접속 세션별로 저장합니다. 같은 탭 새로고침은 복원되며, 저장 정보 없는 새 탭은 새 전적입니다.</p><p>랭킹은 최대 연승, 누적 승 순입니다. 무승부는 연승을 유지합니다. 랭킹 기록·캐릭터·도복은 같은 브라우저의 익명 방문자 정보에 저장됩니다.</p></dialog>
<dialog id="rules-dialog"><div class="dialog-top"><h2>규칙</h2><button class="quiet" data-close aria-label="규칙 닫기">닫기</button></div><p>기 0으로 시작합니다. 공격에 한 번 맞으면 종료됩니다.</p><div id="rules-list"></div><p>선택은 바꿀 수 없습니다. 봇 대전은 선택 즉시, 사용자 간 대전은 양쪽 선택이 끝나면 함께 공개됩니다.</p><p>한쪽 미입력은 패배, 양쪽 미입력은 무승부입니다. 상대에게 나가기가 전달되면 기권 패배입니다.</p><p>친구 대전 연결이 끊기면 최대 10초간 복구합니다. 확정되지 않은 선택은 취소됩니다. 복구하지 못하면 전적 변경 없이 중단합니다.</p><p>같은 공격끼리는 상쇄됩니다. 공격과 순간이동은 실패해도 기를 소모합니다.</p><p>봇·친구 연승은 탭의 접속 세션별로 저장합니다. 같은 탭 새로고침은 복원되며, 저장 정보 없는 새 탭은 새 전적입니다.</p><p>랭킹은 최대 연승, 누적 승 순입니다. 무승부는 연승을 유지합니다. 랭킹 기록·캐릭터·도복은 같은 브라우저의 익명 방문자 정보에 저장됩니다.</p></dialog>
<dialog id="privacy-dialog"><div class="dialog-top"><h2>개인정보·분석 안내</h2><button class="quiet" data-close aria-label="개인정보·분석 안내 닫기">닫기</button></div><p>브라우저에 익명 방문자 토큰을, 탭에 전적 복원 정보를 저장합니다. 새 전적은 마지막 대전 등록·결과 저장부터 90일간 보관합니다.</p><p>익명 방문·화면 전환·메뉴 조작·대전 시작과 결과·기기 분류·유입 도메인을 수집합니다. 방문 분석은 90일간 보관하며, 과거 서버 판정 대전 기록은 계속 보관합니다.</p><p>자동으로 부여한 랭킹 이름·대전 결과·선택한 캐릭터와 도복은 계속 보관합니다. 랭킹 이름과 최대 연승·누적 승은 리더보드에 공개됩니다.</p><p>저장소를 삭제하면 이전 기록에 다시 연결할 수 없습니다.</p></dialog>
<dialog id="contact-dialog" aria-labelledby="contact-title"><div class="dialog-top"><h2 id="contact-title">문의</h2><button type="button" class="quiet" data-close aria-label="문의 닫기">닫기</button></div><div id="contact-content"><a href="mailto:sunha321@gmail.com">sunha321@gmail.com</a></div></dialog>
<script type="module" src="./main.ts"></script>
+2
View File
@@ -40,6 +40,8 @@ body.player-app #appearance-dialog .color-condition { color: #242526; font-weigh
.leaderboard-content th:nth-child(2), .leaderboard-content td:nth-child(2) { text-align: left; overflow-wrap: anywhere; }
.leaderboard-content .self-ranking { background: #ffdfae; }
body[data-screen=ranked-queue] .app { min-height: 100svh; }
.match-introduction { min-height: calc(100svh - 80px); }
.rematch-status.opponent-left { color: #dc2626; }
@media(max-width:760px) {
#home .home-actions > button:not(.primary) { min-height: 54px; font-size: 23px; }
#home .home-actions .split button { min-height: 54px; font-size: 20px; padding-inline: 12px; }
+9 -4
View File
@@ -9,7 +9,7 @@ import { fighterArt } from './fighter-art.js';
interface LobbyOptions {
api: GameApi;
onQueue(): void;
onQueue(message: string): void;
onMatch(state: LocalMatchState): void;
onHome(): void;
onAppearance(appearance: FighterAppearance): void;
@@ -29,7 +29,7 @@ export function createLobbyFeatures(options: LobbyOptions) {
tools.innerHTML = '<button id="leaderboard-open" disabled>리더보드</button><button id="appearance-open" disabled>캐릭터·장비</button>';
actions.after(tools);
const queue = document.createElement('section'); queue.id = 'ranked-queue'; queue.className = 'waiting ranked-queue'; queue.hidden = true;
queue.innerHTML = '<div class="queue-fighter" aria-hidden="true"></div><h1>대전 상대 찾는 중</h1><button id="ranked-cancel">매칭 취소</button>';
queue.innerHTML = '<div class="queue-fighter" aria-hidden="true"></div><h1 role="status">랭킹대전 매칭 중…</h1><button id="ranked-cancel">매칭 취소</button>';
document.getElementById('waiting')!.after(queue);
const appearanceDialog = document.createElement('dialog'); appearanceDialog.id = 'appearance-dialog';
appearanceDialog.innerHTML = '<div class="dialog-top"><h2>캐릭터·장비</h2><button class="quiet" aria-label="캐릭터·장비 닫기">닫기</button></div><div class="appearance-layout"><div class="appearance-preview" aria-label="외형 미리보기"></div><div class="appearance-options"><h3 id="skin-label">피부</h3><div class="skin-options" role="group" aria-labelledby="skin-label"></div><h3 id="hair-label">머리</h3><div class="hair-options" role="group" aria-labelledby="hair-label"></div><h3 id="color-label">도복 색상</h3><div class="color-options" role="group" aria-labelledby="color-label"></div><p class="color-condition" role="status"></p><h3 id="equipment-label">장비</h3><div class="equipment-options" role="group" aria-labelledby="equipment-label"></div><h3 id="eyewear-label" class="eyewear-heading">안경</h3><div class="eyewear-options" role="group" aria-labelledby="eyewear-label"></div><button class="equip primary">장착</button></div></div><p class="profile-notice" role="status"></p>';
@@ -169,12 +169,16 @@ export function createLobbyFeatures(options: LobbyOptions) {
content.append(table);
} catch (error) { content.textContent = error instanceof Error ? error.message : '랭킹을 불러오지 못했습니다.'; }
};
function showQueue(message: string) {
select(queue, 'h1').textContent = message;
options.onQueue(message);
}
function consume(status: RankedStatus) {
if (status.match && latest?.match?.matchId === status.match.matchId && status.match.version < latest.match.version) return;
latest = status; updateProfile(status.profile);
active = status.status !== 'idle'; rankedStart.disabled = busy || active;
options.onAvailabilityChange();
if (status.status === 'queued') options.onQueue();
if (status.status === 'queued') showQueue('랭킹대전 매칭 중…');
else if (status.match) options.onMatch(status.match);
else options.onHome();
}
@@ -195,8 +199,9 @@ export function createLobbyFeatures(options: LobbyOptions) {
if (busy || active) return;
busy = true; rankedStart.disabled = true; generation++;
options.onAvailabilityChange();
showQueue('랭킹대전 매칭 중…');
try { consume(await api.request<RankedStatus>('/api/ranked/queue', 'POST', {})); schedule(); }
catch (error) { options.onError(error); }
catch (error) { options.onHome(); options.onError(error); }
finally { busy = false; options.onAvailabilityChange(); }
}
async function leave() {
+50 -13
View File
@@ -15,13 +15,24 @@ 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 './game.css';
import './match.css';
import './home.css';
import './ui-theme.css';
import './lobby-features.css';
import './app-footer.css';
const el = <T extends HTMLElement = HTMLElement>(id: string) => document.getElementById(id)! as T;
const footer = createAppFooter(el('app-footer'), {
rules: el<HTMLDialogElement>('rules-dialog'), privacy: el<HTMLDialogElement>('privacy-dialog'), contact: el<HTMLDialogElement>('contact-dialog'),
});
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 = '<div class="home-fighter home-fighter-left">' + fighterArt(0) + '</div><div class="home-fighter home-fighter-teleport">' + fighterArt(0, false, HOME_TELEPORT_APPEARANCE) + '</div><div class="home-fighter home-fighter-right">' + fighterArt(1) + '</div>';
@@ -46,10 +57,11 @@ const analytics = createClientAnalytics({
let reporter: ReturnType<typeof createResultReporter>;
let controller: MatchController | undefined, view: ReturnType<typeof createMatchView> | undefined;
let currentMatch: LocalMatchState | undefined, currentRoom: PeerRoom | undefined, currentScreen = '';
let matchReceivedAt = 0;
let waitingSocket: Socket | undefined, roomPoll: ReturnType<typeof setTimeout> | undefined;
let startingMatch: string | undefined, startedEvent: string | undefined, finishedEvent: string | undefined;
function notice(message = '') { el('notice').textContent = message; }
function connection(message: string, connected: boolean) { el('connection').textContent = message; el('connection').classList.toggle('connected', connected); }
const connection = footer.setConnection;
function controls() {
['bot-start', 'room-create', 'join-open'].forEach(id => { el<HTMLButtonElement>(id).disabled = !ready || pending || !!lobby?.busy || !!lobby?.active; });
lobby?.setReady(ready && !pending);
@@ -94,10 +106,11 @@ function showMatch(state: LocalMatchState) {
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'), () => el('rules-open').click());
() => { void perform(leave); }, () => setScreen('result'), footer.openRules);
view.update(state);
if (state.finished?.saved && !state.ranked) stats(state.finished.stats);
if (state.saveStatus === 'failed') el('reconnect').hidden = false;
@@ -105,9 +118,10 @@ function showMatch(state: LocalMatchState) {
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));
} 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();
@@ -115,7 +129,7 @@ async function launch(mode: Mode, room?: PeerRoom, existing?: ActiveMatch) {
startingMatch = matchId; stopWaiting();
let active = existing;
try {
const session = await api.sync();
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', '이전 대전 기록을 저장한 뒤 다시 시도하세요.');
@@ -159,7 +173,7 @@ async function acceptRoom(room: PeerRoom) {
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); el('reconnect').hidden = false; });
waitingSocket.on('disconnect', () => { connection('연결 끊김', false, true); el('reconnect').hidden = false; });
waitingSocket.connect();
}
async function rematch() {
@@ -167,23 +181,48 @@ async function rematch() {
if (currentMatch.mode === 'bot') { storage.removeItem(GAME_CHECKPOINT_KEY); await launch('bot'); return; }
if (!currentRoom) return;
const room = currentRoom;
const next = await api.request<PeerRoom>('/api/rooms/' + room.id + '/rematch', 'POST', { round: room.round, requestId: crypto.randomUUID() });
let next: PeerRoom;
try { next = await api.request<PeerRoom>('/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<PeerRoom>('/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);
if (currentMatch) showMatch(currentMatch);
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<PeerRoom>('/api/rooms/' + room.id);
if (currentRoom?.id !== room.id) return;
rememberRoom(updated);
if (currentMatch) showMatch(currentMatch);
refreshRoomMatch();
if (updated.status === 'closed') return;
if (updated.round > room.round) { storage.removeItem(GAME_CHECKPOINT_KEY); await launch('friend', updated); return; }
} catch (error) { errorNotice(error); if (error instanceof ApiClientError && error.status < 500) 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);
@@ -201,7 +240,7 @@ async function boot() {
try {
let token: string | undefined;
try { token = localStorage.getItem(GUEST_TOKEN_KEY) ?? undefined; } catch { storageAvailable = false; }
const session = await api.ensureSession(token);
const [session] = await Promise.all([api.ensureSession(token), globalSettings.refresh()]);
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;
@@ -272,13 +311,11 @@ async function copy(link: boolean) {
}
el('copy-code').onclick = () => { void copy(false); }; el('copy-link').onclick = () => { void copy(true); };
el('rules-list').innerHTML = ACTION_IDS.map(id => '<div class="rule-row"><strong>' + ACTIONS[id].name + '</strong><span>기 ' + ACTIONS[id].cost + ' 소모 · ' + ACTIONS[id].effect + '</span></div>').join('');
for (const name of ['rules', 'privacy', 'contact']) el(name + '-open').onclick = () => el<HTMLDialogElement>(name + '-dialog').showModal();
document.querySelectorAll<HTMLButtonElement>('[data-close]').forEach(b => { b.onclick = () => b.closest('dialog')!.close(); });
const invitation = new URLSearchParams(location.search).get('room');
if (invitation && /^[A-Z0-9]{6}$/i.test(invitation)) el<HTMLInputElement>('room-code').value = invitation.toUpperCase();
lobby = createLobbyFeatures({
api,
onQueue: () => { setScreen('ranked-queue'); connection('상대 찾는 중', true); },
onQueue: message => { setScreen('ranked-queue'); connection(message, true); },
onMatch: state => showMatch(state),
onHome: () => { setScreen('home'); connection('접속됨', true); notice(); el('reconnect').hidden = true; },
onAppearance: appearance => {
+7 -2
View File
@@ -44,7 +44,7 @@ export function createMatchController(config: ControllerConfig) {
if (state.phase === 'countdown' && clock.now() >= state.startsAt) engine.beginTurn(state.startsAt);
else if (state.phase === 'revealing' && clock.now() >= state.reveal!.revealEndsAt) engine.beginTurn(state.reveal!.revealEndsAt);
state = engine.view();
if (state.phase === 'selecting' && clock.now() >= state.deadline) engine.resolve([state.ownAction, engine.botAction()]);
if (state.phase === 'selecting' && (state.ownAction || clock.now() >= state.deadline)) engine.resolve([state.ownAction, engine.botAction()]);
publish(); state = engine.view();
if (state.phase === 'finished') { void record(); return; }
const next = state.phase === 'countdown' ? state.startsAt : state.phase === 'selecting' ? state.deadline : state.reveal!.revealEndsAt;
@@ -69,7 +69,12 @@ export function createMatchController(config: ControllerConfig) {
if (!saved) engine.start();
tick();
},
async submitAction(action: ActionId) { if (friend?.recovering) return false; const accepted = engine.lockAction(action); if (accepted) publish(); return accepted; },
async submitAction(action: ActionId) {
if (friend?.recovering) return false;
const accepted = engine.lockAction(action);
if (accepted) { if (friend) { publish(); friend.submitAction(); } else tick(); }
return accepted;
},
async leave() {
if (friend) { await friend.leave(); await record(); return; }
clock.clearTimeout(timer);
+32 -14
View File
@@ -1,10 +1,11 @@
import { ACTIONS, ACTION_IDS, MATCH_COUNTDOWN_MS, TURN_DURATION_MS } from '../../constants/gameplay.js';
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';
const fighter = (i: number, appearance?: FighterAppearance) => `<div class="fighter" data-fighter="${i}">
<div class="avatar"><div class="aura"></div><div class="motion-trails"><i></i><i></i><i></i></div><div class="character-direction">${fighterArt(i, false, appearance)}</div>${ashPileArt()}<div class="projectile"></div><div class="shield"></div><div class="impact"></div></div>
@@ -12,7 +13,7 @@ const fighter = (i: number, appearance?: FighterAppearance) => `<div class="figh
</div>`;
export function createMatchView(container: HTMLElement, onAction: (action: ActionId) => Promise<boolean>, onRematch: () => void, onLeave: () => void, onResult: () => void = () => {}, onRules: () => void = () => {}, appearances?: [FighterAppearance, FighterAppearance]) {
container.innerHTML = `<div class="match-top"><div class="match-title"><span class="mode-label"></span><h1 class="turn-heading"></h1></div><details class="match-menu"><summary aria-label="대전 메뉴">메뉴</summary><div class="match-menu-items"><button class="match-rules quiet">규칙</button><button class="leave quiet">나가기</button></div></details></div>
container.innerHTML = `<section class="match-introduction waiting" hidden><div class="queue-fighter" aria-hidden="true"></div><h1 role="status"></h1></section><div class="match-top"><div class="match-title"><span class="mode-label"></span><h1 class="turn-heading"></h1></div><details class="match-menu"><summary aria-label="대전 메뉴">메뉴</summary><div class="match-menu-items"><button class="match-rules quiet">규칙</button><button class="leave quiet">나가기</button></div></details></div>
<div class="timing"><span class="selection-status" role="status"></span><span class="time-label" aria-label="남은 시간"></span><div class="time-track" role="progressbar" aria-label="남은 선택 시간" aria-valuemin="0" aria-valuemax="100"><span></span></div></div>
<div class="arena"><div class="arena-floor" aria-hidden="true"></div><div class="result-banner" hidden><h2></h2><p class="finish-reason"></p></div>${fighter(0, appearances?.[0])}<div class="duel-cue"><span class="versus" aria-hidden="true">VS</span><strong class="countdown-number" role="status"></strong></div><span class="clash-label" role="status"></span><div class="clash-burst" aria-hidden="true"></div>${fighter(1, appearances?.[1])}</div>
<div class="player-controls" role="group" aria-label="내 행동 선택"><div class="energy">${actionIcon('charge')}<strong class="energy-count"></strong></div><div class="action-buttons">${ACTION_IDS.map(id => `<button data-action="${id}" aria-label="${ACTIONS[id].name}, ${ACTIONS[id].effect}${ACTIONS[id].cost ? `, 기 ${ACTIONS[id].cost} 소모` : ''}"><span class="action-symbol">${actionIcon(id)}</span><strong>${ACTIONS[id].name}</strong><small>${id === 'charge' || id === 'guard' ? ACTIONS[id].effect : `기 ${ACTIONS[id].cost} 소모`}</small><span class="chosen-mark">${checkIcon}</span></button>`).join('')}</div></div>
@@ -48,31 +49,39 @@ export function createMatchView(container: HTMLElement, onAction: (action: Actio
function tick() {
if (!current) return;
const now = Date.now() + offset, s = current;
const revealing = !!s.reveal && now < s.reveal.revealEndsAt;
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;
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('.player-controls').hidden = finishing || resultVisible;
el('.timing').hidden = finishing || resultVisible;
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' ? '대전 준비' : resultVisible ? '대전 종료' : `${s.turnId} 턴`;
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;
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 ? `${(remaining / 1000).toFixed(1)}초` : '';
const progress = s.phase === 'countdown' ? remaining / MATCH_COUNTDOWN_MS : s.phase === 'selecting' ? remaining / TURN_DURATION_MS : 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(s.phase === 'selecting' && remaining <= 1000 && !s.ownAction);
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;
@@ -95,6 +104,7 @@ export function createMatchView(container: HTMLElement, onAction: (action: Actio
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';
container.dataset.outcome = outcome;
arena.dataset.result = outcome;
@@ -105,7 +115,9 @@ export function createMatchView(container: HTMLElement, onAction: (action: Actio
el('.result-stats').textContent = f.saved ? `현재 연승 ${stats.current} · 최고 연승 ${stats.best}` : '';
el('.save-status').textContent = s.saveStatus === 'failed' ? '기록을 저장하지 못했습니다. 다시 시도하세요.' : f.saved ? '' : '기록 저장 중';
el<HTMLButtonElement>('.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').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;
@@ -116,7 +128,10 @@ export function createMatchView(container: HTMLElement, onAction: (action: Actio
return {
update(state: LocalMatchState) {
if (current?.matchId === state.matchId && current.version > state.version) return;
if (lastId !== state.matchId) { cutscene.reset(); renderer.reset(); lastId = state.matchId; busy = false; submission++; }
if (lastId !== state.matchId) {
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);
@@ -133,8 +148,11 @@ export function createMatchView(container: HTMLElement, onAction: (action: Actio
node.setAttribute('role', 'group');
node.setAttribute('aria-label', i === state.selfIndex ? '내 캐릭터' : state.mode === 'bot' ? '봇 캐릭터' : '상대 캐릭터');
});
if (state.reveal && !state.aborted && (!state.finished || state.finished.reason === 'hit')) renderer.play(state.reveal, Date.now() + offset);
else renderer.reset();
// 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(); container.innerHTML = ''; current = undefined; },
-33
View File
@@ -108,37 +108,6 @@ body.player-app #match .chosen-mark { background: var(--ink); border-color: #fff
body.player-app #match .match-menu-items { background: #fff; border: 2px solid var(--ink); border-radius: 0; box-shadow: 4px 4px 0 #24252640; padding: 8px; }
body.player-app #match .match-menu-items button + button { margin-top: 8px; }
/* Keep the footer in layout during combat so it never covers action buttons. */
body.player-app .app > footer.app-footer {
display: flex;
flex: 0 0 auto;
flex-direction: column;
align-items: flex-end;
justify-content: flex-end;
gap: 0;
padding: 8px max(24px, env(safe-area-inset-right)) max(8px, env(safe-area-inset-bottom)) max(16px, env(safe-area-inset-left));
background: #fff;
color: var(--muted);
}
body.player-app .app-footer nav { display: flex; align-items: center; gap: 4px; }
body.player-app .app-footer nav > span { font-size: 12px; }
body.player-app .app-footer #connection { min-height: 16px; font-size: 10px; color: var(--muted); }
body.player-app .app-footer button.quiet {
min-height: 44px;
padding: 8px;
border: 0;
background: transparent;
color: #454545;
box-shadow: none;
font-size: 12px;
transform: none;
}
body.player-app .app-footer button.quiet:hover { background: #fff0df; color: var(--ink); }
body.player-app .app-footer button.quiet:active { background: #ffe2bd; transform: none; }
body.player-app:is([data-screen=home], [data-screen=join]) .app-footer { position: absolute; right: 0; bottom: 0; background: transparent; }
body.player-app:is([data-screen=match], [data-screen=result]) .app > footer.app-footer { display: flex; }
body.player-app dialog:not(.finish-cutscene) {
padding: 24px;
border: 2px solid var(--ink);
@@ -153,9 +122,7 @@ body.player-app .dialog-top button { flex-shrink: 0; }
body.player-app #contact-content { overflow-wrap: anywhere; }
@media (max-width: 760px) {
body.player-app:is([data-screen=home], [data-screen=join]) .app > footer.app-footer { position: static; margin-top: auto; }
body.player-app[data-screen=waiting] .app { padding-inline: max(20px, env(safe-area-inset-left)); }
body.player-app[data-screen=waiting] .app > footer.app-footer { padding-inline: 0; }
body.player-app dialog:not(.finish-cutscene) { padding: 18px; }
body.player-app .dialog-top h2 { font-size: 19px; }
}