From 42b35bce4fcb1b1f92f43cf63196cd039913d330 Mon Sep 17 00:00:00 2001 From: SomeTroglodyte <63000004+SomeTroglodyte@users.noreply.github.com> Date: Sat, 3 Jun 2023 21:43:35 +0200 Subject: [PATCH] Allow image overlay and changing world wrap in map editor (#9493) --- android/assets/Skin.json | 3 +- .../jsons/translations/template.properties | 12 +- .../mapeditorscreen/MapEditorScreen.kt | 139 ++++++++++++++++-- .../tabs/MapEditorOptionsTab.kt | 92 +++++++++++- 4 files changed, 225 insertions(+), 21 deletions(-) diff --git a/android/assets/Skin.json b/android/assets/Skin.json index 8806a90965..fc7dfb4b3a 100644 --- a/android/assets/Skin.json +++ b/android/assets/Skin.json @@ -208,7 +208,8 @@ "font": "button", "fontColor": "color", "downFontColor": "pressed", - "overFontColor": "highlight" + "overFontColor": "highlight", + "disabledFontColor": "gray" } }, "com.badlogic.gdx.scenes.scene2d.ui.Label$LabelStyle": { diff --git a/android/assets/jsons/translations/template.properties b/android/assets/jsons/translations/template.properties index a072ce77e7..bd37b50c92 100644 --- a/android/assets/jsons/translations/template.properties +++ b/android/assets/jsons/translations/template.properties @@ -514,6 +514,14 @@ River generation failed! = Please don't use step 'Landmass' with map type 'Empty', create a new empty map instead. = This map has errors: = The incompatible elements have been removed. = +Current map: World Wrap = +Overlay image = +Click to choose a file = +Choose an image = +Overlay transparency: = +Invalid overlay image = +World wrap is incompatible with an overlay and was deactivated. = +An overlay image is incompatible with world wrap and was deactivated. = ## Map/Tool names My new map = @@ -1208,11 +1216,13 @@ Default Focus = Please enter a new name for your city = Please select a tile for this building's [improvement] = -# Ask for text or numbers popup UI +# Specialized Popups - Ask for text or numbers, file picker Invalid input! Please enter a different string. = Invalid input! Please enter a valid number. = Please enter some text = +Please enter a file name = +File name: = # Technology UI diff --git a/core/src/com/unciv/ui/screens/mapeditorscreen/MapEditorScreen.kt b/core/src/com/unciv/ui/screens/mapeditorscreen/MapEditorScreen.kt index 053e959125..0e215e78e8 100644 --- a/core/src/com/unciv/ui/screens/mapeditorscreen/MapEditorScreen.kt +++ b/core/src/com/unciv/ui/screens/mapeditorscreen/MapEditorScreen.kt @@ -2,9 +2,17 @@ package com.unciv.ui.screens.mapeditorscreen import com.badlogic.gdx.Application import com.badlogic.gdx.Gdx +import com.badlogic.gdx.files.FileHandle import com.badlogic.gdx.graphics.Color +import com.badlogic.gdx.graphics.Texture +import com.badlogic.gdx.graphics.g2d.TextureRegion +import com.badlogic.gdx.scenes.scene2d.Group +import com.badlogic.gdx.scenes.scene2d.Touchable +import com.badlogic.gdx.scenes.scene2d.ui.Image +import com.badlogic.gdx.scenes.scene2d.utils.TextureRegionDrawable import com.unciv.UncivGame import com.unciv.logic.map.MapParameters +import com.unciv.logic.map.MapShape import com.unciv.logic.map.MapSize import com.unciv.logic.map.MapSizeNew import com.unciv.logic.map.TileMap @@ -20,10 +28,13 @@ import com.unciv.ui.components.tilegroups.TileGroup import com.unciv.ui.screens.basescreen.BaseScreen import com.unciv.ui.components.KeyCharAndCode import com.unciv.ui.components.KeyboardPanningListener +import com.unciv.ui.images.ImageWithCustomSize +import com.unciv.ui.popups.ToastPopup import com.unciv.ui.screens.basescreen.RecreateOnResize import com.unciv.ui.screens.worldscreen.ZoomButtonPair import com.unciv.utils.Concurrency import com.unciv.utils.Dispatcher +import com.unciv.utils.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -125,6 +136,34 @@ class MapEditorScreen(map: TileMap? = null): BaseScreen(), RecreateOnResize { fun getToolsWidth() = stage.width * 0.4f + fun setWorldWrap(newValue: Boolean) { + if (newValue == tileMap.mapParameters.worldWrap) return + setWorldWrapFixOddWidth(newValue) + if (newValue && overlayFile != null) { + overlayFile = null + ToastPopup("An overlay image is incompatible with world wrap and was deactivated.", stage, 4000) + tabs.options.update() + } + recreateMapHolder() + } + + private fun setWorldWrapFixOddWidth(newValue: Boolean) = tileMap.mapParameters.run { + // Turning *off* WW and finding an odd width means it must have been rounded + // down by the TileMap constructor - fix so we can turn it back on later + if (worldWrap && mapSize.width % 2 != 0 && shape == MapShape.rectangular) + mapSize.width-- + worldWrap = newValue + } + + private fun recreateMapHolder(actionWhileRemoved: ()->Unit = {}) { + val savedScale = mapHolder.scaleX + clearOverlayImages() + mapHolder.remove() + actionWhileRemoved() + mapHolder = newMapHolder() + mapHolder.zoom(savedScale) + } + private fun newMapHolder(): EditorMapHolder { ImageGetter.setNewRuleset(ruleset) // setNewRuleset is missing some graphics - those "EmojiIcons"&co already rendered as font characters @@ -138,18 +177,20 @@ class MapEditorScreen(map: TileMap? = null): BaseScreen(), RecreateOnResize { tileMap.setStartingLocationsTransients() UncivGame.Current.translations.translationActiveMods = ruleset.mods - val result = EditorMapHolder(this, tileMap) { + val newHolder = EditorMapHolder(this, tileMap) { tileClickHandler?.invoke(it) } for (oldPanningListener in stage.root.listeners.filterIsInstance()) stage.removeListener(oldPanningListener) // otherwise they accumulate - result.mapPanningSpeed = UncivGame.Current.settings.mapPanningSpeed - stage.addListener(KeyboardPanningListener(result, allowWASD = false)) + newHolder.mapPanningSpeed = UncivGame.Current.settings.mapPanningSpeed + stage.addListener(KeyboardPanningListener(newHolder, allowWASD = false)) if (Gdx.app.type == Application.ApplicationType.Desktop) - result.isAutoScrollEnabled = UncivGame.Current.settings.mapAutoScroll + newHolder.isAutoScrollEnabled = UncivGame.Current.settings.mapAutoScroll - stage.root.addActorAt(0, result) - stage.scrollFocus = result + addOverlayToMapHolder(newHolder.actor as Group) // That's the initially empty Group ZoomableScrollPane allocated + + stage.root.addActorAt(0, newHolder) + stage.scrollFocus = newHolder isDirty = true modsTabNeedsRefresh = true @@ -157,15 +198,16 @@ class MapEditorScreen(map: TileMap? = null): BaseScreen(), RecreateOnResize { naturalWondersNeedRefresh = true if (UncivGame.Current.settings.showZoomButtons) { - zoomController = ZoomButtonPair(result) + zoomController = ZoomButtonPair(newHolder) zoomController!!.setPosition(10f, 10f) stage.addActor(zoomController) } - return result + return newHolder } fun loadMap(map: TileMap, newRuleset: Ruleset? = null, selectPage: Int = 0) { + clearOverlayImages() mapHolder.remove() tileMap = map ruleset = newRuleset ?: RulesetCache.getComplexRuleset(map.mapParameters) @@ -181,11 +223,12 @@ class MapEditorScreen(map: TileMap? = null): BaseScreen(), RecreateOnResize { } fun applyRuleset(newRuleset: Ruleset, newBaseRuleset: String, mods: LinkedHashSet) { - mapHolder.remove() - tileMap.mapParameters.baseRuleset = newBaseRuleset - tileMap.mapParameters.mods = mods - tileMap.ruleset = newRuleset - ruleset = newRuleset + recreateMapHolder { + tileMap.mapParameters.baseRuleset = newBaseRuleset + tileMap.mapParameters.mods = mods + tileMap.ruleset = newRuleset + ruleset = newRuleset + } mapHolder = newMapHolder() modsTabNeedsRefresh = false } @@ -251,4 +294,74 @@ class MapEditorScreen(map: TileMap? = null): BaseScreen(), RecreateOnResize { job.cancel() jobs.clear() } + + //region Overlay Image + + // To support world wrap with an overlay, one could maybe do up to tree versions of the same + // Image tiled side by side (therefore "clearOverlayImages"), they _could_ use the same Texture + // instance - that part works. But how to position and clip them properly escapes me - better + // coders are welcome to try. To work around, we simply turn world wrap off when an overlay is + // loaded, and allow to freely turn WW on and off. After all, the distinction becomes relevant + // *only* when a game is started, units move, and tile neighbors get a meaning. + + private var imageOverlay: Image? = null + + internal var overlayFile: FileHandle? = null + set(value) { + field = value + overlayFileChanged(value) + } + + internal var overlayAlpha = 0.33f + set(value) { + field = value + overlayAlphaChanged(value) + } + + private fun clearOverlayImages() { + val oldImage = imageOverlay ?: return + imageOverlay = null + oldImage.remove() + (oldImage.drawable as? TextureRegionDrawable)?.region?.texture?.dispose() + } + + private fun overlayFileChanged(value: FileHandle?) { + clearOverlayImages() + if (value == null) return + if (tileMap.mapParameters.worldWrap) { + setWorldWrapFixOddWidth(false) + ToastPopup("World wrap is incompatible with an overlay and was deactivated.", stage, 4000) + tabs.options.update() + } + recreateMapHolder() + } + + private fun overlayAlphaChanged(value: Float) { + imageOverlay?.color?.a = value + } + + private fun addOverlayToMapHolder(newHolderContent: Group) { + clearOverlayImages() + if (overlayFile == null) return + + try { + val texture = Texture(overlayFile) + texture.setFilter(Texture.TextureFilter.Linear, Texture.TextureFilter.Linear) + imageOverlay = ImageWithCustomSize(TextureRegion(texture)) + } catch (ex: Throwable) { + Log.error("Invalid overlay image", ex) + overlayFile = null + ToastPopup("Invalid overlay image", stage, 3000) + tabs.options.update() + return + } + + imageOverlay?.apply { + touchable = Touchable.disabled + setFillParent(true) + color.a = overlayAlpha + newHolderContent.addActor(this) + } + } + //endregion } diff --git a/core/src/com/unciv/ui/screens/mapeditorscreen/tabs/MapEditorOptionsTab.kt b/core/src/com/unciv/ui/screens/mapeditorscreen/tabs/MapEditorOptionsTab.kt index 39aba3ab4c..e4664f15f8 100644 --- a/core/src/com/unciv/ui/screens/mapeditorscreen/tabs/MapEditorOptionsTab.kt +++ b/core/src/com/unciv/ui/screens/mapeditorscreen/tabs/MapEditorOptionsTab.kt @@ -1,17 +1,21 @@ package com.unciv.ui.screens.mapeditorscreen.tabs import com.badlogic.gdx.Gdx +import com.badlogic.gdx.files.FileHandle import com.badlogic.gdx.graphics.Color import com.badlogic.gdx.scenes.scene2d.ui.ButtonGroup import com.badlogic.gdx.scenes.scene2d.ui.CheckBox import com.badlogic.gdx.scenes.scene2d.ui.Table +import com.badlogic.gdx.scenes.scene2d.ui.TextButton +import com.badlogic.gdx.utils.Align +import com.unciv.logic.files.FileChooser import com.unciv.logic.files.MapSaver +import com.unciv.logic.map.MapShape +import com.unciv.logic.map.MapSize import com.unciv.models.translations.tr -import com.unciv.ui.screens.mapeditorscreen.MapEditorScreen -import com.unciv.ui.popups.ToastPopup -import com.unciv.ui.screens.basescreen.BaseScreen import com.unciv.ui.components.KeyCharAndCode import com.unciv.ui.components.TabbedPager +import com.unciv.ui.components.UncivSlider import com.unciv.ui.components.extensions.addSeparator import com.unciv.ui.components.extensions.isEnabled import com.unciv.ui.components.extensions.keyShortcuts @@ -20,6 +24,9 @@ import com.unciv.ui.components.extensions.onClick import com.unciv.ui.components.extensions.toCheckBox import com.unciv.ui.components.extensions.toLabel import com.unciv.ui.components.extensions.toTextButton +import com.unciv.ui.popups.ToastPopup +import com.unciv.ui.screens.basescreen.BaseScreen +import com.unciv.ui.screens.mapeditorscreen.MapEditorScreen import com.unciv.utils.Log class MapEditorOptionsTab( @@ -30,6 +37,9 @@ class MapEditorOptionsTab( private val tileMatchGroup = ButtonGroup() private val copyMapButton = "Copy to clipboard".toTextButton() private val pasteMapButton = "Load copied data".toTextButton() + private val worldWrapCheckBox: CheckBox + private val overlayFileButton= TextButton(null, BaseScreen.skin) + private val overlayAlphaSlider: UncivSlider private var seedToCopy = "" private var tileMatchFuzziness = TileMatchFuzziness.CompleteMatch @@ -41,6 +51,7 @@ class MapEditorOptionsTab( BaseTerrain("Base terrain only"), LandOrWater("Land or water only"), } + init { top() defaults().pad(10f) @@ -64,10 +75,47 @@ class MapEditorOptionsTab( add("Map copy and paste".toLabel(Color.GOLD)).row() copyMapButton.onActivation { copyHandler() } copyMapButton.keyShortcuts.add(KeyCharAndCode.ctrl('c')) - add(copyMapButton).row() pasteMapButton.onActivation { pasteHandler() } pasteMapButton.keyShortcuts.add(KeyCharAndCode.ctrl('v')) - add(pasteMapButton).row() + add(Table().apply { + add(copyMapButton).padRight(15f) + add(pasteMapButton) + }).row() + addSeparator(Color.GRAY) + + worldWrapCheckBox = "Current map: World Wrap".toCheckBox(editorScreen.tileMap.mapParameters.worldWrap) { + editorScreen.setWorldWrap(it) + } + add(worldWrapCheckBox).growX().row() + addSeparator(Color.GRAY) + + add("Overlay image".toLabel(Color.GOLD)).row() + overlayFileButton.style = TextButton.TextButtonStyle(overlayFileButton.style) + showOverlayFileName() + overlayFileButton.onClick { + // TODO - to allow accessing files *outside the app scope* on Android, switch to + // [UncivFiles.saverLoader] and teach PlatformSaverLoader to deliver a stream or + // ByteArray or PixMap instead of doing a text file load using system/JVM default encoding.. + // Then we'd need to make a *managed* PixMap-based Texture out of that, because only + // managed will survive GL context loss automatically. Cespenar says "could get messy". + FileChooser.createLoadDialog(stage, "Choose an image", editorScreen.overlayFile) { + success: Boolean, file: FileHandle -> + if (!success) return@createLoadDialog + editorScreen.overlayFile = file + showOverlayFileName() + }.apply { + filter = FileChooser.createExtensionFilter("png", "jpg", "jpeg") + }.open() + } + add(overlayFileButton).fillX().row() + + overlayAlphaSlider = UncivSlider(0f, 1f, 0.05f, initial = editorScreen.overlayAlpha) { + editorScreen.overlayAlpha = it + } + add(Table().apply { + add("Overlay opacity:".toLabel(alignment = Align.left)).left() + add(overlayAlphaSlider).right() + }).row() } private fun copyHandler() { @@ -85,10 +133,42 @@ class MapEditorOptionsTab( } } + private fun showOverlayFileName() = overlayFileButton.run { + if (editorScreen.overlayFile == null) { + setText("Click to choose a file") + style.fontColor.a = 0.5f + } else { + setText(editorScreen.overlayFile!!.path()) + style.fontColor.a = 1f + } + } + + /** Check whether we can flip world wrap without ruining geometry */ + private fun canChangeWorldWrap(): Boolean { + val params = editorScreen.tileMap.mapParameters + // Can't change for hexagonal at all, as non-ww must always have an odd number of columns and ww nust have an even number of columns + if (params.shape != MapShape.rectangular) return false + // Too small? + if (params.mapSize.radius < MapSize.Tiny.radius) return false + // Even-width rectangular have no problems, but that has not necessarily been saved in mapSize! + if (params.mapSize.width % 2 == 0) return true + // The recorded width may have been reduced to even by the TileMap constructor. + // In such a case we allow turning WW off, and editorScreen.setWorldWrap will fix the width. + return (params.worldWrap) + } + + fun update() { + pasteMapButton.isEnabled = Gdx.app.clipboard.hasContents() + worldWrapCheckBox.isChecked = editorScreen.tileMap.mapParameters.worldWrap + worldWrapCheckBox.isDisabled = !canChangeWorldWrap() + showOverlayFileName() + } + override fun activated(index: Int, caption: String, pager: TabbedPager) { seedToCopy = editorScreen.tileMap.mapParameters.seed.toString() seedLabel.setText("Current map RNG seed: [$seedToCopy]".tr()) - pasteMapButton.isEnabled = Gdx.app.clipboard.hasContents() + update() + overlayAlphaSlider.value = editorScreen.overlayAlpha } override fun deactivated(index: Int, caption: String, pager: TabbedPager) {