From 31164687c87daaf2e8c741ffbc19d01b900cc73b Mon Sep 17 00:00:00 2001 From: "adnan.mujkic" Date: Tue, 13 Oct 2026 17:21:21 +0200 Subject: [PATCH] Initial audio subystem for music testing and first song to loop over --- Config/DefaultGame.ini | 5 + Content/Sounds/Music/LoFi_First_Try.uasset | 3 + Dev/AUDIO/MUSIC/LoFi_First_Try.wav | 3 + .../Audio/EleriAudioSubsystem.cpp | 407 ++++++++++++++++++ .../ProjectEleri/Audio/EleriAudioSubsystem.h | 173 ++++++++ Source/ProjectEleri/Audio/EleriAudioTypes.h | 24 ++ .../GameEventSystem/GameEventDataAsset.h | 7 + .../GameEventSystem/GameEventSubsystem.cpp | 12 + .../ProjectEleri/Settings/EleriGameSettings.h | 22 + 9 files changed, 656 insertions(+) create mode 100644 Content/Sounds/Music/LoFi_First_Try.uasset create mode 100644 Dev/AUDIO/MUSIC/LoFi_First_Try.wav create mode 100644 Source/ProjectEleri/Audio/EleriAudioSubsystem.cpp create mode 100644 Source/ProjectEleri/Audio/EleriAudioSubsystem.h create mode 100644 Source/ProjectEleri/Audio/EleriAudioTypes.h diff --git a/Config/DefaultGame.ini b/Config/DefaultGame.ini index 70b4eae3..0ca26be3 100644 --- a/Config/DefaultGame.ini +++ b/Config/DefaultGame.ini @@ -200,6 +200,11 @@ MovementDisableEffectClass=/Script/Engine.BlueprintGeneratedClass'/Game/Blueprin NpcSchedules=(((TagName="NpcTags.Test"), "/Game/GameData/Npc/DT_Test_Npc_Schedule.DT_Test_Npc_Schedule")) GlobalSettingsDataSheet=/Game/GameData/CSV/GlobalKeyValueSettings.GlobalKeyValueSettings SpriteShadowMaterial=/Game/Materials/Sprite/MI_SpriteShadow_Inst.MI_SpriteShadow_Inst +AudioChannelSoundClasses=((Master, "/Game/EasyOptionsMenu/Core/Audio/SC_EOM_MasterAudio.SC_EOM_MasterAudio"),(Music, "/Game/EasyOptionsMenu/Core/Audio/SC_EOM_MusicAudio.SC_EOM_MusicAudio"),(SFX, "/Game/EasyOptionsMenu/Core/Audio/SC_EOM_EffectsAudio.SC_EOM_EffectsAudio")) ++DefaultSoundtracks=/Game/Sounds/Music/LoFi_First_Try.LoFi_First_Try +SoundtrackMinDelay=20.000000 +SoundtrackMaxDelay=60.000000 +MusicFadeTime=2.000000 [/Script/InteractionSystem.InteractionSettings] DefaultInteractionWidgetClass=/Script/UMG.WidgetBlueprintGeneratedClass'/Game/UI/InteractionUI/WBP_Eleri_Interaction.WBP_Eleri_Interaction_C' diff --git a/Content/Sounds/Music/LoFi_First_Try.uasset b/Content/Sounds/Music/LoFi_First_Try.uasset new file mode 100644 index 00000000..293e757b --- /dev/null +++ b/Content/Sounds/Music/LoFi_First_Try.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:646aee939f6e1a8d5b3abf68f6872b993de8d2e63016889e01d6191abe4552d4 +size 17001349 diff --git a/Dev/AUDIO/MUSIC/LoFi_First_Try.wav b/Dev/AUDIO/MUSIC/LoFi_First_Try.wav new file mode 100644 index 00000000..fcc4f064 --- /dev/null +++ b/Dev/AUDIO/MUSIC/LoFi_First_Try.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f492ddf0c5036d30727aa40548504a2351dfa211773e49f0c301594e39ce3ae5 +size 34985264 diff --git a/Source/ProjectEleri/Audio/EleriAudioSubsystem.cpp b/Source/ProjectEleri/Audio/EleriAudioSubsystem.cpp new file mode 100644 index 00000000..c0a60f9f --- /dev/null +++ b/Source/ProjectEleri/Audio/EleriAudioSubsystem.cpp @@ -0,0 +1,407 @@ +// Fill out your copyright notice in the Description page of Project Settings. + + +#include "EleriAudioSubsystem.h" + +#include "Components/AudioComponent.h" +#include "Engine/GameInstance.h" +#include "Engine/World.h" +#include "Kismet/GameplayStatics.h" +#include "ProjectEleri/GameEventSystem/GameEventDataAsset.h" +#include "ProjectEleri/Settings/EleriGameSettings.h" +#include "Sound/SoundBase.h" +#include "Sound/SoundClass.h" +#include "TimerManager.h" + +void UEleriAudioSubsystem::Initialize(FSubsystemCollectionBase& Collection) +{ + Super::Initialize(Collection); + + // The rotation starts on its own, so that a level does not have to remember to ask for music. Safe + // here because a game instance is handed the world it will run in before its subsystems are + // initialised, so GetWorld() already answers with the world the music is going to live in. + if (UWorld* World = GetWorld()) + { + World->GetTimerManager().SetTimer(SoundtrackTimerHandle, this, &UEleriAudioSubsystem::StartDefaultMusic, 1.f, false); + } +} + +void UEleriAudioSubsystem::Deinitialize() +{ + if (UWorld* World = GetWorld()) + { + World->GetTimerManager().ClearTimer(SoundtrackTimerHandle); + } + + if (UAudioComponent* Component = MusicComponent) + { + Component->OnAudioFinished.RemoveDynamic(this, &UEleriAudioSubsystem::HandleMusicFinished); + Component->Stop(); + } + + Super::Deinitialize(); +} + +UEleriAudioSubsystem* UEleriAudioSubsystem::Get(const UObject* WorldContext) +{ + if (WorldContext == nullptr || WorldContext->GetWorld() == nullptr) + { + return nullptr; + } + + if (UGameInstance* GameInstance = WorldContext->GetWorld()->GetGameInstance()) + { + return GameInstance->GetSubsystem(); + } + + return nullptr; +} + +void UEleriAudioSubsystem::PlayMusic(USoundBase* Cue, float FadeTime) +{ + if (Cue == nullptr) + { + StopMusic(FadeTime); + return; + } + + // An explicit request outranks whatever an event had put on, so the override is dropped and the + // track the caller named becomes the one that plays. + MusicOverride = nullptr; + CancelPendingSoundtrack(); + + PlayMusicInternal(Cue, FadeTime); +} + +void UEleriAudioSubsystem::OverrideMusic(USoundBase* Cue, float FadeTime) +{ + if (Cue == nullptr) + { + ClearMusicOverride(FadeTime); + return; + } + + // Recorded before the track starts, so that a track which finishes at once, a short one-shot for + // instance, cannot be mistaken for the rotation's own and have the next one queued over it. + MusicOverride = Cue; + CancelPendingSoundtrack(); + + PlayMusicInternal(Cue, FadeTime); +} + +void UEleriAudioSubsystem::ClearMusicOverride(float FadeTime) +{ + if (MusicOverride == nullptr) + { + return; + } + + MusicOverride = nullptr; + CancelPendingSoundtrack(); + + // Fades the event's track out and a fresh rotation track in, through the same crossfade any other + // music change uses, so coming back from an override sounds like every other track change. + PlayMusicInternal(PickNextDefaultSoundtrack(), FadeTime); +} + +void UEleriAudioSubsystem::PlayMusicForEvent(const UGameEventDataAsset* Event) +{ + if (Event == nullptr || Event->MusicCue.IsNull()) + { + return; + } + + if (!Event->MusicCue.IsValid()) + { + Event->MusicCue.LoadAsync(FLoadSoftObjectPathAsyncDelegate::CreateWeakLambda(this, [this, Event](const FSoftObjectPath& Path, UObject* Object) + { + PlayMusicForEvent(Event); + })); + return; + } + + OverrideMusic(Event->MusicCue.Get()); +} + +void UEleriAudioSubsystem::StopMusic(float FadeTime) +{ + CancelPendingSoundtrack(); + MusicOverride = nullptr; + + if (UAudioComponent* Old = MusicComponent) + { + // Detached first: the fade-out will finish the component off, and that finish must not be read + // as the current track playing out and put a new one on. + Old->OnAudioFinished.RemoveDynamic(this, &UEleriAudioSubsystem::HandleMusicFinished); + + const float EffectiveFade = FadeTime >= 0.f ? FadeTime : GetMusicFadeTime(); + if (EffectiveFade > 0.f) + { + Old->FadeOut(EffectiveFade, 0.f); + Old->StopDelayed(EffectiveFade); + } + else + { + Old->Stop(); + } + } + + MusicComponent = nullptr; + CurrentMusic = nullptr; + OnMusicChanged.Broadcast(nullptr); +} + +void UEleriAudioSubsystem::PlaySfx(USoundBase* Cue, float VolumeScale, float Pitch) +{ + if (Cue == nullptr) + { + return; + } + + // Created rather than spawned for the same reason the music is: the sfx channel's class has to be + // on the component before it plays, not after. The component destroys itself when the cue is done, + // which is the whole lifetime an sfx needs. + UAudioComponent* Component = UGameplayStatics::CreateSound2D(this, Cue, GetEffectiveVolume(EEleriAudioChannel::SFX) * VolumeScale, Pitch); + if (Component == nullptr) + { + return; + } + + Component->bIsUISound = false; + Component->SoundClassOverride = GetRoutingSoundClass(EEleriAudioChannel::SFX); + Component->Play(); +} + +float UEleriAudioSubsystem::GetChannelVolume(EEleriAudioChannel Channel) const +{ + switch (Channel) + { + case EEleriAudioChannel::Master: + return MasterVolume; + case EEleriAudioChannel::Music: + return MusicVolume; + case EEleriAudioChannel::SFX: + return SfxVolume; + default: + return 1.f; + } +} + +void UEleriAudioSubsystem::SetChannelVolume(EEleriAudioChannel Channel, float Volume) +{ + const float Clamped = FMath::Clamp(Volume, 0.f, 1.f); + + switch (Channel) + { + case EEleriAudioChannel::Master: + MasterVolume = Clamped; + break; + case EEleriAudioChannel::Music: + MusicVolume = Clamped; + break; + case EEleriAudioChannel::SFX: + SfxVolume = Clamped; + break; + default: + break; + } + + // Music already playing is retargeted straight away, because the multiplier is the level a fade + // moves towards and not something a fade would have to be restarted to change. Sfx already in + // flight is left alone: it is a one-shot and will be gone before the change could be heard. + if (UAudioComponent* Component = MusicComponent) + { + Component->SetVolumeMultiplier(GetEffectiveVolume(EEleriAudioChannel::Music)); + } +} + +float UEleriAudioSubsystem::GetEffectiveVolume(EEleriAudioChannel Channel) const +{ + // Master multiplies rather than replaces, so a cue plays at Master * channel and the getters above + // stay the plain per-channel numbers a menu would show rather than already-folded ones. + if (Channel == EEleriAudioChannel::Master) + { + return MasterVolume; + } + + return GetChannelVolume(Channel) * MasterVolume; +} + +USoundClass* UEleriAudioSubsystem::GetRoutingSoundClass(EEleriAudioChannel Channel) const +{ + const UEleriGameSettings* Settings = GetDefault(); + if (Settings == nullptr) + { + return nullptr; + } + + if (const TSoftObjectPtr* Found = Settings->AudioChannelSoundClasses.Find(Channel)) + { + return Found->LoadSynchronous(); + } + + return nullptr; +} + +float UEleriAudioSubsystem::GetMusicFadeTime() const +{ + const UEleriGameSettings* Settings = GetDefault(); + return Settings != nullptr ? FMath::Max(0.f, Settings->MusicFadeTime) : 2.f; +} + +float UEleriAudioSubsystem::GetNextSoundtrackDelay() const +{ + const UEleriGameSettings* Settings = GetDefault(); + if (Settings == nullptr) + { + return 0.f; + } + + // A range rather than one number, so the gaps between tracks do not settle into a rhythm the ear + // can predict and start listening for instead of the music. + const float Low = FMath::Min(Settings->SoundtrackMinDelay, Settings->SoundtrackMaxDelay); + const float High = FMath::Max(Settings->SoundtrackMinDelay, Settings->SoundtrackMaxDelay); + return FMath::FRandRange(Low, High); +} + +void UEleriAudioSubsystem::StartDefaultMusic() +{ + if (MusicOverride != nullptr) + { + return; + } + + PlayMusicInternal(PickNextDefaultSoundtrack(), GetMusicFadeTime()); +} + +USoundBase* UEleriAudioSubsystem::PickNextDefaultSoundtrack() const +{ + const UEleriGameSettings* Settings = GetDefault(); + if (Settings == nullptr) + { + return nullptr; + } + + // Both pools are built so that a soundtrack of one track still loops: leaving the current pick out + // keeps the rotation from repeating itself, and when that would leave nothing to play the + // exclusion is dropped rather than the music. + TArray AllCues; + TArray OtherCues; + for (const TSoftObjectPtr& SoftCue : Settings->DefaultSoundtracks) + { + if (USoundBase* Cue = SoftCue.LoadSynchronous()) + { + AllCues.Add(Cue); + if (Cue != CurrentMusic) + { + OtherCues.Add(Cue); + } + } + } + + const TArray& Pool = OtherCues.IsEmpty() ? AllCues : OtherCues; + if (Pool.IsEmpty()) + { + return nullptr; + } + + return Pool[FMath::RandRange(0, Pool.Num() - 1)]; +} + +void UEleriAudioSubsystem::CancelPendingSoundtrack() +{ + if (UWorld* World = GetWorld()) + { + World->GetTimerManager().ClearTimer(SoundtrackTimerHandle); + } +} + +void UEleriAudioSubsystem::PlayMusicInternal(USoundBase* Cue, float FadeTime) +{ + if (GetWorld() == nullptr) + { + return; + } + + if (Cue == nullptr) + { + StopMusic(FadeTime); + return; + } + + const float EffectiveFade = FadeTime >= 0.f ? FadeTime : GetMusicFadeTime(); + + // The outgoing track is detached before it is faded, so that its own finish, which the fade-out + // brings forward, cannot be read as the current track playing out and queue a new one over it. + if (UAudioComponent* Old = MusicComponent) + { + Old->OnAudioFinished.RemoveDynamic(this, &UEleriAudioSubsystem::HandleMusicFinished); + + if (EffectiveFade > 0.f) + { + Old->FadeOut(EffectiveFade, 0.f); + Old->StopDelayed(EffectiveFade); + } + else + { + Old->Stop(); + } + } + + // Created rather than spawned so the routing can be set before a single sample is heard, and so + // the component is silent until the fade decides how loud it should fade up to. + UAudioComponent* NewComponent = UGameplayStatics::CreateSound2D(this, Cue, GetEffectiveVolume(EEleriAudioChannel::Music)); + if (NewComponent == nullptr) + { + MusicComponent = nullptr; + CurrentMusic = nullptr; + return; + } + + // The channel's class is what puts this cue where an artist would look for it in the mixer, and it + // is deliberately the override and not the cue's own class, so that everything played as music ends + // up under the music channel whatever it shipped with. + NewComponent->bIsUISound = false; + NewComponent->SoundClassOverride = GetRoutingSoundClass(EEleriAudioChannel::Music); + NewComponent->OnAudioFinished.AddDynamic(this, &UEleriAudioSubsystem::HandleMusicFinished); + + MusicComponent = NewComponent; + CurrentMusic = Cue; + + if (EffectiveFade > 0.f) + { + // Fades the fader and leaves the multiplier alone, so the level the setters keep in step with + // stays the level the fade moves towards rather than something the fade overwrites. + NewComponent->FadeIn(EffectiveFade, 1.f); + } + else + { + NewComponent->Play(); + } + + OnMusicChanged.Broadcast(Cue); +} + +void UEleriAudioSubsystem::HandleMusicFinished() +{ + if (MusicOverride != nullptr) + { + // The override belongs to whoever set it, so the rotation does not queue itself underneath it. + return; + } + + // The gap is taken here, when the track has run out, rather than when it started, which is what + // makes it a gap between tracks and not a cut-off partway through a long one. + if (UWorld* World = GetWorld()) + { + World->GetTimerManager().SetTimer(SoundtrackTimerHandle, this, &UEleriAudioSubsystem::StartDefaultMusic, GetNextSoundtrackDelay(), false); + } + else + { + StartDefaultMusic(); + } +} + + + + diff --git a/Source/ProjectEleri/Audio/EleriAudioSubsystem.h b/Source/ProjectEleri/Audio/EleriAudioSubsystem.h new file mode 100644 index 00000000..58eb8f7a --- /dev/null +++ b/Source/ProjectEleri/Audio/EleriAudioSubsystem.h @@ -0,0 +1,173 @@ +// Fill out your copyright notice in the Description page of Project Settings. + +#pragma once + +#include "CoreMinimal.h" +#include "Subsystems/GameInstanceSubsystem.h" + +#include "EleriAudioTypes.h" + +#include "EleriAudioSubsystem.generated.h" + +class UAudioComponent; +class UGameEventDataAsset; +class USoundBase; +class USoundClass; + +/** Fired with the cue that is playing now, or null when the music has stopped. */ +DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnMusicChanged, USoundBase*, Music); + +/** + * The one place the game asks for sound to come out of. + * + * Cues are handed to a channel (Music or SFX) rather than played directly, and each channel's cues are + * routed through the sound class the project settings name for it. The subsystem owns one volume per + * channel plus a master that multiplies the rest, and it is the only thing that reads them, so a menu + * only ever has to talk to SetChannelVolume and the playing audio follows along. + * + * Music is more than a cue it was handed: when nothing has taken it over the subsystem rotates through + * the default soundtracks in the settings, drawing the next one at random after a random gap, and it + * remembers what is playing so a caller can ask. A gameplay event can take the music over with its own + * cues, and the change in either direction is crossfaded. + */ +UCLASS() +class PROJECTELERI_API UEleriAudioSubsystem : public UGameInstanceSubsystem +{ + GENERATED_BODY() + +public: + + virtual void Initialize(FSubsystemCollectionBase& Collection) override; + virtual void Deinitialize() override; + + /** The subsystem for the game instance behind WorldContext, or null when there is not one yet. */ + static UEleriAudioSubsystem* Get(const UObject* WorldContext); + + UPROPERTY(BlueprintAssignable, Category = "Eleri|Audio") + FOnMusicChanged OnMusicChanged; + + // -- Music -- + + /** + * Plays Cue as the music, crossfading away whatever was on. A call here is a command, so it also + * drops any event override: the track the caller named is the one that plays. A null Cue is the + * same as StopMusic. + */ + UFUNCTION(BlueprintCallable, Category = "Eleri|Audio") + void PlayMusic(USoundBase* Cue, float FadeTime = 1.f); + + /** + * Takes the music over with Cue, crossfading it in. The override stands until ClearMusicOverride, + * unlike PlayMusic, and the default rotation stays quiet underneath it for as long as it does. A + * null Cue is the same as ClearMusicOverride, so an event whose cues all failed to load can call + * this and get its own music back. + */ + UFUNCTION(BlueprintCallable, Category = "Eleri|Audio") + void OverrideMusic(USoundBase* Cue, float FadeTime = 1.f); + + /** Hands the music back to the default rotation, crossfading a fresh track in over the override. */ + UFUNCTION(BlueprintCallable, Category = "Eleri|Audio") + void ClearMusicOverride(float FadeTime = 1.f); + + /** Overrides the music with one of the event's cues, picked at random. Does nothing when the event + * has no cues, which is the common case and is not an error. */ + UFUNCTION(BlueprintCallable, Category = "Eleri|Audio") + void PlayMusicForEvent(const UGameEventDataAsset* Event); + + /** Fades the music out and leaves it stopped, override and all. */ + UFUNCTION(BlueprintCallable, Category = "Eleri|Audio") + void StopMusic(float FadeTime = 1.f); + + UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Eleri|Audio") + USoundBase* GetCurrentMusic() const { return CurrentMusic; } + + UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Eleri|Audio") + bool IsMusicOverridden() const { return MusicOverride != nullptr; } + + // -- SFX -- + + /** Plays Cue on the SFX channel. VolumeScale is a per-call trim on top of the channel's volume, + * for a cue that wants to be quieter or louder than its neighbours without a settings entry. */ + UFUNCTION(BlueprintCallable, Category = "Eleri|Audio") + void PlaySfx(USoundBase* Cue, float VolumeScale = 1.f, float Pitch = 1.f); + + // -- Volumes -- + + /** The channel's own volume, before Master is folded in, which is the number a menu would show. */ + UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Eleri|Audio") + float GetChannelVolume(EEleriAudioChannel Channel) const; + + /** Sets a channel's volume, clamped to 0..1. Not saved anywhere yet: it lasts for the session. */ + UFUNCTION(BlueprintCallable, Category = "Eleri|Audio") + void SetChannelVolume(EEleriAudioChannel Channel, float Volume); + + UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Eleri|Audio") + float GetMasterVolume() const { return MasterVolume; } + + UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Eleri|Audio") + float GetMusicVolume() const { return MusicVolume; } + + UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Eleri|Audio") + float GetSfxVolume() const { return SfxVolume; } + +protected: + + /** Draws the next default soundtrack and plays it, unless an override owns the music. */ + void StartDefaultMusic(); + + /** A random default soundtrack, avoiding the one playing now unless that would leave nothing to + * play, so a soundtrack of a single track still loops. Null when nothing is configured. */ + USoundBase* PickNextDefaultSoundtrack() const; + + /** + * The one that puts a cue on the music component: crossfades the old one out, routes the new one + * through the music channel's class and fades it in. Null Cue stops the music. Shared by every + * caller that changes the music so that none of them can change it differently. + */ + void PlayMusicInternal(USoundBase* Cue, float FadeTime); + + /** Drops the pending next-track timer, which is what has to happen before an override, or a + * rotation track, takes the music somewhere the timer did not plan for. */ + void CancelPendingSoundtrack(); + + /** Called by the current music component when its track plays out, so the rotation can queue the + * next one after the configured gap. */ + UFUNCTION() + void HandleMusicFinished(); + + /** The post-Master volume a channel actually plays at. */ + float GetEffectiveVolume(EEleriAudioChannel Channel) const; + + /** The sound class a channel is routed through in the settings, or null when none is set, in + * which case the cue plays on whatever class it shipped with. */ + USoundClass* GetRoutingSoundClass(EEleriAudioChannel Channel) const; + + float GetMusicFadeTime() const; + float GetNextSoundtrackDelay() const; + + /** The track playing now, or the one still fading out, whichever is newer. */ + UPROPERTY() + TObjectPtr MusicComponent; + + /** What MusicComponent holds, kept apart from it because the component outlives nothing and the + * question "what is playing" has an answer even between components. */ + UPROPERTY() + TObjectPtr CurrentMusic; + + /** Set only while a gameplay event has taken the music over. Non-null means the default rotation + * is held back and the music is this instead. */ + UPROPERTY() + TObjectPtr MusicOverride; + + UPROPERTY() + FTimerHandle SoundtrackTimerHandle; + + UPROPERTY() + float MasterVolume = 1.f; + + UPROPERTY() + float MusicVolume = 1.f; + + UPROPERTY() + float SfxVolume = 1.f; +}; diff --git a/Source/ProjectEleri/Audio/EleriAudioTypes.h b/Source/ProjectEleri/Audio/EleriAudioTypes.h new file mode 100644 index 00000000..7b106640 --- /dev/null +++ b/Source/ProjectEleri/Audio/EleriAudioTypes.h @@ -0,0 +1,24 @@ +// Fill out your copyright notice in the Description page of Project Settings. + +#pragma once + +#include "CoreMinimal.h" + +#include "EleriAudioTypes.generated.h" + +/** + * The mixing channels every cue the game plays is routed through. + * + * The audio subsystem keeps one volume per channel and folds them into each other as Master * channel + * rather than replacing one with the other, so pulling Master down takes the whole game with it while + * a channel keeps its own setting. Which sound class each channel actually plays through is hooked up + * in the Eleri Game Settings; Master has none, because it scales the other two rather than carrying a + * sound of its own. + */ +UENUM(BlueprintType) +enum class EEleriAudioChannel : uint8 +{ + Master UMETA(DisplayName = "Master"), + Music UMETA(DisplayName = "Music"), + SFX UMETA(DisplayName = "SFX") +}; diff --git a/Source/ProjectEleri/GameEventSystem/GameEventDataAsset.h b/Source/ProjectEleri/GameEventSystem/GameEventDataAsset.h index 90bc6055..dbe13638 100644 --- a/Source/ProjectEleri/GameEventSystem/GameEventDataAsset.h +++ b/Source/ProjectEleri/GameEventSystem/GameEventDataAsset.h @@ -13,6 +13,7 @@ class UTexture2D; +class USoundBase; UENUM(BlueprintType) enum class EEventStage : uint8 @@ -78,6 +79,12 @@ public: UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Data") TArray TagValueArray; + /** Soundtracks this event can put on. One is drawn at random and crossfaded in over whatever is + * playing already, and it holds the music until the override is cleared. An event with none + * leaves the music alone, which is what most of them will do. */ + UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Audio") + TSoftObjectPtr MusicCue; + UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Newspaper event") bool bIsNewspaperEvent; UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Newspaper event", meta = (EditCondition = "bIsNewspaperEvent", EditConditionHides)) diff --git a/Source/ProjectEleri/GameEventSystem/GameEventSubsystem.cpp b/Source/ProjectEleri/GameEventSystem/GameEventSubsystem.cpp index c29e9fbc..4c0457c3 100644 --- a/Source/ProjectEleri/GameEventSystem/GameEventSubsystem.cpp +++ b/Source/ProjectEleri/GameEventSystem/GameEventSubsystem.cpp @@ -6,6 +6,7 @@ #include "AssetRegistry/IAssetRegistry.h" #include "Kismet/GameplayStatics.h" #include "GameFramework/Character.h" +#include "ProjectEleri/Audio/EleriAudioSubsystem.h" void UGameEventSubsystem::Initialize(FSubsystemCollectionBase &Collection) { @@ -59,6 +60,17 @@ bool UGameEventSubsystem::StartEvent(FSoftObjectPath EventPath) EventData->EventStage = EEventStage::STARTED; else EventData->EventStage = EEventStage::COMPLETED; + + // A starting event takes the music over with its own cues, when it has any. Done here rather + // than at whatever happens to start an event, so that every caller changes the music the same + // way and an event with no cues stays silent on the subject. + if (EventData->EventStage == EEventStage::STARTED) + { + if (UEleriAudioSubsystem* AudioSubsystem = UEleriAudioSubsystem::Get(this)) + { + AudioSubsystem->PlayMusicForEvent(EventData->EventPtr); + } + } } return false; } diff --git a/Source/ProjectEleri/Settings/EleriGameSettings.h b/Source/ProjectEleri/Settings/EleriGameSettings.h index 94420a0d..2fd63bb0 100644 --- a/Source/ProjectEleri/Settings/EleriGameSettings.h +++ b/Source/ProjectEleri/Settings/EleriGameSettings.h @@ -8,6 +8,7 @@ #include "Data/GameplayStatEffect.h" #include "Templates/SubclassOf.h" #include "UObject/SoftObjectPtr.h" +#include "ProjectEleri/Audio/EleriAudioTypes.h" #include "EleriGameSettings.generated.h" @@ -17,6 +18,8 @@ class UNpcDataAsset; class UNpcScheduleAsset; class UItemDataAsset; class UGameplayEffect; +class USoundBase; +class USoundClass; /** * @@ -56,6 +59,25 @@ public: UPROPERTY(Config, EditAnywhere, BlueprintReadOnly, Category = "Materials") TSoftObjectPtr SpriteShadowMaterial; + /** Which sound class each channel's cues are routed through when the audio subsystem plays them. + * Master has no entry because it carries no sound of its own, it scales the other two. */ + UPROPERTY(Config, EditAnywhere, BlueprintReadOnly, Category = "Audio") + TMap> AudioChannelSoundClasses; + + /** The soundtracks the audio subsystem rotates through while no event has taken the music over. */ + UPROPERTY(Config, EditAnywhere, BlueprintReadOnly, Category = "Audio") + TArray> DefaultSoundtracks; + + /** Bounds of the random gap left between two default soundtracks, in seconds. */ + UPROPERTY(Config, EditAnywhere, BlueprintReadOnly, Category = "Audio", meta = (ClampMin = "0.0")) + float SoundtrackMinDelay = 20.f; + UPROPERTY(Config, EditAnywhere, BlueprintReadOnly, Category = "Audio", meta = (ClampMin = "0.0")) + float SoundtrackMaxDelay = 60.f; + + /** How long the audio subsystem takes to crossfade from one track to the next, in seconds. */ + UPROPERTY(Config, EditAnywhere, BlueprintReadOnly, Category = "Audio", meta = (ClampMin = "0.0")) + float MusicFadeTime = 2.f; + public: UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Eleri Game Settingss")