mirror of
https://github.com/yairm210/Unciv.git
synced 2025-07-11 00:08:58 +07:00
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:
42
android/assets/jsons/WesnothImportMappings.json
Normal file
42
android/assets/jsons/WesnothImportMappings.json
Normal 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
|
||||||
|
}
|
@ -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 =
|
||||||
|
@ -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)
|
||||||
|
@ -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) }
|
||||||
|
}
|
||||||
|
}
|
@ -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() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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) {
|
||||||
|
Reference in New Issue
Block a user