feat: require ranked match acceptance with configurable timeout
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
.lobby-tools { display: flex; gap: 10px; margin-top: 16px; }
|
||||
.ranked-queue-actions { display: flex; justify-content: center; gap: 12px; }
|
||||
.ranked-queue .accept-time { font-size: 28px; font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||
.lobby-tools button { flex: 1; min-width: 0; min-height: 48px; padding: 10px; font-size: 17px; }
|
||||
#home .home-actions > button:not(.primary) { min-height: 64px; font-size: 26px; }
|
||||
#home .home-actions .split { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
|
||||
@@ -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 role="status">랭킹대전 매칭 중…</h1><button id="ranked-cancel">매칭 취소</button>';
|
||||
queue.innerHTML = '<div class="queue-fighter" aria-hidden="true"></div><h1 role="status">랭킹대전 매칭 중…</h1><p class="accept-status" role="status" hidden></p><p class="accept-time" aria-label="수락 남은 시간" hidden></p><div class="ranked-queue-actions"><button id="ranked-accept" class="primary" hidden>수락</button><button id="ranked-cancel">매칭 취소</button></div>';
|
||||
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>';
|
||||
@@ -42,6 +42,10 @@ export function createLobbyFeatures(options: LobbyOptions) {
|
||||
const appearanceOpen = document.getElementById('appearance-open') as HTMLButtonElement;
|
||||
const leaderboardOpen = document.getElementById('leaderboard-open') as HTMLButtonElement;
|
||||
const cancel = document.getElementById('ranked-cancel') as HTMLButtonElement;
|
||||
const accept = document.getElementById('ranked-accept') as HTMLButtonElement;
|
||||
const acceptStatus = select(queue, '.accept-status'), acceptTime = select(queue, '.accept-time');
|
||||
let confirmationTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let confirmationDeadline = 0;
|
||||
let profile: PlayerProfile | undefined, preview: FighterAppearance = normalizeAppearance(undefined);
|
||||
let latest: RankedStatus | undefined, active = false, busy = false, profileBusy = false, timer: ReturnType<typeof setTimeout> | undefined, generation = 0;
|
||||
let profileRevision = 0;
|
||||
@@ -177,11 +181,27 @@ export function createLobbyFeatures(options: LobbyOptions) {
|
||||
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;
|
||||
clearTimeout(confirmationTimer);
|
||||
accept.hidden = acceptStatus.hidden = acceptTime.hidden = status.status !== 'confirming';
|
||||
cancel.textContent = status.status === 'confirming' ? '취소' : '매칭 취소';
|
||||
options.onAvailabilityChange();
|
||||
if (status.status === 'queued') showQueue('랭킹대전 매칭 중…');
|
||||
else if (status.status === 'confirming' && status.offer) {
|
||||
confirmationDeadline = Date.now() + status.offer.expiresAt - status.offer.serverNow;
|
||||
acceptStatus.textContent = status.offer.accepted ? '상대 수락 대기' : '';
|
||||
showQueue('매칭 성공!'); updateConfirmation();
|
||||
}
|
||||
else if (status.match) options.onMatch(status.match);
|
||||
else options.onHome();
|
||||
}
|
||||
function updateConfirmation() {
|
||||
clearTimeout(confirmationTimer);
|
||||
if (!latest?.offer) return;
|
||||
const remaining = Math.max(0, confirmationDeadline - Date.now());
|
||||
acceptTime.textContent = `${Math.ceil(remaining / 1000)}초`;
|
||||
accept.disabled = busy || latest.offer.accepted || remaining === 0;
|
||||
if (remaining > 0) confirmationTimer = setTimeout(updateConfirmation, RANKED_POLL_MS);
|
||||
}
|
||||
function schedule() {
|
||||
clearTimeout(timer);
|
||||
if (!active || latest?.match?.phase === 'finished') return;
|
||||
@@ -197,24 +217,42 @@ export function createLobbyFeatures(options: LobbyOptions) {
|
||||
}
|
||||
async function start() {
|
||||
if (busy || active) return;
|
||||
busy = true; rankedStart.disabled = true; generation++;
|
||||
busy = true; rankedStart.disabled = true; cancel.disabled = true;
|
||||
const currentGeneration = ++generation;
|
||||
options.onAvailabilityChange();
|
||||
showQueue('랭킹대전 매칭 중…');
|
||||
try { consume(await api.request<RankedStatus>('/api/ranked/queue', 'POST', {})); schedule(); }
|
||||
catch (error) { options.onHome(); options.onError(error); }
|
||||
finally { busy = false; options.onAvailabilityChange(); }
|
||||
try {
|
||||
const status = await api.request<RankedStatus>('/api/ranked/queue', 'POST', {});
|
||||
if (currentGeneration === generation) { consume(status); schedule(); }
|
||||
}
|
||||
catch (error) { if (currentGeneration === generation) { options.onHome(); options.onError(error); } }
|
||||
finally { if (currentGeneration === generation) { busy = false; cancel.disabled = false; updateConfirmation(); options.onAvailabilityChange(); } }
|
||||
}
|
||||
async function leave() {
|
||||
if (busy) return;
|
||||
busy = true; cancel.disabled = true; generation++; clearTimeout(timer);
|
||||
busy = true; cancel.disabled = true; accept.disabled = true; clearTimeout(timer);
|
||||
const currentGeneration = ++generation;
|
||||
try {
|
||||
const status = latest?.match
|
||||
? await api.request<RankedStatus>('/api/ranked/leave', 'POST', { matchId: latest.match.matchId })
|
||||
: await api.request<RankedStatus>('/api/ranked/cancel', 'POST', {});
|
||||
consume(status);
|
||||
} catch (error) { options.onError(error); }
|
||||
finally { busy = false; cancel.disabled = false; options.onAvailabilityChange(); schedule(); }
|
||||
: await api.request<RankedStatus>('/api/ranked/cancel', 'POST', latest?.offer ? { matchId: latest.offer.matchId } : {});
|
||||
if (currentGeneration === generation) consume(status);
|
||||
} catch (error) { if (currentGeneration === generation) options.onError(error); }
|
||||
finally { if (currentGeneration === generation) { busy = false; cancel.disabled = false; updateConfirmation(); options.onAvailabilityChange(); schedule(); } }
|
||||
}
|
||||
accept.onclick = async () => {
|
||||
if (busy || !latest?.offer || latest.offer.accepted || Date.now() >= confirmationDeadline) return;
|
||||
const matchId = latest.offer.matchId;
|
||||
busy = true; accept.disabled = true; cancel.disabled = true; clearTimeout(timer);
|
||||
const currentGeneration = ++generation;
|
||||
options.onAvailabilityChange();
|
||||
try {
|
||||
const status = await api.request<RankedStatus>('/api/ranked/accept', 'POST', { matchId });
|
||||
if (currentGeneration === generation) consume(status);
|
||||
}
|
||||
catch (error) { if (currentGeneration === generation) options.onError(error); }
|
||||
finally { if (currentGeneration === generation) { busy = false; cancel.disabled = false; updateConfirmation(); options.onAvailabilityChange(); schedule(); } }
|
||||
};
|
||||
rankedStart.onclick = () => { void start(); };
|
||||
cancel.onclick = () => { void leave(); };
|
||||
return {
|
||||
@@ -222,7 +260,8 @@ export function createLobbyFeatures(options: LobbyOptions) {
|
||||
get busy() { return busy; },
|
||||
setReady(ready: boolean) { rankedStart.disabled = !ready || !profile || busy || active; appearanceOpen.disabled = !ready || !profile || busy || active; leaderboardOpen.disabled = !ready || !profile || busy || active; },
|
||||
async initialize(restoreRanked: boolean) {
|
||||
generation++; clearTimeout(timer);
|
||||
generation++; clearTimeout(timer); clearTimeout(confirmationTimer);
|
||||
busy = false; cancel.disabled = false;
|
||||
if (!restoreRanked) { updateProfile(await api.request<PlayerProfile>('/api/profile')); return false; }
|
||||
const status = await api.request<RankedStatus>('/api/ranked/status');
|
||||
updateProfile(status.profile); latest = status; active = status.status !== 'idle';
|
||||
|
||||
+2
-2
@@ -321,8 +321,8 @@ 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: message => { setScreen('ranked-queue'); connection(message, true); },
|
||||
onMatch: state => showMatch(state),
|
||||
onQueue: message => { setScreen('ranked-queue'); connection(message, true); notice(); el('reconnect').hidden = true; },
|
||||
onMatch: state => { el('reconnect').hidden = true; showMatch(state); },
|
||||
onHome: () => { setScreen('home'); connection('접속됨', true); notice(); el('reconnect').hidden = true; },
|
||||
onAppearance: appearance => {
|
||||
el('waiting-fighter').innerHTML = fighterArt(0, false, appearance);
|
||||
|
||||
Reference in New Issue
Block a user