Add music and voice assets
This commit is contained in:
@@ -15,6 +15,7 @@ Build a small complete tactical RPG loop:
|
||||
- Vite, TypeScript, and Phaser project foundation
|
||||
- Cinematic title scene with an original Taoyuan Oath background, subtle motion, menu UI, and first-input audio
|
||||
- Cinematic prologue pages with high-resolution story backgrounds, character portraits, and scenario data
|
||||
- File-based original BGM loops, local generated Korean narration/voice lines, and automatic music ducking during speech
|
||||
- First battle scene with a 12x12 map, terrain colors, unit placement, and movement range preview
|
||||
- Flow verification script from title to first battle
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build": "tsc && vite build",
|
||||
"generate:audio": "node scripts/generate-bgm.mjs && node scripts/generate-voice-assets.mjs",
|
||||
"preview": "vite preview --host 0.0.0.0",
|
||||
"verify:flow": "node scripts/verify-flow.mjs"
|
||||
},
|
||||
|
||||
227
scripts/generate-bgm.mjs
Normal file
227
scripts/generate-bgm.mjs
Normal file
@@ -0,0 +1,227 @@
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const sampleRate = 22050;
|
||||
const duration = 32;
|
||||
const outputDir = 'src/assets/audio/bgm';
|
||||
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
|
||||
const tracks = [
|
||||
{
|
||||
name: 'title-theme',
|
||||
root: 196,
|
||||
tempo: 72,
|
||||
drum: 0.34,
|
||||
melody: [0, 2, 4, 7, 9, 7, 4, 2],
|
||||
bass: [0, 0, -5, -3],
|
||||
color: 'warm'
|
||||
},
|
||||
{
|
||||
name: 'story-dark',
|
||||
root: 146.83,
|
||||
tempo: 58,
|
||||
drum: 0.22,
|
||||
melody: [0, -3, 0, 2, -5, -3, 0, -7],
|
||||
bass: [0, -7, -5, -3],
|
||||
color: 'dark'
|
||||
},
|
||||
{
|
||||
name: 'oath-theme',
|
||||
root: 220,
|
||||
tempo: 66,
|
||||
drum: 0.24,
|
||||
melody: [0, 4, 7, 9, 12, 9, 7, 4],
|
||||
bass: [0, -5, -3, -7],
|
||||
color: 'gold'
|
||||
},
|
||||
{
|
||||
name: 'militia-theme',
|
||||
root: 174.61,
|
||||
tempo: 82,
|
||||
drum: 0.42,
|
||||
melody: [0, 2, 5, 7, 9, 7, 5, 2],
|
||||
bass: [0, -5, 0, -3],
|
||||
color: 'earth'
|
||||
},
|
||||
{
|
||||
name: 'battle-prep',
|
||||
root: 164.81,
|
||||
tempo: 94,
|
||||
drum: 0.5,
|
||||
melody: [0, 3, 5, 7, 10, 7, 5, 3],
|
||||
bass: [0, 0, -2, -5],
|
||||
color: 'steel'
|
||||
}
|
||||
];
|
||||
|
||||
for (const track of tracks) {
|
||||
const buffer = new Float32Array(sampleRate * duration);
|
||||
buildTrack(buffer, track);
|
||||
normalize(buffer, 0.86);
|
||||
writeFileSync(join(outputDir, `${track.name}.wav`), encodeWav(buffer));
|
||||
}
|
||||
|
||||
function buildTrack(buffer, track) {
|
||||
const beat = 60 / track.tempo;
|
||||
const bar = beat * 4;
|
||||
|
||||
for (let t = 0; t < duration; t += bar) {
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
const bassFreq = note(track.root, track.bass[i % track.bass.length] - 12);
|
||||
addTone(buffer, t + i * beat, beat * 1.9, bassFreq, 0.075, 'soft');
|
||||
addTone(buffer, t + i * beat, beat * 1.9, bassFreq * 2, 0.025, 'soft');
|
||||
}
|
||||
}
|
||||
|
||||
for (let t = 0; t < duration; t += beat * 2) {
|
||||
const step = Math.floor(t / (beat * 2)) % track.melody.length;
|
||||
const freq = note(track.root, track.melody[step]);
|
||||
addPluck(buffer, t + 0.04, beat * 1.65, freq, 0.12, track.color);
|
||||
addPluck(buffer, t + beat * 0.95, beat * 0.9, freq * 1.5, 0.045, track.color);
|
||||
}
|
||||
|
||||
for (let t = 0; t < duration; t += beat) {
|
||||
if (Math.round(t / beat) % 4 === 0) {
|
||||
addDrum(buffer, t, 0.26, track.drum);
|
||||
} else if (track.drum > 0.3 && Math.round(t / beat) % 2 === 0) {
|
||||
addDrum(buffer, t, 0.16, track.drum * 0.45);
|
||||
}
|
||||
}
|
||||
|
||||
for (let t = 0; t < duration; t += bar * 2) {
|
||||
addNoiseWash(buffer, t, bar * 1.8, track.color === 'dark' ? 0.012 : 0.007);
|
||||
}
|
||||
|
||||
for (let i = 0; i < buffer.length; i += 1) {
|
||||
const seconds = i / sampleRate;
|
||||
const edge = Math.min(1, seconds / 0.6, (duration - seconds) / 0.6);
|
||||
buffer[i] *= Math.max(0, edge);
|
||||
}
|
||||
}
|
||||
|
||||
function note(root, semitone) {
|
||||
return root * 2 ** (semitone / 12);
|
||||
}
|
||||
|
||||
function addTone(buffer, start, length, freq, gain, shape) {
|
||||
const startIndex = Math.floor(start * sampleRate);
|
||||
const count = Math.floor(length * sampleRate);
|
||||
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const index = startIndex + i;
|
||||
if (index >= buffer.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
const t = i / sampleRate;
|
||||
const env = Math.sin(Math.PI * Math.min(1, i / count));
|
||||
const wave = Math.sin(2 * Math.PI * freq * t) + Math.sin(2 * Math.PI * freq * 2 * t) * 0.18;
|
||||
buffer[index] += wave * gain * env * (shape === 'soft' ? 0.78 : 1);
|
||||
}
|
||||
}
|
||||
|
||||
function addPluck(buffer, start, length, freq, gain, color) {
|
||||
const startIndex = Math.floor(start * sampleRate);
|
||||
const count = Math.floor(length * sampleRate);
|
||||
const brightness = color === 'gold' ? 0.36 : color === 'steel' ? 0.42 : 0.28;
|
||||
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const index = startIndex + i;
|
||||
if (index >= buffer.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
const t = i / sampleRate;
|
||||
const env = Math.exp(-t * 4.8) * Math.min(1, t / 0.02);
|
||||
const wave =
|
||||
Math.sin(2 * Math.PI * freq * t) +
|
||||
Math.sin(2 * Math.PI * freq * 2 * t) * brightness +
|
||||
Math.sin(2 * Math.PI * freq * 3 * t) * 0.14;
|
||||
buffer[index] += wave * gain * env;
|
||||
}
|
||||
}
|
||||
|
||||
function addDrum(buffer, start, length, gain) {
|
||||
const startIndex = Math.floor(start * sampleRate);
|
||||
const count = Math.floor(length * sampleRate);
|
||||
let seed = 1337 + startIndex;
|
||||
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const index = startIndex + i;
|
||||
if (index >= buffer.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
const t = i / sampleRate;
|
||||
const env = Math.exp(-t * 15);
|
||||
const freq = 96 - t * 120;
|
||||
seed = (seed * 1664525 + 1013904223) >>> 0;
|
||||
const noise = ((seed / 0xffffffff) * 2 - 1) * 0.18;
|
||||
const wave = Math.sin(2 * Math.PI * Math.max(42, freq) * t) + noise;
|
||||
buffer[index] += wave * gain * env;
|
||||
}
|
||||
}
|
||||
|
||||
function addNoiseWash(buffer, start, length, gain) {
|
||||
const startIndex = Math.floor(start * sampleRate);
|
||||
const count = Math.floor(length * sampleRate);
|
||||
let seed = 4219 + startIndex;
|
||||
let previous = 0;
|
||||
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const index = startIndex + i;
|
||||
if (index >= buffer.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
seed = (seed * 1103515245 + 12345) >>> 0;
|
||||
const raw = (seed / 0xffffffff) * 2 - 1;
|
||||
previous = previous * 0.98 + raw * 0.02;
|
||||
const env = Math.sin(Math.PI * i / count);
|
||||
buffer[index] += previous * gain * env;
|
||||
}
|
||||
}
|
||||
|
||||
function normalize(buffer, target) {
|
||||
let peak = 0;
|
||||
for (const value of buffer) {
|
||||
peak = Math.max(peak, Math.abs(value));
|
||||
}
|
||||
|
||||
if (peak === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scale = target / peak;
|
||||
for (let i = 0; i < buffer.length; i += 1) {
|
||||
buffer[i] *= scale;
|
||||
}
|
||||
}
|
||||
|
||||
function encodeWav(buffer) {
|
||||
const bytesPerSample = 2;
|
||||
const dataSize = buffer.length * bytesPerSample;
|
||||
const wav = Buffer.alloc(44 + dataSize);
|
||||
|
||||
wav.write('RIFF', 0);
|
||||
wav.writeUInt32LE(36 + dataSize, 4);
|
||||
wav.write('WAVE', 8);
|
||||
wav.write('fmt ', 12);
|
||||
wav.writeUInt32LE(16, 16);
|
||||
wav.writeUInt16LE(1, 20);
|
||||
wav.writeUInt16LE(1, 22);
|
||||
wav.writeUInt32LE(sampleRate, 24);
|
||||
wav.writeUInt32LE(sampleRate * bytesPerSample, 28);
|
||||
wav.writeUInt16LE(bytesPerSample, 32);
|
||||
wav.writeUInt16LE(16, 34);
|
||||
wav.write('data', 36);
|
||||
wav.writeUInt32LE(dataSize, 40);
|
||||
|
||||
for (let i = 0; i < buffer.length; i += 1) {
|
||||
const value = Math.max(-1, Math.min(1, buffer[i]));
|
||||
wav.writeInt16LE(Math.round(value * 32767), 44 + i * bytesPerSample);
|
||||
}
|
||||
|
||||
return wav;
|
||||
}
|
||||
127
scripts/generate-voice-assets.mjs
Normal file
127
scripts/generate-voice-assets.mjs
Normal file
@@ -0,0 +1,127 @@
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
const outputDir = resolve('src/assets/audio/voice/prologue');
|
||||
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
|
||||
const voiceName = 'Microsoft Heami Desktop';
|
||||
|
||||
const lines = [
|
||||
{
|
||||
name: 'yellow-turban-chaos',
|
||||
role: 'narrator',
|
||||
rate: -2,
|
||||
volume: 86,
|
||||
text: '중평 원년, 황건의 깃발이 각지에서 일어섰다. 관군은 흩어지고, 마을마다 불안한 소문이 번져갔다.'
|
||||
},
|
||||
{
|
||||
name: 'liu-bei-resolve',
|
||||
role: 'liuBei',
|
||||
rate: -1,
|
||||
volume: 92,
|
||||
text: '천실의 백성이 이토록 고통받는데, 어찌 초개처럼 그늘에 숨어 있을 수 있겠는가.'
|
||||
},
|
||||
{
|
||||
name: 'zhang-fei-joins',
|
||||
role: 'zhangFei',
|
||||
rate: 1,
|
||||
volume: 100,
|
||||
text: '좋소! 나라를 바로잡고 백성을 구한다면, 내 재산과 힘을 모두 아끼지 않겠소.'
|
||||
},
|
||||
{
|
||||
name: 'guan-yu-joins',
|
||||
role: 'guanYu',
|
||||
rate: -3,
|
||||
volume: 95,
|
||||
text: '서로의 뜻을 믿고 의로운 길을 함께한다면, 천 리라도 멀다 하지 않겠습니다.'
|
||||
},
|
||||
{
|
||||
name: 'oath-narration',
|
||||
role: 'narrator',
|
||||
rate: -2,
|
||||
volume: 88,
|
||||
text: '복사꽃 향기가 흩날리는 동산에서 세 사람은 하늘과 땅 앞에 고했다.'
|
||||
},
|
||||
{
|
||||
name: 'oath-liu-bei',
|
||||
role: 'liuBei',
|
||||
rate: -1,
|
||||
volume: 94,
|
||||
text: '우리는 태어난 날은 달라도, 오늘부터 뜻을 함께한다. 백성을 구하고 천하를 바로 세우는 길에서 물러서지 않겠다.'
|
||||
},
|
||||
{
|
||||
name: 'oath-brothers',
|
||||
role: 'guanYu',
|
||||
rate: -3,
|
||||
volume: 96,
|
||||
text: '의리를 저버리지 않고, 형제를 저버리지 않겠습니다.'
|
||||
},
|
||||
{
|
||||
name: 'militia-rises',
|
||||
role: 'zhangFei',
|
||||
rate: 1,
|
||||
volume: 100,
|
||||
text: '창을 들 사람은 앞으로 나오시오! 오늘 모인 이름 없는 무리가 내일의 방패가 될 것이오!'
|
||||
},
|
||||
{
|
||||
name: 'first-sortie',
|
||||
role: 'liuBei',
|
||||
rate: -1,
|
||||
volume: 94,
|
||||
text: '우리는 아직 작다. 그러나 오늘 한 마을을 지켜낸다면, 그 뜻은 천하로 번져갈 것이다.'
|
||||
},
|
||||
{
|
||||
name: 'battle-briefing',
|
||||
role: 'narrator',
|
||||
rate: -2,
|
||||
volume: 88,
|
||||
text: '의용군은 마을 입구로 향했다. 첫 전투가 곧 시작된다.'
|
||||
}
|
||||
];
|
||||
|
||||
for (const line of lines) {
|
||||
const output = resolve(outputDir, `${line.name}.wav`);
|
||||
generateVoice({ ...line, output });
|
||||
}
|
||||
|
||||
function generateVoice(item) {
|
||||
const payload = Buffer.from(JSON.stringify({ ...item, voiceName }), 'utf8').toString('base64');
|
||||
const powershell = `
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
Add-Type -AssemblyName System.Speech
|
||||
$payload = '${payload}'
|
||||
$json = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($payload))
|
||||
$item = $json | ConvertFrom-Json
|
||||
$synth = New-Object System.Speech.Synthesis.SpeechSynthesizer
|
||||
try {
|
||||
try {
|
||||
$synth.SelectVoice($item.voiceName)
|
||||
} catch {
|
||||
$installed = $synth.GetInstalledVoices() | ForEach-Object { $_.VoiceInfo.Name }
|
||||
if ($installed.Count -gt 0) {
|
||||
$synth.SelectVoice($installed[0])
|
||||
}
|
||||
}
|
||||
$synth.Rate = [int]$item.rate
|
||||
$synth.Volume = [int]$item.volume
|
||||
$synth.SetOutputToWaveFile($item.output)
|
||||
$synth.Speak($item.text)
|
||||
} finally {
|
||||
$synth.Dispose()
|
||||
}
|
||||
`;
|
||||
|
||||
const encodedCommand = Buffer.from(powershell, 'utf16le').toString('base64');
|
||||
const result = spawnSync(
|
||||
'powershell.exe',
|
||||
['-NoProfile', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', encodedCommand],
|
||||
{ stdio: 'inherit' }
|
||||
);
|
||||
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`Failed to generate voice asset: ${item.name}`);
|
||||
}
|
||||
}
|
||||
BIN
src/assets/audio/bgm/battle-prep.wav
Normal file
BIN
src/assets/audio/bgm/battle-prep.wav
Normal file
Binary file not shown.
BIN
src/assets/audio/bgm/militia-theme.wav
Normal file
BIN
src/assets/audio/bgm/militia-theme.wav
Normal file
Binary file not shown.
BIN
src/assets/audio/bgm/oath-theme.wav
Normal file
BIN
src/assets/audio/bgm/oath-theme.wav
Normal file
Binary file not shown.
BIN
src/assets/audio/bgm/story-dark.wav
Normal file
BIN
src/assets/audio/bgm/story-dark.wav
Normal file
Binary file not shown.
BIN
src/assets/audio/bgm/title-theme.wav
Normal file
BIN
src/assets/audio/bgm/title-theme.wav
Normal file
Binary file not shown.
BIN
src/assets/audio/voice/prologue/battle-briefing.wav
Normal file
BIN
src/assets/audio/voice/prologue/battle-briefing.wav
Normal file
Binary file not shown.
BIN
src/assets/audio/voice/prologue/first-sortie.wav
Normal file
BIN
src/assets/audio/voice/prologue/first-sortie.wav
Normal file
Binary file not shown.
BIN
src/assets/audio/voice/prologue/guan-yu-joins.wav
Normal file
BIN
src/assets/audio/voice/prologue/guan-yu-joins.wav
Normal file
Binary file not shown.
BIN
src/assets/audio/voice/prologue/liu-bei-resolve.wav
Normal file
BIN
src/assets/audio/voice/prologue/liu-bei-resolve.wav
Normal file
Binary file not shown.
BIN
src/assets/audio/voice/prologue/militia-rises.wav
Normal file
BIN
src/assets/audio/voice/prologue/militia-rises.wav
Normal file
Binary file not shown.
BIN
src/assets/audio/voice/prologue/oath-brothers.wav
Normal file
BIN
src/assets/audio/voice/prologue/oath-brothers.wav
Normal file
Binary file not shown.
BIN
src/assets/audio/voice/prologue/oath-liu-bei.wav
Normal file
BIN
src/assets/audio/voice/prologue/oath-liu-bei.wav
Normal file
Binary file not shown.
BIN
src/assets/audio/voice/prologue/oath-narration.wav
Normal file
BIN
src/assets/audio/voice/prologue/oath-narration.wav
Normal file
Binary file not shown.
BIN
src/assets/audio/voice/prologue/yellow-turban-chaos.wav
Normal file
BIN
src/assets/audio/voice/prologue/yellow-turban-chaos.wav
Normal file
Binary file not shown.
BIN
src/assets/audio/voice/prologue/zhang-fei-joins.wav
Normal file
BIN
src/assets/audio/voice/prologue/zhang-fei-joins.wav
Normal file
Binary file not shown.
@@ -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 {
|
||||
|
||||
39
src/game/audio/audioAssets.ts
Normal file
39
src/game/audio/audioAssets.ts
Normal 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;
|
||||
@@ -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: '의용군은 마을 어귀로 향했다. 첫 싸움은 곧 시작된다.'
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user