mirror of
https://github.com/yairm210/Unciv.git
synced 2025-07-28 21:58:54 +07:00
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
This commit is contained in:
@ -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())
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -23,8 +23,11 @@ class GameSettings {
|
||||
var tutorialsShown = HashSet<String>()
|
||||
var tutorialTasksCompleted = HashSet<String>()
|
||||
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
|
||||
|
335
core/src/com/unciv/ui/audio/MusicController.kt
Normal file
335
core/src/com/unciv/ui/audio/MusicController.kt
Normal file
@ -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<String>()
|
||||
|
||||
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<String>(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<MusicTrackChooserFlags>): 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<String> ) {
|
||||
//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<MusicTrackChooserFlags> = 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
|
||||
}
|
14
core/src/com/unciv/ui/audio/MusicTrackChooserFlags.kt
Normal file
14
core/src/com/unciv/ui/audio/MusicTrackChooserFlags.kt
Normal file
@ -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,
|
||||
}
|
152
core/src/com/unciv/ui/audio/MusicTrackController.kt
Normal file
152
core/src/com/unciv/ui/audio/MusicTrackController.kt
Normal file
@ -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
|
||||
}
|
||||
}
|
@ -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 {
|
||||
|
@ -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() {
|
||||
|
@ -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 {
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user