Support for Leader voices (#10395)

* Prepare Leader Voices: Framework

* Leader Voices: Hooks and corresponding text field comments

* Leader Voices: wiki

* Leader Voices: oops, comments

* Decouple voice play calls to make global modification easier

* Move voices to own folder and give them a separate volume setting

* Oops, template needed too

* Oops, wiki needed too
This commit is contained in:
SomeTroglodyte 2023-11-20 21:21:30 +01:00 committed by GitHub
parent 450813fa49
commit 08c3f97f82
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 121 additions and 69 deletions

View File

@ -792,6 +792,7 @@ Sound =
Sound effects volume =
Music volume =
City ambient sound volume =
Leader voices volume =
Pause between tracks =
Pause =
Music =

View File

@ -46,6 +46,7 @@ class GameSettings {
var soundEffectsVolume = 0.5f
var citySoundsVolume = 0.5f
var musicVolume = 0.5f
var voicesVolume = 0.5f
var pauseBetweenTracks = 10
var turnsBetweenAutosaves = 1

View File

@ -27,13 +27,22 @@ class Nation : RulesetObject() {
var cityStateType: String? = null
var preferredVictoryType: String = Constants.neutralVictoryType
var declaringWar = ""
var attacked = ""
var defeated = ""
var introduction = ""
var tradeRequest = ""
/// The following all have audio hooks to play corresponding leader
/// voice clips - named <civName>.<fieldName>, e.g. "America.defeated.ogg"
/** Shown for AlertType.WarDeclaration, when other Civs declare war on a player */
var declaringWar = ""
/** Shown in DiplomacyScreen when a player declares war */
var attacked = ""
/** Shown for AlertType.Defeated */
var defeated = ""
/** Shown for AlertType.FirstContact */
var introduction = ""
/** Shown in TradePopup when other Civs initiate trade with a player */
var tradeRequest = ""
/** Shown in DiplomacyScreen when a player contacts another major civ with RelationshipLevel.Afraid or better */
var neutralHello = ""
/** Shown in DiplomacyScreen when a player contacts another major civ with RelationshipLevel.Enemy or worse */
var hateHello = ""
lateinit var outerColor: List<Int>

View File

@ -14,7 +14,7 @@ class CityAmbiencePlayer(
val volume = UncivGame.Current.settings.citySoundsVolume
if (volume > 0f) {
UncivGame.Current.musicController
.playOverlay("sounds", city.civ.getEra().citySound, volume)
.playOverlay(city.civ.getEra().citySound, volume = volume, isLooping = true)
}
}

View File

@ -22,7 +22,7 @@ import kotlin.math.roundToInt
*
* Main methods: [chooseTrack], [pause], [resume], [setModList], [isPlaying], [gracefulShutdown]
*
* City ambience feature: [playOverlay], [stopOverlay]
* City ambience / Leader voice feature: [playOverlay], [stopOverlay], [playVoice]
* * This plays entirely independent of all other functionality as linked above.
* * Can load from internal (jar,apk) - music is always local, nothing is packaged into a release.
*/
@ -103,7 +103,7 @@ class MusicController {
//region Fields
/** mirrors [GameSettings.musicVolume] - use [setVolume] to update */
private var baseVolume: Float = UncivGame.Current.settings.musicVolume
private var baseVolume: Float = settings.musicVolume
/** Pause in seconds between tracks unless [chooseTrack] is called to force a track change */
var silenceLength: Float
@ -111,7 +111,7 @@ class MusicController {
set(value) { silenceLengthInTicks = (ticksPerSecond * value).toInt() }
private var silenceLengthInTicks =
(UncivGame.Current.settings.pauseBetweenTracks * ticksPerSecond).roundToInt()
(settings.pauseBetweenTracks * ticksPerSecond).roundToInt()
private var mods = HashSet<String>()
@ -206,6 +206,8 @@ class MusicController {
//endregion
//region Internal helpers
private val settings get() = UncivGame.Current.settings
private fun clearCurrent() {
current?.clear()
current = null
@ -333,7 +335,7 @@ class MusicController {
getDefault: () -> FileHandle = { getFile(folder) }
) = sequence<FileHandle> {
yieldAll(
(UncivGame.Current.settings.visualMods + mods).asSequence()
(settings.visualMods + mods).asSequence()
.map { getFile(modPath).child(it).child(folder) }
)
yield(getDefault())
@ -598,19 +600,35 @@ class MusicController {
}
/** Play [name] from any mod's [folder] or internal assets,
* fading in to [volume] then looping */
fun playOverlay(folder: String, name: String, volume: Float) {
* fading in to [volume] then looping if [isLooping] is set.
* does nothing if no such file is found.
* Note that [volume] intentionally defaults to soundEffectsVolume, not musicVolume
* as that fits the "Leader voice" usecase better.
*/
fun playOverlay(
name: String,
folder: String = "sounds",
volume: Float = settings.soundEffectsVolume,
isLooping: Boolean = false,
fadeIn: Boolean = false
) {
val file = getMatchingFiles(folder, name).firstOrNull() ?: return
playOverlay(file, volume)
playOverlay(file, volume, isLooping, fadeIn)
}
/** Play [file], fading in to [volume] then looping */
/** Called for Leader Voices */
fun playVoice(name: String) = playOverlay(name, "voices", settings.voicesVolume)
/** Determines if any 'voices' folder exists in any currently active mod */
fun isVoicesAvailable() = getMusicFolders("voices").any()
/** Play [file], [optionally][fadeIn] fading in to [volume] then looping if [isLooping] is set */
@Suppress("MemberVisibilityCanBePrivate") // open to future use
fun playOverlay(file: FileHandle, volume: Float) {
fun playOverlay(file: FileHandle, volume: Float, isLooping: Boolean, fadeIn: Boolean) {
clearOverlay()
MusicTrackController(volume, initialFadeVolume = 0f).load(file) {
it.music?.isLooping = true
MusicTrackController(volume, initialFadeVolume = if (fadeIn) 0f else 1f).load(file) {
it.music?.isLooping = isLooping
it.play()
//todo Needs to be called even when no fade desired to correctly set state - Think about changing that
it.startFade(MusicTrackController.State.FadeIn)
overlay = it
}
@ -622,7 +640,7 @@ class MusicController {
}
private fun MusicTrackController.overlayTick() {
if (timerTick() == MusicTrackController.State.Idle)
if (timerTick() == MusicTrackController.State.Idle || !isPlaying())
clearOverlay() // means FadeOut finished
}

View File

@ -8,15 +8,15 @@ import com.unciv.models.metadata.GameSettings
import com.unciv.models.translations.tr
import com.unciv.ui.audio.MusicController
import com.unciv.ui.audio.MusicTrackChooserFlags
import com.unciv.ui.screens.basescreen.BaseScreen
import com.unciv.ui.components.widgets.UncivSlider
import com.unciv.ui.components.widgets.WrappableLabel
import com.unciv.ui.components.extensions.disable
import com.unciv.ui.components.input.onClick
import com.unciv.ui.components.extensions.toImageButton
import com.unciv.ui.components.extensions.toLabel
import com.unciv.ui.components.extensions.toTextButton
import com.unciv.ui.components.extensions.toImageButton
import com.unciv.ui.components.input.onClick
import com.unciv.ui.components.widgets.UncivSlider
import com.unciv.ui.components.widgets.WrappableLabel
import com.unciv.ui.popups.Popup
import com.unciv.ui.screens.basescreen.BaseScreen
import com.unciv.utils.Concurrency
import com.unciv.utils.launchOnGLThread
import kotlin.math.floor
@ -33,9 +33,11 @@ fun soundTab(
addSoundEffectsVolumeSlider(this, settings)
addCitySoundsVolumeSlider(this, settings)
if (UncivGame.Current.musicController.isMusicAvailable()) {
if (UncivGame.Current.musicController.isVoicesAvailable())
addVoicesVolumeSlider(this, settings)
if (UncivGame.Current.musicController.isMusicAvailable())
addMusicControls(this, settings, music)
}
if (!UncivGame.Current.musicController.isDefaultFileAvailable())
addDownloadMusic(this, optionsPopup)
@ -70,49 +72,42 @@ private fun addDownloadMusic(table: Table, optionsPopup: OptionsPopup) {
}
}
private fun addSoundEffectsVolumeSlider(table: Table, settings: GameSettings) {
table.add("Sound effects volume".tr()).left().fillX()
private fun Table.addVolumeSlider(text: String, initial: Float, silent: Boolean = false, onChange: (Float)->Unit) {
add(text.tr()).left().fillX()
val soundEffectsVolumeSlider = UncivSlider(
val volumeSlider = UncivSlider(
0f, 1.0f, 0.05f,
initial = settings.soundEffectsVolume,
getTipText = UncivSlider::formatPercent
) {
initial = initial,
sound = if (silent) UncivSound.Silent else UncivSound.Slider,
getTipText = UncivSlider::formatPercent,
onChange = onChange
)
add(volumeSlider).pad(5f).row()
}
private fun addSoundEffectsVolumeSlider(table: Table, settings: GameSettings) =
table.addVolumeSlider("Sound effects volume", settings.soundEffectsVolume) {
settings.soundEffectsVolume = it
}
table.add(soundEffectsVolumeSlider).pad(5f).row()
}
private fun addCitySoundsVolumeSlider(table: Table, settings: GameSettings) {
table.add("City ambient sound volume".tr()).left().fillX()
val citySoundVolumeSlider = UncivSlider(
0f, 1.0f, 0.05f,
initial = settings.citySoundsVolume,
getTipText = UncivSlider::formatPercent
) {
private fun addCitySoundsVolumeSlider(table: Table, settings: GameSettings) =
table.addVolumeSlider("City ambient sound volume", settings.citySoundsVolume) {
settings.citySoundsVolume = it
}
table.add(citySoundVolumeSlider).pad(5f).row()
}
private fun addMusicVolumeSlider(table: Table, settings: GameSettings, music: MusicController) {
table.add("Music volume".tr()).left().fillX()
private fun addVoicesVolumeSlider(table: Table, settings: GameSettings) =
table.addVolumeSlider("Leader voices volume", settings.voicesVolume) {
settings.voicesVolume = it
}
val musicVolumeSlider = UncivSlider(
0f, 1.0f, 0.05f,
initial = settings.musicVolume,
sound = UncivSound.Silent,
getTipText = UncivSlider::formatPercent
) {
private fun addMusicVolumeSlider(table: Table, settings: GameSettings, music: MusicController) =
table.addVolumeSlider("Music volume".tr(), settings.musicVolume, true) {
settings.musicVolume = it
music.setVolume(it)
if (!music.isPlaying())
music.chooseTrack(flags = MusicTrackChooserFlags.setPlayDefault)
}
table.add(musicVolumeSlider).pad(5f).row()
}
private fun addMusicPauseSlider(table: Table, settings: GameSettings, music: MusicController) {
// map to/from 0-1-2..10-12-14..30-35-40..60-75-90-105-120

View File

@ -242,7 +242,9 @@ class DiplomacyScreen(
diplomacyManager.declareWar()
setRightSideFlavorText(otherCiv, otherCiv.nation.attacked, "Very well.")
updateLeftSideTable(otherCiv)
UncivGame.Current.musicController.chooseTrack(otherCiv.civName, MusicMood.War, MusicTrackChooserFlags.setSpecific)
val music = UncivGame.Current.musicController
music.chooseTrack(otherCiv.civName, MusicMood.War, MusicTrackChooserFlags.setSpecific)
music.playVoice("${otherCiv.civName}.attacked")
}.open()
}
if (isNotPlayersTurn()) declareWarButton.disable()

View File

@ -4,6 +4,7 @@ import com.badlogic.gdx.graphics.Color
import com.badlogic.gdx.scenes.scene2d.ui.Table
import com.badlogic.gdx.scenes.scene2d.ui.TextButton
import com.unciv.Constants
import com.unciv.UncivGame
import com.unciv.logic.civilization.AlertType
import com.unciv.logic.civilization.Civilization
import com.unciv.logic.civilization.PopupAlert
@ -33,9 +34,15 @@ class MajorCivDiplomacyTable(private val diplomacyScreen: DiplomacyScreen) {
val diplomacyTable = Table()
diplomacyTable.defaults().pad(10f)
val helloText = if (otherCivDiplomacyManager.isRelationshipLevelLE(RelationshipLevel.Enemy))
otherCiv.nation.hateHello
else otherCiv.nation.neutralHello
val helloText: String
val helloVoice: String
if (otherCivDiplomacyManager.isRelationshipLevelLE(RelationshipLevel.Enemy)) {
helloText = otherCiv.nation.hateHello
helloVoice = "${otherCiv.civName}.hateHello"
} else {
helloText = otherCiv.nation.neutralHello
helloVoice = "${otherCiv.civName}.neutralHello"
}
val leaderIntroTable = LeaderIntroTable(otherCiv, helloText)
diplomacyTable.add(leaderIntroTable).row()
diplomacyTable.addSeparator()
@ -85,6 +92,9 @@ class MajorCivDiplomacyTable(private val diplomacyScreen: DiplomacyScreen) {
if (promisesTable != null) diplomacyTable.add(promisesTable).row()
}
// Starting playback here assumes the MajorCivDiplomacyTable is shown immediately
UncivGame.Current.musicController.playVoice(helloVoice)
return diplomacyTable
}

View File

@ -189,6 +189,7 @@ internal class ModInfoAndActionPane : Table() {
}
if (isSubFolderNotEmpty(folder, "music")) return true
if (isSubFolderNotEmpty(folder, "sounds")) return true
if (isSubFolderNotEmpty(folder, "voices")) return true
return folder.list("atlas").isNotEmpty()
}
}

View File

@ -211,6 +211,7 @@ class AlertPopup(
addGoodSizedLabel(civInfo.nation.defeated).row()
addCloseButton("Farewell.")
music.chooseTrack(civInfo.civName, MusicMood.Defeat, EnumSet.of(MusicTrackChooserFlags.SuffixMustMatch))
music.playVoice("${civInfo.civName}.defeated")
}
private fun addDemandToStopSettlingCitiesNear() {
@ -253,6 +254,7 @@ class AlertPopup(
val nation = civInfo.nation
addLeaderName(civInfo)
music.chooseTrack(civInfo.civName, MusicMood.themeOrPeace, MusicTrackChooserFlags.setSpecific)
music.playVoice("${civInfo.civName}.introduction")
if (civInfo.isCityState()) {
addGoodSizedLabel("We have encountered the City-State of [${nation.name}]!").row()
addCloseButton("Excellent!")
@ -357,6 +359,7 @@ class AlertPopup(
addCloseButton("You'll pay for this!")
addCloseButton("Very well.")
music.chooseTrack(civInfo.civName, MusicMood.War, MusicTrackChooserFlags.setSpecific)
music.playVoice("${civInfo.civName}.declaringWar")
}
private fun addWonderBuilt() {

View File

@ -2,18 +2,19 @@ package com.unciv.ui.screens.worldscreen
import com.badlogic.gdx.graphics.Color
import com.badlogic.gdx.scenes.scene2d.ui.Table
import com.unciv.UncivGame
import com.unciv.logic.civilization.NotificationCategory
import com.unciv.logic.civilization.NotificationIcon
import com.unciv.logic.trade.TradeLogic
import com.unciv.logic.trade.TradeOffer
import com.unciv.logic.trade.TradeType
import com.unciv.models.translations.tr
import com.unciv.ui.components.extensions.pad
import com.unciv.ui.components.extensions.toLabel
import com.unciv.ui.components.input.KeyCharAndCode
import com.unciv.ui.popups.Popup
import com.unciv.ui.screens.diplomacyscreen.DiplomacyScreen
import com.unciv.ui.screens.diplomacyscreen.LeaderIntroTable
import com.unciv.ui.components.input.KeyCharAndCode
import com.unciv.ui.components.extensions.pad
import com.unciv.ui.components.extensions.toLabel
import kotlin.math.max
import kotlin.math.min
import com.unciv.ui.components.widgets.AutoScrollPane as ScrollPane
@ -76,6 +77,8 @@ class TradePopup(worldScreen: WorldScreen) : Popup(worldScreen) {
addSeparator(Color.DARK_GRAY, height = 1f)
// Starting playback here assumes the TradePopup is shown immediately
UncivGame.Current.musicController.playVoice("${requestingCiv.civName}.tradeRequest")
addGoodSizedLabel(nation.tradeRequest).pad(15f).row()
addButton("Sounds good!", 'y') {

View File

@ -160,3 +160,11 @@ Legend:
- [^3]: According to your relation to the picked player.
- [^4]: Excluding City States.
- [^5]: Both in the alert when another player declares War on you and declaring War yourself in Diplomacy screen.
## Supply Leader Voices
Sound files named from a Nation name and the corresponding text message's [field name](Mod-file-structure/2-Civilization-related-JSON-files.md#nationsjson),
placed in a mod's `voices` folder, will play whenever that message is displayed. Nation name and message name must be joined with a dot '.', for example `voices/Zulu.defeated.ogg`.
Leader voice audio clips will be streamed, not cached, so they are allowed to be long - however, if another Leader voice or a city ambient sound needs to be played, they will be cut off without fade-out
Also note that voices for City-State leaders work only for those messages a City-state can actually use: `attacked`, `defeated`, and `introduction`.

View File

@ -74,20 +74,20 @@ This file contains all the nations and city states, including Barbarians and Spe
| preferredVictoryType | Enum | Default Neutral | Neutral, Cultural, Diplomatic, Domination or Scientific |
| startIntroPart1 | String | Default empty | Introductory blurb shown to Player on game start... |
| startIntroPart2 | String | Default empty | ... second paragraph. ***NO*** "TBD"!!! Leave empty to skip that alert. |
| declaringWar | String | Default empty | another greeting |
| attacked | String | Default empty | another greeting |
| defeated | String | Default empty | another greeting |
| introduction | String | Default empty | another greeting |
| neutralHello | String | Default empty | another greeting |
| hateHello | String | Default empty | another greeting |
| tradeRequest | String | Default empty | another greeting |
| declaringWar | String | Default empty | another greeting, voice hook supported [^V] |
| attacked | String | Default empty | another greeting, voice hook supported [^V] |
| defeated | String | Default empty | another greeting, voice hook supported [^V] |
| introduction | String | Default empty | another greeting, voice hook supported [^V] |
| neutralHello | String | Default empty | another greeting, voice hook supported [^V] |
| hateHello | String | Default empty | another greeting, voice hook supported [^V] |
| tradeRequest | String | Default empty | another greeting, voice hook supported [^V] |
| innerColor | 3x Integer | Default black | R, G, B for outer ring of nation icon |
| outerColor | 3x Integer | Required | R, G, B for inner circle of nation icon |
| uniqueName | String | Default empty | Decorative name for the special characteristic of this Nation |
| uniqueText | String | Default empty | Replacement text for "uniques". If empty, uniques are listed individually. |
| uniques | List | Default empty | Properties of the civilization - see [here](../Unique-parameters.md#general-uniques) |
| uniques | List | Default empty | Properties of the civilization - see [here](../Unique-parameters.md#general-uniques) |
| cities | List | Default empty | City names used sequentially for newly founded cities. |
| civilopediaText | List | Default empty | see [civilopediaText chapter](5-Miscellaneous-JSON-files.md#civilopedia-text) |
| civilopediaText | List | Default empty | see [civilopediaText chapter](5-Miscellaneous-JSON-files.md#civilopedia-text) |
[^S]: A "Coast" preference (_unless_ combined with "Avoid") is translated to a complex test for coastal land tiles, tiles next to Lakes, river tiles or near-river tiles, and such civs are processed first. Other startBias entries are ignored in that case.
Other positive (no "Avoid") startBias are processed next. Multiple positive preferences are treated equally, but get no "fallback".
@ -95,6 +95,7 @@ This file contains all the nations and city states, including Barbarians and Spe
Multiple "Avoid" entries are treated equally (and reduce chance for success - if no region is left avoiding _all_ specified types that civ gets a random one).
When combining preferred terrain with "Avoid", the latter takes precedence, and preferred terrain only has minor weight when choosing between regions that are not of a type to avoid.
These notes are **only** valid when playing on generated maps, loaded maps from map editor get no "regions" and startBias is processed differently (but you can expect single-entry startBias to work best).
[^V]: See [Supply Leader Voices](../Images-and-Audio.md#supply-leader-voices)
## Policies.json