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', `${shortcut}`);
+ });
+ 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); }