diff --git a/constants/emotes.ts b/constants/emotes.ts index 0f8d9fe..c17a114 100644 --- a/constants/emotes.ts +++ b/constants/emotes.ts @@ -1,9 +1,11 @@ -export const EMOTE_LABELS = { greet: '인사', thanks: '감사', taunt: '조롱', threaten: '위협' } as const; +export const EMOTE_LABELS = { greet: '인사', thanks: '감사', mistake: '실수', threaten: '위협' } as const; export type EmoteId = keyof typeof EMOTE_LABELS; export const EMOTE_IDS = Object.keys(EMOTE_LABELS) as EmoteId[]; export const EMOTE_DURATION_MS = 3000; export const EMOTE_COOLDOWN_MS = 4000; -export const EMOTE_LINES: readonly Record[] = [ - { greet: '잘 부탁해!', thanks: '고마워!', taunt: '벌써 지친 거야?', threaten: '이번엔 끝낸다!' }, - { greet: '한 수 겨뤄 보자.', thanks: '고맙군.', taunt: '그게 전부인가?', threaten: '각오하는 게 좋겠군.' }, -]; +export const EMOTE_LINES: Record = { + greet: ['반가워', '안녕하세요?'], + thanks: ['고마워', '감사합니다'], + mistake: ['실수였어', '실수할 수도 있지'], + threaten: ['끝장내주지', '도망칠 기회는 지금뿐이다'], +}; diff --git a/src/client/friend-match.ts b/src/client/friend-match.ts index 3ee9375..0c289d8 100644 --- a/src/client/friend-match.ts +++ b/src/client/friend-match.ts @@ -14,7 +14,7 @@ interface FriendConfig { matchId: string; slot: 0 | 1; engine: MatchEngine; peer: GamePeer; clock: GameClock; setOffset(offset: number): void; changed(): void; finished(): void; saved?: { confirmed?: MatchCheckpoint; epoch: number }; - onEmote?(id: unknown): void; + onEmote?(id: unknown, variant: unknown): void; } export function createFriendMatch(config: FriendConfig) { const { peer, engine, clock } = config; @@ -162,7 +162,7 @@ export function createFriendMatch(config: FriendConfig) { if (message.type === 'clock:pong') { if (Number.isFinite(message.remoteAt)) clockReplies.get(message.sentAt)?.resolve(message.remoteAt); clockReplies.delete(message.sentAt); return; } if (message.type === 'pong') return; if (message.type === 'emote') { - if (!recovering && engine.view().phase !== 'finished') config.onEmote?.(message.id); + if (!recovering && engine.view().phase !== 'finished') config.onEmote?.(message.id, message.variant); return; } if (message.type === 'ready') { if (message.slot === config.slot) throw new Error('INVALID_SLOT'); peerReady = true; maybeStart(); return; } @@ -210,9 +210,9 @@ export function createFriendMatch(config: FriendConfig) { catch { abort('connection_failed'); } }, get recovering() { return recovering; }, - sendEmote(id: import('../../constants/emotes.js').EmoteId) { + sendEmote(id: import('../../constants/emotes.js').EmoteId, variant: 0 | 1) { if (stopped || recovering || !peer.connected || engine.view().phase === 'finished') return false; - try { send('emote', { id }); return true; } catch { return false; } + try { send('emote', { id, variant }); return true; } catch { return false; } }, submitAction() { if (!recovering && !stopped) run(() => exchange!.commit(engine.view().ownAction)); }, requestRematch() { engine.requestRematch(config.slot); if (peer.connected) send('match:rematch'); config.changed(); }, diff --git a/src/client/match-controller.ts b/src/client/match-controller.ts index 8f8979d..ab938ef 100644 --- a/src/client/match-controller.ts +++ b/src/client/match-controller.ts @@ -68,7 +68,7 @@ export function createMatchController(config: ControllerConfig) { if (!config.peer) throw new Error('PEER_REQUIRED'); friend = createFriendMatch({ matchId: config.activeMatch.matchId, slot: config.selfIndex, engine, peer: config.peer, clock, setOffset: value => { offset = value; }, changed: publish, finished: () => { void record(); }, saved, - onEmote: id => { if (emotes.send(config.selfIndex === 0 ? 1 : 0, id, clock.now() + offset)) publish(); } }); + onEmote: (id, variant) => { if (emotes.send(config.selfIndex === 0 ? 1 : 0, id, clock.now() + offset, variant)) publish(); } }); await friend.start(); return; } if (!saved) engine.start(); @@ -96,7 +96,7 @@ export function createMatchController(config: ControllerConfig) { const now = clock.now() + offset; if (config.activeMatch.mode === 'friend' && !config.peer?.connected) return false; if (!emotes.send(config.selfIndex, id, now)) return false; - if (friend && !friend.sendEmote(id)) return false; + if (friend && !friend.sendEmote(id, emotes.view(now)[config.selfIndex]!.variant)) return false; publish(); return true; }, view, diff --git a/src/client/match-view.ts b/src/client/match-view.ts index c4bf977..ee26357 100644 --- a/src/client/match-view.ts +++ b/src/client/match-view.ts @@ -10,7 +10,7 @@ import { EMOTE_COOLDOWN_MS, EMOTE_IDS, EMOTE_LABELS, EMOTE_LINES, type EmoteId } import { sound } from './sound.js'; const fighter = (i: number, appearance?: FighterAppearance) => `
-
${fighterArt(i, false, appearance)}
${ashPileArt()}
+
${fighterArt(i, false, appearance)}
${ashPileArt()}
`; @@ -19,17 +19,38 @@ export function createMatchView(container: HTMLElement, onAction: (action: Actio
${fighter(0, appearances?.[0])}
${fighter(1, appearances?.[1])}
${actionIcon('charge')}
${ACTION_IDS.map(id => ``).join('')}
-
감정표현
${EMOTE_IDS.map(id => ``).join('')}
+ `; const arena = container.querySelector('.arena')!; - const emoteMenu = container.querySelector('.emote-menu')!; - let emoteReadyAt = 0, sendingEmote = false; + const emoteMenu = container.querySelector('.emote-menu')!; + let emoteReadyAt = 0, sendingEmote = false, emoteOpen = false; + const emoteTriggers = Array.from(container.querySelectorAll('[data-emote-trigger]')); + function closeEmotes(restoreFocus = false) { + emoteOpen = false; emoteMenu.hidden = true; + emoteTriggers.forEach(button => button.setAttribute('aria-expanded', 'false')); + if (restoreFocus) emoteTriggers.find(button => Number(button.dataset.emoteTrigger) === current?.selfIndex)?.focus(); + } + emoteTriggers.forEach(button => { + button.onclick = () => { + if (!current || !onEmote || Number(button.dataset.emoteTrigger) !== current.selfIndex || current.phase === 'finished' || !current.opponentPresent) return; + if (emoteOpen) { closeEmotes(); return; } + const actor = button.getBoundingClientRect(), root = container.getBoundingClientRect(); + emoteMenu.style.left = `${Math.max(8, Math.min(root.width - 164, actor.left - root.left + actor.width / 2 - 78))}px`; + emoteMenu.style.top = `${Math.max(72, actor.top - root.top - 104)}px`; + emoteOpen = true; tick(); + button.setAttribute('aria-expanded', 'true'); + emoteMenu.querySelector('button:not(:disabled)')?.focus(); + }; + }); + container.onpointerdown = event => { + if (event.target instanceof Element && !event.target.closest('.emote-menu, .emote-trigger')) closeEmotes(); + }; + container.onkeydown = event => { if (event.key === 'Escape' && emoteOpen) { event.preventDefault(); closeEmotes(true); } }; emoteMenu.querySelectorAll('[data-emote]').forEach(button => { button.onclick = async () => { if (!current || sendingEmote || Date.now() < emoteReadyAt) return; const matchId = current.matchId; - sendingEmote = true; emoteMenu.open = false; - emoteMenu.querySelector('summary')?.focus(); + sendingEmote = true; closeEmotes(true); try { if (await onEmote?.(button.dataset.emote as EmoteId) && current?.matchId === matchId) emoteReadyAt = Date.now() + EMOTE_COOLDOWN_MS; } finally { sendingEmote = false; } }; @@ -70,7 +91,10 @@ export function createMatchView(container: HTMLElement, onAction: (action: Actio arena.hidden = !!introduction; const revealing = resultReported !== s.matchId && !!s.reveal && now < s.reveal.revealEndsAt; const resultVisible = s.phase === 'finished' && !revealing; - emoteMenu.hidden = !onEmote || !!introduction || s.phase === 'finished' || !s.opponentPresent; + const emotesAvailable = !!onEmote && !introduction && s.phase !== 'finished' && s.opponentPresent; + if (!emotesAvailable) closeEmotes(); + emoteMenu.hidden = !emoteOpen; + emoteTriggers.forEach(button => { button.hidden = !emotesAvailable || Number(button.dataset.emoteTrigger) !== s.selfIndex; }); emoteMenu.querySelectorAll('button').forEach(button => { button.disabled = sendingEmote || Date.now() < emoteReadyAt; }); if (resultVisible && resultReported !== s.matchId) renderer.reset(); const recovering = !resultVisible && !s.opponentPresent; @@ -113,7 +137,7 @@ export function createMatchView(container: HTMLElement, onAction: (action: Actio const bubble = node.querySelector('.emote-bubble'); const emote = s.emotes?.[i]; if (bubble) { - const line = emote && emote.expiresAt > now && !resultVisible ? EMOTE_LINES[(s.appearances ?? appearances)?.[i]?.characterId ?? i]?.[emote.id] : ''; + const line = emote && emote.expiresAt > now && !resultVisible ? EMOTE_LINES[emote.id]?.[emote.variant] : ''; bubble.hidden = !line; if (bubble.textContent !== (line ?? '')) bubble.textContent = line ?? ''; } @@ -154,7 +178,7 @@ export function createMatchView(container: HTMLElement, onAction: (action: Actio update(state: LocalMatchState) { if (current?.matchId === state.matchId && current.version > state.version) return; if (lastId !== state.matchId) { - emoteReadyAt = 0; emoteMenu.open = false; + emoteReadyAt = 0; closeEmotes(); 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]); } @@ -181,6 +205,6 @@ export function createMatchView(container: HTMLElement, onAction: (action: Actio } cutscene.update(state); tick(); - }, destroy() { submission++; clearInterval(interval); cutscene.destroy(); renderer.destroy(); sound.stop(); container.innerHTML = ''; current = undefined; }, + }, destroy() { submission++; clearInterval(interval); 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 234c320..590236e 100644 --- a/src/client/match.css +++ b/src/client/match.css @@ -1,11 +1,8 @@ /* Match layout only; character poses, projectiles and finish cutscenes stay in game.css. */ -.emote-menu { position: absolute; z-index: 16; right: var(--edge); top: calc(110px + env(safe-area-inset-top)); } -.emote-menu summary { cursor: pointer; padding: 8px 12px; border: 1px solid #56615a; background: #fffdf1; color: #232a2c; } -.emote-menu > div { position: absolute; top: 100%; right: 0; display: grid; grid-template-columns: repeat(2, 64px); gap: 4px; padding: 6px; background: #fffdf1; border: 1px solid #56615a; } +.emote-menu { position: absolute; z-index: 16; display: grid; grid-template-columns: repeat(2, 68px); gap: 4px; padding: 6px; background: #fffdf1; border: 1px solid #56615a; } .emote-menu button { padding: 10px 6px; min-height: 44px; } -.emote-bubble { position: absolute; z-index: 7; top: -28px; left: 50%; transform: translateX(-50%); width: max-content; max-width: min(180px, 39vw); padding: 9px 12px; border: 2px solid #36433b; border-radius: 14px; background: #fffdf1; color: #232a2c; font-size: clamp(12px, 2vw, 17px); text-align: center; overflow-wrap: anywhere; pointer-events: none; } +.emote-bubble { position: absolute; z-index: 7; bottom: 100%; left: 50%; transform: translateX(-50%); width: max-content; max-width: min(220px, 43vw); padding: 10px 12px; border: 2px solid #36433b; border-radius: 14px; background: #fffdf1; color: #232a2c; font-family: var(--ui-display); font-weight: 400; font-size: clamp(15px, 2vw, 20px); line-height: 1.4; text-align: center; word-break: keep-all; overflow-wrap: anywhere; pointer-events: none; } .emote-bubble::after { content: ''; position: absolute; top: 100%; left: 50%; border: 7px solid transparent; border-top-color: #36433b; } -@media (max-height: 500px) { .emote-menu { top: 66px; } } body:is([data-screen=match], [data-screen=result]) { overflow: hidden; background: #d9ddd4; diff --git a/src/client/ui-theme.css b/src/client/ui-theme.css index c4eb2ef..a158608 100644 --- a/src/client/ui-theme.css +++ b/src/client/ui-theme.css @@ -1,4 +1,12 @@ /* Import after game.css, match.css and home.css. */ +body.player-app #match .emote-trigger, +body.player-app #match .emote-trigger:hover, +body.player-app #match .emote-trigger:active { + position: absolute; inset: 0; z-index: 8; width: 100%; height: 100%; + margin: 0; padding: 0; border: 0; border-radius: 0; background: transparent; + box-shadow: none; transform: none; cursor: pointer; +} +body.player-app #match .emote-trigger:focus-visible { outline: 3px solid #266acc; outline-offset: 3px; } @font-face { font-family: 'Black Han Sans'; src: url('./fonts/BlackHanSans-Regular.ttf') format('truetype'); diff --git a/src/shared/emotes.ts b/src/shared/emotes.ts index e3b0ae5..6510c7f 100644 --- a/src/shared/emotes.ts +++ b/src/shared/emotes.ts @@ -1,14 +1,15 @@ import { EMOTE_COOLDOWN_MS, EMOTE_DURATION_MS, EMOTE_IDS, type EmoteId } from '../../constants/emotes.js'; import type { Pair } from './types.js'; -export interface Emote { id: EmoteId; at: number; expiresAt: number } +export interface Emote { id: EmoteId; variant: 0 | 1; at: number; expiresAt: number } export function createEmotes() { const latest: Pair = [null, null]; return { - send(slot: 0 | 1, id: unknown, now: number): boolean { + send(slot: 0 | 1, id: unknown, now: number, variant: unknown = Math.random() < 0.5 ? 0 : 1): boolean { + if (variant !== 0 && variant !== 1) return false; if (!EMOTE_IDS.includes(id as EmoteId) || !Number.isFinite(now)) return false; if (latest[slot] && now - latest[slot]!.at < EMOTE_COOLDOWN_MS) return false; - latest[slot] = { id: id as EmoteId, at: now, expiresAt: now + EMOTE_DURATION_MS }; + latest[slot] = { id: id as EmoteId, variant, at: now, expiresAt: now + EMOTE_DURATION_MS }; return true; }, view(now: number): Pair { diff --git a/src/shared/p2p-types.ts b/src/shared/p2p-types.ts index cb612fa..e512a48 100644 --- a/src/shared/p2p-types.ts +++ b/src/shared/p2p-types.ts @@ -41,7 +41,7 @@ export interface PeerHeader { protocolVersion: number; rulesVersion: string; matchId: string; connectionEpoch: number; turnId: number; } export type PeerMessage = PeerHeader & ( - | { type: 'emote'; id: EmoteId } + | { type: 'emote'; id: EmoteId; variant: 0 | 1 } | { type: 'ready'; slot: 0 | 1 } | { type: 'clock:ping' | 'ping'; sentAt: number } | { type: 'clock:pong' | 'pong'; sentAt: number; remoteAt: number } diff --git a/tests/emotes.test.ts b/tests/emotes.test.ts index 73e186c..6da0e00 100644 --- a/tests/emotes.test.ts +++ b/tests/emotes.test.ts @@ -7,12 +7,21 @@ test('emotes validate fixed choices, rate limit each player and expire without c const emotes = createEmotes(); assert.equal(emotes.send(0, '