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

@@ -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

View File

@@ -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
View 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;
}

View 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}`);
}
}

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) {