Add a map import tool able to read "Battle for Wesnoth" maps (#10541)

* Add a map import tool able to read "Battle for Wesnoth" maps

* Fix and explain vertical distortion
This commit is contained in:
SomeTroglodyte
2023-11-22 22:59:22 +01:00
committed by GitHub
parent 30c4b323ec
commit 3604d82fa9
6 changed files with 216 additions and 9 deletions

View File

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

View File

@ -503,11 +503,12 @@ Except improvements =
Base and terrain features = Base and terrain features =
Base terrain only = Base terrain only =
Land or water only = Land or water only =
Import a Wesnoth map =
## Labels/messages ## Labels/messages
Brush ([size]): = Brush ([size]): =
# The single letter shown in the [size] parameter above for setting "Floodfill". # 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 = Floodfill_Abbreviation =
Error loading map! = Error loading map! =
Map saved successfully! = Map saved successfully! =
@ -534,6 +535,9 @@ Overlay opacity: =
Invalid overlay image = Invalid overlay image =
World wrap is incompatible with an overlay and was deactivated. = World wrap is incompatible with an overlay and was deactivated. =
An overlay image is incompatible with world wrap 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 ## Map/Tool names
My new map = My new map =

View File

@ -248,6 +248,9 @@ class MapEditorScreen(map: TileMap? = null) : BaseScreen(), RecreateOnResize {
if (!isDirty) return action() if (!isDirty) return action()
ConfirmPopup(screen = this, question, confirmText, isConfirmPositive, action = action).open() 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() { fun hideSelection() {
for (group in highlightedTileGroups) for (group in highlightedTileGroups)

View File

@ -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("""^((?<start>\d+) )?(?<base>[A-Z_][a-z\\|/]{1,3})(\^(?<layer>[A-Z_][a-z\\|/]{1,3}))?$""")
}
private val translationCodes: LinkedHashMap<String,ArrayList<String>> by lazy {
json().fromJson(
linkedMapOf<String,ArrayList<String>>()::class.java,
arrayListOf<String>()::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) }
}
}

View File

@ -9,13 +9,13 @@ import com.unciv.logic.UncivShowableException
import com.unciv.logic.files.MapSaver import com.unciv.logic.files.MapSaver
import com.unciv.models.ruleset.RulesetCache import com.unciv.models.ruleset.RulesetCache
import com.unciv.models.translations.tr 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.isEnabled
import com.unciv.ui.components.extensions.toTextButton import com.unciv.ui.components.extensions.toTextButton
import com.unciv.ui.components.input.KeyCharAndCode import com.unciv.ui.components.input.KeyCharAndCode
import com.unciv.ui.components.input.keyShortcuts import com.unciv.ui.components.input.keyShortcuts
import com.unciv.ui.components.input.onActivation 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.ConfirmPopup
import com.unciv.ui.popups.LoadingPopup import com.unciv.ui.popups.LoadingPopup
import com.unciv.ui.popups.Popup import com.unciv.ui.popups.Popup
@ -64,10 +64,7 @@ class MapEditorLoadTab(
private fun loadHandler() { private fun loadHandler() {
if (chosenMap == null) return if (chosenMap == null) return
editorScreen.askIfDirty( editorScreen.askIfDirtyForLoad {
"Do you want to load another map without saving the recent changes?",
"Load map"
) {
editorScreen.startBackgroundJob("MapLoader") { loaderThread() } editorScreen.startBackgroundJob("MapLoader") { loaderThread() }
} }
} }

View File

@ -13,8 +13,6 @@ import com.unciv.logic.files.MapSaver
import com.unciv.logic.map.MapShape import com.unciv.logic.map.MapShape
import com.unciv.logic.map.MapSize import com.unciv.logic.map.MapSize
import com.unciv.models.translations.tr 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.addSeparator
import com.unciv.ui.components.extensions.isEnabled import com.unciv.ui.components.extensions.isEnabled
import com.unciv.ui.components.extensions.toCheckBox 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.keyShortcuts
import com.unciv.ui.components.input.onActivation import com.unciv.ui.components.input.onActivation
import com.unciv.ui.components.input.onClick 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.popups.ToastPopup
import com.unciv.ui.screens.basescreen.BaseScreen import com.unciv.ui.screens.basescreen.BaseScreen
import com.unciv.ui.screens.mapeditorscreen.MapEditorScreen import com.unciv.ui.screens.mapeditorscreen.MapEditorScreen
import com.unciv.ui.screens.mapeditorscreen.MapEditorWesnothImporter
import com.unciv.utils.Log import com.unciv.utils.Log
class MapEditorOptionsTab( class MapEditorOptionsTab(
@ -81,6 +82,10 @@ class MapEditorOptionsTab(
add(copyMapButton).padRight(15f) add(copyMapButton).padRight(15f)
add(pasteMapButton) add(pasteMapButton)
}).row() }).row()
add("Import a Wesnoth map".toTextButton().onActivation {
MapEditorWesnothImporter(editorScreen).onImportButtonClicked()
})
addSeparator(Color.GRAY) addSeparator(Color.GRAY)
worldWrapCheckBox = "Current map: World Wrap".toCheckBox(editorScreen.tileMap.mapParameters.worldWrap) { worldWrapCheckBox = "Current map: World Wrap".toCheckBox(editorScreen.tileMap.mapParameters.worldWrap) {