From 5e4aff90e928b08ce419d35fbf6ce8ae070af3d2 Mon Sep 17 00:00:00 2001 From: SomeTroglodyte <63000004+SomeTroglodyte@users.noreply.github.com> Date: Wed, 22 Sep 2021 08:35:33 +0200 Subject: [PATCH] Music controller with fade-over and mod capabilities. (#5273) * Music controller with fade-over and mod capabilities. - Preparation for music following game situations - Minimal in-game hooks for now - Already allows mods providing music, will play randomly * Music controller - template --- .../jsons/translations/template.properties | 1 + core/src/com/unciv/MainMenuScreen.kt | 1 - core/src/com/unciv/UncivGame.kt | 29 +- .../com/unciv/models/metadata/GameSettings.kt | 3 + .../src/com/unciv/ui/audio/MusicController.kt | 335 ++++++++++++++++++ .../unciv/ui/audio/MusicTrackChooserFlags.kt | 14 + .../unciv/ui/audio/MusicTrackController.kt | 152 ++++++++ .../ui/newgamescreen/GameOptionsTable.kt | 1 + .../unciv/ui/newgamescreen/NewGameScreen.kt | 1 + .../ui/worldscreen/mainmenu/OptionsPopup.kt | 69 +++- .../mainmenu/WorldScreenMenuPopup.kt | 9 +- 11 files changed, 575 insertions(+), 40 deletions(-) create mode 100644 core/src/com/unciv/ui/audio/MusicController.kt create mode 100644 core/src/com/unciv/ui/audio/MusicTrackChooserFlags.kt create mode 100644 core/src/com/unciv/ui/audio/MusicTrackController.kt diff --git a/android/assets/jsons/translations/template.properties b/android/assets/jsons/translations/template.properties index 2eb5a76ae4..21bb8ad398 100644 --- a/android/assets/jsons/translations/template.properties +++ b/android/assets/jsons/translations/template.properties @@ -469,6 +469,7 @@ See online Readme = Turns between autosaves = Sound effects volume = Music volume = +Pause between tracks = Download music = Downloading... = Could not download music! = diff --git a/core/src/com/unciv/MainMenuScreen.kt b/core/src/com/unciv/MainMenuScreen.kt index e45396bdc9..5cfe17d7e8 100644 --- a/core/src/com/unciv/MainMenuScreen.kt +++ b/core/src/com/unciv/MainMenuScreen.kt @@ -67,7 +67,6 @@ class MainMenuScreen: CameraStageBaseScreen() { // If we were in a mod, some of the resource images for the background map we're creating // will not exist unless we reset the ruleset and images ImageGetter.ruleset = RulesetCache.getBaseRuleset() - //ImageGetter.refreshAtlas() thread(name = "ShowMapBackground") { val newMap = MapGenerator(RulesetCache.getBaseRuleset()) diff --git a/core/src/com/unciv/UncivGame.kt b/core/src/com/unciv/UncivGame.kt index 5e344ed0f5..3f3386f556 100644 --- a/core/src/com/unciv/UncivGame.kt +++ b/core/src/com/unciv/UncivGame.kt @@ -4,7 +4,6 @@ import com.badlogic.gdx.Application import com.badlogic.gdx.Game import com.badlogic.gdx.Gdx import com.badlogic.gdx.Input -import com.badlogic.gdx.audio.Music import com.badlogic.gdx.scenes.scene2d.actions.Actions import com.badlogic.gdx.utils.Align import com.unciv.logic.GameInfo @@ -15,6 +14,7 @@ import com.unciv.models.ruleset.RulesetCache import com.unciv.models.tilesets.TileSetCache import com.unciv.models.translations.Translations import com.unciv.ui.LanguagePickerScreen +import com.unciv.ui.audio.MusicController import com.unciv.ui.utils.* import com.unciv.ui.worldscreen.PlayerReadyScreen import com.unciv.ui.worldscreen.WorldScreen @@ -37,6 +37,8 @@ class UncivGame(parameters: UncivGameParameters) : Game() { fun isGameInfoInitialized() = this::gameInfo.isInitialized lateinit var settings: GameSettings lateinit var crashController: CrashController + lateinit var musicController: MusicController + /** * This exists so that when debugging we can see the entire map. * Remember to turn this to false before commit and upload! @@ -57,8 +59,6 @@ class UncivGame(parameters: UncivGameParameters) : Game() { lateinit var worldScreen: WorldScreen - var music: Music? = null - val musicLocation = "music/thatched-villagers.mp3" var isInitialized = false @@ -86,6 +86,7 @@ class UncivGame(parameters: UncivGameParameters) : Game() { */ settings = GameSaver.getGeneralSettings() // needed for the screen screen = LoadingScreen() // NOT dependent on any atlas or skin + musicController = MusicController() // early, but at this point does only copy volume from settings ImageGetter.resetAtlases() ImageGetter.setNewRuleset(ImageGetter.ruleset) // This needs to come after the settings, since we may have default visual mods @@ -110,11 +111,10 @@ class UncivGame(parameters: UncivGameParameters) : Game() { // This stuff needs to run on the main thread because it needs the GL context Gdx.app.postRunnable { + musicController.chooseTrack() + ImageGetter.ruleset = RulesetCache.getBaseRuleset() // so that we can enter the map editor without having to load a game first - - thread(name="Music") { startMusic() } - if (settings.isFreshlyCreated) { setScreen(LanguagePickerScreen()) } else { setScreen(MainMenuScreen()) } @@ -127,6 +127,7 @@ class UncivGame(parameters: UncivGameParameters) : Game() { fun loadGame(gameInfo: GameInfo) { this.gameInfo = gameInfo ImageGetter.setNewRuleset(gameInfo.ruleSet) + musicController.setModList(gameInfo.gameParameters.mods) Gdx.input.inputProcessor = null // Since we will set the world screen when we're ready, if (gameInfo.civilizations.count { it.playerType == PlayerType.Human } > 1 && !gameInfo.gameParameters.isOnlineMultiplayer) setScreen(PlayerReadyScreen(gameInfo, gameInfo.getPlayerToViewAs())) @@ -136,18 +137,6 @@ class UncivGame(parameters: UncivGameParameters) : Game() { } } - fun startMusic() { - if (settings.musicVolume < 0.01) return - - val musicFile = Gdx.files.local(musicLocation) - if (musicFile.exists()) { - music = Gdx.audio.newMusic(musicFile) - music!!.isLooping = true - music!!.volume = 0.4f * settings.musicVolume - music!!.play() - } - } - fun setScreen(screen: CameraStageBaseScreen) { Gdx.input.inputProcessor = screen.stage super.setScreen(screen) @@ -178,13 +167,14 @@ class UncivGame(parameters: UncivGameParameters) : Game() { override fun dispose() { cancelDiscordEvent?.invoke() Sounds.clearCache() + if (::musicController.isInitialized) musicController.shutdown() // Log still running threads (on desktop that should be only this one and "DestroyJavaVM") val numThreads = Thread.activeCount() val threadList = Array(numThreads) { _ -> Thread() } Thread.enumerate(threadList) - if (isGameInfoInitialized()){ + if (isGameInfoInitialized()) { val autoSaveThread = threadList.firstOrNull { it.name == "Autosave" } if (autoSaveThread != null && autoSaveThread.isAlive) { // auto save is already in progress (e.g. started by onPause() event) @@ -217,4 +207,3 @@ private class LoadingScreen : CameraStageBaseScreen() { stage.addActor(happinessImage) } } - diff --git a/core/src/com/unciv/models/metadata/GameSettings.kt b/core/src/com/unciv/models/metadata/GameSettings.kt index 5807127d3d..6a52247d2b 100644 --- a/core/src/com/unciv/models/metadata/GameSettings.kt +++ b/core/src/com/unciv/models/metadata/GameSettings.kt @@ -23,8 +23,11 @@ class GameSettings { var tutorialsShown = HashSet() var tutorialTasksCompleted = HashSet() var hasCrashedRecently = false + var soundEffectsVolume = 0.5f var musicVolume = 0.5f + var pauseBetweenTracks = 10 + var turnsBetweenAutosaves = 1 var tileSet: String = "FantasyHex" var showTutorials: Boolean = true diff --git a/core/src/com/unciv/ui/audio/MusicController.kt b/core/src/com/unciv/ui/audio/MusicController.kt new file mode 100644 index 0000000000..f6d64dd0e7 --- /dev/null +++ b/core/src/com/unciv/ui/audio/MusicController.kt @@ -0,0 +1,335 @@ +package com.unciv.ui.audio + +import com.badlogic.gdx.Gdx +import com.badlogic.gdx.Files.FileType +import com.badlogic.gdx.files.FileHandle +import com.unciv.UncivGame +import com.unciv.models.metadata.GameSettings +import com.unciv.ui.worldscreen.mainmenu.DropBox +import java.util.* +import kotlin.concurrent.timer + + +/** + * Play, choose, fade-in/out and generally manage music track playback. + * + * Main methods: [chooseTrack], [pause], [resume], [setModList], [isPlaying], [gracefulShutdown] + */ +class MusicController { + companion object { + /** Mods live in Local - but this file prepares for music living in External just in case */ + private val musicLocation = FileType.Local + const val musicPath = "music" + const val modPath = "mods" + const val musicFallbackLocation = "music/thatched-villagers.mp3" + const val maxVolume = 0.6f // baseVolume has range 0.0-1.0, which is multiplied by this for the API + private const val ticksPerSecond = 20 // Timer frequency defines smoothness of fade-in/out + private const val timerPeriod = 1000L / ticksPerSecond + const val defaultFadingStep = 0.08f // this means fade is 12 ticks or 0.6 seconds + private const val musicHistorySize = 8 // number of names to keep to avoid playing the same in short succession + private val fileExtensions = listOf("mp3", "ogg") // flac, opus, m4a... blocked by Gdx, `wav` we don't want + + internal const val consoleLog = false + + private fun getFile(path: String) = + if (musicLocation == FileType.External && Gdx.files.isExternalStorageAvailable) + Gdx.files.external(path) + else Gdx.files.local(path) + } + + //region Fields + /** mirrors [GameSettings.musicVolume] - use [setVolume] to update */ + var baseVolume: Float = UncivGame.Current.settings.musicVolume + private set + + /** Pause in seconds between tracks unless [chooseTrack] is called to force a track change */ + var silenceLength: Float + get() = silenceLengthInTicks.toFloat() / ticksPerSecond + set(value) { silenceLengthInTicks = (ticksPerSecond * value).toInt() } + private var silenceLengthInTicks = UncivGame.Current.settings.pauseBetweenTracks * ticksPerSecond + + private var mods = HashSet() + + private var state = ControllerState.Idle + + private var ticksOfSilence: Int = 0 + + private var musicTimer: Timer? = null + + private enum class ControllerState { + /** As the name says. Timer will stop itself if it encounters this state. */ + Idle, + /** Play a track to its end, then silence for a while, then choose another track */ + Playing, + /** Play a track to its end, then go [Idle] */ + PlaySingle, + /** Wait for a while in silence to start next track */ + Silence, + /** Music fades to pause or is paused. Continue with chooseTrack or resume. */ + Pause, + /** Fade out then stop */ + Shutdown + } + + /** Simple two-entry only queue, for smooth fade-overs from one track to another */ + var current: MusicTrackController? = null + var next: MusicTrackController? = null + + /** Keeps paths of recently played track to reduce repetition */ + private val musicHistory = ArrayDeque(musicHistorySize) + + //endregion + //region Pure functions + + /** @return the path of the playing track or null if none playing */ + fun currentlyPlaying() = if (state != ControllerState.Playing && state != ControllerState.PlaySingle) null + else musicHistory.peekLast() + + /** + * Determines whether any music tracks are available for the options menu + */ + fun isMusicAvailable() = getAllMusicFiles().any() + + /** @return `true` if there's a current music track and if it's actively playing */ + fun isPlaying(): Boolean { + return current?.isPlaying() == true + } + + //endregion + //region Internal helpers + + private fun clearCurrent() { + current?.clear() + current = null + } + + private fun musicTimerTask() { + // This ticks [ticksPerSecond] times per second + when (state) { + ControllerState.Playing, ControllerState.PlaySingle -> + if (current == null) { + if (next == null) { + // no music to play - begin silence or shut down + ticksOfSilence = 0 + state = if (state == ControllerState.PlaySingle) ControllerState.Shutdown else ControllerState.Silence + } else if (next!!.state.canPlay) { + // Next track - if top slot empty and a next exists, move it to top and start + current = next + next = null + if (!current!!.play()) + state = ControllerState.Shutdown + } // else wait for the thread of next.load() to finish + } else if (!current!!.isPlaying()) { + // normal end of track + clearCurrent() + // rest handled next tick + } else { + if (current?.timerTick() == MusicTrackController.State.Idle) + clearCurrent() + next?.timerTick() + } + ControllerState.Silence -> + if (++ticksOfSilence > silenceLengthInTicks) { + ticksOfSilence = 0 + chooseTrack() + } + ControllerState.Shutdown, ControllerState.Idle -> { + state = ControllerState.Idle + shutdown() + } + ControllerState.Pause -> + current?.timerTick() + } + } + + /** Get sequence of potential music locations */ + private fun getMusicFolders() = sequence { + yieldAll( + (UncivGame.Current.settings.visualMods + mods).asSequence() + .map { getFile(modPath).child(it).child(musicPath) } + ) + yield(getFile(musicPath)) + } + + /** Get sequence of all existing music files */ + private fun getAllMusicFiles() = getMusicFolders() + .filter { it.exists() && it.isDirectory } + .flatMap { it.list().asSequence() } + // ensure only normal files with common sound extension + .filter { it.exists() && !it.isDirectory && it.extension() in fileExtensions } + + /** Choose adequate entry from [getAllMusicFiles] */ + private fun chooseFile(prefix: String, suffix: String, flags: EnumSet): FileHandle? { + if (flags.contains(MusicTrackChooserFlags.PlayDefaultFile)) + return getFile(musicFallbackLocation) + // Scan whole music folder and mods to find best match for desired prefix and/or suffix + // get a path list (as strings) of music folder candidates - existence unchecked + return getAllMusicFiles() + .filter { + (!flags.contains(MusicTrackChooserFlags.PrefixMustMatch) || it.nameWithoutExtension().startsWith(prefix)) + && (!flags.contains(MusicTrackChooserFlags.SuffixMustMatch) || it.nameWithoutExtension().endsWith(suffix)) + } + // sort them by prefix match / suffix match / not last played / random + .sortedWith(compareBy( + { if (it.nameWithoutExtension().startsWith(prefix)) 0 else 1 } + , { if (it.nameWithoutExtension().endsWith(suffix)) 0 else 1 } + , { if (it.path() in musicHistory) 1 else 0 } + , { Random().nextInt() })) + // Then just pick the first one. Not as wasteful as it looks - need to check all names anyway + .firstOrNull() + } + + //endregion + //region State changing methods + + /** This tells the music controller about active mods - all are allowed to provide tracks */ + fun setModList ( newMods: HashSet ) { + //todo: Ensure this gets updated where appropriate. + // loadGame; newGame: Choose Map with Mods?; map editor... + // check against "ImageGetter.ruleset=" ? + mods = newMods + } + + /** + * Chooses and plays a music track using an adaptable approach - for details see the wiki. + * Called without parameters it will choose a new ambient music track and start playing it with fade-in/out. + * Will do nothing when no music files exist. + * + * @param prefix file name prefix, meant to represent **Context** - in most cases a Civ name or default "Ambient" + * @param suffix file name suffix, meant to represent **Mood** - e.g. Peace, War, Theme... + * @param flags a set of optional flags to tune the choice and playback. + * @return `true` = success, `false` = no match, no playback change + */ + fun chooseTrack ( + prefix: String = "", + suffix: String = "", + flags: EnumSet = EnumSet.noneOf(MusicTrackChooserFlags::class.java) + ): Boolean { + val musicFile = chooseFile(prefix, suffix, flags) + + if (musicFile == null) { + // MustMatch flags at work or Music folder empty + if (consoleLog) + println("No music found for prefix=$prefix, suffix=$suffix, flags=$flags") + return false + } + if (musicFile.path() == currentlyPlaying()) + return true // picked file already playing + if (!musicFile.exists()) + return false // Safety check - nothing to play found? + + next?.clear() + next = MusicTrackController(baseVolume * maxVolume) + + next!!.load(musicFile, onError = { + ticksOfSilence = 0 + state = ControllerState.Silence // will retry after one silence period + next = null + }, onSuccess = { + if (consoleLog) + println("Music queued: ${musicFile.path()} for prefix=$prefix, suffix=$suffix, flags=$flags") + + if (musicHistory.size >= musicHistorySize) musicHistory.removeFirst() + musicHistory.addLast(musicFile.path()) + + val fadingStep = defaultFadingStep / (if (flags.contains(MusicTrackChooserFlags.SlowFade)) 5 else 1) + it.startFade(MusicTrackController.State.FadeIn, fadingStep) + + when (state) { + ControllerState.Playing, ControllerState.PlaySingle -> + current?.startFade(MusicTrackController.State.FadeOut, fadingStep) + ControllerState.Pause -> + if (current?.state == MusicTrackController.State.Idle) clearCurrent() + else -> Unit + } + }) + + // Yes while the loader is doing its thing we wait for it in a Playing state + state = if (flags.contains(MusicTrackChooserFlags.PlaySingle)) ControllerState.PlaySingle else ControllerState.Playing + + // Start background TimerTask which manages track changes + if (musicTimer == null) + musicTimer = timer("MusicTimer", true, 0, timerPeriod) { + musicTimerTask() + } + + return true + } + + /** + * Pause playback with fade-out + * + * @param speedFactor accelerate (>1) or slow down (<1) the fade-out. Clamped to 1/1000..1000. + */ + fun pause(speedFactor: Float = 1f) { + if ((state != ControllerState.Playing && state != ControllerState.PlaySingle) || current == null) return + val fadingStep = defaultFadingStep * speedFactor.coerceIn(0.001f..1000f) + current!!.startFade(MusicTrackController.State.FadeOut, fadingStep) + state = ControllerState.Pause + } + + /** + * Resume playback with fade-in - from a pause will resume where playback left off, + * otherwise it will start a new ambient track choice. + * + * @param speedFactor accelerate (>1) or slow down (<1) the fade-in. Clamped to 1/1000..1000. + */ + fun resume(speedFactor: Float = 1f) { + if (state == ControllerState.Pause && current != null) { + val fadingStep = defaultFadingStep * speedFactor.coerceIn(0.001f..1000f) + current!!.startFade(MusicTrackController.State.FadeIn, fadingStep) + state = ControllerState.Playing // this may circumvent a PlaySingle, but, currently only the main menu resumes, and then it's perfect + current!!.play() + } else if (state == ControllerState.Idle) { + chooseTrack() + } + } + + /** Fade out then shutdown with a given [duration] in seconds */ + fun fadeoutToSilence(duration: Float = 4.0f) { + val fadingStep = 1f / ticksPerSecond / duration + current?.startFade(MusicTrackController.State.FadeOut, fadingStep) + next?.startFade(MusicTrackController.State.FadeOut, fadingStep) + state = ControllerState.Shutdown + } + + /** Update playback volume, to be called from options popup */ + fun setVolume(volume: Float) { + baseVolume = volume + if ( volume < 0.01 ) shutdown() + else if (isPlaying()) current!!.setVolume(baseVolume * maxVolume) + } + + /** Soft shutdown of music playback, with fadeout */ + fun gracefulShutdown() { + if (state == ControllerState.Idle) shutdown() + else state = ControllerState.Shutdown + } + + /** Forceful shutdown of music playback and timers - see [gracefulShutdown] */ + fun shutdown() { + state = ControllerState.Idle + if (musicTimer != null) { + musicTimer!!.cancel() + musicTimer = null + } + if (next != null) { + next!!.clear() + next = null + } + if (current != null) { + current!!.clear() + current = null + } + musicHistory.clear() + if (consoleLog) + println("MusicController shut down.") + } + + fun downloadDefaultFile() { + val file = DropBox.downloadFile(musicFallbackLocation) + getFile(musicFallbackLocation).write(file, false) + } + + //endregion +} diff --git a/core/src/com/unciv/ui/audio/MusicTrackChooserFlags.kt b/core/src/com/unciv/ui/audio/MusicTrackChooserFlags.kt new file mode 100644 index 0000000000..0cffd89a17 --- /dev/null +++ b/core/src/com/unciv/ui/audio/MusicTrackChooserFlags.kt @@ -0,0 +1,14 @@ +package com.unciv.ui.audio + +enum class MusicTrackChooserFlags { + /** Makes prefix parameter a mandatory match */ + PrefixMustMatch, + /** Makes suffix parameter a mandatory match */ + SuffixMustMatch, + /** Extends fade duration by factor 5 */ + SlowFade, + /** Lets music controller shut down after track ends instead of choosing a random next track */ + PlaySingle, + /** directly choose the 'fallback' file for playback */ + PlayDefaultFile, +} diff --git a/core/src/com/unciv/ui/audio/MusicTrackController.kt b/core/src/com/unciv/ui/audio/MusicTrackController.kt new file mode 100644 index 0000000000..b7d5aafe7b --- /dev/null +++ b/core/src/com/unciv/ui/audio/MusicTrackController.kt @@ -0,0 +1,152 @@ +package com.unciv.ui.audio + +import com.badlogic.gdx.Gdx +import com.badlogic.gdx.audio.Music +import com.badlogic.gdx.files.FileHandle +import kotlin.concurrent.thread + +/** Wraps one Gdx Music instance and manages threaded loading, playback, fading and cleanup */ +class MusicTrackController(private var volume: Float) { + + /** Internal state of this Music track */ + enum class State(val canPlay: Boolean) { + None(false), + Loading(false), + Idle(true), + FadeIn(true), + Playing(true), + FadeOut(true), + Error(false) + } + + var state = State.None + private set + var music: Music? = null + private set + private var loaderThread: Thread? = null + private var fadeStep = MusicController.defaultFadingStep + private var fadeVolume: Float = 1f + + /** Clean up and dispose resources */ + fun clear() { + state = State.None + clearLoader() + clearMusic() + } + private fun clearLoader() { + if (loaderThread == null) return + loaderThread!!.interrupt() + loaderThread = null + } + private fun clearMusic() { + if (music == null) return + music!!.stop() + music!!.dispose() + music = null + } + + /** Loads [file] into this controller's [music] and optionally calls [onSuccess] when done. + * Failures are silently logged to console, and [onError] is called. + * Callbacks run on the background thread. + * @throws IllegalStateException if called in the wrong state (fresh or cleared instance only) + */ + fun load( + file: FileHandle, + onError: ((MusicTrackController)->Unit)? = null, + onSuccess: ((MusicTrackController)->Unit)? = null + ) { + if (state != State.None || loaderThread != null || music != null) + throw IllegalStateException("MusicTrackController.load should only be called once") + loaderThread = thread(name = "MusicLoader") { + state = State.Loading + try { + music = Gdx.audio.newMusic(file) + if (state != State.Loading) { // in case clear was called in the meantime + clearMusic() + } else { + state = State.Idle + if (MusicController.consoleLog) + println("Music loaded: $file") + onSuccess?.invoke(this) + } + } catch (ex: Exception) { + println("Exception loading $file: ${ex.message}") + if (MusicController.consoleLog) + ex.printStackTrace() + state = State.Error + onError?.invoke(this) + } + loaderThread = null + } + } + + /** Called by the [MusicController] in its timer "tick" event handler, implements fading */ + fun timerTick(): State { + if (state == State.FadeIn) fadeInStep() + if (state == State.FadeOut) fadeOutStep() + return state + } + private fun fadeInStep() { + // fade-in: linearly ramp fadeVolume to 1.0, then continue playing + fadeVolume += fadeStep + if (fadeVolume < 1f && music != null && music!!.isPlaying) { + music!!.volume = volume * fadeVolume + return + } + music!!.volume = volume + fadeVolume = 1f + state = State.Playing + } + private fun fadeOutStep() { + // fade-out: linearly ramp fadeVolume to 0.0, then act according to Status (Playing->Silence/Pause/Shutdown) + fadeVolume -= fadeStep + if (fadeVolume >= 0.001f && music != null && music!!.isPlaying) { + music!!.volume = volume * fadeVolume + return + } + fadeVolume = 0f + state = State.Idle + } + + /** Starts fadeIn or fadeOut. + * + * Note this does _not_ set the current fade "percentage" to allow smoothly changing direction mid-fade + * @param step Overrides current fade step only if >0 + */ + fun startFade(fade: State, step: Float = 0f) { + if (!state.canPlay) return + if (fadeStep > 0f) fadeStep = step + state = fade + } + + /** @return [Music.isPlaying] (Gdx music stream is playing) unless [state] says it won't make sense */ + fun isPlaying() = state.canPlay && music?.isPlaying == true + + /** Calls play() on the wrapped Gdx Music, catching exceptions to console. + * @return success + * @throws IllegalStateException if called on uninitialized instance + */ + fun play(): Boolean { + if (!state.canPlay || music == null) { + throw IllegalStateException("MusicTrackController.play called on uninitialized instance") + } + return try { + music!!.volume = volume + if (!music!!.isPlaying) // for fade-over this could be called by the end of the previous track + music!!.play() + true + } catch (ex: Exception) { + println("Exception playing music: ${ex.message}") + if (MusicController.consoleLog) + ex.printStackTrace() + state = State.Error + false + } + } + + /** Adjust master volume without affecting a fade-in/out */ + fun setVolume(newVolume: Float) { + volume = newVolume + music?.volume = volume * fadeVolume + } +} diff --git a/core/src/com/unciv/ui/newgamescreen/GameOptionsTable.kt b/core/src/com/unciv/ui/newgamescreen/GameOptionsTable.kt index caf60f4b1a..c71e73763c 100644 --- a/core/src/com/unciv/ui/newgamescreen/GameOptionsTable.kt +++ b/core/src/com/unciv/ui/newgamescreen/GameOptionsTable.kt @@ -170,6 +170,7 @@ class GameOptionsTable( ruleset.modOptions = newRuleset.modOptions ImageGetter.setNewRuleset(ruleset) + UncivGame.Current.musicController.setModList(gameParameters.mods) } fun getModCheckboxes(isPortrait: Boolean = false): Table { diff --git a/core/src/com/unciv/ui/newgamescreen/NewGameScreen.kt b/core/src/com/unciv/ui/newgamescreen/NewGameScreen.kt index f9f4a3c294..6418dcae44 100644 --- a/core/src/com/unciv/ui/newgamescreen/NewGameScreen.kt +++ b/core/src/com/unciv/ui/newgamescreen/NewGameScreen.kt @@ -226,6 +226,7 @@ class NewGameScreen( ruleset.clear() ruleset.add(RulesetCache.getComplexRuleset(gameSetupInfo.gameParameters.mods)) ImageGetter.setNewRuleset(ruleset) + game.musicController.setModList(gameSetupInfo.gameParameters.mods) } fun lockTables() { diff --git a/core/src/com/unciv/ui/worldscreen/mainmenu/OptionsPopup.kt b/core/src/com/unciv/ui/worldscreen/mainmenu/OptionsPopup.kt index ad52310dd3..91f835fe19 100644 --- a/core/src/com/unciv/ui/worldscreen/mainmenu/OptionsPopup.kt +++ b/core/src/com/unciv/ui/worldscreen/mainmenu/OptionsPopup.kt @@ -21,6 +21,8 @@ import com.unciv.models.tilesets.TileSetCache import com.unciv.models.translations.TranslationFileWriter import com.unciv.models.translations.Translations import com.unciv.models.translations.tr +import com.unciv.ui.audio.MusicController +import com.unciv.ui.audio.MusicTrackChooserFlags import com.unciv.ui.civilopedia.FormattedLine import com.unciv.ui.civilopedia.MarkupRenderer import com.unciv.ui.civilopedia.SimpleCivilopediaText @@ -30,6 +32,8 @@ import com.unciv.ui.utils.UncivTooltip.Companion.addTooltip import com.unciv.ui.worldscreen.WorldScreen import java.util.* import kotlin.concurrent.thread +import kotlin.math.floor +import kotlin.math.roundToInt import com.badlogic.gdx.utils.Array as GdxArray /** @@ -207,11 +211,12 @@ class OptionsPopup(val previousScreen: CameraStageBaseScreen) : Popup(previousSc addSoundEffectsVolumeSlider() - val musicLocation = Gdx.files.local(previousScreen.game.musicLocation) - if (musicLocation.exists()) + if (previousScreen.game.musicController.isMusicAvailable()) { addMusicVolumeSlider() - else - addDownloadMusic(musicLocation) + addMusicPauseSlider() + } else { + addDownloadMusic() + } } private fun getMultiplayerTab(): Table = Table(CameraStageBaseScreen.skin).apply { @@ -247,7 +252,7 @@ class OptionsPopup(val previousScreen: CameraStageBaseScreen) : Popup(previousSc addYesNoRow("Enable portrait orientation", settings.allowAndroidPortrait) { settings.allowAndroidPortrait = it // Note the following might close the options screen indirectly and delayed - previousScreen.game.limitOrientationsHelper!!.allowPortrait(it) + previousScreen.game.limitOrientationsHelper.allowPortrait(it) } } @@ -392,7 +397,7 @@ class OptionsPopup(val previousScreen: CameraStageBaseScreen) : Popup(previousSc private fun Table.addSoundEffectsVolumeSlider() { add("Sound effects volume".tr()).left().fillX() - val soundEffectsVolumeSlider = UncivSlider(0f, 1.0f, 0.1f, + val soundEffectsVolumeSlider = UncivSlider(0f, 1.0f, 0.05f, initial = settings.soundEffectsVolume ) { settings.soundEffectsVolume = it @@ -404,24 +409,55 @@ class OptionsPopup(val previousScreen: CameraStageBaseScreen) : Popup(previousSc private fun Table.addMusicVolumeSlider() { add("Music volume".tr()).left().fillX() - val musicVolumeSlider = UncivSlider(0f, 1.0f, 0.1f, + val musicVolumeSlider = UncivSlider(0f, 1.0f, 0.05f, initial = settings.musicVolume, sound = UncivSound.Silent ) { settings.musicVolume = it settings.save() - val music = previousScreen.game.music - if (music == null) // restart music, if it was off at the app start - thread(name = "Music") { previousScreen.game.startMusic() } - - music?.volume = 0.4f * it + val music = previousScreen.game.musicController + music.setVolume(it) + if (!music.isPlaying()) + music.chooseTrack(flags = EnumSet.of(MusicTrackChooserFlags.PlayDefaultFile, MusicTrackChooserFlags.PlaySingle)) } - musicVolumeSlider.value = settings.musicVolume add(musicVolumeSlider).pad(5f).row() } - private fun Table.addDownloadMusic(musicLocation: FileHandle) { + private fun Table.addMusicPauseSlider() { + val music = previousScreen.game.musicController + + // map to/from 0-1-2..10-12-14..30-35-40..60-75-90-105-120 + fun posToLength(pos: Float): Float = when (pos) { + in 0f..10f -> pos + in 11f..20f -> pos * 2f - 10f + in 21f..26f -> pos * 5f - 70f + else -> pos * 15f - 330f + } + fun lengthToPos(length: Float): Float = floor(when (length) { + in 0f..10f -> length + in 11f..30f -> (length + 10f) / 2f + in 31f..60f -> (length + 10f) / 5f + else -> (length + 330f) / 15f + }) + val getTipText: (Float)->String = { + "%.0f".format(posToLength(it)) + } + + add("Pause between tracks".tr()).left().fillX() + + val pauseLengthSlider = UncivSlider(0f, 30f, 1f, + initial = lengthToPos(music.silenceLength), + sound = UncivSound.Silent, + getTipText = getTipText + ) { + music.silenceLength = posToLength(it) + settings.pauseBetweenTracks = music.silenceLength.toInt() + } + add(pauseLengthSlider).pad(5f).row() + } + + private fun Table.addDownloadMusic() { val downloadMusicButton = "Download music".toTextButton() add(downloadMusicButton).colspan(2).row() val errorTable = Table() @@ -435,11 +471,10 @@ class OptionsPopup(val previousScreen: CameraStageBaseScreen) : Popup(previousSc // So the whole game doesn't get stuck while downloading the file thread(name = "Music") { try { - val file = DropBox.downloadFile("/Music/thatched-villagers.mp3") - musicLocation.write(file, false) + previousScreen.game.musicController.downloadDefaultFile() Gdx.app.postRunnable { tabs.replacePage("Sound", getSoundTab()) - previousScreen.game.startMusic() + previousScreen.game.musicController.chooseTrack(flags = EnumSet.of(MusicTrackChooserFlags.PlayDefaultFile, MusicTrackChooserFlags.PlaySingle)) } } catch (ex: Exception) { Gdx.app.postRunnable { diff --git a/core/src/com/unciv/ui/worldscreen/mainmenu/WorldScreenMenuPopup.kt b/core/src/com/unciv/ui/worldscreen/mainmenu/WorldScreenMenuPopup.kt index 934724c155..6b0a95ec1d 100644 --- a/core/src/com/unciv/ui/worldscreen/mainmenu/WorldScreenMenuPopup.kt +++ b/core/src/com/unciv/ui/worldscreen/mainmenu/WorldScreenMenuPopup.kt @@ -14,6 +14,8 @@ import com.unciv.ui.worldscreen.WorldScreen class WorldScreenMenuPopup(val worldScreen: WorldScreen) : Popup(worldScreen) { init { defaults().fillX() + worldScreen.game.musicController.pause() + addButton("Main menu") { worldScreen.game.setScreen(MainMenuScreen()) } addButton("Civilopedia") { worldScreen.game.setScreen(CivilopediaScreen(worldScreen.gameInfo.ruleSet)) } addButton("Save game") { worldScreen.game.setScreen(SaveGameScreen(worldScreen.gameInfo)) } @@ -30,8 +32,11 @@ class WorldScreenMenuPopup(val worldScreen: WorldScreen) : Popup(worldScreen) { addButton("Options") { worldScreen.openOptionsPopup() } addButton("Community") { close() - WorldScreenCommunityPopup(worldScreen).open(force = true) } - addCloseButton() + WorldScreenCommunityPopup(worldScreen).open(force = true) + } + addCloseButton { + worldScreen.game.musicController.resume() + } pack() } }