package net.torvald.terrarum.modulebasegame import com.badlogic.gdx.Gdx import com.badlogic.gdx.utils.GdxRuntimeException import com.jme3.math.FastMath import net.torvald.terrarum.* import net.torvald.terrarum.App.printdbg import net.torvald.terrarum.audio.AudioMixer import net.torvald.terrarum.audio.MusicContainer import net.torvald.terrarum.gameworld.WorldTime.Companion.DAY_LENGTH import java.io.File class TerrarumMusicGovernor : MusicGovernor() { private val STATE_INIT = 0 private val STATE_FIREPLAY = 1 private val STATE_PLAYING = 2 private val STATE_INTERMISSION = 3 init { musicState = STATE_INTERMISSION } private var songs: List = emptyList() var playlistName = ""; private set /** canonicalPath with path separators converted to forward slash */ var playlistSource = "" ; private set private var musicBin: ArrayList = ArrayList() private var shuffled = true private var diskJockeyingMode = "intermittent" // intermittent, continuous private fun registerSongsFromDir(musicDir: String, fileToName: ((String) -> String)?) { val musicDir = musicDir.replace('\\', '/') playlistSource = musicDir printdbg(this, "registerSongsFromDir $musicDir") val fileToName = if (fileToName == null) { { name: String -> name.substringBeforeLast('.').replace('_', ' ').split(" ").map { it.capitalize() }.joinToString(" ") } } else fileToName playlistName = musicDir.substringAfterLast('/') songs = File(musicDir).listFiles()?.sortedBy { it.name }?.mapNotNull { printdbg(this, "Music: ${it.absolutePath}") try { MusicContainer( fileToName(it.name), it ).also { muscon -> printdbg(this, "MusicTitle: ${muscon.name}") muscon.songFinishedHook = { if (it == App.audioMixer.musicTrack.currentTrack?.gdxMusic) { stopMusic(this, true, getRandomMusicInterval()) } } } } catch (e: GdxRuntimeException) { e.printStackTrace() null } } ?: emptyList() // TODO test code } private fun restockMusicBin() { musicBin = ArrayList(if (shuffled) songs.shuffled() else songs.slice(songs.indices)) } /** * @param musicDir where the music files are. Absolute path. * @param shuffled if the tracks are to be shuffled * @param diskJockeyingMode `intermittent` to give random gap between tracks, `continuous` for continuous playback */ fun queueDirectory(musicDir: String, shuffled: Boolean, diskJockeyingMode: String, fileToName: ((String) -> String)? = null) { if (musicState != STATE_INIT && musicState != STATE_INTERMISSION) { App.audioMixer.requestFadeOut(App.audioMixer.fadeBus, AudioMixer.DEFAULT_FADEOUT_LEN) // explicit call for fade-out when the game instance quits stopMusic0(App.audioMixer.musicTrack.currentTrack) } songs.forEach { it.gdxMusic.tryDispose() } registerSongsFromDir(musicDir, fileToName) this.shuffled = shuffled this.diskJockeyingMode = diskJockeyingMode restockMusicBin() } /** * Adds a song to the head of the internal playlist (`musicBin`) */ fun queueMusicToPlayNext(music: MusicContainer) { musicBin.add(0, music) } /** * Unshifts an internal playlist (`musicBin`). The `music` argument must be the song that exists on the `songs`. */ fun unshiftPlaylist(music: MusicContainer) { val indexAtMusicBin = songs.indexOf(music) if (indexAtMusicBin < 0) throw IllegalArgumentException("The music does not exist on the internal songs list ($music)") // rewrite musicBin val newMusicBin = if (shuffled) songs.shuffled().toTypedArray().also { // if shuffled, // 1. create a shuffled version of songlist // 2. swap two songs such that the songs[indexAtMusicBin] comes first val swapTo = it.indexOf(songs[indexAtMusicBin]) val tmp = it[swapTo] it[swapTo] = it[0] it[0] = tmp } else Array(songs.size - indexAtMusicBin) { offset -> val k = offset + indexAtMusicBin songs[k] } musicBin = ArrayList(newMusicBin.toList()) } fun queueIndexFromPlaylist(indexAtMusicBin: Int) { if (indexAtMusicBin !in songs.indices) throw IndexOutOfBoundsException("The index is outside of the internal songs list ($indexAtMusicBin/${songs.size})") // rewrite musicBin val newMusicBin = if (shuffled) songs.shuffled().toTypedArray().also { // if shuffled, // 1. create a shuffled version of songlist // 2. swap two songs such that the songs[indexAtMusicBin] comes first val swapTo = it.indexOf(songs[indexAtMusicBin]) val tmp = it[swapTo] it[swapTo] = it[0] it[0] = tmp } else Array(songs.size - indexAtMusicBin) { offset -> val k = offset + indexAtMusicBin songs[k] } musicBin = ArrayList(newMusicBin.toList()) } private val ambients: HashMap> = HashMap(Terrarum.audioCodex.audio.filter { it.key.startsWith("ambient.") }.map { it.key to it.value.mapNotNull { fileHandle -> try { MusicContainer( fileHandle.nameWithoutExtension().replace('_', ' ').split(" ").map { it.capitalize() }.joinToString(" "), fileHandle.file(), loop = true, ) } catch (e: GdxRuntimeException) { e.printStackTrace() null } }.toHashSet() }.toMap()) private val musicStartHooks = ArrayList<(MusicContainer) -> Unit>() private val musicStopHooks = ArrayList<(MusicContainer) -> Unit>() init { queueDirectory(App.customMusicDir, true, "intermittent") } fun addMusicStartHook(f: (MusicContainer) -> Unit) { musicStartHooks.add(f) } fun addMusicStopHook(f: (MusicContainer) -> Unit) { musicStopHooks.add(f) } init { songs.forEach { App.disposables.add(it.gdxMusic) } ambients.forEach { (k, v) -> printdbg(this, "Ambients: $k -> $v") v.forEach { App.disposables.add(it.gdxMusic) } } } private var warningPrinted = false protected var ambState = 0 protected var ambFired = false fun getRandomMusicInterval() = 3.6f + Math.random().toFloat() * 1.2f // use shorter gap a la mixtape var stopCaller: Any? = null; private set var playCaller: Any? = null; private set var stopCallTime: Long? = null; private set private fun stopMusic0(song: MusicContainer?, callStopMusicHook: Boolean = true, customPauseLen: Float? = null) { musicState = if (customPauseLen == Float.POSITIVE_INFINITY) STATE_INIT else STATE_INTERMISSION // printdbg(this, "stopMusic1 customLen=$customPauseLen, stateNow: $musicState, called by") // printStackTrace(this) intermissionAkku = 0f intermissionLength = customPauseLen ?: getRandomMusicInterval() musicFired = false if (callStopMusicHook && musicStopHooks.isNotEmpty()) musicStopHooks.forEach { if (song != null) { it(song) } } // printdbg(this, "StopMusic Intermission: $intermissionLength seconds") } fun stopMusic(caller: Any?, callStopMusicHook: Boolean = true, pauseLen: Float = Float.POSITIVE_INFINITY) { val timeNow = System.currentTimeMillis() val trackThis = App.audioMixer.musicTrack.currentTrack if (caller is TerrarumMusicGovernor) { if (stopCaller == null) { // printdbg(this, "Caller: this, prev caller: $stopCaller, len: $pauseLen, obliging stop request") stopMusic0(trackThis, callStopMusicHook, pauseLen) } else { // printdbg(this, "Caller: this, prev caller: $stopCaller, len: $pauseLen, ignoring stop request") } } else { // printdbg(this, "Caller: $caller, prev caller: , len: $pauseLen, obliging stop request") stopMusic0(trackThis, callStopMusicHook, pauseLen) } stopCaller = caller?.javaClass?.canonicalName stopCallTime = System.currentTimeMillis() // printStackTrace(this) } fun startMusic(caller: Any?) { playCaller = caller startMusic0(pullNextMusicTrack()) } private fun startMusic0(song: MusicContainer) { stopCaller = null stopCallTime = null App.audioMixer.startMusic(song) // printdbg(this, "startMusic Now playing: ${song.name}, called by:") // printStackTrace(this) // INGAME.sendNotification("Now Playing $EMDASH ${song.name}") if (musicStartHooks.isNotEmpty()) musicStartHooks.forEach { it(song) } musicState = STATE_PLAYING intermissionLength = 42.42424f } // MixerTrackProcessor will call this function externally to make gapless playback work fun pullNextMusicTrack(callNextMusicHook: Boolean = false): MusicContainer { // printStackTrace(this) // prevent same song to play twice in row (for the most time) if (musicBin.isEmpty()) { restockMusicBin() } return musicBin.removeAt(0).also { mus -> if (callNextMusicHook && musicStartHooks.isNotEmpty()) musicStartHooks.forEach { it(mus) } } } private fun stopAmbient() { // if (::currentAmbientTrack.isInitialized) // App.audioMixer.ambientTrack.nextTrack = currentAmbientTrack } private fun startAmbient1(song: MusicContainer) { App.audioMixer.startAmb1(song) printdbg(this, "startAmbient1 Now playing: $song") ambState = STATE_PLAYING } private fun startAmbient2(song: MusicContainer) { App.audioMixer.startAmb2(song) printdbg(this, "startAmbient2 Now playing: $song") ambState = STATE_PLAYING } private fun queueAmbientForce1(song: MusicContainer) { App.audioMixer.ambientTrack1.let { it.nextTrack = song it.stop() } printdbg(this, "startAmbient1 Now playing: $song") ambState = STATE_PLAYING } private fun queueAmbientForce2(song: MusicContainer) { App.audioMixer.ambientTrack2.let { it.nextTrack = song it.stop() } printdbg(this, "startAmbient2 Now playing: $song") ambState = STATE_PLAYING } var firstTime = true override fun update(ingame: IngameInstance, delta: Float) { val timeNow = System.currentTimeMillis() val callerRecordExpired = (timeNow - (stopCallTime ?: 0L) > 1000) if (callerRecordExpired && stopCaller != null) { stopCaller = null stopCallTime = null } // start the song queueing if there is one to play if (firstTime) { firstTime = false if (songs.isNotEmpty()) musicState = STATE_INTERMISSION if (ambients.isNotEmpty()) ambState = STATE_INTERMISSION } when (musicState) { STATE_FIREPLAY -> { if (!musicFired) { musicFired = true startMusic0(pullNextMusicTrack()) } } STATE_PLAYING -> { // stopMusic() will be called when the music finishes; it's on the setOnCompletionListener } STATE_INTERMISSION -> { intermissionAkku += delta if (intermissionAkku >= intermissionLength && songs.isNotEmpty()) { intermissionAkku = 0f musicState = STATE_FIREPLAY } } } val season = ingame.world.worldTime.ecologicalSeason val isAM = (ingame.world.worldTime.todaySeconds < DAY_LENGTH / 2) // 0 until DAY_LENGTH (86400) val solarElevDeg = ingame.world.worldTime.solarElevationDeg val isSunUp = (solarElevDeg >= 0) val seasonName = when (season) { in 0f..2f -> "autumn" in 2f..3f -> "summer" in 3f..5f -> "autumn" else -> "winter" } when (ambState) { STATE_FIREPLAY -> { if (!ambFired) { ambFired = true // ambient track 1: diurnal/nocturnal // ambient track 2: crepuscular/matutinal val track1 = if (isSunUp) ambients["ambient.season.diurnal_$seasonName"]!!.random() // mad respect to Klankbeeld else ambients["ambient.season.nocturnal"]!!.random() // as it turns out ambient recordings of a wild place AT NIGHT is quite rare val track2 = if (isAM) ambients["ambient.season.matutinal"]!!.random() else ambients["ambient.season.crepuscular"]!!.random() startAmbient1(track1) startAmbient2(track2) } } STATE_PLAYING -> { // mix ambient tracks // queue up nocturnal if (!isSunUp && oldWorldSolarElev >= 0) queueAmbientForce1(ambients["ambient.season.nocturnal"]!!.random()) // as it turns out ambient recordings of a wild place AT NIGHT is quite rare // queue up diurnal else if (isSunUp && oldWorldSolarElev < 0) queueAmbientForce1(ambients["ambient.season.diurnal_$seasonName"]!!.random()) // mad respect to Klankbeeld // queue up crepuscular if (!isAM && oldWorldTime < DAY_LENGTH / 2) queueAmbientForce2(ambients["ambient.season.crepuscular"]!!.random()) else if (isAM && oldWorldTime >= DAY_LENGTH / 2) queueAmbientForce2(ambients["ambient.season.matutinal"]!!.random()) // play around the fader val track2vol = if (isAM) when (solarElevDeg) { in TRACK2_DAWN_ELEV_UP_MIN..TRACK2_DAWN_ELEV_UP_MAX -> FastMath.interpolateLinear( (solarElevDeg - TRACK2_DAWN_ELEV_UP_MIN) / (TRACK2_DAWN_ELEV_UP_MAX - TRACK2_DAWN_ELEV_UP_MIN), 1.0, 0.0 ) in TRACK2_DAWN_ELEV_DN_MAX..TRACK2_DAWN_ELEV_DN_MIN -> FastMath.interpolateLinear( (solarElevDeg - TRACK2_DAWN_ELEV_DN_MIN) / (TRACK2_DAWN_ELEV_DN_MAX - TRACK2_DAWN_ELEV_DN_MIN), 1.0, 0.0 ) in TRACK2_DAWN_ELEV_DN_MIN..TRACK2_DAWN_ELEV_UP_MIN -> 1.0 else -> 0.0 } else when (solarElevDeg) { in TRACK2_DUSK_ELEV_UP_MIN..TRACK2_DUSK_ELEV_UP_MAX -> FastMath.interpolateLinear( (solarElevDeg - TRACK2_DUSK_ELEV_UP_MIN) / (TRACK2_DUSK_ELEV_UP_MAX - TRACK2_DUSK_ELEV_UP_MIN), 1.0, 0.0 ) in TRACK2_DUSK_ELEV_DN_MAX..TRACK2_DUSK_ELEV_DN_MIN -> FastMath.interpolateLinear( (solarElevDeg - TRACK2_DUSK_ELEV_DN_MIN) / (TRACK2_DUSK_ELEV_DN_MAX - TRACK2_DUSK_ELEV_DN_MIN), 1.0, 0.0 ) in TRACK2_DUSK_ELEV_DN_MIN..TRACK2_DUSK_ELEV_UP_MIN -> 1.0 else -> 0.0 } val track1vol = 1.0 - track2vol App.audioMixer.ambientTrack1.volume = track1vol App.audioMixer.ambientTrack2.volume = track2vol } STATE_INTERMISSION -> { ambState = STATE_FIREPLAY } } oldWorldSolarElev = solarElevDeg oldWorldTime = ingame.world.worldTime.todaySeconds } private var oldWorldSolarElev = Terrarum.ingame?.world?.worldTime?.solarElevationDeg ?: 0.0 private var oldWorldTime = Terrarum.ingame?.world?.worldTime?.todaySeconds ?: 0 private val TRACK2_DUSK_ELEV_UP_MAX = 15.0 private val TRACK2_DUSK_ELEV_UP_MIN = 0.1 private val TRACK2_DUSK_ELEV_DN_MIN = -0.1 private val TRACK2_DUSK_ELEV_DN_MAX = -15.0 private val TRACK2_DAWN_ELEV_UP_MAX = 20.0 private val TRACK2_DAWN_ELEV_UP_MIN = 5.0 private val TRACK2_DAWN_ELEV_DN_MIN = 5.0 private val TRACK2_DAWN_ELEV_DN_MAX = -10.0 override fun dispose() { App.audioMixer.requestFadeOut(App.audioMixer.fadeBus, AudioMixer.DEFAULT_FADEOUT_LEN) // explicit call for fade-out when the game instance quits stopMusic0(App.audioMixer.musicTrack.currentTrack) stopAmbient() } }