From 48ede93bfa1b0f18f62ff5c1f6ed8ce16a6dd702 Mon Sep 17 00:00:00 2001 From: SomeTroglodyte <63000004+SomeTroglodyte@users.noreply.github.com> Date: Tue, 28 Nov 2023 11:01:53 +0100 Subject: [PATCH] Wesnoth map import polished up (#10580) --- .../assets/jsons/WesnothImportMappings.json | 33 ++++++++++-- .../MapEditorWesnothImporter.kt | 53 ++++++++++++++----- 2 files changed, 70 insertions(+), 16 deletions(-) diff --git a/android/assets/jsons/WesnothImportMappings.json b/android/assets/jsons/WesnothImportMappings.json index 95b82d873d..7debd415c5 100644 --- a/android/assets/jsons/WesnothImportMappings.json +++ b/android/assets/jsons/WesnothImportMappings.json @@ -6,8 +6,19 @@ "Aa":["Snow"], "Ai":["Coast","Ice"], "Br":["Railroad"], + "C":["Plains"], // Most Castle variants look dark + "Cd":["Desert"], // Desert Castle + "Cea":["Plains"], // Snowy Encampment + "Cfa":["Tundra"], // Winter Dwarven Castle + "Cha":["Plains"], // Snowy Human Castle + "Chs":["Marsh"], // Swamp Human Ruin + "Chw":["Coast"], // Sunken Human Ruin + "Cm":["Coast"], // Aquatic Castle + "Coa":["Plains"], // Snowy Orcish Castle + "Cv":["Grassland"], // Elven Castle + "Cva":["Tundra"], // Winter Elven Castle "D":["Desert"], - // "Dc" to listOf(Constants.mountain, "Barringer Crater"), // "Crater" - Nope can have many + // "Dc":["Mountain","Barringer Crater"], // "Crater" - Nope can have many "Do":["Desert","Oasis"], "F":["Forest"], "Fda":["Tundra","Forest"], // Snowy Deciduous Forest @@ -23,7 +34,19 @@ "Hhd":["Plains","Hill"], // Dry Hills "Hd":["Desert","Hill"], // Dunes "Ha":["Snow","Hill"], // Snow Hills + "K":["Plains"], // Most Keep variants look brownish + "Kd":["Desert","Hill"], // Desert Keep + "Kea":["Tundra"], // Snowy Encampment Keep + "Kfa":["Tundra"], // Winter Dwarven Keep + "Kha":["Plains"], // Snowy Human Castle Keep + "Khr":["Plains","City ruins"], // Ruined Human Castle Keep + "Khw":["Plains","Hill"], // Sunken Human Castle Keep - let's make such a start... + "Km":["Plains","Hill"], // Aquatic Keep - ...a tiny island w/2 prod + "Koa":["Tundra","Hill"], // Snowy Orcish Keep + "Kv":["Forest"], // Elvish Keep + "Kva":["Tundra","Forest"], // Winter Elven Keep "M":["Mountain"], + "Q":["Ocean"], // "Unwalkable" (fliers can enter), e.g. underground chasms - or make them Lakes? "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 @@ -35,8 +58,12 @@ "Vhr":["Ancient ruins"], // Ruined Cottage "Vhcr":["City ruins"], // Ruined Human City "Vhhr":["Ancient ruins"], // Ruined Hill Stone Village - "Vo":["Barbarian encampment"], // Orcish Village + "Vm":["Coast"], // Mermaid village:? + "Vo":["Barbarian encampment"], // Orcish Village: Will not function as spawner in game "Wo":["Ocean"], "Ww":["Coast"], - "Wwrt":["Coast","Atoll"] // Tropical Coastal Reef, may be too many + "Wwrt":["Coast","Atoll"], // Tropical Coastal Reef, may be too many + "X":["Mountain"], // Cave walls and obstacles + // Special, allows defining the fallback for the required Base Terrain: If this does not contain one, the importer can crash + "fallback":["Grassland"] } diff --git a/core/src/com/unciv/ui/screens/mapeditorscreen/MapEditorWesnothImporter.kt b/core/src/com/unciv/ui/screens/mapeditorscreen/MapEditorWesnothImporter.kt index cd52570591..f9d76139fa 100644 --- a/core/src/com/unciv/ui/screens/mapeditorscreen/MapEditorWesnothImporter.kt +++ b/core/src/com/unciv/ui/screens/mapeditorscreen/MapEditorWesnothImporter.kt @@ -40,8 +40,15 @@ class MapEditorWesnothImporter(private val editorScreen: MapEditorScreen) : Disp private var importJob: Job? = null + private val ignoreLines = setOf("usage=map", "border_size=1") + private val parseTerrain by lazy { - Regex("""^((?\d+) )?(?[A-Z_][a-z\\|/]{1,3})(\^(?[A-Z_][a-z\\|/]{1,3}))?$""") + Regex( + """^ + (((?\d+)|[A-Za-z\d_]+)\ )* # Space-separated prefixes allowed, if numeric it's a starting location, others are scenario-specific (we ignore) + (?[A-Z_][a-z\\|/]{1,3}) # mandatory layer + (\^(?[A-Z_][a-z\\|/]{1,3}))? # optional layer separated by `^` + $""".trimIndent(), RegexOption.COMMENTS) } private val translationCodes: LinkedHashMap> by lazy { @@ -52,6 +59,10 @@ class MapEditorWesnothImporter(private val editorScreen: MapEditorScreen) : Disp ) } + // The json must define a fallback that includes a BaseTerrain to ensure we can always find a BaseTerrain. + // (using key "" and letting translateTerrainWML loop downto 0 is close, but inserts the fallback twice, and we want it only at the end) + private val fallback: List by lazy { translationCodes["fallback"] ?: listOf(Constants.grassland) } + override fun dispose() { importJob?.cancel() } @@ -81,7 +92,7 @@ class MapEditorWesnothImporter(private val editorScreen: MapEditorScreen) : Disp } catch (ex: UncivShowableException) { Log.error("Could not load map", ex) Concurrency.runOnGLThread { - ToastPopup(ex.message, editorScreen) + ToastPopup(ex.message, editorScreen, 4000L) } } catch (ex: Throwable) { Log.error("Could not load map", ex) @@ -92,9 +103,13 @@ class MapEditorWesnothImporter(private val editorScreen: MapEditorScreen) : Disp } } + /** Parses receiver string for a complete Wesnoth map */ 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() + // first we need to know the size. + // There can be a header, optional, and not used in any campaign map or saved by the editor, skip anyway. + // isNotBlank also ensures the line break after the last row won't count as line. + // Wesnoth maps have a non-playable border - exclude. + val lines = lineSequence().filter { it.isNotBlank() && it !in ignoreLines }.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!") @@ -110,7 +125,7 @@ class MapEditorWesnothImporter(private val editorScreen: MapEditorScreen) : Disp val rowOffset = height / 2 for ((row, line) in lines.withIndex()) { for ((column, cellCode) in line.split(',').withIndex()) { - val effectiveRow = rowOffset - row + column % 2 + val effectiveRow = rowOffset - row + column % 2 // see comment block at the top of the file val pos = HexMath.getTileCoordsFromColumnRow(column - colOffset, effectiveRow) if (!map.contains(pos)) continue map[pos].paintFromWesnothCode(cellCode.trim(), map) @@ -120,37 +135,49 @@ class MapEditorWesnothImporter(private val editorScreen: MapEditorScreen) : Disp return map } + /** Modify receiver Tile so it matches a Wesnoth cell code (e.g. "Wo", "Gs^Fp", "1 Ke" or "sceptre Uu^Ii") */ private fun Tile.paintFromWesnothCode(cellCode: String, map: TileMap) { // See https://wiki.wesnoth.org/TerrainCodesWML + // First do a pattern match that validates and splits off player start indices, base and layer codes, and ignores other scenario prefixes 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 + // Now build a loose collection of candidate elements + val allStrings: Sequence = + translateTerrainWML(base) + translateTerrainWML(layer) + fallback + // Map to RulesetObjects and ensure Hills are always under other features val allObjects = allStrings .sortedBy { it != Constants.hill } .mapNotNull { ruleset.tileImprovements[it] ?: ruleset.terrains[it] } .toList() + // BaseTerrain is simply the first candidate, the fallback above makes first() not crash (unless the json breaks it) baseTerrain = allObjects.first { it is Terrain && it.type.isBaseTerrain }.name + // Features are all valid candiates in order, enduring no duplicates 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) } + // Optional improvement - mainly Wesnoth Villages to Ancient ruins allObjects.firstOrNull { it is TileImprovement }?.apply { improvement = name } + // Start locations are separate - and for now very simple: + // If a side number prefix is there, define an "Any" starting location. To actually match names + // a separate Wesnoth file would have to be read - and the existing ones match no Civ nation anyway. + // ('../scenarios/.cfg', WMLpath('scenario/side[i-1]/user_team_name') or team_name, + // with a Mod defining Elves, Orcs, Dwarves and so on, and decoupling Civilization from Nation that may even work) if (start == null) return map.addStartingLocation(Constants.spectator, this) } + /** Get all relevant matches for one layer code from the definition map, + * in order from most to least specific, flattened to a Sequence. + * (That is, match original then shorten the string stepwise and append all hits together) */ 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) } + for (length in code.length downTo 1) { + translationCodes[code.slice(0 until length)]?.also { yieldAll(it) } + } } }