Remove TTS narration and improve BGM
This commit is contained in:
@@ -1,18 +1,4 @@
|
||||
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;
|
||||
@@ -20,24 +6,16 @@ class SoundDirector {
|
||||
private started = false;
|
||||
private muted = false;
|
||||
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;
|
||||
private readonly musicVolume = 0.56;
|
||||
|
||||
registerMusicTracks(tracks: AudioTrackMap) {
|
||||
this.musicTracks = tracks;
|
||||
}
|
||||
|
||||
registerVoiceTracks(tracks: AudioTrackMap) {
|
||||
this.voiceTracks = tracks;
|
||||
}
|
||||
|
||||
start() {
|
||||
if (this.started) {
|
||||
this.resume();
|
||||
@@ -74,9 +52,6 @@ class SoundDirector {
|
||||
if (this.music) {
|
||||
this.music.muted = muted;
|
||||
}
|
||||
if (this.voice) {
|
||||
this.voice.muted = muted;
|
||||
}
|
||||
|
||||
if (!this.context || !this.master) {
|
||||
return;
|
||||
@@ -130,64 +105,7 @@ class SoundDirector {
|
||||
this.music = next;
|
||||
this.currentMusicKey = key;
|
||||
void next.play().catch(() => undefined);
|
||||
this.fadeMusic(previous, next, this.voice ? this.duckedMusicVolume : this.musicVolume);
|
||||
}
|
||||
|
||||
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 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);
|
||||
}
|
||||
this.fadeMusic(previous, next, this.musicVolume);
|
||||
}
|
||||
|
||||
private playTone(
|
||||
@@ -246,38 +164,6 @@ class SoundDirector {
|
||||
}, 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 {
|
||||
|
||||
@@ -3,16 +3,6 @@ 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,
|
||||
@@ -22,18 +12,4 @@ export const musicTracks = {
|
||||
'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;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
export type PortraitKey = 'liuBei' | 'guanYu' | 'zhangFei';
|
||||
export type VoiceRole = 'narrator' | PortraitKey;
|
||||
|
||||
export type StoryPage = {
|
||||
id: string;
|
||||
@@ -8,8 +7,6 @@ export type StoryPage = {
|
||||
speaker?: string;
|
||||
portrait?: PortraitKey;
|
||||
bgm?: string;
|
||||
voice?: string;
|
||||
voiceRole?: VoiceRole;
|
||||
text: string;
|
||||
};
|
||||
|
||||
@@ -38,8 +35,6 @@ export const prologuePages: StoryPage[] = [
|
||||
{
|
||||
id: 'yellow-turban-chaos',
|
||||
bgm: 'story-dark',
|
||||
voice: 'yellow-turban-chaos',
|
||||
voiceRole: 'narrator',
|
||||
chapter: '난세의 시작',
|
||||
background: 'story-chaos',
|
||||
text: '중평 원년, 황건의 깃발이 각지에서 일어났다. 관군은 흩어지고, 마을마다 피난 행렬이 이어졌다.'
|
||||
@@ -47,8 +42,6 @@ export const prologuePages: StoryPage[] = [
|
||||
{
|
||||
id: 'liu-bei-resolve',
|
||||
bgm: 'story-dark',
|
||||
voice: 'liu-bei-resolve',
|
||||
voiceRole: 'liuBei',
|
||||
chapter: '탁현의 방',
|
||||
background: 'story-liu-bei',
|
||||
speaker: '유비',
|
||||
@@ -58,8 +51,6 @@ export const prologuePages: StoryPage[] = [
|
||||
{
|
||||
id: 'zhang-fei-joins',
|
||||
bgm: 'story-dark',
|
||||
voice: 'zhang-fei-joins',
|
||||
voiceRole: 'zhangFei',
|
||||
chapter: '뜻이 모이다',
|
||||
background: 'story-three-heroes',
|
||||
speaker: '장비',
|
||||
@@ -69,8 +60,6 @@ export const prologuePages: StoryPage[] = [
|
||||
{
|
||||
id: 'guan-yu-joins',
|
||||
bgm: 'story-dark',
|
||||
voice: 'guan-yu-joins',
|
||||
voiceRole: 'guanYu',
|
||||
chapter: '뜻이 모이다',
|
||||
background: 'story-three-heroes',
|
||||
speaker: '관우',
|
||||
@@ -80,8 +69,6 @@ export const prologuePages: StoryPage[] = [
|
||||
{
|
||||
id: 'oath-narration',
|
||||
bgm: 'oath-theme',
|
||||
voice: 'oath-narration',
|
||||
voiceRole: 'narrator',
|
||||
chapter: '도원결의',
|
||||
background: 'title-taoyuan',
|
||||
text: '복숭아꽃이 흩날리는 동산에서 세 사람은 하늘과 땅에 고했다.'
|
||||
@@ -89,8 +76,6 @@ export const prologuePages: StoryPage[] = [
|
||||
{
|
||||
id: 'oath-liu-bei',
|
||||
bgm: 'oath-theme',
|
||||
voice: 'oath-liu-bei',
|
||||
voiceRole: 'liuBei',
|
||||
chapter: '도원결의',
|
||||
background: 'title-taoyuan',
|
||||
speaker: '유비',
|
||||
@@ -100,8 +85,6 @@ export const prologuePages: StoryPage[] = [
|
||||
{
|
||||
id: 'oath-brothers',
|
||||
bgm: 'oath-theme',
|
||||
voice: 'oath-brothers',
|
||||
voiceRole: 'guanYu',
|
||||
chapter: '도원결의',
|
||||
background: 'title-taoyuan',
|
||||
speaker: '관우',
|
||||
@@ -111,8 +94,6 @@ export const prologuePages: StoryPage[] = [
|
||||
{
|
||||
id: 'militia-rises',
|
||||
bgm: 'militia-theme',
|
||||
voice: 'militia-rises',
|
||||
voiceRole: 'zhangFei',
|
||||
chapter: '의용군 모집',
|
||||
background: 'story-militia',
|
||||
speaker: '장비',
|
||||
@@ -122,8 +103,6 @@ export const prologuePages: StoryPage[] = [
|
||||
{
|
||||
id: 'first-sortie',
|
||||
bgm: 'battle-prep',
|
||||
voice: 'first-sortie',
|
||||
voiceRole: 'liuBei',
|
||||
chapter: '첫 출진',
|
||||
background: 'story-sortie',
|
||||
speaker: '유비',
|
||||
@@ -133,8 +112,6 @@ export const prologuePages: StoryPage[] = [
|
||||
{
|
||||
id: 'battle-briefing',
|
||||
bgm: 'battle-prep',
|
||||
voice: 'battle-briefing',
|
||||
voiceRole: 'narrator',
|
||||
chapter: '황건적 토벌',
|
||||
background: 'story-sortie',
|
||||
text: '의용군은 마을 어귀로 향했다. 첫 싸움은 곧 시작된다.'
|
||||
|
||||
@@ -25,7 +25,6 @@ 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();
|
||||
|
||||
@@ -116,7 +116,6 @@ 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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user