diff --git a/constants/match-controls.ts b/constants/match-controls.ts new file mode 100644 index 0000000..82be7d4 --- /dev/null +++ b/constants/match-controls.ts @@ -0,0 +1,7 @@ +export const ACTION_SHORTCUTS = { + charge: 'Q', + blast: 'W', + guard: 'E', + beam: 'R', + dodge: 'T', +} as const; diff --git a/src/client/match-view.ts b/src/client/match-view.ts index 9858b26..01918a8 100644 --- a/src/client/match-view.ts +++ b/src/client/match-view.ts @@ -8,8 +8,10 @@ import { createFinishCutscene } from './finish-cutscene.js'; import { matchIntroductionMessage } from '../shared/match-introduction.js'; import { EMOTE_COOLDOWN_MS, EMOTE_IDS, EMOTE_LABELS, EMOTE_LINES, type EmoteId } from '../../constants/emotes.js'; import { sound } from './sound.js'; +import { ACTION_SHORTCUTS } from '../../constants/match-controls.js'; const fighter = (i: number, appearance?: FighterAppearance) => `
+
${fighterArt(i, false, appearance)}
${ashPileArt()}
`; @@ -62,6 +64,23 @@ export function createMatchView(container: HTMLElement, onAction: (action: Actio const el = (selector: string) => container.querySelector(selector)!; el('.result-banner h2').tabIndex = -1; const buttons = Array.from(container.querySelectorAll('[data-action]')); + buttons.forEach(button => { + const shortcut = ACTION_SHORTCUTS[button.dataset.action as ActionId]; + button.setAttribute('aria-keyshortcuts', shortcut); + button.insertAdjacentHTML('beforeend', ``); + }); + const onKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented || event.repeat || event.ctrlKey || event.altKey || event.metaKey || emoteOpen + || container.hidden || document.querySelector('dialog[open]')) return; + if (event.target instanceof HTMLElement && (event.target.isContentEditable || event.target.closest('input, textarea, select'))) return; + const button = buttons.find(button => event.code === `Key${ACTION_SHORTCUTS[button.dataset.action as ActionId]}`); + if (!button || button.disabled) return; + tick(); + if (button.disabled) return; + event.preventDefault(); + button.click(); + }; + document.addEventListener('keydown', onKeyDown); buttons.forEach(button => button.addEventListener('click', async () => { if (busy || !current || current.phase !== 'selecting' || current.ownAction || Date.now() + offset >= current.deadline) return; const action = button.dataset.action as ActionId; @@ -191,6 +210,7 @@ export function createMatchView(container: HTMLElement, onAction: (action: Actio appearanceSignatures[i] = signature; } node.classList.toggle('self', i === state.selfIndex); + node.querySelector('.fighter-position')!.textContent = i === state.selfIndex ? '▼ 나' : '▼ 상대'; node.setAttribute('role', 'group'); node.setAttribute('aria-label', i === state.selfIndex ? '내 캐릭터' : state.mode === 'bot' ? '봇 캐릭터' : '상대 캐릭터'); }); @@ -201,6 +221,6 @@ export function createMatchView(container: HTMLElement, onAction: (action: Actio } cutscene.update(state); tick(); - }, destroy() { submission++; clearInterval(interval); cutscene.destroy(); renderer.destroy(); sound.stop(); container.onpointerdown = null; container.onkeydown = null; container.innerHTML = ''; current = undefined; }, + }, destroy() { submission++; clearInterval(interval); document.removeEventListener('keydown', onKeyDown); cutscene.destroy(); renderer.destroy(); sound.stop(); container.onpointerdown = null; container.onkeydown = null; container.innerHTML = ''; current = undefined; }, }; } diff --git a/src/client/match.css b/src/client/match.css index cd0b7bb..069f709 100644 --- a/src/client/match.css +++ b/src/client/match.css @@ -15,7 +15,7 @@ body:is([data-screen=match], [data-screen=result]) main { padding: 0; min-height #match { --stage-top: calc(120px + env(safe-area-inset-top)); --stage-bottom: 24px; - --fighter-size: min(32vw, calc((100cqh - 38px) * .866667), 460px); + --fighter-size: min(32vw, calc((100cqh - 62px) * .866667), 460px); --edge: clamp(20px, 3vw, 56px); --self-position: 26.63%; position: relative; isolation: isolate; flex: 1; width: 100%; max-width: none; @@ -47,6 +47,8 @@ body:is([data-screen=match], [data-screen=result]) main { padding: 0; min-height } /* Fighter slots stay fixed so effects and the controls share the same coordinates. */ #match .fighter { position: static; width: 43%; } +#match .fighter-position { margin-bottom: 8px; color: #59665d; font-size: 13px; line-height: 16px; text-align: center; } +#match .fighter.self .fighter-position { color: #242526; font-weight: 700; } #match .avatar, #match[data-phase=finished] .avatar { width: var(--fighter-size); max-width: none; margin: 0; left: 50%; translate: -50% 0; @@ -101,6 +103,10 @@ body:is([data-screen=match], [data-screen=result]) main { padding: 0; min-height grid-row: span 2; align-self: center; width: 28px; height: 28px; } #match .action-buttons strong { font-size: 13px; white-space: nowrap; } +#match .action-shortcut { display: none; font: 600 10px/1.2 var(--ui-body, sans-serif); border: 1px solid currentColor; padding: 1px 4px; } +@media (orientation: landscape) { + #match .action-shortcut { display: inline-block; } +} #match .action-buttons small { color: #58604f; font-size: 10px; } #match .action-buttons button:hover:not(:disabled) { background: #fff9e9; border-color: var(--action-color); transform: translateY(-2px); } #match .action-buttons button:active:not(:disabled) { transform: translateY(1px); } @@ -147,7 +153,7 @@ body:is([data-screen=match], [data-screen=result]) #reconnect { --edge: 16px; --stage-bottom: 16px; --self-position: 25.09%; --stage-top: calc(100px + env(safe-area-inset-top)); - --fighter-size: min(48vw, calc((100cqh - 38px) * .866667), 288px); + --fighter-size: min(48vw, calc((100cqh - 62px) * .866667), 288px); } #match[data-self="1"] { --self-position: 74.91%; } #match[data-phase=finished] { --stage-top: calc(210px + env(safe-area-inset-top)); } @@ -187,7 +193,7 @@ body:is([data-screen=match], [data-screen=result]) #reconnect { --edge: max(16px, env(safe-area-inset-left)); --stage-bottom: 8px; --stage-top: calc(78px + env(safe-area-inset-top)); --self-position: 28.34%; - --fighter-size: min(26vw, calc((100cqh - 38px) * .866667), 260px); + --fighter-size: min(26vw, calc((100cqh - 62px) * .866667), 260px); } #match[data-self="1"] { --self-position: 71.66%; } #match[data-phase=finished] { --stage-top: calc(96px + env(safe-area-inset-top)); } diff --git a/src/client/ui-theme.css b/src/client/ui-theme.css index c34cfdf..8b6965a 100644 --- a/src/client/ui-theme.css +++ b/src/client/ui-theme.css @@ -55,8 +55,7 @@ body.player-app #match button { } body.player-app button.primary, -body.player-app #match button.primary, -body.player-app #match .action-buttons button[data-action=charge] { +body.player-app #match button.primary { border-radius: 0; background: var(--ui-orange); color: var(--ink); @@ -114,9 +113,8 @@ body.player-app #waiting-leave { margin-top: 12px; } body.player-app #match .action-buttons { background: transparent; border: 0; border-radius: 0; box-shadow: none; } body.player-app #match .action-buttons button { box-shadow: 3px 3px 0 #24252640; } -body.player-app #match .action-buttons :is(button[data-action=charge], button.chosen) { box-shadow: 3px 3px 0 var(--ink); } +body.player-app #match .action-buttons button.chosen { box-shadow: 3px 3px 0 var(--ink); } body.player-app #match .action-buttons button:disabled:not(.chosen) { box-shadow: none; } -body.player-app #match .action-buttons button[data-action=charge]:disabled:not(.chosen) { background: #e4e4e4; color: #666; } body.player-app #match .action-buttons small { color: inherit; font-family: var(--ui-body); font-weight: 400; } body.player-app #match .action-buttons .action-symbol { color: inherit; opacity: 1; filter: none; } body.player-app #match .energy > svg { color: var(--ink); }