UI candy: WLTK fireworks (#11616)

* Particle effect fireworks for WLTK

* Refactor fireworks, try another location calculation

* Save todo list

* Fireworks assets

* ParticleEffectAnimation framework refactor and FasterUIDevelopment

* ParticleEffectAnimation rework - works correctly now

* ParticleEffectAnimation - credits and fine-tune

* ParticleEffectAnimation - atlas

* ParticleEffectAnimation - clean up testing code

* ParticleEffectAnimation - fix bungled texture move
This commit is contained in:
SomeTroglodyte 2024-06-04 17:00:44 +02:00 committed by GitHub
parent 7e3bbb6053
commit a046e43dbf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 2968 additions and 353 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 923 KiB

After

Width:  |  Height:  |  Size: 928 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,200 @@
package com.unciv.ui.components
import com.badlogic.gdx.Gdx
import com.badlogic.gdx.graphics.GL20
import com.badlogic.gdx.graphics.g2d.ParticleEffect
import com.badlogic.gdx.graphics.g2d.ParticleEmitter
import com.badlogic.gdx.graphics.g2d.ParticleEmitter.RangedNumericValue
import com.badlogic.gdx.math.Interpolation
import com.badlogic.gdx.math.Rectangle
import com.badlogic.gdx.math.Vector2
import com.badlogic.gdx.scenes.scene2d.Stage
import com.badlogic.gdx.utils.Array
import com.badlogic.gdx.utils.Disposable
import com.unciv.UncivGame
import com.unciv.ui.images.ImageGetter
/** Hosts one template ParticleEffect and any number of clones, and each can travel linearly over part of its lifetime.
* @property load Must be called from a subclass at least once - see detailed Kdoc
* @property configure Optionally modifies all clones - see detailed Kdoc
* @property getTargetBounds Where to draw on the stage - see detailed Kdoc
* @property getScale Optionally scales all effects - see detailed Kdoc
* @property render Needs to be called from the hosting Screen in its render override
* @property dispose Required - this is just the Gdx Disposable interface, no automatic call
*/
abstract class ParticleEffectAnimation : Disposable {
@Suppress("MemberVisibilityCanBePrivate", "ConstPropertyName")
companion object {
const val defaultAtlasName = "ConstructionIcons"
fun isEnabled(game: UncivGame, atlasName: String = defaultAtlasName) =
game.settings.continuousRendering && ImageGetter.getSpecificAtlas(atlasName) != null
private val halfPoint = Vector2(0.5f, 0.5f)
}
private val templateEffect = ParticleEffect()
/** Unit: ms */
private var maxDuration: Float = 0f
// val pool: ParticleEffectPool - can't, since we're filtering emitters by removing them, we need a fresh clone for every repetition. A pool won't give fresh clones...
private var nextIndex = 0
/**
* Represents one currently running effect
* - Points are relative to the bounds provided by [getTargetBounds], and would normally stay in the (0..1) range. They default to (0.5,0.5).
* - Time units are seconds (while Gdx [ParticleEffect] uses milliseconds), matching [render] delta.
* @property index A sequential counter, just in case a [configure] or [onComplete] needs some handle to differentiate parallel or repeated effects. Starts at 0.
* @property effect One clone of the template effect [ParticleEffectAnimation] loads (use e.g. [removeEmitters] to modify)
* @property startPoint If the effect should travel over time, this is the start point.
* @property endPoint If the effect should travel over time, this is the end point.
* @property delay This is the initial delay before travel starts. Defaults to 0, when >0 all emitters in the effect get this added to their own delay value.
* @property travelTime This is the travel duration - after this is up, the effect stays at [endPoint]. Defaults to the maximum (duration+delay) of all emitters.
* @property interpolation Applied to travel time percentage
* @property accumulatedTime For [render] to accumulate time in, as effect has none, and individual emitters do not accumulate in a readable way over the entire effect duration.
*/
protected data class ParticleEffectData(
val index: Int,
val effect: ParticleEffect,
var startPoint: Vector2 = halfPoint,
var endPoint: Vector2 = halfPoint,
var delay: Float = 0f,
var travelTime: Float,
var interpolation: Interpolation = Interpolation.linear
) {
private var accumulatedTime = 0f
private var percent = 0f
fun update(delta: Float) {
accumulatedTime += delta
val rawPercent = (accumulatedTime - delay) / travelTime
percent = interpolation.apply(rawPercent.coerceIn(0f, 1f))
}
fun currentX() = startPoint.x + percent * (endPoint.x - startPoint.x)
fun currentY() = startPoint.y + percent * (endPoint.y - startPoint.y)
}
private val activeEffectData = arrayListOf<ParticleEffectData>()
//private val effectsBatch: Batch = SpriteBatch()
private val targetBounds = Rectangle()
private var lastScale = 1f
/** Fetch where to draw in stage coordinates
* - Implementation should ***set*** the fields of the provided rectangle instance.
* - Note the bounds do not limit the effects themselves, but determine effect travel over time.
* - Actual size of the effects are determined by the effect definition and [getScale].
* - For stationary effects, width=0 and height=0 are perfectly acceptable.
*/
abstract fun getTargetBounds(bounds: Rectangle)
/** Return how the effects should be scaled relative to their definition (which already uses world coordinates).
* - Changing this while the effects are rendering is relatively expensive
*/
protected open fun getScale() = 1f
/** Allows the subclass to change an effect about to be started.
* - You *can* modify the effect directly, e.g. to [remove emitters][removeEmitters]
* - You can alter startPoint and endPoint by assigning new Vector2 instances - do not mutate the default
* - You can alter delay and travelTime
* @see ParticleEffectData
*/
protected open fun ParticleEffectData.configure() {}
/** Called whenever an effect says it's complete.
* @param effectData The info on the just-completed effect - most clients won't need this
* @return whether and how many effects should ***restart*** once completed (actually creates new clones of the template effect and also calls [configure])
*/
protected open fun onComplete(effectData: ParticleEffectData) = 0
/** @return number of currently running effect clones */
protected fun activeCount() = activeEffectData.size
/** Loads the effect definition, creates a pool from it and starts [count] instances potentially modified by [configure]. */
protected fun load(effectsFile: String, atlasName: String, count: Int) {
activeEffectData.clear()
val atlas = ImageGetter.getSpecificAtlas(atlasName)!!
templateEffect.load(Gdx.files.internal(effectsFile), atlas)
templateEffect.setEmittersCleanUpBlendFunction(false) // Treat it as Unknown whether the effect file changes blend state -> do it ourselves
maxDuration = templateEffect.emitters.maxOf { it.getDuration().lowMax + (if (it.delay.isActive) it.delay.lowMax else 0f) }
repeat(count, ::newEffect)
}
@Suppress("UNUSED_PARAMETER") // Signature to match `repeat` argument
private fun newEffect(dummy: Int) {
val effect = ParticleEffect(templateEffect)
val data = ParticleEffectData(nextIndex, effect, travelTime = maxDuration / 1000)
nextIndex++
data.configure()
if (data.delay > 0f) {
for (emitter in effect.emitters)
emitter.delay.add(data.delay * 1000)
}
if (lastScale != 1f)
effect.scaleEffect(lastScale)
effect.start()
activeEffectData += data
}
private fun RangedNumericValue.add(delta: Float) {
if (isActive) {
lowMin += delta
lowMax += delta
} else {
isActive = true
lowMin = delta
lowMax = delta
}
}
override fun dispose() {
val effects = activeEffectData.toList()
activeEffectData.clear()
for (effect in effects) effect.effect.dispose()
templateEffect.dispose()
}
fun render(stage: Stage?, delta: Float) {
if (maxDuration == 0f) return
val effectsBatch = stage?.batch ?: return
effectsBatch.projectionMatrix = stage.viewport.camera.combined
getTargetBounds(targetBounds)
val newScale = getScale()
if (newScale != lastScale) {
val scaleChange = newScale / lastScale
lastScale = newScale
for ((_, effect) in activeEffectData) {
effect.scaleEffect(scaleChange)
}
}
var repeatCount = 0
effectsBatch.begin()
val iterator = activeEffectData.iterator()
while (iterator.hasNext()) {
val effectData = iterator.next()
val effect = effectData.effect
effectData.update(delta)
val x = targetBounds.x + targetBounds.width * effectData.currentX()
val y = targetBounds.y + targetBounds.height * effectData.currentY()
effect.setPosition(x, y)
effect.draw(effectsBatch, delta)
if (effect.isComplete) {
repeatCount += onComplete(effectData)
effect.dispose()
iterator.remove()
}
}
effectsBatch.setBlendFunction(GL20.GL_SRC_ALPHA, GL20.GL_ONE_MINUS_SRC_ALPHA)
effectsBatch.end()
repeat(repeatCount, ::newEffect)
}
protected fun ParticleEffect.removeEmitters(predicate: (String)->Boolean) {
val matches = Array<ParticleEmitter>()
for (emitter in emitters)
if (predicate(emitter.name)) matches.add(emitter)
emitters.removeAll(matches, true) // This is a Gdx method, not kotlin MutableCollection.removeAll
}
}

View File

@ -0,0 +1,73 @@
package com.unciv.ui.components
import com.badlogic.gdx.math.Interpolation
import com.badlogic.gdx.math.Vector2
import kotlin.random.Random
abstract class ParticleEffectFireworks : ParticleEffectAnimation() {
@Suppress("ConstPropertyName")
companion object {
const val effectsFile = "effects/fireworks.p"
private val initialEndPoints = arrayOf(
Vector2(0.5f, 1.5f),
Vector2(0.2f, 1f),
Vector2(0.8f, 1f),
)
private val startPoint = Vector2(0.5f, 0f)
// These define the range of random endPoints and thus how far out of the offered Actor bounds the rockets can fly
private const val amplitudeX = 1f
private const val offsetX = 0f
private const val amplitudeY = 1f
private const val offsetY = 0.5f
// Duration of tracer effect taken from the definition file
private const val travelTime = 0.6f
// Delay between initial effects
private const val initialDelay = travelTime
// Max delay for next effect
private const val amplitudeDelay = 0.25f
// These two determine how often onComplete will ask for an extra restart or omit one
private const val minEffects = 3
private const val maxEffects = 5
}
fun load() {
load(effectsFile, defaultAtlasName, initialEndPoints.size)
}
override fun ParticleEffectData.configure() {
startPoint = Companion.startPoint
endPoint = if (index in initialEndPoints.indices) initialEndPoints[index]
else Vector2(offsetX + amplitudeX * Random.nextFloat(), offsetY + amplitudeY * Random.nextFloat())
delay = if (index in initialEndPoints.indices) index * initialDelay
else Random.nextFloat() * amplitudeDelay
travelTime = Companion.travelTime
interpolation = Interpolation.fastSlow
// The file definition has a whole bunch of "explosions" - a "rainbow" and six "shower-color" ones.
// Show either "rainbow" alone or a random selection of "shower" emitters.
// It also has some "dazzler" emitters that shouldn't be included in most runs.
val type = Random.nextInt(-1, 5)
if (type < 0) {
// Leave only rainbow emitter
effect.removeEmitters { it.startsWith("shower") }
} else {
// remove rainbow emitter and [type] "shower-color" emitters
val names = effect.emitters.asSequence()
.map { it.name }.filter { it.startsWith("shower") }
.shuffled().take(type)
.toSet() + "rainbow"
effect.removeEmitters { it in names }
}
if (Random.nextInt(4) > 0)
effect.removeEmitters { it.startsWith("dazzler") }
}
override fun onComplete(effectData: ParticleEffectData): Int {
if (Random.nextInt(4) > 0) return 1
if (activeCount() <= minEffects) return Random.nextInt(1, 3)
if (activeCount() >= maxEffects) return Random.nextInt(0, 2)
return Random.nextInt(0, 3)
}
}

View File

@ -0,0 +1,50 @@
package com.unciv.ui.components
import com.badlogic.gdx.math.Rectangle
import com.unciv.UncivGame
import com.unciv.ui.components.tilegroups.CityTileGroup
import com.unciv.ui.components.widgets.ZoomableScrollPane
import com.unciv.ui.screens.cityscreen.CityMapHolder
// todo sound
// todo moddability (refactor media search first)
/**
* Display fireworks using the Gdx ParticleEffect system, over a map view, centered on a specific tile.
* - Use the [create] factory for instantiation - it handles checking the continuousRendering setting and asset existence.
* - Repeats endlessly
* - Handles the zooming and panning of the map
* - Intentionally exceeds the bounds of the passed (TileGroup) actor bounds, but not by much
* @param mapHolder the CityMapHolder (or WorldMapHolder) this should draw over
* @property setActorBounds Informs this where, relative to the TileGroupMap that is zoomed and panned through the ZoomableScrollPane, to draw - can be constant over lifetime
*/
class ParticleEffectMapFireworks(
private val mapHolder: ZoomableScrollPane
) : ParticleEffectFireworks() {
companion object {
fun create(game: UncivGame, mapScrollPane: CityMapHolder): ParticleEffectMapFireworks? {
if (!isEnabled(game, defaultAtlasName)) return null
return ParticleEffectMapFireworks(mapScrollPane).apply { load() }
}
}
private val actorBounds = Rectangle()
private val tempViewport = Rectangle()
// The factors below are just fine-tuning the looks, and avoid lengthy particle effect file edits
fun setActorBounds(tileGroup: CityTileGroup) {
tileGroup.run { actorBounds.set(x + (width - hexagonImageWidth) / 2, y + height / 4, hexagonImageWidth, height * 1.667f) }
}
override fun getScale() = mapHolder.scaleX * 0.667f
override fun getTargetBounds(bounds: Rectangle) {
// Empiric math - any attempts to ask Gdx via localToStageCoordinates were way off
val scale = mapHolder.scaleX // just assume scaleX==scaleY
mapHolder.getViewport(tempViewport)
bounds.x = (actorBounds.x - tempViewport.x) * scale
bounds.y = (actorBounds.y - tempViewport.y) * scale
bounds.width = actorBounds.width * scale
bounds.height = actorBounds.height * scale
}
}

View File

@ -24,7 +24,7 @@ enum class CityTileState {
BLOCKADED BLOCKADED
} }
class CityTileGroup(val city: City, tile: Tile, tileSetStrings: TileSetStrings) : TileGroup(tile,tileSetStrings) { class CityTileGroup(val city: City, tile: Tile, tileSetStrings: TileSetStrings, private val nightMode: Boolean) : TileGroup(tile,tileSetStrings) {
var tileState = CityTileState.NONE var tileState = CityTileState.NONE
@ -40,11 +40,20 @@ class CityTileGroup(val city: City, tile: Tile, tileSetStrings: TileSetStrings)
layerMisc.removeWorkedIcon() layerMisc.removeWorkedIcon()
var icon: Actor? = null var icon: Actor? = null
val setDimmed = if (nightMode) fun(factor: Float) {
layerTerrain.dim(0.25f * factor)
} else fun(factor: Float) {
layerTerrain.dim(0.5f * factor)
}
val setUndimmed = if (nightMode) fun() {
layerTerrain.dim(0.5f)
} else fun() {}
when { when {
// Does not belong to us // Does not belong to us
tile.getOwner() != city.civ -> { tile.getOwner() != city.civ -> {
layerTerrain.dim(0.3f) setDimmed(0.6f)
layerMisc.setYieldVisible(UncivGame.Current.settings.showTileYields) layerMisc.setYieldVisible(UncivGame.Current.settings.showTileYields)
layerMisc.dimYields(true) layerMisc.dimYields(true)
@ -70,31 +79,34 @@ class CityTileGroup(val city: City, tile: Tile, tileSetStrings: TileSetStrings)
// Out of city range // Out of city range
tile !in city.tilesInRange -> { tile !in city.tilesInRange -> {
layerTerrain.dim(0.5f) setDimmed(1f)
layerMisc.dimYields(true) layerMisc.dimYields(true)
} }
// Worked by another city // Worked by another city
tile.isWorked() && tile.getWorkingCity() != city -> { tile.isWorked() && tile.getWorkingCity() != city -> {
layerTerrain.dim(0.5f) setDimmed(1f)
layerMisc.dimYields(true) layerMisc.dimYields(true)
} }
// City Center // City Center
tile.isCityCenter() -> { tile.isCityCenter() -> {
icon = ImageGetter.getImage("TileIcons/CityCenter") icon = ImageGetter.getImage("TileIcons/CityCenter")
// Night mode does not apply to the city tile itself
layerMisc.dimYields(false) layerMisc.dimYields(false)
} }
// Does not provide yields // Does not provide yields
tile.stats.getTileStats(city, city.civ).isEmpty() -> { tile.stats.getTileStats(city, city.civ).isEmpty() -> {
// Do nothing // Do nothing except night-mode dimming
setUndimmed()
} }
// Blockaded // Blockaded
tile.isBlockaded() -> { tile.isBlockaded() -> {
icon = ImageGetter.getImage("TileIcons/Blockaded") icon = ImageGetter.getImage("TileIcons/Blockaded")
tileState = CityTileState.BLOCKADED tileState = CityTileState.BLOCKADED
setUndimmed()
layerMisc.dimYields(true) layerMisc.dimYields(true)
} }
@ -102,6 +114,7 @@ class CityTileGroup(val city: City, tile: Tile, tileSetStrings: TileSetStrings)
tile.isLocked() -> { tile.isLocked() -> {
icon = ImageGetter.getImage("TileIcons/Locked") icon = ImageGetter.getImage("TileIcons/Locked")
tileState = CityTileState.WORKABLE tileState = CityTileState.WORKABLE
setUndimmed()
layerMisc.dimYields(false) layerMisc.dimYields(false)
} }
@ -109,18 +122,21 @@ class CityTileGroup(val city: City, tile: Tile, tileSetStrings: TileSetStrings)
tile.isWorked() -> { tile.isWorked() -> {
icon = ImageGetter.getImage("TileIcons/Worked") icon = ImageGetter.getImage("TileIcons/Worked")
tileState = CityTileState.WORKABLE tileState = CityTileState.WORKABLE
setUndimmed()
layerMisc.dimYields(false) layerMisc.dimYields(false)
} }
// Provides yield without worker assigned (isWorked already tested above) // Provides yield without worker assigned (isWorked already tested above)
tile.providesYield() -> { tile.providesYield() -> {
// defaults are OK // defaults are OK
setUndimmed()
} }
// Not-worked // Not-worked
else -> { else -> {
icon = ImageGetter.getImage("TileIcons/NotWorked") icon = ImageGetter.getImage("TileIcons/NotWorked")
tileState = CityTileState.WORKABLE tileState = CityTileState.WORKABLE
setUndimmed()
layerMisc.dimYields(true) layerMisc.dimYields(true)
} }
} }

View File

@ -396,18 +396,20 @@ open class ZoomableScrollPane(
return true return true
} }
/** @return the currently scrolled-to viewport of the whole scrollable area */ /** Overwrite [rect] with the currently scrolled-to viewport of the whole scrollable area */
private fun getViewport(): Rectangle { fun getViewport(rect: Rectangle) {
val viewportFromLeft = scrollX val viewportFromLeft = scrollX
/** In the default coordinate system, the y origin is at the bottom, but scrollY is from the top, so we need to invert. */ /** In the default coordinate system, the y origin is at the bottom, but scrollY is from the top, so we need to invert. */
val viewportFromBottom = maxY - scrollY val viewportFromBottom = maxY - scrollY
return Rectangle( rect.x = viewportFromLeft - horizontalPadding
viewportFromLeft - horizontalPadding, rect.y = viewportFromBottom - verticalPadding
viewportFromBottom - verticalPadding, rect.width = width
width, rect.height = height
height)
} }
/** @return the currently scrolled-to viewport of the whole scrollable area */
private fun getViewport() = Rectangle().also { getViewport(it) }
private fun onViewportChanged() { private fun onViewportChanged() {
onViewportChangedListener?.invoke(maxX, maxY, getViewport()) onViewportChangedListener?.invoke(maxX, maxY, getViewport())
} }

View File

@ -56,6 +56,8 @@ object ImageGetter {
private val textureRegionDrawables = HashMap<String, TextureRegionDrawable>() private val textureRegionDrawables = HashMap<String, TextureRegionDrawable>()
private val ninePatchDrawables = HashMap<String, NinePatchDrawable>() private val ninePatchDrawables = HashMap<String, NinePatchDrawable>()
fun getSpecificAtlas(name: String): TextureAtlas? = atlases[name]
fun resetAtlases() { fun resetAtlases() {
atlases.values.forEach { it.dispose() } atlases.values.forEach { it.dispose() }
atlases.clear() atlases.clear()

View File

@ -21,6 +21,7 @@ import com.unciv.models.stats.Stat
import com.unciv.models.translations.tr import com.unciv.models.translations.tr
import com.unciv.ui.audio.CityAmbiencePlayer import com.unciv.ui.audio.CityAmbiencePlayer
import com.unciv.ui.audio.SoundPlayer import com.unciv.ui.audio.SoundPlayer
import com.unciv.ui.components.ParticleEffectMapFireworks
import com.unciv.ui.components.extensions.colorFromRGB import com.unciv.ui.components.extensions.colorFromRGB
import com.unciv.ui.components.extensions.disable import com.unciv.ui.components.extensions.disable
import com.unciv.ui.components.extensions.packIfNeeded import com.unciv.ui.components.extensions.packIfNeeded
@ -137,16 +138,20 @@ class CityScreen(
private var cityAmbiencePlayer: CityAmbiencePlayer? = ambiencePlayer ?: CityAmbiencePlayer(city) private var cityAmbiencePlayer: CityAmbiencePlayer? = ambiencePlayer ?: CityAmbiencePlayer(city)
/** Particle effects for WLTK day decoration */
private val isWLTKday = city.isWeLoveTheKingDayActive()
private val fireworks: ParticleEffectMapFireworks?
init { init {
if (city.isWeLoveTheKingDayActive() && UncivGame.Current.settings.citySoundsVolume > 0) { if (isWLTKday && UncivGame.Current.settings.citySoundsVolume > 0) {
SoundPlayer.play(UncivSound("WLTK")) SoundPlayer.play(UncivSound("WLTK"))
} }
fireworks = if (isWLTKday) ParticleEffectMapFireworks.create(game, mapScrollPane) else null
UncivGame.Current.settings.addCompletedTutorialTask("Enter city screen") UncivGame.Current.settings.addCompletedTutorialTask("Enter city screen")
addTiles() addTiles()
//stage.setDebugTableUnderMouse(true)
stage.addActor(cityStatsTable) stage.addActor(cityStatsTable)
// If we are spying then we shoulden't be able to see their construction screen. // If we are spying then we shoulden't be able to see their construction screen.
constructionsTable.addActorsToStage() constructionsTable.addActorsToStage()
@ -251,6 +256,7 @@ class CityScreen(
else -> Color.GREEN to 0.5f else -> Color.GREEN to 0.5f
} }
} }
for (tileGroup in tileGroups) { for (tileGroup in tileGroups) {
tileGroup.update() tileGroup.update()
tileGroup.layerMisc.removeHexOutline() tileGroup.layerMisc.removeHexOutline()
@ -268,6 +274,9 @@ class CityScreen(
getPickImprovementColor(tileGroup.tile).run { getPickImprovementColor(tileGroup.tile).run {
tileGroup.layerMisc.addHexOutline(first.cpy().apply { this.a = second }) } tileGroup.layerMisc.addHexOutline(first.cpy().apply { this.a = second }) }
} }
if (fireworks == null || tileGroup.tile.position != city.location) continue
fireworks.setActorBounds(tileGroup)
} }
} }
@ -277,7 +286,7 @@ class CityScreen(
fun addWltkIcon(name: String, apply: Image.()->Unit = {}) = fun addWltkIcon(name: String, apply: Image.()->Unit = {}) =
razeCityButtonHolder.add(ImageGetter.getImage(name).apply(apply)).size(wltkIconSize) razeCityButtonHolder.add(ImageGetter.getImage(name).apply(apply)).size(wltkIconSize)
if (city.isWeLoveTheKingDayActive()) { if (isWLTKday && fireworks == null) {
addWltkIcon("OtherIcons/WLTK LR") { color = Color.GOLD } addWltkIcon("OtherIcons/WLTK LR") { color = Color.GOLD }
addWltkIcon("OtherIcons/WLTK 1") { color = Color.FIREBRICK }.padRight(10f) addWltkIcon("OtherIcons/WLTK 1") { color = Color.FIREBRICK }.padRight(10f)
} }
@ -309,7 +318,7 @@ class CityScreen(
razeCityButtonHolder.add(stopRazingCityButton) //.colspan(cityPickerTable.columns) razeCityButtonHolder.add(stopRazingCityButton) //.colspan(cityPickerTable.columns)
} }
if (city.isWeLoveTheKingDayActive()) { if (isWLTKday && fireworks == null) {
addWltkIcon("OtherIcons/WLTK 2") { color = Color.FIREBRICK }.padLeft(10f) addWltkIcon("OtherIcons/WLTK 2") { color = Color.FIREBRICK }.padLeft(10f)
addWltkIcon("OtherIcons/WLTK LR") { addWltkIcon("OtherIcons/WLTK LR") {
color = Color.GOLD color = Color.GOLD
@ -329,7 +338,7 @@ class CityScreen(
val tileSetStrings = TileSetStrings() val tileSetStrings = TileSetStrings()
val cityTileGroups = city.getCenterTile().getTilesInDistance(5) val cityTileGroups = city.getCenterTile().getTilesInDistance(5)
.filter { selectedCiv.hasExplored(it) } .filter { selectedCiv.hasExplored(it) }
.map { CityTileGroup(city, it, tileSetStrings) } .map { CityTileGroup(city, it, tileSetStrings, fireworks != null) }
for (tileGroup in cityTileGroups) { for (tileGroup in cityTileGroups) {
tileGroup.onClick { tileGroupOnClick(tileGroup, city) } tileGroup.onClick { tileGroupOnClick(tileGroup, city) }
@ -531,6 +540,12 @@ class CityScreen(
override fun dispose() { override fun dispose() {
cityAmbiencePlayer?.dispose() cityAmbiencePlayer?.dispose()
fireworks?.dispose()
super.dispose() super.dispose()
} }
override fun render(delta: Float) {
super.render(delta)
fireworks?.render(stage, delta)
}
} }

View File

@ -826,3 +826,22 @@ The following audio is from https://pixabay.com/ [Pixabay License](https://pixab
- [Cinematic Boom](https://pixabay.com/sound-effects/cinematic-boom-6872/) by Rizzard for final boom - [Cinematic Boom](https://pixabay.com/sound-effects/cinematic-boom-6872/) by Rizzard for final boom
- [Cymbal Swell 2](https://pixabay.com/sound-effects/cymbal-swell-2-74766/) by rubberduckie for cymbal swells - [Cymbal Swell 2](https://pixabay.com/sound-effects/cymbal-swell-2-74766/) by rubberduckie for cymbal swells
- [hit of orchestral cymbals and bass drum](https://pixabay.com/sound-effects/hit-of-orchestral-cymbals-and-bass-drum-14471/) by Selector for intro crash - [hit of orchestral cymbals and bass drum](https://pixabay.com/sound-effects/hit-of-orchestral-cymbals-and-bass-drum-14471/) by Selector for intro crash
## Visual effects
The fireworks on the City screen of a WLTK-celebrating city are loosely based on the Fireworks.p file included in [Particle Park](https://github.com/raeleus/Particle-Park).
All differences and edits done by the Unciv team.
License quoted:
```
Particle Park Fireworks License
------------------------------------------------------------------------------------------
Copyright © 2019 Raymond Buckley
Particle Park Fireworks can be used under the Creative Commons Attribution 4.0 International license.
See a human readable version here: https://creativecommons.org/licenses/by/4.0/
------------------------------------------------------------------------------------------
```

View File

@ -4,6 +4,7 @@ import com.badlogic.gdx.Game
import com.badlogic.gdx.Gdx import com.badlogic.gdx.Gdx
import com.badlogic.gdx.backends.lwjgl3.Lwjgl3Application import com.badlogic.gdx.backends.lwjgl3.Lwjgl3Application
import com.badlogic.gdx.backends.lwjgl3.Lwjgl3ApplicationConfiguration import com.badlogic.gdx.backends.lwjgl3.Lwjgl3ApplicationConfiguration
import com.badlogic.gdx.files.FileHandle
import com.badlogic.gdx.graphics.Color import com.badlogic.gdx.graphics.Color
import com.badlogic.gdx.graphics.Pixmap import com.badlogic.gdx.graphics.Pixmap
import com.badlogic.gdx.math.Vector2 import com.badlogic.gdx.math.Vector2
@ -13,7 +14,9 @@ import com.badlogic.gdx.scenes.scene2d.utils.ClickListener
import com.badlogic.gdx.scenes.scene2d.utils.Layout import com.badlogic.gdx.scenes.scene2d.utils.Layout
import com.unciv.UncivGame import com.unciv.UncivGame
import com.unciv.dev.FasterUIDevelopment.DevElement import com.unciv.dev.FasterUIDevelopment.DevElement
import com.unciv.json.json
import com.unciv.logic.files.UncivFiles import com.unciv.logic.files.UncivFiles
import com.unciv.models.metadata.GameSettings
import com.unciv.ui.components.extensions.center import com.unciv.ui.components.extensions.center
import com.unciv.ui.components.extensions.toLabel import com.unciv.ui.components.extensions.toLabel
import com.unciv.ui.components.fonts.FontFamilyData import com.unciv.ui.components.fonts.FontFamilyData
@ -31,11 +34,13 @@ import java.awt.image.BufferedImage
/** Creates a basic GDX application that mimics [UncivGame] as closely as possible, /** Creates a basic GDX application that mimics [UncivGame] as closely as possible,
* starts up fast and shows one UI element, to be returned by [DevElement.createDevElement]. * starts up fast and shows one UI element, to be returned by [DevElement.createDevElement].
* *
* * The parent will not size your Widget as the Gdx [Layout] contract promises, * - The parent will not size your Widget as the Gdx [Layout] contract promises,
* you'll need to do it yourself. E.g, if you're a [WidgetGroup], call [pack()][WidgetGroup.pack]. * you'll need to do it yourself. E.g, if you're a [WidgetGroup], call [pack()][WidgetGroup.pack].
* If you forget, you'll see an orange dot in the window center. * If you forget, you'll see an orange dot in the window center.
* * Resizing the window is not supported. You might lose interactivity. * - Resizing the window is not supported. You might lose interactivity.
* * The middle mouse button toggles Scene2D debug mode, like the full game offers in the Debug Options. * - However, settings including window size are saved separately from main Unciv, so you **can** test with different sizes.
* - Language is default English and there's no UI to change it - edit the settings file by hand, set once in the debugger, or hardcode in createDevElement if needed.
* - The middle mouse button toggles Scene2D debug mode, like the full game offers in the Debug Options.
*/ */
object FasterUIDevelopment { object FasterUIDevelopment {
@ -60,7 +65,7 @@ object FasterUIDevelopment {
val config = Lwjgl3ApplicationConfiguration() val config = Lwjgl3ApplicationConfiguration()
val settings = UncivFiles.getSettingsForPlatformLaunchers() val settings = Settings.load()
if (!settings.isFreshlyCreated) { if (!settings.isFreshlyCreated) {
val (width, height) = settings.windowState.coerceIn() val (width, height) = settings.windowState.coerceIn()
config.setWindowedMode(width, height) config.setWindowedMode(width, height)
@ -77,7 +82,7 @@ object FasterUIDevelopment {
Fonts.fontImplementation = FontDesktop() Fonts.fontImplementation = FontDesktop()
UncivGame.Current = game UncivGame.Current = game
UncivGame.Current.files = UncivFiles(Gdx.files) UncivGame.Current.files = UncivFiles(Gdx.files)
game.settings = UncivGame.Current.files.getGeneralSettings() game.settings = Settings.load()
ImageGetter.resetAtlases() ImageGetter.resetAtlases()
ImageGetter.reloadImages() ImageGetter.reloadImages()
BaseScreen.setSkin() BaseScreen.setSkin()
@ -88,6 +93,27 @@ object FasterUIDevelopment {
override fun render() { override fun render() {
game.render() game.render()
} }
override fun pause() {
Settings.save(UncivGame.Current.settings)
super.pause()
}
}
/** Persist window size over invocations, but separately from main Unciv */
private object Settings {
const val SETTINGS_FILE_NAME = "FasterUIDevSettings.json"
val file: FileHandle = FileHandle(".").child(SETTINGS_FILE_NAME)
fun load(): GameSettings {
if (!file.exists()) return GameSettings().apply { isFreshlyCreated = true }
return json().fromJson(GameSettings::class.java, file)
}
fun save(settings: GameSettings) {
settings.isFreshlyCreated = false
// settings.refreshWindowSize() - No, we don't have the platform-dependent helpers initialized
settings.windowState = GameSettings.WindowState.current()
file.writeString(json().toJson(settings), false, Charsets.UTF_8.name())
}
} }
class UIDevScreen : BaseScreen() { class UIDevScreen : BaseScreen() {