Prepare game updates and single-process PM2 deployment
This commit is contained in:
@@ -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; }
|
||||
}
|
||||
@@ -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(); },
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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}}
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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
@@ -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 => {
|
||||
|
||||
@@ -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
@@ -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; },
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user