Compare commits
9
Commits
c798753418
...
6a3a0ad4b9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a3a0ad4b9 | ||
|
|
a533d6e221 | ||
|
|
32d00bb60a | ||
|
|
013807c873 | ||
|
|
32d32a6ebc | ||
|
|
7c414655c8 | ||
|
|
16380eac79 | ||
|
|
05bc16e364 | ||
|
|
493af700aa |
@@ -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<EmoteId, string>[] = [
|
||||
{ greet: '잘 부탁해!', thanks: '고마워!', taunt: '벌써 지친 거야?', threaten: '이번엔 끝낸다!' },
|
||||
{ greet: '한 수 겨뤄 보자.', thanks: '고맙군.', taunt: '그게 전부인가?', threaten: '각오하는 게 좋겠군.' },
|
||||
];
|
||||
@@ -0,0 +1,2 @@
|
||||
export const PRESENCE_AUTH_REFRESH_MS = 60000;
|
||||
export const PRESENCE_RETRY_MS = 5000;
|
||||
@@ -0,0 +1,23 @@
|
||||
export const SOUND_MUTED_KEY = 'ssessesse:sound-muted';
|
||||
export const SOUND_MASTER_VOLUME = 0.35;
|
||||
export const SOUND_EVENT_HISTORY = 128;
|
||||
export const SOUND_MAX_VOICES = 32;
|
||||
export interface SoundPart {
|
||||
wave: OscillatorType | 'noise'; from: number; to: number; duration: number; volume: number; delay?: number;
|
||||
}
|
||||
// Original synthesis recipes: no recordings, samples, borrowed melodies or voices.
|
||||
export const SOUNDS = {
|
||||
click: [{ wave: 'sine', from: 900, to: 540, duration: .055, volume: .18 }],
|
||||
charge: [{ wave: 'sine', from: 140, to: 480, duration: .48, volume: .2 }, { wave: 'triangle', from: 210, to: 720, duration: .38, volume: .1, delay: .06 }],
|
||||
blast: [{ wave: 'noise', from: 2200, to: 400, duration: .22, volume: .3 }, { wave: 'sine', from: 320, to: 65, duration: .18, volume: .22 }],
|
||||
guard: [{ wave: 'triangle', from: 840, to: 650, duration: .2, volume: .2 }, { wave: 'sine', from: 1260, to: 980, duration: .24, volume: .09 }],
|
||||
beam: [{ wave: 'sawtooth', from: 90, to: 180, duration: .6, volume: .1 }, { wave: 'noise', from: 700, to: 3600, duration: .65, volume: .24 }],
|
||||
dodge: [{ wave: 'noise', from: 3800, to: 250, duration: .18, volume: .15 }, { wave: 'sine', from: 1200, to: 300, duration: .12, volume: .1, delay: .08 }],
|
||||
cutscene1: [{ wave: 'sawtooth', from: 65, to: 260, duration: .7, volume: .11 }, { wave: 'noise', from: 300, to: 2300, duration: .6, volume: .18 }],
|
||||
cutscene2: [{ wave: 'noise', from: 2400, to: 120, duration: .4, volume: .45 }, { wave: 'sine', from: 150, to: 35, duration: .5, volume: .35 }],
|
||||
cutscene3: [{ wave: 'noise', from: 650, to: 100, duration: .55, volume: .18 }, { wave: 'triangle', from: 100, to: 40, duration: .45, volume: .16 }],
|
||||
win: [{ wave: 'sine', from: 440, to: 660, duration: .2, volume: .2 }, { wave: 'triangle', from: 660, to: 880, duration: .35, volume: .18, delay: .18 }],
|
||||
loss: [{ wave: 'triangle', from: 330, to: 165, duration: .38, volume: .16 }, { wave: 'sine', from: 165, to: 82, duration: .45, volume: .16, delay: .24 }],
|
||||
draw: [{ wave: 'sine', from: 380, to: 380, duration: .18, volume: .14 }, { wave: 'sine', from: 380, to: 380, duration: .2, volume: .1, delay: .25 }],
|
||||
} satisfies Record<string, readonly SoundPart[]>;
|
||||
export type SoundId = keyof typeof SOUNDS;
|
||||
@@ -0,0 +1,34 @@
|
||||
# 효과음 출처 및 권리 검토
|
||||
|
||||
검토일: 2026-10-14
|
||||
|
||||
## 적용 소스
|
||||
|
||||
모든 효과음은 `constants/sound.ts`의 수치 설정과 `src/client/sound.ts`의 Web Audio 합성 코드로 실행 시 생성한다. 외부 녹음물, 샘플 라이브러리, 음악 파일, 음성, TTS 서비스, 게임에서 추출한 오디오는 사용하지 않았다. 새로운 오디오 의존성도 추가하지 않았다.
|
||||
|
||||
| 대상 | 생성 방식 |
|
||||
| --- | --- |
|
||||
| 메뉴·행동 선택 | 짧은 사인파 하강음 |
|
||||
| 기 충전 | 사인파·삼각파 상승음 |
|
||||
| 파 | 저역 하강음과 필터링한 노이즈 |
|
||||
| 막기 | 두 주파수의 삼각파·사인파 |
|
||||
| 에네르기파 | 톱니파와 필터 주파수가 상승하는 노이즈 |
|
||||
| 순간이동 | 빠르게 하강하는 노이즈·사인파 |
|
||||
| 컷씬 1단계: 공격 | 상승하는 톱니파·노이즈 |
|
||||
| 컷씬 2단계: 명중 | 저역 충격음·노이즈 |
|
||||
| 컷씬 3단계: 후속 반응 | 감쇠하는 저역음·노이즈 |
|
||||
| 승리·패배·무승부 | 각각 상승·하강·반복하는 짧은 합성 신호 |
|
||||
|
||||
기존 게임의 음악·멜로디·시그니처 효과음·캐릭터 음성을 복제하거나 재현하도록 설계하지 않았다. 하스스톤은 캐릭터마다 같은 감정표현 항목을 다른 대사로 표현하는 상호작용만 참고했다. 대사는 프로젝트에서 새로 작성했다.
|
||||
|
||||
## 검토 기준과 범위
|
||||
|
||||
미국 저작권청의 안내는 음악 저작물과 녹음물이 별개 권리임을 설명한다. 따라서 단순히 녹음을 새로 만들었다는 이유로 기존 곡을 사용할 수 있다고 판단하지 않았다. 이번 변경은 기존 곡 및 녹음물을 입력으로 사용하지 않는 자체 합성으로 구성했다.
|
||||
|
||||
- https://www.copyright.gov/engage/musicians/
|
||||
- https://www.copyright.gov/register/pa-sr.html
|
||||
- 감정표현 참고: https://hearthstone.blizzard.com/en-us/heroes/hunter
|
||||
|
||||
이번 구현에는 별도 제3자 음원 라이선스나 출처 표기 조건이 적용되는 외부 오디오 자산이 없다. 이 기록은 사용 소스에 대한 기술적 검토이며, 모든 관할에서의 비침해나 합성 결과의 독점적 저작권을 보증하는 법률 의견은 아니다. 기존 게임 이름·그래픽 등 사운드 변경 밖의 자산은 이 검토 범위에 포함하지 않는다.
|
||||
|
||||
향후 외부 음원으로 교체할 때는 원본 URL, 저작자, 라이선스 전문·버전·확인일, 상업 이용·수정·배포 허용 여부, 표시 의무를 파일별로 기록한다. '무료 다운로드'만으로 사용을 승인하지 않는다.
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ACTIONS } from '../../constants/gameplay.js';
|
||||
import type { TurnReveal } from '../shared/types.js';
|
||||
import { sound } from './sound.js';
|
||||
|
||||
export function createActionRenderer(container: HTMLElement) {
|
||||
let last = '', timer: ReturnType<typeof setTimeout> | undefined;
|
||||
@@ -22,6 +23,8 @@ export function createActionRenderer(container: HTMLElement) {
|
||||
const id = `${reveal.matchId}:${reveal.turnId}`;
|
||||
if (id === last || serverNow >= reveal.revealEndsAt) return;
|
||||
reset(); last = id;
|
||||
// Finishing attacks are scored by the cutscene instead of stacking both presentations.
|
||||
if (reveal.hitIndex === null) reveal.actions.forEach((action, slot) => sound.play(action, `${id}:action:${slot}`, slot === 0 ? -.45 : .45));
|
||||
// Effects follow the authoritative reveal window, including reconnects.
|
||||
container.style.setProperty('--reveal-duration', `${reveal.revealEndsAt - reveal.revealedAt}ms`);
|
||||
container.style.setProperty('--elapsed', `${-Math.min(reveal.revealEndsAt - reveal.revealedAt, Math.max(0, serverNow - reveal.revealedAt))}ms`);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/* Keep the footer in layout during combat so it never covers action buttons. */
|
||||
body.player-app .app-footer #sound-toggle { margin-right: auto; white-space: nowrap; }
|
||||
body.player-app .app > footer.app-footer {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
|
||||
@@ -4,7 +4,7 @@ type FooterDialog = typeof FOOTER_LINKS[number]['id'];
|
||||
|
||||
export function createAppFooter(container: HTMLElement, dialogs: Record<FooterDialog, HTMLDialogElement>) {
|
||||
container.classList.add('app-footer');
|
||||
container.innerHTML = `<span id="connection" role="status">연결 중</span><span class="footer-divider" aria-hidden="true">|</span><nav aria-label="안내">${FOOTER_LINKS.map(({ id, label }) =>
|
||||
container.innerHTML = `<button type="button" id="sound-toggle" class="sound-toggle quiet" aria-pressed="true">소리 끄기</button><span id="connection" role="status">연결 중</span><span class="footer-divider" aria-hidden="true">|</span><nav aria-label="안내">${FOOTER_LINKS.map(({ id, label }) =>
|
||||
`<button type="button" class="quiet" id="${id}-open" aria-haspopup="dialog" aria-controls="${id}-dialog">${label}</button>`).join('<span aria-hidden="true">|</span>')}</nav>`;
|
||||
const status = container.querySelector<HTMLElement>('#connection')!;
|
||||
for (const { id } of FOOTER_LINKS) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { LocalMatchState } from '../shared/p2p-types.js';
|
||||
import { appearanceKey, type FighterAppearance } from '../shared/cosmetics.js';
|
||||
import { finisherArt } from './fighter-art.js';
|
||||
import { DEFAULT_GLOBAL_SETTINGS } from '../../constants/global-settings.js';
|
||||
import { sound } from './sound.js';
|
||||
|
||||
// Survives view replacement on reconnect, without persisting anything to game state.
|
||||
let presentation: { matchId: string; startedAt: number; dismissed: boolean } | undefined;
|
||||
@@ -44,6 +45,10 @@ export function createFinishCutscene(onDismiss: () => void, appearances?: [Fight
|
||||
: elapsed >= timing.fallen ? 'reaction'
|
||||
: elapsed >= timing.impact ? 'impact' : 'attack';
|
||||
dialog.dataset.stage = stage;
|
||||
if (stage !== 'complete') {
|
||||
const cue = stage === 'attack' ? 'cutscene1' : stage === 'impact' ? 'cutscene2' : 'cutscene3';
|
||||
sound.play(cue, `${activeMatch}:${cue}`);
|
||||
}
|
||||
if (complete) {
|
||||
dismiss();
|
||||
} else {
|
||||
@@ -57,6 +62,7 @@ export function createFinishCutscene(onDismiss: () => void, appearances?: [Fight
|
||||
|
||||
reducedMotion.addEventListener('change', advance);
|
||||
return {
|
||||
get active() { return !!dialog; },
|
||||
update(state: LocalMatchState) {
|
||||
const reveal = state.reveal;
|
||||
if (activeMatch && activeMatch !== state.matchId) close(true);
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -175,3 +175,4 @@ body.player-app:is([data-screen=home], [data-screen=join]) #reconnect { position
|
||||
body.player-app .home-actions > .primary { min-height: 68px; font-size: 28px; }
|
||||
body.player-app .home .stat-mode > div { gap: 16px; }
|
||||
}
|
||||
.online-count { position: absolute; top: max(16px, env(safe-area-inset-top)); right: max(20px, env(safe-area-inset-right)); z-index: 5; color: #45534a; font-size: 13px; font-variant-numeric: tabular-nums; }
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<div class="app">
|
||||
<main id="main">
|
||||
<section id="home" class="home">
|
||||
<span id="online-count" class="online-count" role="status">접속 확인 중</span>
|
||||
<div class="home-stage"><h1 aria-label="쎄쎄쎄:기공대전"><span>쎄쎄쎄</span><span>:기공대전</span></h1><div id="home-fighters" aria-hidden="true"></div></div>
|
||||
<div class="home-menu">
|
||||
<div class="home-actions"><button id="bot-start" class="primary" disabled><span>봇과 대전</span><svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M4 12h15m-6-6 6 6-6 6" stroke="currentColor" stroke-width="3" stroke-linecap="square" stroke-linejoin="miter"/></svg></button><div class="split"><button id="room-create" disabled><span>방 만들기</span><svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M12 4v16M4 12h16" stroke="currentColor" stroke-width="2"/></svg></button><button id="join-open" disabled><span>방 참가</span><svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M13 4h7v16h-7M3 12h12m-5-5 5 5-5 5" stroke="currentColor" stroke-width="2"/></svg></button></div></div>
|
||||
|
||||
@@ -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<RankedStatus>('/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(); },
|
||||
};
|
||||
|
||||
+7
-1
@@ -17,6 +17,8 @@ import { fighterArt } from './fighter-art.js';
|
||||
import { createLobbyFeatures } from './lobby-features.js';
|
||||
import { createGlobalSettingsCache } from './global-settings.js';
|
||||
import { createAppFooter } from './app-footer.js';
|
||||
import { installSoundControls } from './sound.js';
|
||||
import { createPresence } from './presence.js';
|
||||
import './game.css';
|
||||
import './match.css';
|
||||
import './home.css';
|
||||
@@ -28,6 +30,8 @@ const el = <T extends HTMLElement = HTMLElement>(id: string) => document.getElem
|
||||
const footer = createAppFooter(el('app-footer'), {
|
||||
rules: el<HTMLDialogElement>('rules-dialog'), privacy: el<HTMLDialogElement>('privacy-dialog'), contact: el<HTMLDialogElement>('contact-dialog'),
|
||||
});
|
||||
installSoundControls();
|
||||
const presence = createPresence(el('online-count'));
|
||||
const globalSettings = createGlobalSettingsCache({
|
||||
getItem: key => localStorage.getItem(key), setItem: (key, value) => localStorage.setItem(key, value),
|
||||
});
|
||||
@@ -110,7 +114,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;
|
||||
@@ -241,6 +246,7 @@ async function boot() {
|
||||
let token: string | undefined;
|
||||
try { token = localStorage.getItem(GUEST_TOKEN_KEY) ?? undefined; } catch { storageAvailable = false; }
|
||||
const [session] = await Promise.all([api.ensureSession(token), globalSettings.refresh()]);
|
||||
presence.connect(session);
|
||||
if (session.guestToken) try { localStorage.setItem(GUEST_TOKEN_KEY, session.guestToken); } catch { storageAvailable = false; }
|
||||
reporter?.destroy(); reporter = createResultReporter({ gameSessionId: session.gameSessionId, record: value => api.record(value) }, storage);
|
||||
analytics.ready(); stats(session.stats); connection('접속됨', true); el('reconnect').hidden = true;
|
||||
|
||||
@@ -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<typeof createFriendMatch> | 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(); },
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
import { sound } from './sound.js';
|
||||
|
||||
const fighter = (i: number, appearance?: FighterAppearance) => `<div class="fighter" data-fighter="${i}">
|
||||
<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="avatar"><div class="emote-bubble" role="status" hidden></div><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';
|
||||
@@ -106,6 +130,7 @@ export function createMatchView(container: HTMLElement, onAction: (action: Actio
|
||||
const f = s.finished;
|
||||
el('.finish-reason').hidden = f.reason === 'hit';
|
||||
const outcome = s.aborted ? 'aborted' : f.winnerIndex === null ? 'draw' : f.winnerIndex === s.selfIndex ? 'win' : 'loss';
|
||||
if (outcome !== 'aborted' && !cutscene.active) sound.play(outcome, `${s.matchId}:result`);
|
||||
container.dataset.outcome = outcome;
|
||||
arena.dataset.result = outcome;
|
||||
el('.result-banner h2').textContent = ({ win: '승리!', loss: '패배', draw: '무승부', aborted: '대전 중단' })[outcome];
|
||||
@@ -129,6 +154,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]);
|
||||
}
|
||||
@@ -153,8 +179,8 @@ export function createMatchView(container: HTMLElement, onAction: (action: Actio
|
||||
if (state.reveal && !state.aborted && (!state.finished || state.finished.reason === 'hit')) renderer.play(state.reveal, Date.now() + offset);
|
||||
else renderer.reset();
|
||||
}
|
||||
tick();
|
||||
cutscene.update(state);
|
||||
}, destroy() { submission++; clearInterval(interval); cutscene.destroy(); renderer.destroy(); container.innerHTML = ''; current = undefined; },
|
||||
tick();
|
||||
}, destroy() { submission++; clearInterval(interval); cutscene.destroy(); renderer.destroy(); sound.stop(); container.innerHTML = ''; current = undefined; },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
/* 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 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 { top: 66px; } }
|
||||
body:is([data-screen=match], [data-screen=result]) {
|
||||
overflow: hidden;
|
||||
background: #d9ddd4;
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { io, type Socket } from 'socket.io-client';
|
||||
import type { GameSessionAuth } from '../shared/p2p-types.js';
|
||||
import { PRESENCE_RETRY_MS } from '../../constants/presence.js';
|
||||
|
||||
export function createPresence(counter: HTMLElement) {
|
||||
let socket: Socket | undefined, sessionId = '';
|
||||
let retry: ReturnType<typeof setTimeout> | undefined;
|
||||
const unknown = () => { counter.textContent = '접속 확인 중'; };
|
||||
window.addEventListener('pagehide', () => { clearTimeout(retry); socket?.disconnect(); unknown(); });
|
||||
window.addEventListener('pageshow', () => { socket?.connect(); });
|
||||
return {
|
||||
connect(auth: GameSessionAuth) {
|
||||
if (socket && sessionId === auth.gameSessionId) { socket.connect(); return; }
|
||||
clearTimeout(retry); socket?.removeAllListeners(); socket?.disconnect(); unknown();
|
||||
sessionId = auth.gameSessionId;
|
||||
socket = io('/presence', { auth: { gameSessionId: auth.gameSessionId, token: auth.token }, forceNew: true, autoConnect: false });
|
||||
socket.on('presence:count', (count: unknown) => {
|
||||
if (Number.isSafeInteger(count) && (count as number) >= 0) counter.textContent = `접속 ${count}명`;
|
||||
});
|
||||
socket.on('connect', () => clearTimeout(retry));
|
||||
socket.on('disconnect', unknown);
|
||||
socket.on('connect_error', error => {
|
||||
unknown(); clearTimeout(retry);
|
||||
// Middleware errors do not trigger Socket.IO's transport reconnection loop.
|
||||
if (error.message !== 'INVALID_SESSION') retry = setTimeout(() => socket?.connect(), PRESENCE_RETRY_MS);
|
||||
});
|
||||
socket.connect();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { SOUNDS, SOUND_EVENT_HISTORY, SOUND_MASTER_VOLUME, SOUND_MAX_VOICES, SOUND_MUTED_KEY, type SoundId, type SoundPart } from '../../constants/sound.js';
|
||||
|
||||
export function createSound(options: { context?: () => AudioContext; storage?: Pick<Storage, 'getItem' | 'setItem'> } = {}) {
|
||||
let context: AudioContext | undefined, master: GainNode | undefined, muted = false;
|
||||
const events = new Set<string>(), voices = new Set<AudioScheduledSourceNode>();
|
||||
const storage = () => options.storage ?? globalThis.localStorage;
|
||||
try { muted = storage()?.getItem(SOUND_MUTED_KEY) === 'true'; } catch { /* Optional preference storage. */ }
|
||||
function stop() {
|
||||
for (const voice of voices) { try { voice.stop(); } catch { /* Already ended. */ } }
|
||||
voices.clear();
|
||||
}
|
||||
return {
|
||||
get muted() { return muted; },
|
||||
async unlock() {
|
||||
if (muted) return;
|
||||
try {
|
||||
if (!context) {
|
||||
context = options.context?.() ?? new AudioContext();
|
||||
master = context.createGain(); master.gain.value = SOUND_MASTER_VOLUME;
|
||||
const compressor = context.createDynamicsCompressor();
|
||||
master.connect(compressor); compressor.connect(context.destination);
|
||||
}
|
||||
if (context.state === 'suspended') await context.resume();
|
||||
} catch { /* Audio support/autoplay failures must not interrupt gameplay. */ }
|
||||
},
|
||||
setMuted(value: boolean) {
|
||||
muted = value; if (muted) stop();
|
||||
try { storage()?.setItem(SOUND_MUTED_KEY, String(value)); } catch { /* Keep the in-memory preference. */ }
|
||||
},
|
||||
stop,
|
||||
play(id: SoundId, key?: string, pan = 0) {
|
||||
if (key) {
|
||||
if (events.has(key)) return;
|
||||
events.add(key);
|
||||
if (events.size > SOUND_EVENT_HISTORY) events.delete(events.values().next().value!);
|
||||
}
|
||||
if (muted || !context || !master || context.state !== 'running' || (typeof document !== 'undefined' && document.hidden)) return;
|
||||
const ctx = context;
|
||||
for (const part of SOUNDS[id] as readonly SoundPart[]) {
|
||||
if (voices.size >= SOUND_MAX_VOICES) break;
|
||||
const at = ctx.currentTime + (part.delay ?? 0), end = at + part.duration;
|
||||
const envelope = ctx.createGain(), panner = ctx.createStereoPanner();
|
||||
panner.pan.value = Math.max(-1, Math.min(1, pan));
|
||||
envelope.gain.setValueAtTime(0, at);
|
||||
envelope.gain.linearRampToValueAtTime(part.volume, at + Math.min(.012, part.duration / 4));
|
||||
envelope.gain.exponentialRampToValueAtTime(.0001, end);
|
||||
envelope.gain.linearRampToValueAtTime(0, end + .01);
|
||||
envelope.connect(panner); panner.connect(master);
|
||||
let source: AudioScheduledSourceNode, filter: BiquadFilterNode | undefined;
|
||||
if (part.wave === 'noise') {
|
||||
const buffer = ctx.createBuffer(1, Math.ceil(ctx.sampleRate * (part.duration + .02)), ctx.sampleRate);
|
||||
const data = buffer.getChannelData(0);
|
||||
for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1;
|
||||
const noise = ctx.createBufferSource(); noise.buffer = buffer; source = noise;
|
||||
filter = ctx.createBiquadFilter(); filter.type = 'lowpass';
|
||||
filter.frequency.setValueAtTime(part.from, at); filter.frequency.exponentialRampToValueAtTime(part.to, end);
|
||||
source.connect(filter); filter.connect(envelope);
|
||||
} else {
|
||||
const oscillator = ctx.createOscillator(); source = oscillator; oscillator.type = part.wave;
|
||||
oscillator.frequency.setValueAtTime(part.from, at); oscillator.frequency.exponentialRampToValueAtTime(part.to, end);
|
||||
source.connect(envelope);
|
||||
}
|
||||
voices.add(source);
|
||||
source.onended = () => { voices.delete(source); source.disconnect(); filter?.disconnect(); envelope.disconnect(); panner.disconnect(); };
|
||||
source.start(at); source.stop(end + .02);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
export const sound = createSound();
|
||||
|
||||
export function installSoundControls() {
|
||||
const button = document.getElementById('sound-toggle') as HTMLButtonElement;
|
||||
const refresh = () => { button.textContent = sound.muted ? '소리 켜기' : '소리 끄기'; button.setAttribute('aria-pressed', String(!sound.muted)); };
|
||||
refresh();
|
||||
button.onclick = () => { sound.setMuted(!sound.muted); refresh(); void sound.unlock(); };
|
||||
document.addEventListener('pointerdown', () => { void sound.unlock(); }, { passive: true });
|
||||
document.addEventListener('keydown', () => { void sound.unlock(); });
|
||||
document.addEventListener('click', event => {
|
||||
const target = event.target instanceof Element ? event.target.closest('button, summary') : null;
|
||||
if (!target || target.id === 'sound-toggle' || target.matches(':disabled')) return;
|
||||
void sound.unlock().then(() => sound.play('click'));
|
||||
}, true);
|
||||
document.addEventListener('visibilitychange', () => { if (document.hidden) sound.stop(); });
|
||||
window.addEventListener('pagehide', () => sound.stop());
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { createGameSessionStore } from './game-session-store.js';
|
||||
import { createRoomStore } from './rooms.js';
|
||||
import { registerAnalyticsApi, registerGameApi, registerRoomApi } from './game-api.js';
|
||||
import { registerSignaling, type RtcServerOptions } from './signaling.js';
|
||||
import { registerPresence } from './presence.js';
|
||||
import { createRankedService } from './ranked-service.js';
|
||||
import { registerRankedApi } from './ranked-api.js';
|
||||
import { createGlobalSettingsStore } from './global-settings.js';
|
||||
@@ -32,6 +33,7 @@ export async function createGameServer(options: ServerOptions): Promise<FastifyI
|
||||
? { revision: current.revision } : { revision: current.revision, settings: current };
|
||||
});
|
||||
const gameSessions = await createGameSessionStore(store.db, () => clock.now(), options.environment ?? 'production');
|
||||
registerPresence(io, gameSessions);
|
||||
const peerRooms = await createRoomStore(store.db, () => clock.now());
|
||||
const ranked = await createRankedService(store.db, clock, options.environment ?? 'production', () => logSafeError(app.log, 'ranked_background', undefined));
|
||||
const signaling = registerSignaling(io, gameSessions, peerRooms, options.rtc, () => clock.now());
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { Server } from 'socket.io';
|
||||
import { ApiError, type GameSessionStore } from './game-session-store.js';
|
||||
import { PRESENCE_AUTH_REFRESH_MS } from '../../constants/presence.js';
|
||||
|
||||
// The application is deployed as one process, as is the ranked match service.
|
||||
export function registerPresence(io: Server, sessions: GameSessionStore) {
|
||||
const namespace = io.of('/presence');
|
||||
let count = 0;
|
||||
function publish() {
|
||||
const next = new Set([...namespace.sockets.values()].filter(socket => socket.connected).map(socket => socket.data.playerId)).size;
|
||||
if (next !== count) { count = next; namespace.emit('presence:count', count); }
|
||||
}
|
||||
namespace.use((socket, next) => {
|
||||
const { gameSessionId, token } = socket.handshake.auth;
|
||||
void sessions.get({ gameSessionId, token }).then(session => {
|
||||
socket.data.playerId = session.playerId ?? session.gameSessionId;
|
||||
next();
|
||||
}, error => next(new Error(error instanceof ApiError && error.statusCode === 401 ? 'INVALID_SESSION' : 'PRESENCE_UNAVAILABLE')));
|
||||
});
|
||||
namespace.on('connection', socket => {
|
||||
const before = count; publish();
|
||||
if (before === count) socket.emit('presence:count', count);
|
||||
let checking = false;
|
||||
const timer = setInterval(() => {
|
||||
if (checking) return;
|
||||
checking = true;
|
||||
const { gameSessionId, token } = socket.handshake.auth;
|
||||
void sessions.get({ gameSessionId, token }).catch(error => {
|
||||
if (error instanceof ApiError && error.statusCode === 401) socket.disconnect(true);
|
||||
else socket.conn.close(); // Transport closure permits Socket.IO to reconnect after a transient outage.
|
||||
}).finally(() => { checking = false; });
|
||||
}, PRESENCE_AUTH_REFRESH_MS);
|
||||
timer.unref();
|
||||
socket.on('disconnect', () => { clearInterval(timer); publish(); });
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,8 @@ interface TerminalMatch {
|
||||
}
|
||||
interface QueueEntry { playerId: string; gameSessionId: string; expiresAt: number }
|
||||
interface RankedMatch {
|
||||
emotes: ReturnType<typeof createEmotes>;
|
||||
emoteVersion: number;
|
||||
id: string; playerIds: Pair<string>; appearances: Pair<FighterAppearance>; engine: MatchEngine;
|
||||
actions: Pair<ActionId | null>; departed: Set<string>; timer?: ReturnType<Clock['setTimeout']>;
|
||||
terminal?: TerminalMatch; saved: boolean; finishedExpiresAt?: Pair<number>;
|
||||
@@ -220,8 +223,9 @@ export async function createRankedService(db: Db, clock: Clock = systemClock, en
|
||||
state.selfIndex = selfIndex; state.ownAction = match.actions[selfIndex];
|
||||
// Own submissions change this participant's version without revealing whether the
|
||||
// opponent has locked a choice. Engine transitions always move past either value.
|
||||
state.version = state.version * 2 + (state.ownAction === null ? 0 : 1);
|
||||
state.version = (state.version + match.emoteVersion) * 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 +326,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(), emoteVersion: 0, 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 +342,17 @@ 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<RankedStatus> {
|
||||
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');
|
||||
match.emoteVersion++;
|
||||
return statusView(playerId);
|
||||
});
|
||||
},
|
||||
action(playerId: string, gameSessionId: string, matchId: string, turnId: number, action: ActionId): Promise<RankedStatus> {
|
||||
return serial(async () => {
|
||||
await identity(playerId, gameSessionId);
|
||||
|
||||
@@ -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<Emote | null> = [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<Emote | null> {
|
||||
return latest.map(value => value && now < value.expiresAt ? { ...value } : null) as Pair<Emote | null>;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<MatchState, 'serverNow' | 'finished'> {
|
||||
emotes?: Pair<Emote | null>;
|
||||
settings?: GlobalSettings;
|
||||
clockNow: number; aborted: boolean;
|
||||
appearances?: Pair<FighterAppearance>;
|
||||
@@ -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 }
|
||||
|
||||
@@ -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, '<script>', 1000), false);
|
||||
assert.equal(emotes.send(0, 'greet', 1000), true);
|
||||
assert.equal(emotes.send(0, 'taunt', 1001), false);
|
||||
assert.equal(emotes.send(1, 'thanks', 1001), true);
|
||||
assert.equal(emotes.view(1001)[0]?.id, 'greet');
|
||||
const snapshot = emotes.view(1001);
|
||||
snapshot[0]!.id = 'threaten';
|
||||
assert.equal(emotes.view(1001)[0]?.id, 'greet');
|
||||
assert.equal(emotes.view(1000 + EMOTE_DURATION_MS)[0], null);
|
||||
assert.equal(emotes.send(0, 'taunt', 1000 + EMOTE_COOLDOWN_MS), true);
|
||||
});
|
||||
@@ -341,6 +341,47 @@ test('global cutscene speed scales stages and CSS timing while respecting result
|
||||
}
|
||||
});
|
||||
|
||||
test('result sound waits for all three cutscene stages', t => {
|
||||
const { container, state } = domHarness(t, false);
|
||||
const started = state.clockNow, cues: string[] = [];
|
||||
t.mock.timers.enable({ apis: ['Date', 'setTimeout', 'setInterval'], now: started });
|
||||
t.mock.method(performance, 'now', () => Date.now() - started);
|
||||
t.mock.method(sound, 'play', (id: string) => { if (!cues.includes(id)) cues.push(id); });
|
||||
state.matchId = 'audio-stage-order'; state.phase = 'finished';
|
||||
state.reveal = { matchId: state.matchId, turnId: 1, actions: ['blast', 'charge'], energyBefore: [1, 1], energy: [0, 1], hitIndex: 1, winnerIndex: 0,
|
||||
revealedAt: started, revealEndsAt: started + 1200 };
|
||||
state.finished = { matchId: state.matchId, winnerIndex: 0, reason: 'hit', stats: emptyPlayerStats(), revealEndsAt: started + 1200, saved: true };
|
||||
const view = createMatchView(container as unknown as HTMLElement, async () => true, () => {}, () => {});
|
||||
try {
|
||||
view.update(state);
|
||||
t.mock.timers.tick(1100); t.mock.timers.tick(100);
|
||||
assert.deepEqual(cues, ['cutscene1', 'cutscene2']);
|
||||
t.mock.timers.tick(1200);
|
||||
assert.deepEqual(cues, ['cutscene1', 'cutscene2', 'cutscene3']);
|
||||
t.mock.timers.tick(1200);
|
||||
assert.deepEqual(cues, ['cutscene1', 'cutscene2', 'cutscene3', 'win']);
|
||||
} finally { view.destroy(); }
|
||||
});
|
||||
|
||||
test('a delayed finishing snapshot plays the remaining cutscene before result audio', t => {
|
||||
const { container, state } = domHarness(t, false);
|
||||
const started = state.clockNow, cues: string[] = [];
|
||||
t.mock.timers.enable({ apis: ['Date', 'setTimeout', 'setInterval'], now: started });
|
||||
t.mock.method(performance, 'now', () => Date.now() - started);
|
||||
t.mock.method(sound, 'play', (id: string) => { if (!cues.includes(id)) cues.push(id); });
|
||||
state.matchId = 'audio-delayed-snapshot'; state.phase = 'finished';
|
||||
state.reveal = { matchId: state.matchId, turnId: 1, actions: ['blast', 'charge'], energyBefore: [1, 1], energy: [0, 1], hitIndex: 1, winnerIndex: 0,
|
||||
revealedAt: started - 1500, revealEndsAt: started - 300 };
|
||||
state.finished = { matchId: state.matchId, winnerIndex: 0, reason: 'hit', stats: emptyPlayerStats(), revealEndsAt: started - 300, saved: true };
|
||||
const view = createMatchView(container as unknown as HTMLElement, async () => true, () => {}, () => {});
|
||||
try {
|
||||
view.update(state);
|
||||
assert.deepEqual(cues, ['cutscene2']);
|
||||
t.mock.timers.tick(900); t.mock.timers.tick(1200);
|
||||
assert.deepEqual(cues, ['cutscene2', 'cutscene3', 'win']);
|
||||
} finally { view.destroy(); }
|
||||
});
|
||||
|
||||
for (const reducedMotion of [false, true]) test(`cutscene timer automatically opens results once (reduced motion: ${reducedMotion})`, t => {
|
||||
const { state } = domHarness(t, reducedMotion);
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
@@ -404,3 +445,4 @@ test('a dismissed finisher stays closed when the opponent disconnects after the
|
||||
assert.equal(dismissals, 1); assert.equal(appended.length, 1);
|
||||
} finally { cutscene.destroy(); }
|
||||
});
|
||||
import { sound } from '../src/client/sound.js';
|
||||
|
||||
@@ -41,6 +41,8 @@ async function setup(kind: 'expired' | 'orphan' | 'rematch-wait' | 'result') {
|
||||
GAME_NAME: '쎄쎄쎄:기공대전', HOME_TELEPORT_APPEARANCE, createGameApi: () => createGameApi(storage, send),
|
||||
createGlobalSettingsCache: () => createGlobalSettingsCache(storage, send),
|
||||
createAppFooter: () => ({ setConnection() {}, openRules() {} }),
|
||||
installSoundControls() {},
|
||||
createPresence: () => ({ connect() {} }),
|
||||
createClientAnalytics: () => ({ ready() {}, screen() {}, gameFinished() {}, gameStarted() {}, error() {}, click() {} }),
|
||||
createResultReporter: (api: any) => ({ destroy() {}, resumePending: async () => {}, submit: api.record }),
|
||||
createMatchView: () => ({ update() {}, destroy() {} }), createPeer: () => ({}),
|
||||
|
||||
@@ -32,6 +32,24 @@ class Wire {
|
||||
close() { this.connected = false; }
|
||||
disconnect() { this.connected = false; for (const h of this.states) h('disconnected'); }
|
||||
}
|
||||
test('peer emotes reach the correct slot without exposing choices or changing the turn', async () => {
|
||||
const h = setup(), [a, b] = h.controllers;
|
||||
try {
|
||||
await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(4000);
|
||||
await a.submitAction('charge'); await h.clock.advance(1);
|
||||
const before = b.view();
|
||||
assert.equal(a.sendEmote('greet'), true);
|
||||
assert.equal(a.sendEmote('taunt'), false);
|
||||
await h.clock.advance(1);
|
||||
assert.equal(b.view().emotes?.[0]?.id, 'greet');
|
||||
assert.equal(b.view().ownAction, null);
|
||||
assert.equal(b.view().turnId, before.turnId);
|
||||
assert.deepEqual(b.view().energy, before.energy);
|
||||
assert.equal(b.view().phase, 'selecting');
|
||||
await b.leave();
|
||||
assert.equal(b.sendEmote('threaten'), false);
|
||||
} finally { h.close(); }
|
||||
});
|
||||
function setup(onRematch?: () => Promise<void>, guestClockOffset = 0, guestStartDelay = 0) {
|
||||
const clock = new ManualClock(); clock.time = 1000;
|
||||
const a = new Wire(), b = new Wire(); a.remote = b; b.remote = a;
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createServer } from 'node:http';
|
||||
import { Server } from 'socket.io';
|
||||
import { io as client, type Socket } from 'socket.io-client';
|
||||
import { registerPresence } from '../src/server/presence.js';
|
||||
import { ApiError, type GameSessionStore } from '../src/server/game-session-store.js';
|
||||
import { PRESENCE_AUTH_REFRESH_MS } from '../constants/presence.js';
|
||||
|
||||
test('presence counts authenticated players once across tabs and removes disconnected players', async () => {
|
||||
const http = createServer(), io = new Server(http);
|
||||
registerPresence(io, { async get(auth: { token: string; gameSessionId: string }) {
|
||||
if (auth.token !== 'valid') throw new ApiError(401, 'INVALID_SESSION');
|
||||
return { playerId: auth.gameSessionId.startsWith('a') ? 'a' : 'b' };
|
||||
} } as unknown as GameSessionStore);
|
||||
await new Promise<void>(resolve => http.listen(0, '127.0.0.1', resolve));
|
||||
const url = `http://127.0.0.1:${(http.address() as { port: number }).port}/presence`;
|
||||
const sockets: Socket[] = [];
|
||||
const open = (id: string, token = 'valid') => {
|
||||
const socket = client(url, { autoConnect: false, forceNew: true, auth: { gameSessionId: id, token }, reconnection: false });
|
||||
sockets.push(socket); return socket;
|
||||
};
|
||||
const next = (socket: Socket) => new Promise<number>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('PRESENCE_TIMEOUT')), 3000);
|
||||
socket.once('presence:count', n => { clearTimeout(timer); resolve(n); });
|
||||
});
|
||||
try {
|
||||
const a = open('a1'), aFirst = next(a); a.connect(); assert.equal(await aFirst, 1);
|
||||
const secondTab = open('a2'), tabFirst = next(secondTab); secondTab.connect(); assert.equal(await tabFirst, 1);
|
||||
const b = open('b1'), bFirst = next(b), aUpdate = next(a); b.connect();
|
||||
assert.equal(await bFirst, 2); assert.equal(await aUpdate, 2);
|
||||
const bad = open('bad', 'invalid');
|
||||
const rejected = new Promise<void>(resolve => bad.once('connect_error', () => resolve())); bad.connect(); await rejected;
|
||||
const lastA = next(secondTab); a.disconnect(); b.disconnect(); assert.equal(await lastA, 1);
|
||||
} finally { for (const socket of sockets) socket.disconnect(); await new Promise<void>(resolve => io.close(() => resolve())); }
|
||||
});
|
||||
|
||||
test('temporary revalidation errors allow reconnect, invalid sessions remain disconnected', async t => {
|
||||
let revalidate!: () => void;
|
||||
const interval = globalThis.setInterval;
|
||||
t.mock.method(globalThis, 'setInterval', ((fn: () => void, ms: number) => {
|
||||
if (ms !== PRESENCE_AUTH_REFRESH_MS) return interval(fn, ms);
|
||||
revalidate = fn;
|
||||
return { unref() {} };
|
||||
}) as typeof setInterval);
|
||||
let failure: 'temporary' | 'invalid' | undefined;
|
||||
const http = createServer(), io = new Server(http);
|
||||
registerPresence(io, { async get() {
|
||||
if (failure === 'invalid') throw new ApiError(401, 'INVALID_SESSION');
|
||||
if (failure === 'temporary') { failure = undefined; throw new Error('DB_UNAVAILABLE'); }
|
||||
return { playerId: 'one' };
|
||||
} } as unknown as GameSessionStore);
|
||||
await new Promise<void>(resolve => http.listen(0, '127.0.0.1', resolve));
|
||||
const socket = client(`http://127.0.0.1:${(http.address() as { port: number }).port}/presence`, { autoConnect: false, reconnectionDelay: 10 });
|
||||
const connected = () => new Promise<void>(resolve => socket.once('connect', () => resolve()));
|
||||
try {
|
||||
const first = connected(); socket.connect(); await first;
|
||||
const next = connected(); failure = 'temporary'; revalidate(); await next;
|
||||
assert.equal(socket.connected, true);
|
||||
const disconnected = new Promise<string>(resolve => socket.once('disconnect', resolve));
|
||||
failure = 'invalid'; revalidate();
|
||||
assert.equal(await disconnected, 'io server disconnect');
|
||||
assert.equal(socket.active, false);
|
||||
} finally { socket.disconnect(); await new Promise<void>(resolve => io.close(() => resolve())); }
|
||||
});
|
||||
@@ -17,6 +17,28 @@ import { EQUIPMENT_IDS } from '../constants/cosmetics.js';
|
||||
import { createRoomStore } from '../src/server/rooms.js';
|
||||
|
||||
const integration = { skip: !process.env.TEST_MONGODB_URI };
|
||||
test('ranked emotes authenticate membership, reach both players and preserve hidden actions', integration, async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
await f.service.join('player-a', f.a.gameSessionId);
|
||||
const paired = await f.service.join('player-b', f.b.gameSessionId);
|
||||
const matchId = paired.match!.matchId;
|
||||
await f.clock.advance(paired.match!.startsAt - f.clock.now());
|
||||
await f.service.action('player-a', f.a.gameSessionId, matchId, 1, 'charge');
|
||||
const before = await f.service.status('player-b', f.b.gameSessionId);
|
||||
const sent = await f.service.emote('player-a', f.a.gameSessionId, matchId, 'greet');
|
||||
const received = await f.service.status('player-b', f.b.gameSessionId);
|
||||
assert.equal(received.match!.emotes?.[0]?.id, 'greet');
|
||||
assert.ok(received.match!.version > before.match!.version, 'an older poll cannot overwrite this emote');
|
||||
assert.deepEqual(received.match!.emotes, sent.match!.emotes);
|
||||
assert.equal(received.match!.ownAction, null);
|
||||
assert.deepEqual(received.match!.energy, [0, 0]);
|
||||
await assert.rejects(f.service.emote('player-a', f.a.gameSessionId, matchId, 'thanks'), /INVALID_REQUEST/);
|
||||
await assert.rejects(f.service.emote('player-b', f.b.gameSessionId, matchId, 'arbitrary text'), /INVALID_REQUEST/);
|
||||
await assert.rejects(f.service.emote('player-a', f.b.gameSessionId, matchId, 'thanks'), /INVALID_SESSION/);
|
||||
await assert.rejects(f.service.emote('player-b', f.b.gameSessionId, 'other-match', 'thanks'), /NO_ACTIVE_MATCH/);
|
||||
} finally { await f.close(); }
|
||||
});
|
||||
function observeResultReads(db: Db, afterRead: (filter?: Record<string, unknown>) => Promise<void>) {
|
||||
const records = db.collection('ranked_matches');
|
||||
return new Proxy(db, { get(target, key) {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createSound } from '../src/client/sound.js';
|
||||
|
||||
test('audio starts only after unlock, deduplicates events, stops on mute and persists preference', async () => {
|
||||
let created = 0, started = 0, stopped = 0;
|
||||
const values = new Map<string, string>();
|
||||
const param = () => ({ value: 0, setValueAtTime() {}, exponentialRampToValueAtTime() {}, linearRampToValueAtTime() {} });
|
||||
const node = () => ({ connect() {}, disconnect() {}, gain: param(), frequency: param(), pan: param(),
|
||||
start() { started++; }, stop() { stopped++; }, onended: null });
|
||||
const context = { state: 'suspended', currentTime: 0, sampleRate: 44100, destination: {},
|
||||
async resume() { this.state = 'running'; }, createGain: node, createOscillator: node,
|
||||
createStereoPanner: node, createDynamicsCompressor: node,
|
||||
};
|
||||
const sound = createSound({ context: () => { created++; return context as unknown as AudioContext; },
|
||||
storage: { getItem: key => values.get(key) ?? null, setItem: (key, value) => { values.set(key, value); } } });
|
||||
sound.play('click'); assert.equal(created, 0);
|
||||
await sound.unlock(); assert.equal(created, 1);
|
||||
sound.play('click', 'one'); const count = started;
|
||||
sound.play('click', 'one'); assert.equal(started, count);
|
||||
sound.play('click', 'two'); assert.ok(started > count);
|
||||
sound.setMuted(true); assert.ok(stopped > 0);
|
||||
const mutedCount = started; sound.play('click'); assert.equal(started, mutedCount);
|
||||
assert.ok([...values.values()].includes('true'));
|
||||
});
|
||||
Reference in New Issue
Block a user