Wesnoth map import polished up (#10580)

This commit is contained in:
SomeTroglodyte
2023-11-28 11:01:53 +01:00
committed by GitHub
parent 208ad8a641
commit 48ede93bfa
2 changed files with 70 additions and 16 deletions

View File

@ -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"]
}

View File

@ -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("""^((?<start>\d+) )?(?<base>[A-Z_][a-z\\|/]{1,3})(\^(?<layer>[A-Z_][a-z\\|/]{1,3}))?$""")
Regex(
"""^
(((?<start>\d+)|[A-Za-z\d_]+)\ )* # Space-separated prefixes allowed, if numeric it's a starting location, others are scenario-specific (we ignore)
(?<base>[A-Z_][a-z\\|/]{1,3}) # mandatory layer
(\^(?<layer>[A-Z_][a-z\\|/]{1,3}))? # optional layer separated by `^`
$""".trimIndent(), RegexOption.COMMENTS)
}
private val translationCodes: LinkedHashMap<String,ArrayList<String>> 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<String> 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<String> =
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/<mapname>.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) }
}
}
}