Add original synthesized game audio and document sound provenance
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -17,6 +17,7 @@ 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 './game.css';
|
||||
import './match.css';
|
||||
import './home.css';
|
||||
@@ -28,6 +29,7 @@ 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 globalSettings = createGlobalSettingsCache({
|
||||
getItem: key => localStorage.getItem(key), setItem: (key, value) => localStorage.setItem(key, value),
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ 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="emote-bubble" role="status" hidden></div>
|
||||
@@ -130,6 +131,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') sound.play(outcome, `${s.matchId}:result`);
|
||||
container.dataset.outcome = outcome;
|
||||
arena.dataset.result = outcome;
|
||||
el('.result-banner h2').textContent = ({ win: '승리!', loss: '패배', draw: '무승부', aborted: '대전 중단' })[outcome];
|
||||
@@ -180,6 +182,6 @@ export function createMatchView(container: HTMLElement, onAction: (action: Actio
|
||||
}
|
||||
tick();
|
||||
cutscene.update(state);
|
||||
}, destroy() { submission++; clearInterval(interval); cutscene.destroy(); renderer.destroy(); container.innerHTML = ''; current = undefined; },
|
||||
}, destroy() { submission++; clearInterval(interval); cutscene.destroy(); renderer.destroy(); sound.stop(); container.innerHTML = ''; current = undefined; },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
Reference in New Issue
Block a user