diff --git a/android/assets/jsons/WesnothImportMappings.json b/android/assets/jsons/WesnothImportMappings.json new file mode 100644 index 0000000000..95b82d873d --- /dev/null +++ b/android/assets/jsons/WesnothImportMappings.json @@ -0,0 +1,42 @@ +// Definitions how to map Wesnoth map terrain codes to Unciv Terrains and/or TileImprovements. +// The parts before and after "^" are matched individually, and a search is done for the most +// specific partial string from the start, e.g. "Wwf" will look for "Wwf", then "Ww", then "W" +// See https://wiki.wesnoth.org/TerrainCodesWML +{ + "Aa":["Snow"], + "Ai":["Coast","Ice"], + "Br":["Railroad"], + "D":["Desert"], + // "Dc" to listOf(Constants.mountain, "Barringer Crater"), // "Crater" - Nope can have many + "Do":["Desert","Oasis"], + "F":["Forest"], + "Fda":["Tundra","Forest"], // Snowy Deciduous Forest + "Feta":["Tundra","Forest"], // Snowy Great Tree + "Fma":["Tundra","Forest"], // Snowy Mixed Forest + "Fpa":["Tundra","Forest"], // Snowy Pine Forest + "Ft":["Jungle"], // Tropical Forest, Rainforest + "G":["Grassland"], + "Gd":["Plains"], // Dry Grass + "Gll":["Plains"], // Leaf Litter + "Gvs":["Grassland","Farm"], + "H":["Hill"], + "Hhd":["Plains","Hill"], // Dry Hills + "Hd":["Desert","Hill"], // Dunes + "Ha":["Snow","Hill"], // Snow Hills + "M":["Mountain"], + "R":["Plains"], // All their "R" actually means roads - but they mean full-tile low-movement and brownish so.. + "Rd":["Desert"], // Dry Dirt Road + "Rra":["Tundra"], // Icy Cobbles + "S":["Grassland","Marsh"], + "T":["Plains"], // Fungus + "U":["Plains"], // Underground + "V":["Ancient ruins"], // let's convert all other villages into ancient ruins too + "Vdr":["Ancient ruins"], // Ruined Adobe Village + "Vhr":["Ancient ruins"], // Ruined Cottage + "Vhcr":["City ruins"], // Ruined Human City + "Vhhr":["Ancient ruins"], // Ruined Hill Stone Village + "Vo":["Barbarian encampment"], // Orcish Village + "Wo":["Ocean"], + "Ww":["Coast"], + "Wwrt":["Coast","Atoll"] // Tropical Coastal Reef, may be too many +} diff --git a/android/assets/jsons/translations/template.properties b/android/assets/jsons/translations/template.properties index 0973b5619e..49c7b4b7d2 100644 --- a/android/assets/jsons/translations/template.properties +++ b/android/assets/jsons/translations/template.properties @@ -503,11 +503,12 @@ Except improvements = Base and terrain features = Base terrain only = Land or water only = +Import a Wesnoth map = ## Labels/messages Brush ([size]): = # The single letter shown in the [size] parameter above for setting "Floodfill". -# Please do not make this longer, the associated slider will not handle well. +# Please do not make this longer than one character, the associated slider will not handle well. Floodfill_Abbreviation = Error loading map! = Map saved successfully! = @@ -534,6 +535,9 @@ Overlay opacity: = Invalid overlay image = World wrap is incompatible with an overlay and was deactivated. = An overlay image is incompatible with world wrap and was deactivated. = +Choose a Wesnoth map file = +That map is invalid! = +("[code]" does not conform to TerrainCodesWML) = ## Map/Tool names My new map = diff --git a/core/src/com/unciv/ui/screens/mapeditorscreen/MapEditorScreen.kt b/core/src/com/unciv/ui/screens/mapeditorscreen/MapEditorScreen.kt index e34bd74e98..f12ad7cfb1 100644 --- a/core/src/com/unciv/ui/screens/mapeditorscreen/MapEditorScreen.kt +++ b/core/src/com/unciv/ui/screens/mapeditorscreen/MapEditorScreen.kt @@ -248,6 +248,9 @@ class MapEditorScreen(map: TileMap? = null) : BaseScreen(), RecreateOnResize { if (!isDirty) return action() ConfirmPopup(screen = this, question, confirmText, isConfirmPositive, action = action).open() } + fun askIfDirtyForLoad(action: ()->Unit) = askIfDirty( + "Do you want to load another map without saving the recent changes?", + "Load map", action = action) fun hideSelection() { for (group in highlightedTileGroups) diff --git a/core/src/com/unciv/ui/screens/mapeditorscreen/MapEditorWesnothImporter.kt b/core/src/com/unciv/ui/screens/mapeditorscreen/MapEditorWesnothImporter.kt new file mode 100644 index 0000000000..cd52570591 --- /dev/null +++ b/core/src/com/unciv/ui/screens/mapeditorscreen/MapEditorWesnothImporter.kt @@ -0,0 +1,156 @@ +package com.unciv.ui.screens.mapeditorscreen + +import com.badlogic.gdx.Gdx +import com.badlogic.gdx.files.FileHandle +import com.unciv.Constants +import com.unciv.json.json +import com.unciv.logic.UncivShowableException +import com.unciv.logic.files.FileChooser +import com.unciv.logic.map.HexMath +import com.unciv.logic.map.MapShape +import com.unciv.logic.map.MapSizeNew +import com.unciv.logic.map.MapType +import com.unciv.logic.map.TileMap +import com.unciv.logic.map.tile.Tile +import com.unciv.models.metadata.BaseRuleset +import com.unciv.models.ruleset.RulesetCache +import com.unciv.models.ruleset.tile.Terrain +import com.unciv.models.ruleset.tile.TerrainType +import com.unciv.models.ruleset.tile.TileImprovement +import com.unciv.ui.popups.ToastPopup +import com.unciv.utils.Concurrency +import com.unciv.utils.Log +import kotlinx.coroutines.DisposableHandle +import kotlinx.coroutines.Job + +// Wesnoth maps come with a non-playable border of one Hex. +// Wesnoth puts the odd corner with only two neighbors on top, while Unciv puts it on the bottom. +// This means that Wesnoth's coord mapping is a little different, so we need to shift every other column vertically one place. +// To do so, we use half the unplayable hexes Wesnoth's map has on top and alternatingly those on the bottom. +// This means a map loaded in Unciv has its height increased by 1 compared to what Wesnoth showed (they don't include the unplayable border in dimensions). + +//todo Allow different rulesets? + +class MapEditorWesnothImporter(private val editorScreen: MapEditorScreen) : DisposableHandle { + companion object { + var lastFileFolder: FileHandle? = null + } + + private val ruleset by lazy { RulesetCache[BaseRuleset.Civ_V_GnK.fullName]!! } + + private var importJob: Job? = null + + private val parseTerrain by lazy { + Regex("""^((?\d+) )?(?[A-Z_][a-z\\|/]{1,3})(\^(?[A-Z_][a-z\\|/]{1,3}))?$""") + } + + private val translationCodes: LinkedHashMap> by lazy { + json().fromJson( + linkedMapOf>()::class.java, + arrayListOf()::class.java, // else we get Gdx.Array despite the class above stating ArrayList + Gdx.files.local("jsons/WesnothImportMappings.json") + ) + } + + override fun dispose() { + importJob?.cancel() + } + + fun onImportButtonClicked() { + editorScreen.askIfDirtyForLoad(::openFileDialog) + } + private fun openFileDialog() { + FileChooser.createLoadDialog(editorScreen.stage, "Choose a Wesnoth map file", lastFileFolder) { success: Boolean, file: FileHandle -> + if (!success) return@createLoadDialog + startImport(file) + lastFileFolder = file.parent() + }.apply { + filter = FileChooser.createExtensionFilter("map") + }.open() + } + + private fun startImport(file: FileHandle) { + dispose() + importJob = Concurrency.run("Map import") { + try { + val mapData = file.readString(Charsets.UTF_8.name()) // Actually, it's pure ascii, but force of habit... + val map = mapData.parse() + Concurrency.runOnGLThread { + editorScreen.loadMap(map) + } + } catch (ex: UncivShowableException) { + Log.error("Could not load map", ex) + Concurrency.runOnGLThread { + ToastPopup(ex.message, editorScreen) + } + } catch (ex: Throwable) { + Log.error("Could not load map", ex) + Concurrency.runOnGLThread { + ToastPopup("Could not load map!", editorScreen) + } + } + } + } + + private fun String.parse(): TileMap { + // first we need to know the size. Wesnoth maps have a non-playable border - exclude. + val lines = lineSequence().filter { it.isNotBlank() }.toList() + val height = lines.size - 1 + val width = if (height <= 0) 0 else lines[0].split(',').size - 2 + if (width <= 0) throw UncivShowableException("That map is invalid!") + + val map = TileMap(width, height, ruleset, false) + map.mapParameters.apply { + type = MapType.empty + shape = MapShape.rectangular + mapSize = MapSizeNew(width, height) + } + + val colOffset = 1 + width / 2 + val rowOffset = height / 2 + for ((row, line) in lines.withIndex()) { + for ((column, cellCode) in line.split(',').withIndex()) { + val effectiveRow = rowOffset - row + column % 2 + val pos = HexMath.getTileCoordsFromColumnRow(column - colOffset, effectiveRow) + if (!map.contains(pos)) continue + map[pos].paintFromWesnothCode(cellCode.trim(), map) + } + } + + return map + } + + private fun Tile.paintFromWesnothCode(cellCode: String, map: TileMap) { + // See https://wiki.wesnoth.org/TerrainCodesWML + val matches = parseTerrain.matchEntire(cellCode) + ?: throw UncivShowableException("{That map is invalid!}\n{(\"[$cellCode]\" does not conform to TerrainCodesWML)}") + val start = matches.groups["start"]?.value + val base = matches.groups["base"]!!.value // This capture is not optional in the pattern + val layer = matches.groups["layer"]?.value + val allStrings = translateTerrainWML(base) + translateTerrainWML(layer) + Constants.grassland + val allObjects = allStrings + .sortedBy { it != Constants.hill } + .mapNotNull { ruleset.tileImprovements[it] ?: ruleset.terrains[it] } + .toList() + baseTerrain = allObjects.first { it is Terrain && it.type.isBaseTerrain }.name + val features = allObjects.filter { it is Terrain && it.type == TerrainType.TerrainFeature }.map { it.name }.distinct() + if (features.isNotEmpty()) { + setTerrainTransients() // or else can't setTerrainFeatures as baseTerrainObject may be uninitialized + setTerrainFeatures(features) + } + allObjects.firstOrNull { it is TileImprovement }?.apply { improvement = name } + if (start == null) return + map.addStartingLocation(Constants.spectator, this) + } + + private fun translateTerrainWML(code: String?) = sequence { + if (code == null) return@sequence + translationCodes[code]?.also { yieldAll(it) } + if (code.length >= 3) // kotlin slice is unsafe, unlike python slice + translationCodes[code.slice(0..2)]?.also { yieldAll(it) } + if (code.length >= 2) + translationCodes[code.slice(0..1)]?.also { yieldAll(it) } + if (code.isNotEmpty()) + translationCodes[code.slice(0..0)]?.also { yieldAll(it) } + } +} diff --git a/core/src/com/unciv/ui/screens/mapeditorscreen/tabs/MapEditorLoadTab.kt b/core/src/com/unciv/ui/screens/mapeditorscreen/tabs/MapEditorLoadTab.kt index c89469cdaf..92392ec62b 100644 --- a/core/src/com/unciv/ui/screens/mapeditorscreen/tabs/MapEditorLoadTab.kt +++ b/core/src/com/unciv/ui/screens/mapeditorscreen/tabs/MapEditorLoadTab.kt @@ -9,13 +9,13 @@ import com.unciv.logic.UncivShowableException import com.unciv.logic.files.MapSaver import com.unciv.models.ruleset.RulesetCache import com.unciv.models.translations.tr -import com.unciv.ui.components.widgets.AutoScrollPane -import com.unciv.ui.components.widgets.TabbedPager import com.unciv.ui.components.extensions.isEnabled import com.unciv.ui.components.extensions.toTextButton import com.unciv.ui.components.input.KeyCharAndCode import com.unciv.ui.components.input.keyShortcuts import com.unciv.ui.components.input.onActivation +import com.unciv.ui.components.widgets.AutoScrollPane +import com.unciv.ui.components.widgets.TabbedPager import com.unciv.ui.popups.ConfirmPopup import com.unciv.ui.popups.LoadingPopup import com.unciv.ui.popups.Popup @@ -64,10 +64,7 @@ class MapEditorLoadTab( private fun loadHandler() { if (chosenMap == null) return - editorScreen.askIfDirty( - "Do you want to load another map without saving the recent changes?", - "Load map" - ) { + editorScreen.askIfDirtyForLoad { editorScreen.startBackgroundJob("MapLoader") { loaderThread() } } } 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 3df06451cd..8e0cb6535a 100644 --- a/core/src/com/unciv/ui/screens/mapeditorscreen/tabs/MapEditorOptionsTab.kt +++ b/core/src/com/unciv/ui/screens/mapeditorscreen/tabs/MapEditorOptionsTab.kt @@ -13,8 +13,6 @@ 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.components.widgets.TabbedPager -import com.unciv.ui.components.widgets.UncivSlider import com.unciv.ui.components.extensions.addSeparator import com.unciv.ui.components.extensions.isEnabled import com.unciv.ui.components.extensions.toCheckBox @@ -24,9 +22,12 @@ import com.unciv.ui.components.input.KeyCharAndCode import com.unciv.ui.components.input.keyShortcuts import com.unciv.ui.components.input.onActivation import com.unciv.ui.components.input.onClick +import com.unciv.ui.components.widgets.TabbedPager +import com.unciv.ui.components.widgets.UncivSlider import com.unciv.ui.popups.ToastPopup import com.unciv.ui.screens.basescreen.BaseScreen import com.unciv.ui.screens.mapeditorscreen.MapEditorScreen +import com.unciv.ui.screens.mapeditorscreen.MapEditorWesnothImporter import com.unciv.utils.Log class MapEditorOptionsTab( @@ -81,6 +82,10 @@ class MapEditorOptionsTab( add(copyMapButton).padRight(15f) add(pasteMapButton) }).row() + + add("Import a Wesnoth map".toTextButton().onActivation { + MapEditorWesnothImporter(editorScreen).onImportButtonClicked() + }) addSeparator(Color.GRAY) worldWrapCheckBox = "Current map: World Wrap".toCheckBox(editorScreen.tileMap.mapParameters.worldWrap) {