From ad020759c7f0d5113b952d14a7d63669dc41e532 Mon Sep 17 00:00:00 2001 From: Tyler Trahan Date: Mon, 16 Sep 2024 09:29:10 -0400 Subject: [PATCH] Feature: Import town data from JSON file (#10409) --- docs/importing_town_data.md | 97 ++++++++++++++++++++++++ src/fileio_type.h | 5 ++ src/fios.cpp | 41 ++++++++++ src/fios.h | 1 + src/fios_gui.cpp | 77 ++++++++++++++++++- src/genworld.cpp | 179 ++++++++++++++++++++++++++++++++++++++++++++ src/genworld.h | 1 + src/lang/english.txt | 11 +++ src/town_cmd.cpp | 8 +- src/town_gui.cpp | 13 +++- src/widgets/town_widget.h | 1 + 11 files changed, 426 insertions(+), 8 deletions(-) create mode 100644 docs/importing_town_data.md diff --git a/docs/importing_town_data.md b/docs/importing_town_data.md new file mode 100644 index 0000000000..5f79d1596e --- /dev/null +++ b/docs/importing_town_data.md @@ -0,0 +1,97 @@ +# Importing Town Data into OpenTTD + +To aid players in scenario creation, OpenTTD's Scenario Editor can import town data from external JSON files. This enables players to use an image editing program to align town coordinates with a real-world heightmap using a map underlay, instead of guessing at the correct locations in Scenario Editor itself. + +This town data consists of a JSON file storing an array of town data objects, each containing a name, location, target OpenTTD population, and whether it is marked as a city in the game. + +This document describes the standard format for this JSON file and outlines a workflow for creating this data effectively. + +## Table of contents + +- Why load external data? +- How to use this feature + - Creating geodata + - Town data format standards + - Town data values + - Loading geodata into OpenTTD +- Tutorial: Creating town data + +## Why load external data? + +There are three benefits to using an image editing program to create towns instead of the OpenTTD Scenario Editor. + +1. Placing towns accurately is much easier using a map underlay such as OpenStreetMap to match town locations with the corresponding heightmap. +2. Storing town data in a JSON file instead of as an OpenTTD Scenario (.scn) doesn't require choosing your NewGRF house set before placing towns. +3. Town coordinates are scaled by the map size, so you can load the data onto whatever size map you like. + +## How to use this feature + +### Creating geodata + +Town data is a text file in the JSON format, with a list of towns, each containing a coordinate location and properties: name, population, and whether or not it should be a city in OpenTTD. + +The format of this file is standardized for importing into OpenTTD and must be followed for OpenTTD to properly parse the data. + +For use in OpenTTD, you will also need a matching heightmap of the terrain features, as a PNG. + +#### Town data format standards + +The following code sample is complete and can be used in OpenTTD. +- The list of towns is enclosed in an array marked with square brackets `[]` +- Each town is enclosed in curly braces `{}`, with a comma after each town except for the last in the list. +- The properties separated by commas except for the last. +- Property names are enclosed in double quotes `""` with a colon `:` separating it from the property value. +- The name property value is enclosed in double quotes `"London"`, while all other property values `44910`, `true`, etc., are not. + +``` +[ + { + "name": "London", + "population": 44910, + "city": true, + "x": 0.7998046875, + "y": 0.708984375 + }, + { + "name": "Canterbury", + "population": 217.16, + "city": false, + "x": 0.83251953125, + "y": 0.828125 + } +] +``` + +#### Town data values + +- Population is scaled down for use in OpenTTD. It is possible to generate huge cities by using a large number, but there is a practical limit to town size. The larger the town, the longer it will take to import town data, since towns are placed at a relatively small size and then expanded until the population is greater than the player-defined target. +- X and Y coordinates are a proportion of the total map dimension, between 0 and 1. Just take the pixel coordinates of the town's location in the corresponding heightmap (more detail in the tutorial below) and divide each by the maximum value. + - For example, London is at `726, 1638` in a 1024 px by 2048 px heightmap, so `726 / 1024 = 0.7998046875` and `1638 / 2048 = 0.708984375` gives the correct coordinates for OpenTTD. + - The reason for these proportional coordinates is so the data can be used for any map size. +- 0,0 is (approximately) the very top tile in OpenTTD. You can see tile coordinates in-game with the Land Info Tool. +- In most image editing programs, 0,0 is the top-left corner of the image. You can rotate the image however you want relative to compass north to orient the map to your liking. Make sure you crop and resize the image before recording town locations. +- In OpenTTD, X and Y axis are swapped compared to most image editing programs and the standard Cartesian coordinate system. From the 0,0 origin at top left, X is the axis along the left side and Y is the axis along the right side. You can still measure X and Y coordinates in your image editing program, just swap them before importing into OpenTTD or towns won't line up with your heightmap. + +### Loading geodata into OpenTTD +Using geodata to create a real-world location in OpenTTD is done in the Scenario Editor. + +1. Choose the NewGRFs you want to use in the game. +2. Load the heightmap which you created in the geodata workflow. Either rotation will work, but the clockwise rotation is considered "correct" and the coordinates in the Land Info Tool will match your data; counter clockwise maps will align properly but the coordinates won't match your data. +3. In the Town Generation window, click `Load from file` and choose the .json file containing town data. The default directory to search for town data is `OpenTTD\scenario\heightmap`. +4. (Optional) Manually add industries, rivers, trees, and objects. +5. Save the game as a Scenario and exit to the main menu. +6. Load the game with Play Scenario and enjoy. + +Sometimes it's not possible to place a town, such as when the heightmap is very rough and a flat tile can't be found with a 16-tile radius of the target tile. In such cases, a sign will be placed on the target tile with the name of the town. The player can then place the town manually or change the heightmap settings and try again. This fallback also helps debug errors with data creation, such as if towns end up in the ocean. + +## Tutorial: Creating town data + +1. Load both your heightmap and a labeled map like OpenStreetMap as layers in an image editing program. You can use a free/open-source program like QGIS to acquire, align, and export these map images, if you like. +2. Crop the image to your desired bounds, ensuring the aspect ratio is supported in OpenTTD (1:1, 1:2, 1:4, etc.). +3. Resize the image to one of OpenTTD's supported map sizes, such as 512 px by 1024 px. Some image editors let you do this part of step 2. You can always load heightmaps and town data at a reduced size, so you may want to make this larger than your intended use in case you want it later. +4. Use the labeled map layer to find the pixel coordinates of each town you'd like to include in your map. In GIMP this is displayed in the bottom left corner of the image window, and in Photoshop you need to enable the Info panel (F8) and switch to pixel units of measurement if not already. +5. Some spreadsheets including Google Sheets can export data as JSON, so you may want to record it there, to export after step 8. Or you can build the JSON file manually. +6. Adjust population numbers for OpenTTD. +7. Change coordinates from pixels to proportion (0-1) of the total dimension: `x / maximum_x` and `y / maximum_y`, as described in "Town data values" above. +8. Swap X and Y coordinates before importing to OpenTTD, since OpenTTD uses a reverse X and Y system than most image editors. +9. Save the heightmap and town data files in your `OpenTTD\scenario\heightmap` folder. diff --git a/src/fileio_type.h b/src/fileio_type.h index cbec6a5d8f..a8c3469f0f 100644 --- a/src/fileio_type.h +++ b/src/fileio_type.h @@ -18,6 +18,7 @@ enum AbstractFileType { FT_SAVEGAME, ///< old or new savegame FT_SCENARIO, ///< old or new scenario FT_HEIGHTMAP, ///< heightmap file + FT_TOWN_DATA, ///< town data file FT_INVALID = 7, ///< Invalid or unknown file type. FT_NUMBITS = 3, ///< Number of bits required for storing a #AbstractFileType value. @@ -34,6 +35,9 @@ enum DetailedFileType { DFT_HEIGHTMAP_BMP, ///< BMP file. DFT_HEIGHTMAP_PNG, ///< PNG file. + /* Town data files. */ + DFT_TOWN_DATA_JSON, ///< JSON file. + /* fios 'files' */ DFT_FIOS_DRIVE, ///< A drive (letter) entry. DFT_FIOS_PARENT, ///< A parent directory entry. @@ -78,6 +82,7 @@ enum FiosType { FIOS_TYPE_OLD_SCENARIO = MAKE_FIOS_TYPE(FT_SCENARIO, DFT_OLD_GAME_FILE), FIOS_TYPE_PNG = MAKE_FIOS_TYPE(FT_HEIGHTMAP, DFT_HEIGHTMAP_PNG), FIOS_TYPE_BMP = MAKE_FIOS_TYPE(FT_HEIGHTMAP, DFT_HEIGHTMAP_BMP), + FIOS_TYPE_JSON = MAKE_FIOS_TYPE(FT_TOWN_DATA, DFT_TOWN_DATA_JSON), FIOS_TYPE_INVALID = MAKE_FIOS_TYPE(FT_INVALID, DFT_INVALID), }; diff --git a/src/fios.cpp b/src/fios.cpp index 5e4eff57fe..0151467d3f 100644 --- a/src/fios.cpp +++ b/src/fios.cpp @@ -84,6 +84,10 @@ void FileList::BuildFileList(AbstractFileType abstract_filetype, SaveLoadOperati FiosGetHeightmapList(fop, show_dirs, *this); break; + case FT_TOWN_DATA: + FiosGetTownDataList(fop, show_dirs, *this); + break; + default: NOT_REACHED(); } @@ -180,6 +184,7 @@ bool FiosBrowseTo(const FiosItem *item) case FIOS_TYPE_OLD_SCENARIO: case FIOS_TYPE_PNG: case FIOS_TYPE_BMP: + case FIOS_TYPE_JSON: return false; } @@ -554,6 +559,42 @@ void FiosGetHeightmapList(SaveLoadOperation fop, bool show_dirs, FileList &file_ } /** + * Callback for FiosGetTownDataList. + * @param fop Purpose of collecting the list. + * @param file Name of the file to check. + * @return a FIOS_TYPE_JSON type of the found file, FIOS_TYPE_INVALID if not a valid JSON file, and the title of the file (if any). + */ +static std::tuple FiosGetTownDataListCallback(SaveLoadOperation fop, const std::string &file, const std::string_view ext) +{ + if (fop == SLO_LOAD) { + if (StrEqualsIgnoreCase(ext, ".json")) { + return { FIOS_TYPE_JSON, GetFileTitle(file, SAVE_DIR) }; + } + } + + return { FIOS_TYPE_INVALID, {} }; +} + +/** + * Get a list of town data files. + * @param fop Purpose of collecting the list. + * @param show_dirs Whether to show directories. + * @param file_list Destination of the found files. + */ +void FiosGetTownDataList(SaveLoadOperation fop, bool show_dirs, FileList &file_list) +{ + static std::optional fios_town_data_path; + + if (!fios_town_data_path) fios_town_data_path = FioFindDirectory(HEIGHTMAP_DIR); + + _fios_path = &(*fios_town_data_path); + + std::string base_path = FioFindDirectory(HEIGHTMAP_DIR); + Subdirectory subdir = base_path == *_fios_path ? HEIGHTMAP_DIR : NO_DIRECTORY; + FiosGetFileList(fop, show_dirs, &FiosGetTownDataListCallback, subdir, file_list); +} + +/** * Get the directory for screenshots. * @return path to screenshots */ diff --git a/src/fios.h b/src/fios.h index d44eb2f1ff..f981c6c74c 100644 --- a/src/fios.h +++ b/src/fios.h @@ -107,6 +107,7 @@ void ShowSaveLoadDialog(AbstractFileType abstract_filetype, SaveLoadOperation fo void FiosGetSavegameList(SaveLoadOperation fop, bool show_dirs, FileList &file_list); void FiosGetScenarioList(SaveLoadOperation fop, bool show_dirs, FileList &file_list); void FiosGetHeightmapList(SaveLoadOperation fop, bool show_dirs, FileList &file_list); +void FiosGetTownDataList(SaveLoadOperation fop, bool show_dirs, FileList &file_list); bool FiosBrowseTo(const FiosItem *item); diff --git a/src/fios_gui.cpp b/src/fios_gui.cpp index 08824aa395..3db0c391e8 100644 --- a/src/fios_gui.cpp +++ b/src/fios_gui.cpp @@ -23,6 +23,7 @@ #include "querystring_gui.h" #include "engine_func.h" #include "landscape_type.h" +#include "genworld.h" #include "timer/timer_game_calendar.h" #include "core/geometry_func.hpp" #include "gamelog.h" @@ -172,6 +173,48 @@ static constexpr NWidgetPart _nested_load_heightmap_dialog_widgets[] = { EndContainer(), }; +/** Load town data */ +static constexpr NWidgetPart _nested_load_town_data_dialog_widgets[] = { + NWidget(NWID_HORIZONTAL), + NWidget(WWT_CLOSEBOX, COLOUR_GREY), + NWidget(WWT_CAPTION, COLOUR_GREY, WID_SL_CAPTION), + NWidget(WWT_DEFSIZEBOX, COLOUR_GREY), + EndContainer(), + /* Current directory and free space */ + NWidget(WWT_PANEL, COLOUR_GREY, WID_SL_BACKGROUND), SetFill(1, 0), SetResize(1, 0), EndContainer(), + + /* Filter box with label */ + NWidget(WWT_PANEL, COLOUR_GREY), SetFill(1, 1), SetResize(1, 1), + NWidget(NWID_HORIZONTAL), SetPadding(WidgetDimensions::unscaled.framerect.top, 0, WidgetDimensions::unscaled.framerect.bottom, 0), + SetPIP(WidgetDimensions::unscaled.frametext.left, WidgetDimensions::unscaled.frametext.right, 0), + NWidget(WWT_TEXT, COLOUR_GREY), SetFill(0, 1), SetDataTip(STR_SAVELOAD_FILTER_TITLE , STR_NULL), + NWidget(WWT_EDITBOX, COLOUR_GREY, WID_SL_FILTER), SetFill(1, 0), SetResize(1, 0), SetDataTip(STR_LIST_FILTER_OSKTITLE, STR_LIST_FILTER_TOOLTIP), + EndContainer(), + EndContainer(), + /* Sort Buttons */ + NWidget(NWID_HORIZONTAL), + NWidget(NWID_HORIZONTAL, NC_EQUALSIZE), + NWidget(WWT_PUSHTXTBTN, COLOUR_GREY, WID_SL_SORT_BYNAME), SetDataTip(STR_SORT_BY_CAPTION_NAME, STR_TOOLTIP_SORT_ORDER), SetFill(1, 0), SetResize(1, 0), + NWidget(WWT_PUSHTXTBTN, COLOUR_GREY, WID_SL_SORT_BYDATE), SetDataTip(STR_SORT_BY_CAPTION_DATE, STR_TOOLTIP_SORT_ORDER), SetFill(1, 0), SetResize(1, 0), + EndContainer(), + NWidget(WWT_PUSHIMGBTN, COLOUR_GREY, WID_SL_HOME_BUTTON), SetAspect(1), SetDataTip(SPR_HOUSE_ICON, STR_SAVELOAD_HOME_BUTTON), + EndContainer(), + /* Files */ + NWidget(NWID_HORIZONTAL), + NWidget(WWT_PANEL, COLOUR_GREY, WID_SL_FILE_BACKGROUND), + NWidget(WWT_INSET, COLOUR_GREY, WID_SL_DRIVES_DIRECTORIES_LIST), SetFill(1, 1), SetPadding(2, 2, 2, 2), + SetDataTip(0x0, STR_SAVELOAD_LIST_TOOLTIP), SetResize(1, 10), SetScrollbar(WID_SL_SCROLLBAR), EndContainer(), + EndContainer(), + NWidget(NWID_VSCROLLBAR, COLOUR_GREY, WID_SL_SCROLLBAR), + EndContainer(), + /* Load button */ + NWidget(NWID_HORIZONTAL, NC_EQUALSIZE), + NWidget(WWT_PUSHTXTBTN, COLOUR_GREY, WID_SL_LOAD_BUTTON), SetResize(1, 0), SetFill(1, 0), + SetDataTip(STR_SAVELOAD_LOAD_BUTTON, STR_SAVELOAD_LOAD_TOWN_DATA_TOOLTIP), + NWidget(WWT_RESIZEBOX, COLOUR_GREY), + EndContainer(), +}; + /** Save game/scenario */ static constexpr NWidgetPart _nested_save_dialog_widgets[] = { NWidget(NWID_HORIZONTAL), @@ -242,6 +285,7 @@ static const TextColour _fios_colours[] = { TC_ORANGE, // DFT_GAME_FILE TC_YELLOW, // DFT_HEIGHTMAP_BMP TC_ORANGE, // DFT_HEIGHTMAP_PNG + TC_LIGHT_BROWN, // DFT_TOWN_DATA_JSON TC_LIGHT_BLUE, // DFT_FIOS_DRIVE TC_DARK_GREEN, // DFT_FIOS_PARENT TC_DARK_GREEN, // DFT_FIOS_DIR @@ -330,6 +374,7 @@ public: break; default: + /* It's not currently possible to save town data. */ NOT_REACHED(); } } @@ -356,6 +401,10 @@ public: caption_string = (this->fop == SLO_SAVE) ? STR_SAVELOAD_SAVE_HEIGHTMAP : STR_SAVELOAD_LOAD_HEIGHTMAP; break; + case FT_TOWN_DATA: + caption_string = STR_SAVELOAD_LOAD_TOWN_DATA; // It's not currently possible to save town data. + break; + default: NOT_REACHED(); } @@ -391,6 +440,7 @@ public: break; case FT_HEIGHTMAP: + case FT_TOWN_DATA: o_dir.name = FioFindDirectory(HEIGHTMAP_DIR); break; @@ -634,6 +684,9 @@ public: if (this->abstract_filetype == FT_HEIGHTMAP) { this->Close(); ShowHeightmapLoad(); + } else if (this->abstract_filetype == FT_TOWN_DATA) { + this->Close(); + LoadTownData(); } else if (!_load_check_data.HasNewGrfs() || _load_check_data.grf_compatibility != GLC_NOT_FOUND || _settings_client.gui.UserIsAllowedToChangeNewGRFs()) { _switch_mode = (_game_mode == GM_EDITOR) ? SM_LOAD_SCENARIO : SM_LOAD_GAME; ClearErrorMessages(); @@ -689,7 +742,7 @@ public: } else if (!_load_check_data.HasErrors()) { this->selected = file; if (this->fop == SLO_LOAD) { - if (this->abstract_filetype == FT_SAVEGAME || this->abstract_filetype == FT_SCENARIO) { + if (this->abstract_filetype == FT_SAVEGAME || this->abstract_filetype == FT_SCENARIO || this->abstract_filetype == FT_TOWN_DATA) { this->OnClick(pt, WID_SL_LOAD_BUTTON, 1); } else { assert(this->abstract_filetype == FT_HEIGHTMAP); @@ -856,6 +909,7 @@ public: switch (this->abstract_filetype) { case FT_HEIGHTMAP: + case FT_TOWN_DATA: this->SetWidgetDisabledState(WID_SL_LOAD_BUTTON, this->selected == nullptr || _load_check_data.HasErrors()); break; @@ -908,6 +962,14 @@ static WindowDesc _load_heightmap_dialog_desc( _nested_load_heightmap_dialog_widgets ); +/** Load town data */ +static WindowDesc _load_town_data_dialog_desc( + WDP_CENTER, "load_town_data", 257, 320, + WC_SAVELOAD, WC_NONE, + 0, + _nested_load_town_data_dialog_widgets +); + /** Save game/scenario */ static WindowDesc _save_dialog_desc( WDP_CENTER, "save_game", 500, 294, @@ -929,6 +991,17 @@ void ShowSaveLoadDialog(AbstractFileType abstract_filetype, SaveLoadOperation fo new SaveLoadWindow(_save_dialog_desc, abstract_filetype, fop); } else { /* Dialogue for loading a file. */ - new SaveLoadWindow((abstract_filetype == FT_HEIGHTMAP) ? _load_heightmap_dialog_desc : _load_dialog_desc, abstract_filetype, fop); + switch (abstract_filetype) { + case FT_HEIGHTMAP: + new SaveLoadWindow(_load_heightmap_dialog_desc, abstract_filetype, fop); + break; + + case FT_TOWN_DATA: + new SaveLoadWindow(_load_town_data_dialog_desc, abstract_filetype, fop); + break; + + default: + new SaveLoadWindow(_load_dialog_desc, abstract_filetype, fop); + } } } diff --git a/src/genworld.cpp b/src/genworld.cpp index 2499248cb1..56ad72175d 100644 --- a/src/genworld.cpp +++ b/src/genworld.cpp @@ -10,6 +10,10 @@ #include "stdafx.h" #include "landscape.h" #include "company_func.h" +#include "town_cmd.h" +#include "signs_cmd.h" +#include "3rdparty/nlohmann/json.hpp" +#include "strings_func.h" #include "genworld.h" #include "gfxinit.h" #include "window_func.h" @@ -337,3 +341,178 @@ void GenerateWorld(GenWorldMode mode, uint size_x, uint size_y, bool reset_setti _GenerateWorld(); } + +/** Town data imported from JSON files and used to place towns. */ +struct ExternalTownData { + TownID town_id; ///< The TownID of the town in OpenTTD. Not imported, but set during the founding proceess and stored here for convenience. + std::string name; ///< The name of the town. + uint population; ///< The target population of the town when created in OpenTTD. If input is blank, defaults to 0. + bool is_city; ///< Should it be created as a city in OpenTTD? If input is blank, defaults to false. + float x_proportion; ///< The X coordinate of the town, as a proportion 0..1 of the maximum X coordinate. + float y_proportion; ///< The Y coordinate of the town, as a proportion 0..1 of the maximum Y coordinate. +}; + +/** + * Helper for CircularTileSearch to found a town on or near a given tile. + * @param tile The tile to try founding the town upon. + * @param user_data The ExternalTownData to attempt to found. + * @return True if the town was founded successfully. + */ +static bool TryFoundTownNearby(TileIndex tile, void *user_data) +{ + ExternalTownData &town = *static_cast(user_data); + std::tuple result = Command::Do(DC_EXEC, tile, TSZ_SMALL, town.is_city, _settings_game.economy.town_layout, false, 0, town.name); + + TownID id = std::get(result); + + /* Check if the command failed. */ + if (id == INVALID_TOWN) return false; + + /* The command succeeded, send the ID back through user_data. */ + town.town_id = id; + return true; +} + +/** + * Load town data from _file_to_saveload, place towns at the appropriate locations, and expand them to their target populations. + */ +void LoadTownData() +{ + /* Load the JSON file as a string initially. We'll parse it soon. */ + size_t filesize; + FILE *f = FioFOpenFile(_file_to_saveload.name, "rb", HEIGHTMAP_DIR, &filesize); + + if (f == nullptr) { + ShowErrorMessage(STR_TOWN_DATA_ERROR_LOAD_FAILED, STR_TOWN_DATA_ERROR_JSON_FORMATTED_INCORRECTLY, WL_ERROR); + return; + } + + std::string text(filesize, '\0'); + size_t len = fread(text.data(), filesize, 1, f); + FioFCloseFile(f); + if (len != 1) { + ShowErrorMessage(STR_TOWN_DATA_ERROR_LOAD_FAILED, STR_TOWN_DATA_ERROR_JSON_FORMATTED_INCORRECTLY, WL_ERROR); + return; + } + + /* Now parse the JSON. */ + nlohmann::json town_data; + try { + town_data = nlohmann::json::parse(text); + } catch (nlohmann::json::exception &) { + ShowErrorMessage(STR_TOWN_DATA_ERROR_LOAD_FAILED, STR_TOWN_DATA_ERROR_JSON_FORMATTED_INCORRECTLY, WL_ERROR); + return; + } + + /* Check for JSON formatting errors with the array of towns. */ + if (!town_data.is_array()) { + ShowErrorMessage(STR_TOWN_DATA_ERROR_LOAD_FAILED, STR_TOWN_DATA_ERROR_JSON_FORMATTED_INCORRECTLY, WL_ERROR); + return; + } + + std::vector > towns; + uint failed_towns = 0; + + /* Iterate through towns and attempt to found them. */ + for (auto &feature : town_data) { + ExternalTownData town; + + /* Ensure JSON is formatted properly. */ + if (!feature.is_object()) { + ShowErrorMessage(STR_TOWN_DATA_ERROR_LOAD_FAILED, STR_TOWN_DATA_ERROR_JSON_FORMATTED_INCORRECTLY, WL_ERROR); + return; + } + + /* Check to ensure all fields exist and are of the correct type. + * If the town name is formatted wrong, all we can do is give a general warning. */ + if (!feature.contains("name") || !feature.at("name").is_string()) { + ShowErrorMessage(STR_TOWN_DATA_ERROR_LOAD_FAILED, STR_TOWN_DATA_ERROR_JSON_FORMATTED_INCORRECTLY, WL_ERROR); + return; + } + + /* If other fields are formatted wrong, we can actually inform the player which town is the problem. */ + if (!feature.contains("population") || !feature.at("population").is_number() || + !feature.contains("city") || !feature.at("city").is_boolean() || + !feature.contains("x") || !feature.at("x").is_number() || + !feature.contains("y") || !feature.at("y").is_number()) { + feature.at("name").get_to(town.name); + SetDParamStr(0, town.name); + ShowErrorMessage(STR_TOWN_DATA_ERROR_LOAD_FAILED, STR_TOWN_DATA_ERROR_TOWN_FORMATTED_INCORRECTLY, WL_ERROR); + return; + } + + /* Set town properties. */ + feature.at("name").get_to(town.name); + feature.at("population").get_to(town.population); + feature.at("city").get_to(town.is_city); + + /* Set town coordinates. */ + feature.at("x").get_to(town.x_proportion); + feature.at("y").get_to(town.y_proportion); + + /* Check for improper coordinates and warn the player. */ + if (town.x_proportion <= 0.0f || town.y_proportion <= 0.0f || town.x_proportion >= 1.0f || town.y_proportion >= 1.0f) { + SetDParamStr(0, town.name); + ShowErrorMessage(STR_TOWN_DATA_ERROR_LOAD_FAILED, STR_TOWN_DATA_ERROR_BAD_COORDINATE, WL_ERROR); + return; + } + + /* Find the target tile for the town. */ + TileIndex tile; + switch (_settings_game.game_creation.heightmap_rotation) { + case HM_CLOCKWISE: + /* Tile coordinates align with what we expect. */ + tile = TileXY(town.x_proportion * Map::MaxX(), town.y_proportion * Map::MaxY()); + break; + case HM_COUNTER_CLOCKWISE: + /* Tile coordinates are rotated and must be adjusted. */ + tile = TileXY((1 - town.y_proportion * Map::MaxX()), town.x_proportion * Map::MaxY()); + break; + default: NOT_REACHED(); + } + + /* Try founding on the target tile, and if that doesn't work, find the nearest suitable tile up to 16 tiles away. + * The target might be on water, blocked somehow, or on a steep slope that can't be terraformed by the founding command. */ + TileIndex search_tile = tile; + bool success = CircularTileSearch(&search_tile, 16, 0, 0, TryFoundTownNearby, &town); + + /* If we still fail to found the town, we'll create a sign at the intended location and tell the player how many towns we failed to create in an error message. + * This allows the player to diagnose a heightmap misalignment, if towns end up in the sea, or place towns manually, if in rough terrain. */ + if (!success) { + Command::Post(tile, town.name); + failed_towns++; + continue; + } + + towns.emplace_back(std::make_pair(Town::Get(town.town_id), town.population)); + } + + /* If we couldn't found a town (or multiple), display a message to the player with the number of failed towns. */ + if (failed_towns > 0) { + SetDParam(0, failed_towns); + ShowErrorMessage(STR_TOWN_DATA_ERROR_FAILED_TO_FOUND_TOWN, INVALID_STRING_ID, WL_WARNING); + } + + /* Now that we've created the towns, let's grow them to their target populations. */ + for (const auto &item : towns) { + Town *t = item.first; + uint population = item.second; + + /* Grid towns can grow almost forever, but the town growth algorithm gets less and less efficient as it wanders roads randomly, + * so we set an arbitrary limit. With a flat map and a 3x3 grid layout this results in about 4900 houses, or 2800 houses with "Better roads." */ + int try_limit = 1000; + + /* If a town repeatedly fails to grow, continuing to try only wastes time. */ + int fail_limit = 10; + + /* Grow by a constant number of houses each time, instead of growth based on current town size. + * We want our try limit to apply in a predictable way, no matter the road layout and other geography. */ + const int HOUSES_TO_GROW = 10; + + do { + uint before = t->cache.num_houses; + Command::Post(t->index, HOUSES_TO_GROW); + if (t->cache.num_houses <= before) fail_limit--; + } while (fail_limit > 0 && try_limit-- > 0 && t->cache.population < population); + } +} diff --git a/src/genworld.h b/src/genworld.h index 23bdf633fe..14bc5dda9e 100644 --- a/src/genworld.h +++ b/src/genworld.h @@ -89,6 +89,7 @@ void GenerateWorld(GenWorldMode mode, uint size_x, uint size_y, bool reset_setti void AbortGeneratingWorld(); bool IsGeneratingWorldAborted(); void HandleGeneratingWorldAbortion(); +void LoadTownData(); /* genworld_gui.cpp */ void SetNewLandscapeType(uint8_t landscape); diff --git a/src/lang/english.txt b/src/lang/english.txt index ff5607a337..5c0f53fcd5 100644 --- a/src/lang/english.txt +++ b/src/lang/english.txt @@ -3019,6 +3019,8 @@ STR_FOUND_TOWN_RANDOM_TOWN_BUTTON :{BLACK}Random T STR_FOUND_TOWN_RANDOM_TOWN_TOOLTIP :{BLACK}Found town in random location STR_FOUND_TOWN_MANY_RANDOM_TOWNS :{BLACK}Many random towns STR_FOUND_TOWN_RANDOM_TOWNS_TOOLTIP :{BLACK}Cover the map with randomly placed towns +STR_FOUND_TOWN_LOAD_FROM_FILE :{BLACK}Load from file +STR_FOUND_TOWN_LOAD_FROM_FILE_TOOLTIP :{BLACK}Import town data from a JSON file STR_FOUND_TOWN_EXPAND_ALL_TOWNS :{BLACK}Expand all towns STR_FOUND_TOWN_EXPAND_ALL_TOWNS_TOOLTIP :{BLACK}Make all towns grow slightly @@ -3271,6 +3273,7 @@ STR_SAVELOAD_SAVE_SCENARIO :{WHITE}Save Sce STR_SAVELOAD_LOAD_SCENARIO :{WHITE}Load Scenario STR_SAVELOAD_LOAD_HEIGHTMAP :{WHITE}Load Heightmap STR_SAVELOAD_SAVE_HEIGHTMAP :{WHITE}Save Heightmap +STR_SAVELOAD_LOAD_TOWN_DATA :{WHITE}Load Town Data STR_SAVELOAD_HOME_BUTTON :{BLACK}Click here to jump to the current default save/load directory STR_SAVELOAD_BYTES_FREE :{BLACK}{BYTES} free STR_SAVELOAD_LIST_TOOLTIP :{BLACK}List of drives, directories and saved-game files @@ -3282,6 +3285,7 @@ STR_SAVELOAD_SAVE_TOOLTIP :{BLACK}Save the STR_SAVELOAD_LOAD_BUTTON :{BLACK}Load STR_SAVELOAD_LOAD_TOOLTIP :{BLACK}Load the selected game STR_SAVELOAD_LOAD_HEIGHTMAP_TOOLTIP :{BLACK}Load the selected heightmap +STR_SAVELOAD_LOAD_TOWN_DATA_TOOLTIP :{BLACK}Load the selected town data STR_SAVELOAD_DETAIL_CAPTION :{BLACK}Game Details STR_SAVELOAD_DETAIL_NOT_AVAILABLE :{BLACK}No information available STR_SAVELOAD_DETAIL_COMPANY_INDEX :{SILVER}{COMMA}: {WHITE}{STRING1} @@ -3415,6 +3419,13 @@ STR_GENERATION_PREPARING_TILELOOP :{BLACK}Running STR_GENERATION_PREPARING_SCRIPT :{BLACK}Running script STR_GENERATION_PREPARING_GAME :{BLACK}Preparing game +STR_TOWN_DATA_ERROR_LOAD_FAILED :{WHITE}Town data load failed +STR_TOWN_DATA_ERROR_JSON_FORMATTED_INCORRECTLY :{WHITE}JSON file formatted incorrectly +STR_TOWN_DATA_ERROR_TOWN_FORMATTED_INCORRECTLY :{WHITE}{RAW_STRING} data formatted incorrectly +STR_TOWN_DATA_ERROR_BAD_COORDINATE :{WHITE}{RAW_STRING} coordinates formatted incorrectly, must be 0..1 as a percentage of the total heightmap dimension + +STR_TOWN_DATA_ERROR_FAILED_TO_FOUND_TOWN :{WHITE}Could not find valid location to found {NUM} town{P "" s}. Created {P "a " ""}sign{P "" s} at the intended location{P "" s} instead + # NewGRF settings STR_NEWGRF_SETTINGS_CAPTION :{WHITE}NewGRF Settings STR_NEWGRF_SETTINGS_INFO_TITLE :{WHITE}Detailed NewGRF information diff --git a/src/town_cmd.cpp b/src/town_cmd.cpp index ee4b69613f..11582355c0 100644 --- a/src/town_cmd.cpp +++ b/src/town_cmd.cpp @@ -2168,15 +2168,13 @@ std::tuple CmdFoundTown(DoCommandFlag flags, TileInd Town *t; if (random_location) { t = CreateRandomTown(20, townnameparts, size, city, layout); - if (t == nullptr) { - cost = CommandCost(STR_ERROR_NO_SPACE_FOR_TOWN); - } else { - new_town = t->index; - } + if (t == nullptr) return { CommandCost(STR_ERROR_NO_SPACE_FOR_TOWN), 0, INVALID_TOWN }; } else { t = new Town(tile); DoCreateTown(t, tile, townnameparts, size, city, layout, true); } + + new_town = t->index; UpdateNearestTownForRoadTiles(false); old_generating_world.Restore(); diff --git a/src/town_gui.cpp b/src/town_gui.cpp index 32ab8c1e29..656b4efd13 100644 --- a/src/town_gui.cpp +++ b/src/town_gui.cpp @@ -33,6 +33,7 @@ #include "core/backup_type.hpp" #include "core/geometry_func.hpp" #include "genworld.h" +#include "fios.h" #include "stringfilter_type.h" #include "dropdown_func.h" #include "town_kdtree.h" @@ -1108,6 +1109,7 @@ static constexpr NWidgetPart _nested_found_town_widgets[] = { NWidget(WWT_TEXTBTN, COLOUR_GREY, WID_TF_NEW_TOWN), SetDataTip(STR_FOUND_TOWN_NEW_TOWN_BUTTON, STR_FOUND_TOWN_NEW_TOWN_TOOLTIP), SetFill(1, 0), NWidget(WWT_PUSHTXTBTN, COLOUR_GREY, WID_TF_RANDOM_TOWN), SetDataTip(STR_FOUND_TOWN_RANDOM_TOWN_BUTTON, STR_FOUND_TOWN_RANDOM_TOWN_TOOLTIP), SetFill(1, 0), NWidget(WWT_PUSHTXTBTN, COLOUR_GREY, WID_TF_MANY_RANDOM_TOWNS), SetDataTip(STR_FOUND_TOWN_MANY_RANDOM_TOWNS, STR_FOUND_TOWN_RANDOM_TOWNS_TOOLTIP), SetFill(1, 0), + NWidget(WWT_PUSHTXTBTN, COLOUR_GREY, WID_TF_LOAD_FROM_FILE), SetDataTip(STR_FOUND_TOWN_LOAD_FROM_FILE, STR_FOUND_TOWN_LOAD_FROM_FILE_TOOLTIP), SetFill(1, 0), NWidget(WWT_PUSHTXTBTN, COLOUR_GREY, WID_TF_EXPAND_ALL_TOWNS), SetDataTip(STR_FOUND_TOWN_EXPAND_ALL_TOWNS, STR_FOUND_TOWN_EXPAND_ALL_TOWNS_TOOLTIP), SetFill(1, 0), /* Town name selection. */ @@ -1188,7 +1190,7 @@ public: void UpdateButtons(bool check_availability) { if (check_availability && _game_mode != GM_EDITOR) { - this->SetWidgetsDisabledState(true, WID_TF_RANDOM_TOWN, WID_TF_MANY_RANDOM_TOWNS, WID_TF_EXPAND_ALL_TOWNS, WID_TF_SIZE_LARGE); + this->SetWidgetsDisabledState(true, WID_TF_RANDOM_TOWN, WID_TF_MANY_RANDOM_TOWNS, WID_TF_LOAD_FROM_FILE, WID_TF_EXPAND_ALL_TOWNS, WID_TF_SIZE_LARGE); this->SetWidgetsDisabledState(_settings_game.economy.found_town != TF_CUSTOM_LAYOUT, WID_TF_LAYOUT_ORIGINAL, WID_TF_LAYOUT_BETTER, WID_TF_LAYOUT_GRID2, WID_TF_LAYOUT_GRID3, WID_TF_LAYOUT_RANDOM); if (_settings_game.economy.found_town != TF_CUSTOM_LAYOUT) town_layout = _settings_game.economy.town_layout; @@ -1254,6 +1256,10 @@ public: break; } + case WID_TF_LOAD_FROM_FILE: + ShowSaveLoadDialog(FT_TOWN_DATA, SLO_LOAD); + break; + case WID_TF_EXPAND_ALL_TOWNS: for (Town *t : Town::Iterate()) { Command::Do(DC_EXEC, t->index, 0); @@ -1274,6 +1280,11 @@ public: case WID_TF_LAYOUT_ORIGINAL: case WID_TF_LAYOUT_BETTER: case WID_TF_LAYOUT_GRID2: case WID_TF_LAYOUT_GRID3: case WID_TF_LAYOUT_RANDOM: this->town_layout = (TownLayout)(widget - WID_TF_LAYOUT_ORIGINAL); + + /* If we are in the editor, sync the settings of the current game to the chosen layout, + * so that importing towns from file uses the selected layout. */ + if (_game_mode == GM_EDITOR) _settings_game.economy.town_layout = this->town_layout; + this->UpdateButtons(false); break; } diff --git a/src/widgets/town_widget.h b/src/widgets/town_widget.h index c17be0f955..11afc00187 100644 --- a/src/widgets/town_widget.h +++ b/src/widgets/town_widget.h @@ -50,6 +50,7 @@ enum TownFoundingWidgets : WidgetID { WID_TF_NEW_TOWN, ///< Create a new town. WID_TF_RANDOM_TOWN, ///< Randomly place a town. WID_TF_MANY_RANDOM_TOWNS, ///< Randomly place many towns. + WID_TF_LOAD_FROM_FILE, ///< Load town data from file. WID_TF_EXPAND_ALL_TOWNS, ///< Make all towns grow slightly. WID_TF_TOWN_NAME_EDITBOX, ///< Editor for the town name. WID_TF_TOWN_NAME_RANDOM, ///< Generate a random town name. -- 2.11.4.GIT