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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

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');
}

View File

@@ -1,4 +1,6 @@
import Phaser from 'phaser';
import { musicTracks, voiceTracks } from './game/audio/audioAssets';
import { soundDirector } from './game/audio/SoundDirector';
import { BattleScene } from './game/scenes/BattleScene';
import { BootScene } from './game/scenes/BootScene';
import { StoryScene } from './game/scenes/StoryScene';
@@ -24,6 +26,9 @@ const config: Phaser.Types.Core.GameConfig = {
scene: [BootScene, TitleScene, StoryScene, BattleScene]
};
soundDirector.registerMusicTracks(musicTracks);
soundDirector.registerVoiceTracks(voiceTracks);
const game = new Phaser.Game(config);
if (import.meta.env.DEV) {