Add music and voice assets

This commit is contained in:
2026-06-22 00:21:45 +09:00
parent 674fbe6659
commit e45ad9e5ea
26 changed files with 652 additions and 50 deletions

View File

@@ -1,42 +1,82 @@
type AudioTrackMap = Record<string, string>;
export type VoiceRole = 'narrator' | 'liuBei' | 'guanYu' | 'zhangFei';
type PitchSafeAudio = HTMLAudioElement & {
preservesPitch?: boolean;
mozPreservesPitch?: boolean;
webkitPreservesPitch?: boolean;
};
const rolePlayback: Record<VoiceRole, { rate: number; volume: number }> = {
narrator: { rate: 0.98, volume: 0.9 },
liuBei: { rate: 0.96, volume: 0.92 },
guanYu: { rate: 0.84, volume: 0.95 },
zhangFei: { rate: 1.05, volume: 1 }
};
class SoundDirector {
private context?: AudioContext;
private master?: GainNode;
private musicGain?: GainNode;
private started = false;
private muted = false;
private sequenceTimer?: number;
private musicTracks: AudioTrackMap = {};
private voiceTracks: AudioTrackMap = {};
private music?: HTMLAudioElement;
private voice?: HTMLAudioElement;
private currentMusicKey?: string;
private pendingMusicKey?: string;
private musicFadeTimer?: number;
private musicRampTimer?: number;
private readonly musicVolume = 0.46;
private readonly duckedMusicVolume = 0.18;
registerMusicTracks(tracks: AudioTrackMap) {
this.musicTracks = tracks;
}
registerVoiceTracks(tracks: AudioTrackMap) {
this.voiceTracks = tracks;
}
start() {
if (this.started) {
this.resume();
if (this.pendingMusicKey) {
this.playMusic(this.pendingMusicKey);
}
return;
}
const AudioContextCtor = window.AudioContext ?? window.webkitAudioContext;
if (!AudioContextCtor) {
return;
if (AudioContextCtor) {
this.context = new AudioContextCtor();
this.master = this.context.createGain();
this.master.gain.value = this.muted ? 0 : 0.34;
this.master.connect(this.context.destination);
}
this.context = new AudioContextCtor();
this.master = this.context.createGain();
this.master.gain.value = this.muted ? 0 : 0.34;
this.master.connect(this.context.destination);
this.musicGain = this.context.createGain();
this.musicGain.gain.value = 0.0;
this.musicGain.connect(this.master);
this.started = true;
this.createAmbientBed();
this.schedulePlucks();
this.musicGain.gain.linearRampToValueAtTime(0.72, this.context.currentTime + 2.4);
this.resume();
if (this.pendingMusicKey) {
this.playMusic(this.pendingMusicKey);
}
}
resume() {
void this.context?.resume();
if (this.started && this.music) {
void this.music.play().catch(() => undefined);
}
}
setMuted(muted: boolean) {
this.muted = muted;
if (this.music) {
this.music.muted = muted;
}
if (this.voice) {
this.voice.muted = muted;
}
if (!this.context || !this.master) {
return;
@@ -59,60 +99,110 @@ class SoundDirector {
window.setTimeout(() => this.playTone(495, 0.1, 0.08, 'triangle'), 65);
}
private createAmbientBed() {
if (!this.context || !this.musicGain) {
playMusic(key?: string) {
if (!key) {
return;
}
const base = this.context.createOscillator();
const fifth = this.context.createOscillator();
const filter = this.context.createBiquadFilter();
const bedGain = this.context.createGain();
this.pendingMusicKey = key;
if (!this.started) {
return;
}
base.type = 'sine';
fifth.type = 'sine';
base.frequency.value = 110;
fifth.frequency.value = 165;
filter.type = 'lowpass';
filter.frequency.value = 520;
bedGain.gain.value = 0.08;
const src = this.musicTracks[key];
if (!src) {
return;
}
base.connect(filter);
fifth.connect(filter);
filter.connect(bedGain);
bedGain.connect(this.musicGain);
base.start();
fifth.start();
if (this.currentMusicKey === key && this.music) {
this.music.muted = this.muted;
void this.music.play().catch(() => undefined);
return;
}
const previous = this.music;
const next = new Audio(src);
next.loop = true;
next.preload = 'auto';
next.muted = this.muted;
next.volume = 0;
this.music = next;
this.currentMusicKey = key;
void next.play().catch(() => undefined);
this.fadeMusic(previous, next, this.voice ? this.duckedMusicVolume : this.musicVolume);
}
private schedulePlucks() {
if (!this.context) {
playVoice(key?: string, role: VoiceRole = 'narrator') {
if (!key || !this.started) {
this.stopVoice();
return;
}
this.stopVoice(false);
const src = this.voiceTracks[key];
if (!src) {
return;
}
const notes = [220, 247, 294, 330, 392, 330, 294, 247];
let index = 0;
this.sequenceTimer = window.setInterval(() => {
this.playTone(notes[index % notes.length], 0.18, 0.09, 'triangle', true);
index += 1;
}, 1800);
const profile = rolePlayback[role] ?? rolePlayback.narrator;
const voice = new Audio(src) as PitchSafeAudio;
voice.preload = 'auto';
voice.muted = this.muted;
voice.volume = profile.volume;
voice.playbackRate = profile.rate;
voice.preservesPitch = false;
voice.mozPreservesPitch = false;
voice.webkitPreservesPitch = false;
voice.addEventListener('ended', () => {
if (this.voice === voice) {
this.voice = undefined;
this.duckMusic(false);
}
});
voice.addEventListener('error', () => {
if (this.voice === voice) {
this.voice = undefined;
this.duckMusic(false);
}
});
this.voice = voice;
this.duckMusic(true);
void voice.play().catch(() => {
if (this.voice === voice) {
this.voice = undefined;
this.duckMusic(false);
}
});
}
stopVoice(restoreMusic = true) {
if (!this.voice) {
return;
}
this.voice.pause();
this.voice.currentTime = 0;
this.voice = undefined;
if (restoreMusic) {
this.duckMusic(false);
}
}
private playTone(
frequency: number,
duration: number,
gainValue: number,
type: OscillatorType,
routeToMusic = false
type: OscillatorType
) {
if (!this.context) {
if (!this.context || this.muted) {
return;
}
const oscillator = this.context.createOscillator();
const gain = this.context.createGain();
const now = this.context.currentTime;
const destination = routeToMusic && this.musicGain ? this.musicGain : this.master;
oscillator.type = type;
oscillator.frequency.value = frequency;
@@ -121,10 +211,73 @@ class SoundDirector {
gain.gain.exponentialRampToValueAtTime(0.0001, now + duration);
oscillator.connect(gain);
gain.connect(destination ?? this.context.destination);
gain.connect(this.master ?? this.context.destination);
oscillator.start(now);
oscillator.stop(now + duration + 0.04);
}
private fadeMusic(previous: HTMLAudioElement | undefined, next: HTMLAudioElement, targetVolume: number) {
if (this.musicFadeTimer) {
window.clearInterval(this.musicFadeTimer);
}
const duration = 900;
const startedAt = performance.now();
const previousStartVolume = previous?.volume ?? 0;
this.musicFadeTimer = window.setInterval(() => {
const progress = Math.min(1, (performance.now() - startedAt) / duration);
next.volume = targetVolume * progress;
if (previous) {
previous.volume = previousStartVolume * (1 - progress);
}
if (progress >= 1) {
if (this.musicFadeTimer) {
window.clearInterval(this.musicFadeTimer);
this.musicFadeTimer = undefined;
}
if (previous) {
previous.pause();
previous.src = '';
}
}
}, 32);
}
private duckMusic(ducked: boolean) {
if (!this.music) {
return;
}
if (ducked && this.musicFadeTimer) {
window.clearInterval(this.musicFadeTimer);
this.musicFadeTimer = undefined;
}
if (this.musicRampTimer) {
window.clearInterval(this.musicRampTimer);
}
const from = this.music.volume;
const to = ducked ? this.duckedMusicVolume : this.musicVolume;
const duration = 260;
const startedAt = performance.now();
this.musicRampTimer = window.setInterval(() => {
if (!this.music) {
return;
}
const progress = Math.min(1, (performance.now() - startedAt) / duration);
this.music.volume = from + (to - from) * progress;
if (progress >= 1 && this.musicRampTimer) {
window.clearInterval(this.musicRampTimer);
this.musicRampTimer = undefined;
}
}, 32);
}
}
declare global {

View File

@@ -0,0 +1,39 @@
import battlePrepUrl from '../../assets/audio/bgm/battle-prep.wav';
import militiaThemeUrl from '../../assets/audio/bgm/militia-theme.wav';
import oathThemeUrl from '../../assets/audio/bgm/oath-theme.wav';
import storyDarkUrl from '../../assets/audio/bgm/story-dark.wav';
import titleThemeUrl from '../../assets/audio/bgm/title-theme.wav';
import battleBriefingVoiceUrl from '../../assets/audio/voice/prologue/battle-briefing.wav';
import firstSortieVoiceUrl from '../../assets/audio/voice/prologue/first-sortie.wav';
import guanYuJoinsVoiceUrl from '../../assets/audio/voice/prologue/guan-yu-joins.wav';
import liuBeiResolveVoiceUrl from '../../assets/audio/voice/prologue/liu-bei-resolve.wav';
import militiaRisesVoiceUrl from '../../assets/audio/voice/prologue/militia-rises.wav';
import oathBrothersVoiceUrl from '../../assets/audio/voice/prologue/oath-brothers.wav';
import oathLiuBeiVoiceUrl from '../../assets/audio/voice/prologue/oath-liu-bei.wav';
import oathNarrationVoiceUrl from '../../assets/audio/voice/prologue/oath-narration.wav';
import yellowTurbanChaosVoiceUrl from '../../assets/audio/voice/prologue/yellow-turban-chaos.wav';
import zhangFeiJoinsVoiceUrl from '../../assets/audio/voice/prologue/zhang-fei-joins.wav';
export const musicTracks = {
'title-theme': titleThemeUrl,
'story-dark': storyDarkUrl,
'oath-theme': oathThemeUrl,
'militia-theme': militiaThemeUrl,
'battle-prep': battlePrepUrl
} as const;
export const voiceTracks = {
'yellow-turban-chaos': yellowTurbanChaosVoiceUrl,
'liu-bei-resolve': liuBeiResolveVoiceUrl,
'zhang-fei-joins': zhangFeiJoinsVoiceUrl,
'guan-yu-joins': guanYuJoinsVoiceUrl,
'oath-narration': oathNarrationVoiceUrl,
'oath-liu-bei': oathLiuBeiVoiceUrl,
'oath-brothers': oathBrothersVoiceUrl,
'militia-rises': militiaRisesVoiceUrl,
'first-sortie': firstSortieVoiceUrl,
'battle-briefing': battleBriefingVoiceUrl
} as const;
export type MusicTrackKey = keyof typeof musicTracks;
export type VoiceTrackKey = keyof typeof voiceTracks;

View File

@@ -1,4 +1,5 @@
export type PortraitKey = 'liuBei' | 'guanYu' | 'zhangFei';
export type VoiceRole = 'narrator' | PortraitKey;
export type StoryPage = {
id: string;
@@ -6,6 +7,9 @@ export type StoryPage = {
background: string;
speaker?: string;
portrait?: PortraitKey;
bgm?: string;
voice?: string;
voiceRole?: VoiceRole;
text: string;
};
@@ -33,12 +37,18 @@ export type BattleMap = {
export const prologuePages: StoryPage[] = [
{
id: 'yellow-turban-chaos',
bgm: 'story-dark',
voice: 'yellow-turban-chaos',
voiceRole: 'narrator',
chapter: '난세의 시작',
background: 'story-chaos',
text: '중평 원년, 황건의 깃발이 각지에서 일어났다. 관군은 흩어지고, 마을마다 피난 행렬이 이어졌다.'
},
{
id: 'liu-bei-resolve',
bgm: 'story-dark',
voice: 'liu-bei-resolve',
voiceRole: 'liuBei',
chapter: '탁현의 방',
background: 'story-liu-bei',
speaker: '유비',
@@ -47,6 +57,9 @@ export const prologuePages: StoryPage[] = [
},
{
id: 'zhang-fei-joins',
bgm: 'story-dark',
voice: 'zhang-fei-joins',
voiceRole: 'zhangFei',
chapter: '뜻이 모이다',
background: 'story-three-heroes',
speaker: '장비',
@@ -55,6 +68,9 @@ export const prologuePages: StoryPage[] = [
},
{
id: 'guan-yu-joins',
bgm: 'story-dark',
voice: 'guan-yu-joins',
voiceRole: 'guanYu',
chapter: '뜻이 모이다',
background: 'story-three-heroes',
speaker: '관우',
@@ -63,12 +79,18 @@ export const prologuePages: StoryPage[] = [
},
{
id: 'oath-narration',
bgm: 'oath-theme',
voice: 'oath-narration',
voiceRole: 'narrator',
chapter: '도원결의',
background: 'title-taoyuan',
text: '복숭아꽃이 흩날리는 동산에서 세 사람은 하늘과 땅에 고했다.'
},
{
id: 'oath-liu-bei',
bgm: 'oath-theme',
voice: 'oath-liu-bei',
voiceRole: 'liuBei',
chapter: '도원결의',
background: 'title-taoyuan',
speaker: '유비',
@@ -77,6 +99,9 @@ export const prologuePages: StoryPage[] = [
},
{
id: 'oath-brothers',
bgm: 'oath-theme',
voice: 'oath-brothers',
voiceRole: 'guanYu',
chapter: '도원결의',
background: 'title-taoyuan',
speaker: '관우',
@@ -85,6 +110,9 @@ export const prologuePages: StoryPage[] = [
},
{
id: 'militia-rises',
bgm: 'militia-theme',
voice: 'militia-rises',
voiceRole: 'zhangFei',
chapter: '의용군 모집',
background: 'story-militia',
speaker: '장비',
@@ -93,6 +121,9 @@ export const prologuePages: StoryPage[] = [
},
{
id: 'first-sortie',
bgm: 'battle-prep',
voice: 'first-sortie',
voiceRole: 'liuBei',
chapter: '첫 출진',
background: 'story-sortie',
speaker: '유비',
@@ -101,6 +132,9 @@ export const prologuePages: StoryPage[] = [
},
{
id: 'battle-briefing',
bgm: 'battle-prep',
voice: 'battle-briefing',
voiceRole: 'narrator',
chapter: '황건적 토벌',
background: 'story-sortie',
text: '의용군은 마을 어귀로 향했다. 첫 싸움은 곧 시작된다.'

View File

@@ -1,4 +1,5 @@
import Phaser from 'phaser';
import { soundDirector } from '../audio/SoundDirector';
import { firstBattleMap, firstBattleUnits, type TerrainType, type UnitData } from '../data/scenario';
import { palette } from '../ui/palette';
@@ -24,6 +25,8 @@ export class BattleScene extends Phaser.Scene {
create() {
const { width, height } = this.scale;
soundDirector.stopVoice();
soundDirector.playMusic('battle-prep');
this.add.rectangle(0, 0, width, height, 0x0f1418).setOrigin(0);
this.drawMap();
this.drawUnits();

View File

@@ -1,4 +1,5 @@
import Phaser from 'phaser';
import { soundDirector } from '../audio/SoundDirector';
import { prologuePages, type PortraitKey, type StoryPage } from '../data/scenario';
import { palette } from '../ui/palette';
@@ -114,6 +115,8 @@ export class StoryScene extends Phaser.Scene {
}
private applyPage(page: StoryPage) {
soundDirector.playMusic(page.bgm);
soundDirector.playVoice(page.voice, page.voiceRole ?? page.portrait ?? 'narrator');
this.applyBackground(page.background);
this.chapterText?.setText(page.chapter);

View File

@@ -17,8 +17,15 @@ export class TitleScene extends Phaser.Scene {
this.drawTitleMark(width, height);
this.drawMenu(width, height);
this.input.once('pointerdown', () => soundDirector.start());
this.input.keyboard?.once('keydown', () => soundDirector.start());
soundDirector.playMusic('title-theme');
const unlockAudio = () => {
soundDirector.start();
soundDirector.resume();
soundDirector.playMusic('title-theme');
};
this.input.once('pointerdown', unlockAudio);
this.input.keyboard?.once('keydown', unlockAudio);
this.input.keyboard?.once('keydown-ENTER', () => this.startGame());
}
@@ -275,6 +282,8 @@ export class TitleScene extends Phaser.Scene {
}
private startGame() {
soundDirector.start();
soundDirector.resume();
soundDirector.playSelect();
this.scene.start('StoryScene');
}