fix: stabilize ranked lobby layout and pin online count

This commit is contained in:
2026-10-16 12:02:23 +09:00
parent c744e8c2a1
commit dd914d7181
9 changed files with 35 additions and 46 deletions
+1
View File
@@ -6,6 +6,7 @@ body.player-app .app > footer.app-footer {
width: 100vw;
align-self: center;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 4px;
-1
View File
@@ -175,4 +175,3 @@ body.player-app:is([data-screen=home], [data-screen=join]) #reconnect { position
body.player-app .home-actions > .primary { min-height: 68px; font-size: 28px; }
body.player-app .home .stat-mode > div { gap: 16px; }
}
.online-count { position: absolute; top: max(16px, env(safe-area-inset-top)); right: max(20px, env(safe-area-inset-right)); z-index: 5; color: #45534a; font-size: 13px; font-variant-numeric: tabular-nums; }
+1 -1
View File
@@ -2,10 +2,10 @@
<html lang="ko">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"><meta name="theme-color" content="#ffffff"><title>쎄쎄쎄:기공대전</title></head>
<body class="player-app" data-screen="home">
<span id="online-count" class="online-count" role="status">접속 확인 중</span>
<div class="app">
<main id="main">
<section id="home" class="home">
<span id="online-count" class="online-count" role="status">접속 확인 중</span>
<div class="home-stage"><h1 aria-label="쎄쎄쎄:기공대전"><span>쎄쎄쎄</span><span>:기공대전</span></h1><div id="home-fighters" aria-hidden="true"></div></div>
<div class="home-menu">
<div class="home-actions"><button id="bot-start" class="primary" disabled><span>봇과 대전</span><svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M4 12h15m-6-6 6 6-6 6" stroke="currentColor" stroke-width="3" stroke-linecap="square" stroke-linejoin="miter"/></svg></button><div class="split"><button id="room-create" disabled><span>방 만들기</span><svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M12 4v16M4 12h16" stroke="currentColor" stroke-width="2"/></svg></button><button id="join-open" disabled><span>방 참가</span><svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M13 4h7v16h-7M3 12h12m-5-5 5 5-5 5" stroke="currentColor" stroke-width="2"/></svg></button></div></div>
+13
View File
@@ -1,6 +1,19 @@
.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; }
body.player-app[data-screen=ranked-queue] main { padding: 0; }
body.player-app .ranked-queue {
display: grid;
grid-template-rows: minmax(180px, 1fr) auto minmax(180px, 1fr);
flex: 1;
margin: 0 auto;
padding: 32px 0 16px;
}
.ranked-queue::before { content: ''; }
.ranked-queue .queue-fighter { width: min(260px, 26svh, 65vw); }
.ranked-queue .queue-details { display: grid; grid-template-rows: 36px 24px 36px 52px; gap: 8px; padding-top: 8px; }
.ranked-queue .queue-details > :is(h1, p) { margin: 0; align-self: center; }
.ranked-queue .queue-details > p[hidden] { display: block !important; visibility: hidden; }
.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)); }
+1 -1
View File
@@ -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><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>';
queue.innerHTML = '<div class="queue-fighter" aria-hidden="true"></div><div class="queue-details"><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></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>';
+1 -1
View File
@@ -114,7 +114,7 @@ function showMatch(state: LocalMatchState) {
if (currentScreen !== 'result' || state.phase !== 'finished') setScreen('match');
if (!view) view = createMatchView(el('match'), action => currentMatch?.ranked ? lobby!.submitAction(action) : controller?.submitAction(action) ?? Promise.resolve(false),
() => { void perform(() => currentMatch?.ranked ? lobby!.rematch() : controller ? controller.rematch() : rematch(), 'rematch'); },
() => { void perform(leave); }, () => setScreen('result'), footer.openRules, undefined,
() => { void perform(leave); }, () => setScreen('result'), undefined,
id => currentMatch?.ranked ? lobby!.sendEmote(id) : controller?.sendEmote(id) ?? false);
view.update(state);
if (state.finished?.saved && !state.ranked) stats(state.finished.stats);
+3 -8
View File
@@ -14,8 +14,8 @@ const fighter = (i: number, appearance?: FighterAppearance) => `<div class="figh
<div class="fighter-caption"><span class="choice-status"></span><span class="action-label"></span><span class="energy-change" aria-hidden="true"></span></div>
</div>`;
export function createMatchView(container: HTMLElement, onAction: (action: ActionId) => Promise<boolean>, onRematch: () => void, onLeave: () => void, onResult: () => void = () => {}, onRules: () => void = () => {}, appearances?: [FighterAppearance, FighterAppearance], onEmote?: (id: EmoteId) => boolean | Promise<boolean>) {
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>
export function createMatchView(container: HTMLElement, onAction: (action: ActionId) => Promise<boolean>, onRematch: () => void, onLeave: () => void, onResult: () => void = () => {}, appearances?: [FighterAppearance, FighterAppearance], onEmote?: (id: EmoteId) => boolean | Promise<boolean>) {
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></div>
<div class="timing"><span class="selection-status" role="status"></span><span class="timeout-chances" aria-label="남은 초읽기 기회"></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>
@@ -76,12 +76,7 @@ export function createMatchView(container: HTMLElement, onAction: (action: Actio
if (request === submission) { busy = false; tick(); }
}
}));
el('.leave').onclick = onLeave; el('.home-return').onclick = onLeave; el('.rematch').onclick = onRematch;
el('.match-rules').onclick = () => {
el<HTMLDetailsElement>('.match-menu').open = false;
el('.match-menu summary').focus();
onRules();
};
el('.home-return').onclick = onLeave; el('.rematch').onclick = onRematch;
function tick() {
if (!current) return;
const now = Date.now() + offset, s = current;
-25
View File
@@ -74,29 +74,6 @@ body:is([data-screen=match], [data-screen=result]) main { padding: 0; min-height
#match .time-track span { border-radius: 0; background: #a85039; }
#match[data-locked=true] .time-track span { background: #557b85; }
#match .timing[data-urgent=true] .time-track span { background: #c04128; }
#match .match-menu {
pointer-events: auto;
position: absolute; top: calc(25px + env(safe-area-inset-top));
right: max(var(--edge), env(safe-area-inset-right));
}
#match .match-menu summary {
display: grid; place-items: center; list-style: none; cursor: pointer;
min-width: 52px; min-height: 44px; padding: 8px 12px;
border: 1px solid #52615655; border-radius: 3px; background: #f4f0e3;
font-size: 12px; font-weight: 700;
}
#match .match-menu summary::-webkit-details-marker { display: none; }
#match .match-menu summary:focus-visible { outline: 3px solid #266acc; outline-offset: 4px; }
#match .match-menu[open] summary { background: #303c38; color: #fff9e9; }
#match .match-menu-items {
position: absolute; top: calc(100% + 8px); right: 0; width: 132px;
padding: 6px; background: #f4f0e3; border: 1px solid #697568;
box-shadow: 3px 4px 0 #303c3820;
}
#match .match-menu-items button {
display: block; width: 100%; min-height: 44px; border: 0; border-radius: 0;
color: #303c38; text-align: left;
}
#match .duel-cue, #match[data-phase=countdown] .duel-cue { top: 43%; }
#match:not([data-phase=countdown]) .versus { display: none; }
#match[data-phase=countdown] .countdown-number { border-radius: 3px; }
@@ -178,7 +155,6 @@ body:is([data-screen=match], [data-screen=result]) #reconnect {
#match .fighter { width: 47%; }
#match .match-title { top: calc(24px + env(safe-area-inset-top)); left: var(--edge); transform: none; }
#match .turn-heading { font-size: 16px; }
#match .match-menu { top: calc(12px + env(safe-area-inset-top)); }
#match .energy { gap: 3px; }
#match .energy-count { font-size: 25px; }
#match .energy > svg { width: 18px; height: 18px; }
@@ -217,7 +193,6 @@ body:is([data-screen=match], [data-screen=result]) #reconnect {
#match[data-phase=finished] { --stage-top: calc(96px + env(safe-area-inset-top)); }
#match .arena { padding-inline: 12vw; }
#match .match-title { top: calc(12px + env(safe-area-inset-top)); left: 50%; transform: translateX(-50%); }
#match .match-menu { top: calc(10px + env(safe-area-inset-top)); right: max(12px, env(safe-area-inset-right)); }
#match .timing { top: calc(38px + env(safe-area-inset-top)); width: 120px; min-height: 33px; flex-direction: row; padding-bottom: 8px; }
#match .player-controls { width: calc(50% - 24px); margin-bottom: max(8px, env(safe-area-inset-bottom)); }
#match .energy { padding-bottom: 4px; }
+15 -9
View File
@@ -1,4 +1,15 @@
/* Import after game.css, match.css and home.css. */
body.player-app > .online-count {
position: fixed;
top: max(16px, env(safe-area-inset-top));
right: max(20px, env(safe-area-inset-right));
z-index: 50;
color: #45534a;
font-size: 12px;
font-variant-numeric: tabular-nums;
white-space: nowrap;
pointer-events: none;
}
body.player-app #match .emote-trigger,
body.player-app #match .emote-trigger:hover:not(:disabled),
body.player-app #match .emote-trigger:active:not(:disabled) {
@@ -34,8 +45,7 @@ body.player-app #match .action-buttons strong {
}
body.player-app button,
body.player-app #match button,
body.player-app #match .match-menu summary {
body.player-app #match button {
border: 2px solid var(--ink);
border-radius: 0;
background: var(--ink);
@@ -54,8 +64,7 @@ body.player-app #match .action-buttons button[data-action=charge] {
}
body.player-app button:hover:not(:disabled),
body.player-app #match button:hover:not(:disabled),
body.player-app #match .match-menu summary:hover {
body.player-app #match button:hover:not(:disabled) {
background: #ffbd43;
border-color: var(--ink);
color: var(--ink);
@@ -63,8 +72,7 @@ body.player-app #match .match-menu summary:hover {
}
body.player-app button:active:not(:disabled),
body.player-app #match button:active:not(:disabled),
body.player-app #match .match-menu summary:active {
body.player-app #match button:active:not(:disabled) {
border-width: 2px;
box-shadow: none;
transform: translate(3px, 3px);
@@ -113,8 +121,6 @@ body.player-app #match .action-buttons small { color: inherit; font-family: var(
body.player-app #match .action-buttons .action-symbol { color: inherit; opacity: 1; filter: none; }
body.player-app #match .energy > svg { color: var(--ink); }
body.player-app #match .chosen-mark { background: var(--ink); border-color: #fff; color: #fff; border-radius: 0; }
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; }
body.player-app dialog:not(.finish-cutscene) {
padding: 24px;
@@ -136,5 +142,5 @@ body.player-app #contact-content { overflow-wrap: anywhere; }
}
@media (prefers-reduced-motion: reduce) {
body.player-app button, body.player-app #match button, body.player-app #match .match-menu summary { transition: none; }
body.player-app button, body.player-app #match button { transition: none; }
}