Add character emotes across bot friend and ranked matches

This commit is contained in:
2026-10-14 23:56:32 +09:00
parent c798753418
commit 493af700aa
14 changed files with 177 additions and 7 deletions
+26 -1
View File
@@ -6,19 +6,34 @@ import { createActionRenderer } from './action-renderer.js';
import { actionIcon, ashPileArt, checkIcon, fighterArt } from './fighter-art.js';
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';
const fighter = (i: number, appearance?: FighterAppearance) => `<div class="fighter" data-fighter="${i}">
<div class="emote-bubble" role="status" hidden></div>
<div class="avatar"><div class="aura"></div><div class="motion-trails"><i></i><i></i><i></i></div><div class="character-direction">${fighterArt(i, false, appearance)}</div>${ashPileArt()}<div class="projectile"></div><div class="shield"></div><div class="impact"></div></div>
<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]) {
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>
<div class="timing"><span class="selection-status" role="status"></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>
<details class="emote-menu"><summary>감정표현</summary><div>${EMOTE_IDS.map(id => `<button type="button" data-emote="${id}">${EMOTE_LABELS[id]}</button>`).join('')}</div></details>
<div class="match-result" hidden><div class="result-stats"></div><p class="save-status" role="status"></p><div class="split"><button class="rematch primary">다시 대전</button><button class="home-return">시작 화면</button></div><p class="rematch-status" role="status"></p></div>`;
const arena = container.querySelector<HTMLElement>('.arena')!;
const emoteMenu = container.querySelector<HTMLDetailsElement>('.emote-menu')!;
let emoteReadyAt = 0, sendingEmote = false;
emoteMenu.querySelectorAll<HTMLButtonElement>('[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();
try { if (await onEmote?.(button.dataset.emote as EmoteId) && current?.matchId === matchId) emoteReadyAt = Date.now() + EMOTE_COOLDOWN_MS; }
finally { sendingEmote = false; }
};
});
const renderer = createActionRenderer(arena);
const cutscene = createFinishCutscene(() => { tick(); el('.result-banner h2').focus({ preventScroll: true }); }, appearances);
const appearanceSignatures = [0, 1].map(i => appearanceKey(appearances?.[i]));
@@ -55,6 +70,8 @@ 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;
emoteMenu.querySelectorAll<HTMLButtonElement>('button').forEach(button => { button.disabled = sendingEmote || Date.now() < emoteReadyAt; });
if (resultVisible && resultReported !== s.matchId) renderer.reset();
const recovering = !resultVisible && !s.opponentPresent;
const phase = revealing ? 'revealing' : resultVisible ? 'finished' : s.phase;
@@ -93,6 +110,13 @@ export function createMatchView(container: HTMLElement, onAction: (action: Actio
});
container.querySelectorAll<HTMLElement>('[data-fighter]').forEach((node, i) => {
const self = i === s.selfIndex;
const bubble = node.querySelector<HTMLElement>('.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] : '';
bubble.hidden = !line;
if (bubble.textContent !== (line ?? '')) bubble.textContent = line ?? '';
}
node.dataset.locked = String(self && !!s.ownAction && phase === 'selecting');
if (resultVisible && !s.aborted && s.finished?.reason === 'hit' && s.reveal?.matchId === s.matchId
&& s.reveal.hitIndex === i && s.reveal.actions[1 - i] === 'beam') node.dataset.ash = 'true';
@@ -129,6 +153,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;
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]);
}