diff --git a/constants/sound.ts b/constants/sound.ts new file mode 100644 index 0000000..1b99d21 --- /dev/null +++ b/constants/sound.ts @@ -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; +export type SoundId = keyof typeof SOUNDS; diff --git a/docs/sound-provenance.md b/docs/sound-provenance.md new file mode 100644 index 0000000..ea44d94 --- /dev/null +++ b/docs/sound-provenance.md @@ -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, 저작자, 라이선스 전문·버전·확인일, 상업 이용·수정·배포 허용 여부, 표시 의무를 파일별로 기록한다. '무료 다운로드'만으로 사용을 승인하지 않는다. diff --git a/src/client/action-renderer.ts b/src/client/action-renderer.ts index 6db3abe..b3dab76 100644 --- a/src/client/action-renderer.ts +++ b/src/client/action-renderer.ts @@ -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 | 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`); diff --git a/src/client/app-footer.css b/src/client/app-footer.css index 86ff6c7..d19aea1 100644 --- a/src/client/app-footer.css +++ b/src/client/app-footer.css @@ -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; diff --git a/src/client/app-footer.ts b/src/client/app-footer.ts index 1e4f33d..d97fef6 100644 --- a/src/client/app-footer.ts +++ b/src/client/app-footer.ts @@ -4,7 +4,7 @@ type FooterDialog = typeof FOOTER_LINKS[number]['id']; export function createAppFooter(container: HTMLElement, dialogs: Record) { container.classList.add('app-footer'); - container.innerHTML = `연결 중