diff --git a/constants/emotes.ts b/constants/emotes.ts new file mode 100644 index 0000000..0f8d9fe --- /dev/null +++ b/constants/emotes.ts @@ -0,0 +1,9 @@ +export const EMOTE_LABELS = { greet: '인사', thanks: '감사', taunt: '조롱', 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: '각오하는 게 좋겠군.' }, +]; diff --git a/src/client/friend-match.ts b/src/client/friend-match.ts index 0b550e4..3ee9375 100644 --- a/src/client/friend-match.ts +++ b/src/client/friend-match.ts @@ -14,6 +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; } export function createFriendMatch(config: FriendConfig) { const { peer, engine, clock } = config; @@ -160,6 +161,10 @@ export function createFriendMatch(config: FriendConfig) { if (message.type === 'ping' || message.type === 'clock:ping') { send(message.type === 'ping' ? 'pong' : 'clock:pong', { sentAt: message.sentAt, remoteAt: now() }); return; } 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); + return; + } if (message.type === 'ready') { if (message.slot === config.slot) throw new Error('INVALID_SLOT'); peerReady = true; maybeStart(); return; } if (message.type === 'resume' || message.type === 'resume:ack') { if (!recovering) return; @@ -205,6 +210,10 @@ export function createFriendMatch(config: FriendConfig) { catch { abort('connection_failed'); } }, get recovering() { return recovering; }, + sendEmote(id: import('../../constants/emotes.js').EmoteId) { + if (stopped || recovering || !peer.connected || engine.view().phase === 'finished') return false; + try { send('emote', { id }); 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(); }, snapshot: () => ({ confirmed: structuredClone(confirmed), epoch: peer.epoch, privateChoice }), diff --git a/src/client/lobby-features.ts b/src/client/lobby-features.ts index b2978a9..3fe59e1 100644 --- a/src/client/lobby-features.ts +++ b/src/client/lobby-features.ts @@ -238,6 +238,15 @@ export function createLobbyFeatures(options: LobbyOptions) { consume(status); return status.match?.ownAction === action; } catch (error) { options.onError(error); return false; } }, + async sendEmote(id: import('../../constants/emotes.js').EmoteId) { + if (!latest?.match || !active) return false; + const matchId = latest.match.matchId; + try { + const status = await api.request('/api/ranked/emote', 'POST', { matchId, id }); + if (!active || latest?.match?.matchId !== matchId) return false; + consume(status); return true; + } catch (error) { options.onError(error); return false; } + }, leave, async rematch() { await leave(); if (!active) await start(); }, }; diff --git a/src/client/main.ts b/src/client/main.ts index edf65e7..8f0fa20 100644 --- a/src/client/main.ts +++ b/src/client/main.ts @@ -110,7 +110,8 @@ 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); + () => { void perform(leave); }, () => setScreen('result'), footer.openRules, undefined, + id => currentMatch?.ranked ? lobby!.sendEmote(id) : controller?.sendEmote(id) ?? false); view.update(state); if (state.finished?.saved && !state.ranked) stats(state.finished.stats); if (state.saveStatus === 'failed') el('reconnect').hidden = false; diff --git a/src/client/match-controller.ts b/src/client/match-controller.ts index ebc5dab..8f8979d 100644 --- a/src/client/match-controller.ts +++ b/src/client/match-controller.ts @@ -4,6 +4,8 @@ import type { ActiveMatch, LocalMatchState, MatchCheckpoint, RecordAck, ResultSu import type { ActionId } from '../shared/types.js'; import type { SessionStorage } from './result-reporter.js'; import { createFriendMatch, type GamePeer } from './friend-match.js'; +import { createEmotes } from '../shared/emotes.js'; +import type { EmoteId } from '../../constants/emotes.js'; export interface GameClock { now(): number; setTimeout(fn: () => void, ms: number): any; clearTimeout(id: any): void } export interface ControllerConfig { @@ -20,12 +22,14 @@ export function createMatchController(config: ControllerConfig) { const engine = createMatchEngine({ ...config.activeMatch, selfIndex: config.selfIndex, clock: { now: () => clock.now() + offset }, random: config.random }); let friend: ReturnType | undefined; const listeners = new Set<(state: LocalMatchState) => void>(); + const emotes = createEmotes(); + const view = () => ({ ...engine.view(), appearances: config.activeMatch.appearances, emotes: emotes.view(clock.now() + offset) }); let timer: any, destroyed = false, started = false, recording = false; function publish() { if (destroyed) return; const checkpoint: ControllerCheckpoint = { gameSessionId: config.gameSessionId, activeMatch: config.activeMatch, checkpoint: engine.checkpoint(), epoch: 0, ...friend?.snapshot() }; config.storage.setItem(GAME_CHECKPOINT_KEY, JSON.stringify(checkpoint)); - for (const listener of listeners) listener({ ...engine.view(), appearances: config.activeMatch.appearances }); + for (const listener of listeners) listener(view()); } async function record() { const state = engine.view(); if (!state.finished || state.finished.saved || recording || destroyed) return; @@ -63,7 +67,8 @@ export function createMatchController(config: ControllerConfig) { if (config.activeMatch.mode === 'friend') { 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 }); + 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(); } }); await friend.start(); return; } if (!saved) engine.start(); @@ -86,8 +91,16 @@ export function createMatchController(config: ControllerConfig) { await config.onRematch?.(); if (!destroyed) friend?.requestRematch(); }, - view: () => ({ ...engine.view(), appearances: config.activeMatch.appearances }), - subscribe(listener: (state: LocalMatchState) => void) { listeners.add(listener); listener({ ...engine.view(), appearances: config.activeMatch.appearances }); return () => { listeners.delete(listener); }; }, + sendEmote(id: EmoteId) { + if (destroyed || !started || engine.view().phase === 'finished' || friend?.recovering) return false; + 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; + publish(); return true; + }, + view, + subscribe(listener: (state: LocalMatchState) => void) { listeners.add(listener); listener(view()); return () => { listeners.delete(listener); }; }, retrySave: record, destroy() { destroyed = true; clock.clearTimeout(timer); friend?.destroy(); listeners.clear(); }, }; diff --git a/src/client/match-view.ts b/src/client/match-view.ts index 0fdec07..ddf6ac9 100644 --- a/src/client/match-view.ts +++ b/src/client/match-view.ts @@ -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) => `
+
${fighterArt(i, false, appearance)}
${ashPileArt()}
`; -export function createMatchView(container: HTMLElement, onAction: (action: ActionId) => Promise, onRematch: () => void, onLeave: () => void, onResult: () => void = () => {}, onRules: () => void = () => {}, appearances?: [FighterAppearance, FighterAppearance]) { +export function createMatchView(container: HTMLElement, onAction: (action: ActionId) => Promise, onRematch: () => void, onLeave: () => void, onResult: () => void = () => {}, onRules: () => void = () => {}, appearances?: [FighterAppearance, FighterAppearance], onEmote?: (id: EmoteId) => boolean | Promise) { container.innerHTML = `

메뉴
${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; + 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(); + 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('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('[data-fighter]').forEach((node, i) => { const self = i === s.selfIndex; + 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] : ''; + 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]); } diff --git a/src/client/match.css b/src/client/match.css index 8024493..63ca203 100644 --- a/src/client/match.css +++ b/src/client/match.css @@ -1,4 +1,11 @@ /* Match layout only; character poses, projectiles and finish cutscenes stay in game.css. */ +.emote-menu { position: absolute; z-index: 8; left: var(--edge); bottom: 130px; } +.emote-menu summary { cursor: pointer; padding: 8px 12px; border: 1px solid #56615a; background: #fffdf1; color: #232a2c; } +.emote-menu > div { position: absolute; bottom: 100%; display: grid; grid-template-columns: repeat(2, 64px); 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::after { content: ''; position: absolute; top: 100%; left: 50%; border: 7px solid transparent; border-top-color: #36433b; } +@media (max-height: 500px) { .emote-menu { bottom: 95px; } } body:is([data-screen=match], [data-screen=result]) { overflow: hidden; background: #d9ddd4; diff --git a/src/server/ranked-api.ts b/src/server/ranked-api.ts index b78c326..ccf8cb4 100644 --- a/src/server/ranked-api.ts +++ b/src/server/ranked-api.ts @@ -29,6 +29,12 @@ export function registerRankedApi(app: FastifyInstance, sessions: GameSessionSto if (!body) throw new ApiError(400, 'INVALID_REQUEST'); return service.action(playerId, gameSessionId, body.matchId, body.turnId, body.action); }); + gameRoute(app, 'POST', '/api/ranked/emote', async request => { + const { playerId, gameSessionId } = await identity(request); + const body = request.body as { matchId: string; id: unknown } | undefined; + if (!body) throw new ApiError(400, 'INVALID_REQUEST'); + return service.emote(playerId, gameSessionId, body.matchId, body.id); + }); gameRoute(app, 'POST', '/api/ranked/leave', async request => { const { playerId, gameSessionId } = await identity(request); const body = request.body as { matchId: string } | undefined; diff --git a/src/server/ranked-service.ts b/src/server/ranked-service.ts index 9111ac8..96949cf 100644 --- a/src/server/ranked-service.ts +++ b/src/server/ranked-service.ts @@ -16,6 +16,7 @@ import { systemClock, type Clock } from './clock.js'; import { ApiError, validId } from './game-session-store.js'; import { createGlobalSettingsStore } from './global-settings.js'; import { matchIntroductionDuration } from '../shared/match-introduction.js'; +import { createEmotes } from '../shared/emotes.js'; interface ProfileDocument { _id: string; displayName: string; nameVersion?: number; appearance: FighterAppearance; environment: string; createdAt: Date; updatedAt: Date; @@ -26,6 +27,7 @@ interface TerminalMatch { } interface QueueEntry { playerId: string; gameSessionId: string; expiresAt: number } interface RankedMatch { + emotes: ReturnType; id: string; playerIds: Pair; appearances: Pair; engine: MatchEngine; actions: Pair; departed: Set; timer?: ReturnType; terminal?: TerminalMatch; saved: boolean; finishedExpiresAt?: Pair; @@ -222,6 +224,7 @@ export async function createRankedService(db: Db, clock: Clock = systemClock, en // opponent has locked a choice. Engine transitions always move past either value. state.version = state.version * 2 + (state.ownAction === null ? 0 : 1); state.ranked = true; state.appearances = structuredClone(match.appearances); + state.emotes = match.emotes.view(clock.now()); state.opponentLeft = match.departed.has(match.playerIds[1 - selfIndex]); state.opponentPresent = !state.opponentLeft; return { status: 'matched', profile, match: state }; @@ -322,7 +325,7 @@ export async function createRankedService(db: Db, clock: Clock = systemClock, en } const first = await readProfile(candidate.playerId), second = await readProfile(playerId); const id = randomUUID(), engine = createMatchEngine({ matchId: id, mode: 'friend', selfIndex: 0, clock, settings: await createGlobalSettingsStore(db).get() }); - const match: RankedMatch = { id, engine, playerIds: [candidate.playerId, playerId], + const match: RankedMatch = { id, engine, emotes: createEmotes(), playerIds: [candidate.playerId, playerId], appearances: [first.appearance, second.appearance], actions: [null, null], departed: new Set(), saved: false }; engine.start(clock.now() + matchIntroductionDuration(engine.view().settings, true)); matches.add(match); for (const id of match.playerIds) { queue.delete(id); byPlayer.set(id, match); } @@ -338,6 +341,16 @@ export async function createRankedService(db: Db, clock: Clock = systemClock, en queue.delete(playerId); return statusView(playerId); }); }, + emote(playerId: string, gameSessionId: string, matchId: string, id: unknown): Promise { + return serial(async () => { + await identity(playerId, gameSessionId); + const match = memberMatch(playerId, matchId); await advance(match); + if (match.engine.view().phase === 'finished' || match.departed.size) throw new ApiError(409, 'NO_ACTIVE_MATCH'); + const slot = match.playerIds[0] === playerId ? 0 : 1; + if (!match.emotes.send(slot, id, clock.now())) throw new ApiError(400, 'INVALID_REQUEST'); + return statusView(playerId); + }); + }, action(playerId: string, gameSessionId: string, matchId: string, turnId: number, action: ActionId): Promise { return serial(async () => { await identity(playerId, gameSessionId); diff --git a/src/shared/emotes.ts b/src/shared/emotes.ts new file mode 100644 index 0000000..e3b0ae5 --- /dev/null +++ b/src/shared/emotes.ts @@ -0,0 +1,18 @@ +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 function createEmotes() { + const latest: Pair = [null, null]; + return { + send(slot: 0 | 1, id: unknown, now: number): boolean { + 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 }; + return true; + }, + view(now: number): Pair { + return latest.map(value => value && now < value.expiresAt ? { ...value } : null) as Pair; + }, + }; +} diff --git a/src/shared/p2p-types.ts b/src/shared/p2p-types.ts index 0da55e7..cb612fa 100644 --- a/src/shared/p2p-types.ts +++ b/src/shared/p2p-types.ts @@ -1,6 +1,8 @@ import type { ActionId, EndReason, MatchFinished, MatchState, Mode, Pair, PlayerStats } from './types.js'; import type { FighterAppearance } from './cosmetics.js'; import type { GlobalSettings } from './global-settings.js'; +import type { Emote } from './emotes.js'; +import type { EmoteId } from '../../constants/emotes.js'; export type LocalOutcome = 'win' | 'loss' | 'draw' | 'aborted'; export type LocalEndReason = EndReason | 'connection_failed' | 'protocol_error'; @@ -23,6 +25,7 @@ export interface SignalingPacket { } export interface IceConfiguration { iceServers: RTCIceServer[]; expiresAt: number } export interface LocalMatchState extends Omit { + emotes?: Pair; settings?: GlobalSettings; clockNow: number; aborted: boolean; appearances?: Pair; @@ -38,6 +41,7 @@ export interface PeerHeader { protocolVersion: number; rulesVersion: string; matchId: string; connectionEpoch: number; turnId: number; } export type PeerMessage = PeerHeader & ( + | { type: 'emote'; id: EmoteId } | { 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 new file mode 100644 index 0000000..73e186c --- /dev/null +++ b/tests/emotes.test.ts @@ -0,0 +1,18 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createEmotes } from '../src/shared/emotes.js'; +import { EMOTE_COOLDOWN_MS, EMOTE_DURATION_MS } from '../constants/emotes.js'; + +test('emotes validate fixed choices, rate limit each player and expire without changing battle state', () => { + const emotes = createEmotes(); + assert.equal(emotes.send(0, '