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

View File

@ -396,18 +396,20 @@ open class ZoomableScrollPane(
return true
}
/** @return the currently scrolled-to viewport of the whole scrollable area */
private fun getViewport(): Rectangle {
/** Overwrite [rect] with the currently scrolled-to viewport of the whole scrollable area */
fun getViewport(rect: Rectangle) {
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. */
val viewportFromBottom = maxY - scrollY
return Rectangle(
viewportFromLeft - horizontalPadding,
viewportFromBottom - verticalPadding,
width,
height)
rect.x = viewportFromLeft - horizontalPadding
rect.y = viewportFromBottom - verticalPadding
rect.width = width
rect.height = height
}
/** @return the currently scrolled-to viewport of the whole scrollable area */
private fun getViewport() = Rectangle().also { getViewport(it) }
private fun onViewportChanged() {
onViewportChangedListener?.invoke(maxX, maxY, getViewport())
}

View File

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

View File

@ -21,6 +21,7 @@ import com.unciv.models.stats.Stat
import com.unciv.models.translations.tr
import com.unciv.ui.audio.CityAmbiencePlayer
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.disable
import com.unciv.ui.components.extensions.packIfNeeded
@ -137,16 +138,20 @@ class CityScreen(
private var cityAmbiencePlayer: CityAmbiencePlayer? = ambiencePlayer ?: CityAmbiencePlayer(city)
/** Particle effects for WLTK day decoration */
private val isWLTKday = city.isWeLoveTheKingDayActive()
private val fireworks: ParticleEffectMapFireworks?
init {
if (city.isWeLoveTheKingDayActive() && UncivGame.Current.settings.citySoundsVolume > 0) {
if (isWLTKday && UncivGame.Current.settings.citySoundsVolume > 0) {
SoundPlayer.play(UncivSound("WLTK"))
}
fireworks = if (isWLTKday) ParticleEffectMapFireworks.create(game, mapScrollPane) else null
UncivGame.Current.settings.addCompletedTutorialTask("Enter city screen")
addTiles()
//stage.setDebugTableUnderMouse(true)
stage.addActor(cityStatsTable)
// If we are spying then we shoulden't be able to see their construction screen.
constructionsTable.addActorsToStage()
@ -251,6 +256,7 @@ class CityScreen(
else -> Color.GREEN to 0.5f
}
}
for (tileGroup in tileGroups) {
tileGroup.update()
tileGroup.layerMisc.removeHexOutline()
@ -268,6 +274,9 @@ class CityScreen(
getPickImprovementColor(tileGroup.tile).run {
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 = {}) =
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 1") { color = Color.FIREBRICK }.padRight(10f)
}
@ -309,7 +318,7 @@ class CityScreen(
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 LR") {
color = Color.GOLD
@ -329,7 +338,7 @@ class CityScreen(
val tileSetStrings = TileSetStrings()
val cityTileGroups = city.getCenterTile().getTilesInDistance(5)
.filter { selectedCiv.hasExplored(it) }
.map { CityTileGroup(city, it, tileSetStrings) }
.map { CityTileGroup(city, it, tileSetStrings, fireworks != null) }
for (tileGroup in cityTileGroups) {
tileGroup.onClick { tileGroupOnClick(tileGroup, city) }
@ -531,6 +540,12 @@ class CityScreen(
override fun dispose() {
cityAmbiencePlayer?.dispose()
fireworks?.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
- [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
## 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.backends.lwjgl3.Lwjgl3Application
import com.badlogic.gdx.backends.lwjgl3.Lwjgl3ApplicationConfiguration
import com.badlogic.gdx.files.FileHandle
import com.badlogic.gdx.graphics.Color
import com.badlogic.gdx.graphics.Pixmap
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.unciv.UncivGame
import com.unciv.dev.FasterUIDevelopment.DevElement
import com.unciv.json.json
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.toLabel
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,
* 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].
* If you forget, you'll see an orange dot in the window center.
* * 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.
* - Resizing the window is not supported. You might lose interactivity.
* - 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 {
@ -60,7 +65,7 @@ object FasterUIDevelopment {
val config = Lwjgl3ApplicationConfiguration()
val settings = UncivFiles.getSettingsForPlatformLaunchers()
val settings = Settings.load()
if (!settings.isFreshlyCreated) {
val (width, height) = settings.windowState.coerceIn()
config.setWindowedMode(width, height)
@ -77,7 +82,7 @@ object FasterUIDevelopment {
Fonts.fontImplementation = FontDesktop()
UncivGame.Current = game
UncivGame.Current.files = UncivFiles(Gdx.files)
game.settings = UncivGame.Current.files.getGeneralSettings()
game.settings = Settings.load()
ImageGetter.resetAtlases()
ImageGetter.reloadImages()
BaseScreen.setSkin()
@ -88,6 +93,27 @@ object FasterUIDevelopment {
override fun 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() {