feat: add era-based camp soundscapes

This commit is contained in:
2026-07-13 05:18:32 +09:00
parent 1eedd63163
commit b88a2d6ec2
21 changed files with 1347 additions and 14 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.

View File

@@ -380,9 +380,6 @@ export class SoundDirector {
private setLoopTargetVolume(channel: LoopChannel, volume: number) {
channel.targetVolume = Number.isFinite(volume) ? Math.min(1, Math.max(0, volume)) : channel.defaultVolume;
if (channel.current && !channel.transition.active) {
channel.current.volume = channel.targetVolume;
}
}
private requestLoopChannel(channel: LoopChannel, key?: string) {

View File

@@ -1,4 +1,8 @@
import battlePrepUrl from '../../assets/audio/bgm/battle-prep.mp3';
import campFrontierUrl from '../../assets/audio/bgm/camp-frontier.mp3';
import campGrandStrategyUrl from '../../assets/audio/bgm/camp-grand-strategy.mp3';
import campRallyUrl from '../../assets/audio/bgm/camp-rally.mp3';
import campWayfarerUrl from '../../assets/audio/bgm/camp-wayfarer.mp3';
import militiaThemeUrl from '../../assets/audio/bgm/militia-theme.mp3';
import oathThemeUrl from '../../assets/audio/bgm/oath-theme.mp3';
import storyDarkUrl from '../../assets/audio/bgm/story-dark.mp3';
@@ -15,13 +19,28 @@ import strategyCastUrl from '../../assets/audio/sfx/strategy-cast.mp3';
import swordSlashUrl from '../../assets/audio/sfx/sword-slash.mp3';
import uiSelectUrl from '../../assets/audio/sfx/ui-select.mp3';
import victoryFanfareUrl from '../../assets/audio/sfx/victory-fanfare.wav';
import campfireAmbienceUrl from '../../assets/audio/ambience/campfire-ambience.mp3';
import mountainWindAmbienceUrl from '../../assets/audio/ambience/mountain-wind-ambience.mp3';
import nanzhongNightAmbienceUrl from '../../assets/audio/ambience/nanzhong-night-ambience.mp3';
import riverNightAmbienceUrl from '../../assets/audio/ambience/river-night-ambience.mp3';
export const musicTracks = {
'title-theme': titleThemeUrl,
'story-dark': storyDarkUrl,
'oath-theme': oathThemeUrl,
'militia-theme': militiaThemeUrl,
'battle-prep': battlePrepUrl
'battle-prep': battlePrepUrl,
'camp-rally': campRallyUrl,
'camp-wayfarer': campWayfarerUrl,
'camp-grand-strategy': campGrandStrategyUrl,
'camp-frontier': campFrontierUrl
} as const;
export const ambienceTracks = {
'campfire-ambience': campfireAmbienceUrl,
'river-night-ambience': riverNightAmbienceUrl,
'mountain-wind-ambience': mountainWindAmbienceUrl,
'nanzhong-night-ambience': nanzhongNightAmbienceUrl
} as const;
export const effectTracks = {
@@ -40,4 +59,5 @@ export const effectTracks = {
} as const;
export type MusicTrackKey = keyof typeof musicTracks;
export type AmbienceTrackKey = keyof typeof ambienceTracks;
export type EffectTrackKey = keyof typeof effectTracks;

View File

@@ -0,0 +1,119 @@
import type { AmbienceTrackKey, MusicTrackKey } from '../audio/audioAssets';
import type { CampSkinId } from './campSkins';
export type CampMusicGroupId = 'rally' | 'wayfarer' | 'grand-strategy' | 'frontier';
export type CampAmbienceId = 'campfire' | 'river-night' | 'mountain-wind' | 'nanzhong-night';
export type CampMusicTrackKey = Extract<
MusicTrackKey,
'camp-rally' | 'camp-wayfarer' | 'camp-grand-strategy' | 'camp-frontier'
>;
export type CampAmbienceTrackKey = Extract<
AmbienceTrackKey,
'campfire-ambience' | 'river-night-ambience' | 'mountain-wind-ambience' | 'nanzhong-night-ambience'
>;
export type CampMusicGroupDefinition = {
id: CampMusicGroupId;
trackKey: CampMusicTrackKey;
label: string;
volume: number;
};
export type CampAmbienceDefinition = {
id: CampAmbienceId;
trackKey: CampAmbienceTrackKey;
label: string;
volume: number;
};
export type CampSoundscapeMapping = {
musicGroupId: CampMusicGroupId;
ambienceId: CampAmbienceId;
};
export type CampSoundscape = {
skinId: CampSkinId;
music: CampMusicGroupDefinition;
ambience: CampAmbienceDefinition;
};
export const campMusicGroups: Record<CampMusicGroupId, CampMusicGroupDefinition> = {
rally: {
id: 'rally',
trackKey: 'camp-rally',
label: '결의와 출정',
volume: 0.22
},
wayfarer: {
id: 'wayfarer',
trackKey: 'camp-wayfarer',
label: '유랑의 밤',
volume: 0.18
},
'grand-strategy': {
id: 'grand-strategy',
trackKey: 'camp-grand-strategy',
label: '대업의 계책',
volume: 0.2
},
frontier: {
id: 'frontier',
trackKey: 'camp-frontier',
label: '변경과 북벌',
volume: 0.2
}
};
export const campAmbienceDefinitions: Record<CampAmbienceId, CampAmbienceDefinition> = {
campfire: {
id: 'campfire',
trackKey: 'campfire-ambience',
label: '장작불',
volume: 0.12
},
'river-night': {
id: 'river-night',
trackKey: 'river-night-ambience',
label: '밤의 강물',
volume: 0.1
},
'mountain-wind': {
id: 'mountain-wind',
trackKey: 'mountain-wind-ambience',
label: '산바람',
volume: 0.09
},
'nanzhong-night': {
id: 'nanzhong-night',
trackKey: 'nanzhong-night-ambience',
label: '남중의 밤',
volume: 0.11
}
};
export const campSoundscapeMapping: Record<CampSkinId, CampSoundscapeMapping> = {
'yellow-turban': { musicGroupId: 'rally', ambienceId: 'campfire' },
'anti-dong': { musicGroupId: 'rally', ambienceId: 'campfire' },
xuzhou: { musicGroupId: 'wayfarer', ambienceId: 'campfire' },
wandering: { musicGroupId: 'wayfarer', ambienceId: 'campfire' },
wolong: { musicGroupId: 'wayfarer', ambienceId: 'mountain-wind' },
'red-cliffs': { musicGroupId: 'grand-strategy', ambienceId: 'river-night' },
'jing-yi': { musicGroupId: 'grand-strategy', ambienceId: 'river-night' },
'hanzhong-shuhan': { musicGroupId: 'grand-strategy', ambienceId: 'mountain-wind' },
'jingzhou-crisis': { musicGroupId: 'grand-strategy', ambienceId: 'river-night' },
'yiling-baidi': { musicGroupId: 'grand-strategy', ambienceId: 'river-night' },
nanzhong: { musicGroupId: 'frontier', ambienceId: 'nanzhong-night' },
northern: { musicGroupId: 'frontier', ambienceId: 'mountain-wind' }
};
export function getCampSoundscape(skinId: CampSkinId): CampSoundscape {
const mapping = campSoundscapeMapping[skinId];
return {
skinId,
music: campMusicGroups[mapping.musicGroupId],
ambience: campAmbienceDefinitions[mapping.ambienceId]
};
}

View File

@@ -16,6 +16,7 @@ import { compareUnitStrategyCoverage, getUnitStrategyCoverage, getUnitStrategyNa
import { getUnitClass, terrainRules, type TerrainType, type UnitClassKey } from '../data/battleRules';
import { defaultBattleScenario, getBattleScenario, type BattleScenarioDefinition, type BattleScenarioId } from '../data/battles';
import { getSortieFlow } from '../data/campaignFlow';
import { getCampSoundscape, type CampSoundscape } from '../data/campSoundscapes';
import {
campaignPortraitKeysByUnitId,
portraitAssetEntriesForKey,
@@ -11070,6 +11071,7 @@ export class CampScene extends Phaser.Scene {
private campSkinSeal?: Phaser.GameObjects.Arc;
private campSkinBadge?: Phaser.GameObjects.Rectangle;
private campSkinSummary?: Phaser.GameObjects.Text;
private campSoundscape?: CampSoundscape;
private campSkinRenderedTextureKey?: string;
private campSkinUsedFallback = false;
private campSkinTransitionRevision = 0;
@@ -11206,6 +11208,7 @@ export class CampScene extends Phaser.Scene {
nextBattleId: sortieFlow.nextBattleId,
currentBattleId: this.currentCampBattleId()
});
this.campSoundscape = getCampSoundscape(this.campSkinSelection.skin.id);
this.campSkinBackdrop = undefined;
this.campSkinWash = undefined;
this.campSkinVignette = undefined;
@@ -11223,7 +11226,12 @@ export class CampScene extends Phaser.Scene {
this.sortieItemAssignments = this.normalizedSortieItemAssignments(this.campaign.sortieItemAssignments);
this.selectedDialogueId = this.availableCampDialogues()[0]?.id ?? campDialogues[0].id;
this.selectedVisitId = this.availableCampVisits()[0]?.id ?? campVisits[0].id;
soundDirector.playMusic('militia-theme');
soundDirector.playSoundscape({
musicKey: this.campSoundscape.music.trackKey,
ambienceKey: this.campSoundscape.ambience.trackKey,
musicVolume: this.campSoundscape.music.volume,
ambienceVolume: this.campSoundscape.ambience.volume
});
this.input.keyboard?.on('keydown-ESC', () => this.handleEscapeKey());
this.input.keyboard?.on('keydown-ENTER', (event: KeyboardEvent) => this.handleSortieEnterKey(event));
this.input.keyboard?.on('keydown', (event: KeyboardEvent) => this.handleSaveSlotKey(event));
@@ -21673,6 +21681,20 @@ export class CampScene extends Phaser.Scene {
campaignProgress: this.campaignTimelineProgress(),
campBattleId: this.currentCampBattleId(),
campTitle: this.currentCampTitle(),
campSoundscape: this.campSoundscape
? {
skinId: this.campSoundscape.skinId,
musicGroupId: this.campSoundscape.music.id,
musicKey: this.campSoundscape.music.trackKey,
musicLabel: this.campSoundscape.music.label,
musicVolume: this.campSoundscape.music.volume,
ambienceId: this.campSoundscape.ambience.id,
ambienceKey: this.campSoundscape.ambience.trackKey,
ambienceLabel: this.campSoundscape.ambience.label,
ambienceVolume: this.campSoundscape.ambience.volume
}
: null,
audio: soundDirector.getDebugState(),
campSkin: this.campSkinSelection
? {
id: this.campSkinSelection.skin.id,

View File

@@ -1,5 +1,5 @@
import Phaser from 'phaser';
import { effectTracks, musicTracks } from './game/audio/audioAssets';
import { ambienceTracks, effectTracks, musicTracks } from './game/audio/audioAssets';
import { soundDirector } from './game/audio/SoundDirector';
import { BootScene } from './game/scenes/BootScene';
import { startLazySceneFromGame } from './game/scenes/lazyScenes';
@@ -26,6 +26,7 @@ type DebugCampScene = Phaser.Scene & {
type HerosDebugApi = {
game: Phaser.Game;
activeScenes: () => string[];
audio: () => ReturnType<typeof soundDirector.getDebugState>;
battle: () => unknown;
camp: () => unknown;
goToBattle: (battleId?: string) => Promise<void>;
@@ -57,6 +58,7 @@ const config: Phaser.Types.Core.GameConfig = {
};
soundDirector.registerMusicTracks(musicTracks);
soundDirector.registerAmbienceTracks(ambienceTracks);
soundDirector.registerEffectTracks(effectTracks);
const game = new Phaser.Game(config);
@@ -76,6 +78,7 @@ function createDebugApi(game: Phaser.Game): HerosDebugApi {
return {
game,
activeScenes: () => game.scene.getScenes(true).map((activeScene) => activeScene.scene.key),
audio: () => soundDirector.getDebugState(),
battle: () => battleScene()?.getDebugState?.() ?? { active: false, reason: 'BattleScene is not active yet.' },
camp: () => campScene()?.getDebugState?.() ?? { active: false, reason: 'CampScene is not active yet.' },
goToBattle: (battleId) => startLazySceneFromGame(game, 'BattleScene', battleId ? { battleId } : undefined),