////////////////////////////////////////////////////////////////////// // This file is part of Remere's Map Editor ////////////////////////////////////////////////////////////////////// // Remere's Map Editor is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Remere's Map Editor is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. If not, see . ////////////////////////////////////////////////////////////////////// #include "main.h" #include "gui.h" // loadbar #include "map.h" #include #include "string_utils.h" Map::Map() : BaseMap(), width(512), height(512), houses(*this), has_changed(false), unnamed(false), waypoints(*this) { // Earliest version possible // Caller is responsible for converting us to proper version mapVersion.otbm = MAP_OTBM_1; mapVersion.client = CLIENT_VERSION_NONE; } Map::~Map() { //// } bool Map::open(const std::string file) { if (file == filename) { return true; // Do not reopen ourselves! } tilecount = 0; IOMapOTBM maploader(getVersion()); bool success = maploader.loadMap(*this, wxstr(file)); mapVersion = maploader.version; warnings = maploader.getWarnings(); if (!success) { error = maploader.getError(); return false; } has_changed = false; wxFileName fn = wxstr(file); filename = fn.GetFullPath().mb_str(wxConvUTF8); name = fn.GetFullName().mb_str(wxConvUTF8); // convert(getReplacementMapClassic(), true); #if 0 // This will create a replacement map out of one of SO's template files std::ofstream out("templateshit.cpp"); out << "\tConversionMap replacement_map;\n"; int count = 0; out << "\tstd::vector veckey, vecval;\n\n"; for(int x = 20; ; x += 2) { int y = 22; Tile* old = getTile(x, y, GROUND_LAYER); if(old) { y -= 2; Tile* new_ = getTile(x, y, GROUND_LAYER); if(new_) { if(old->ground || old->items.size()) { out << "\tvecval.clear();\n"; if(new_->ground) out << "\tvecval.push_back(" << new_->ground->getID() << ");\n"; for(ItemVector::iterator iter = new_->items.begin(); iter != new_->items.end(); ++iter) out << "\tvecval.push_back(" << (*iter)->getID() << ");\n"; if(old->ground && old->items.empty()) // Single item out << "\treplacement_map.stm[" << old->ground->getID() << "] = vecval;\n\n"; else if(old->ground == nullptr && old->items.size() == 1) // Single item out << "\treplacement_map.stm[" << old->items.front()->getID() << "] = vecval;\n\n"; else { // Many items out << "\tveckey.clear();\n"; if(old->ground) out << "\tveckey.push_back(" << old->ground->getID() << ");\n"; for(ItemVector::iterator iter = old->items.begin(); iter != old->items.end(); ++iter) out << "\tveckey.push_back(" << (*iter)->getID() << ");\n"; out << "\tstd::sort(veckey.begin(), veckey.end());\n"; out << "\treplacement_map.mtm[veckey] = vecval;\n\n"; } } } } else { break; } } out.close(); #endif return true; } bool Map::convert(MapVersion to, bool showdialog) { if (mapVersion.client == to.client) { // Only OTBM version differs // No changes necessary mapVersion = to; return true; } /* TODO if(to.otbm == MAP_OTBM_4 && to.client < CLIENT_VERSION_850) return false; if(mapVersion.client >= CLIENT_VERSION_760 && to.client < CLIENT_VERSION_760) convert(getReplacementMapFrom760To740(), showdialog); if(mapVersion.client < CLIENT_VERSION_810 && to.client >= CLIENT_VERSION_810) convert(getReplacementMapFrom800To810(), showdialog); if(mapVersion.client == CLIENT_VERSION_854_BAD && to.client >= CLIENT_VERSION_854) convert(getReplacementMapFrom854To854(), showdialog); */ mapVersion = to; return true; } bool Map::convert(const ConversionMap& rm, bool showdialog) { if (showdialog) { g_gui.CreateLoadBar("Converting map ..."); } uint64_t tiles_done = 0; std::vector id_list; // std::ofstream conversions("converted_items.txt"); for (MapIterator miter = begin(); miter != end(); ++miter) { Tile* tile = (*miter)->get(); ASSERT(tile); if (tile->size() == 0) { continue; } // id_list try MTM conversion id_list.clear(); if (tile->ground) { id_list.push_back(tile->ground->getID()); } for (ItemVector::const_iterator item_iter = tile->items.begin(); item_iter != tile->items.end(); ++item_iter) { if ((*item_iter)->isBorder()) { id_list.push_back((*item_iter)->getID()); } } std::sort(id_list.begin(), id_list.end()); ConversionMap::MTM::const_iterator cfmtm = rm.mtm.end(); while (id_list.size()) { cfmtm = rm.mtm.find(id_list); if (cfmtm != rm.mtm.end()) { break; } id_list.pop_back(); } // Keep track of how many items have been inserted at the bottom size_t inserted_items = 0; if (cfmtm != rm.mtm.end()) { const std::vector& v = cfmtm->first; if (tile->ground && std::find(v.begin(), v.end(), tile->ground->getID()) != v.end()) { delete tile->ground; tile->ground = nullptr; } for (ItemVector::iterator item_iter = tile->items.begin(); item_iter != tile->items.end();) { if (std::find(v.begin(), v.end(), (*item_iter)->getID()) != v.end()) { delete *item_iter; item_iter = tile->items.erase(item_iter); } else { ++item_iter; } } const std::vector& new_items = cfmtm->second; for (std::vector::const_iterator iit = new_items.begin(); iit != new_items.end(); ++iit) { Item* item = Item::Create(*iit); if (item->isGroundTile()) { tile->ground = item; } else { tile->items.insert(tile->items.begin(), item); ++inserted_items; } } } if (tile->ground) { ConversionMap::STM::const_iterator cfstm = rm.stm.find(tile->ground->getID()); if (cfstm != rm.stm.end()) { uint16_t aid = tile->ground->getActionID(); uint16_t uid = tile->ground->getUniqueID(); delete tile->ground; tile->ground = nullptr; const std::vector& v = cfstm->second; // conversions << "Converted " << tile->getX() << ":" << tile->getY() << ":" << tile->getZ() << " " << id << " -> "; for (std::vector::const_iterator iit = v.begin(); iit != v.end(); ++iit) { Item* item = Item::Create(*iit); // conversions << *iit << " "; if (item->isGroundTile()) { item->setActionID(aid); item->setUniqueID(uid); tile->addItem(item); } else { tile->items.insert(tile->items.begin(), item); ++inserted_items; } } // conversions << std::endl; } } for (ItemVector::iterator replace_item_iter = tile->items.begin() + inserted_items; replace_item_iter != tile->items.end();) { uint16_t id = (*replace_item_iter)->getID(); ConversionMap::STM::const_iterator cf = rm.stm.find(id); if (cf != rm.stm.end()) { // uint16_t aid = (*replace_item_iter)->getActionID(); // uint16_t uid = (*replace_item_iter)->getUniqueID(); delete *replace_item_iter; replace_item_iter = tile->items.erase(replace_item_iter); const std::vector& v = cf->second; for (std::vector::const_iterator iit = v.begin(); iit != v.end(); ++iit) { replace_item_iter = tile->items.insert(replace_item_iter, Item::Create(*iit)); // conversions << "Converted " << tile->getX() << ":" << tile->getY() << ":" << tile->getZ() << " " << id << " -> " << *iit << std::endl; ++replace_item_iter; } } else { ++replace_item_iter; } } ++tiles_done; if (showdialog && tiles_done % 0x10000 == 0) { g_gui.SetLoadDone(int(tiles_done / double(getTileCount()) * 100.0)); } } if (showdialog) { g_gui.DestroyLoadBar(); } return true; } void Map::cleanInvalidTiles(bool showdialog) { uint64_t tiles_done = 0; uint64_t removed_count = 0; bool has_invalid_tiles = false; // First check if there are any invalid tiles to remove for (MapIterator miter = begin(); miter != end(); ++miter) { Tile* tile = (*miter)->get(); if (!tile || tile->size() == 0) { continue; } for (ItemVector::iterator item_iter = tile->items.begin(); item_iter != tile->items.end(); ++item_iter) { if (!g_items.typeExists((*item_iter)->getID())) { has_invalid_tiles = true; break; } } if (has_invalid_tiles) { break; } } if (!has_invalid_tiles) { if (showdialog) { g_gui.SetLoadDone(100); g_gui.PopupDialog("Cleanup Complete", "No invalid tiles found.", wxOK); } return; } // Note: We don't create a loading bar here anymore, it should be created by the caller for (MapIterator miter = begin(); miter != end(); ++miter) { Tile* tile = (*miter)->get(); ASSERT(tile); if (tile->size() == 0) { continue; } for (ItemVector::iterator item_iter = tile->items.begin(); item_iter != tile->items.end();) { if (g_items.typeExists((*item_iter)->getID())) { ++item_iter; } else { delete *item_iter; item_iter = tile->items.erase(item_iter); ++removed_count; } } ++tiles_done; if (showdialog && tiles_done % 0x10000 == 0) { g_gui.SetLoadDone(int(tiles_done / double(getTileCount()) * 100.0)); } } if (showdialog) { g_gui.SetLoadDone(100); // Note: We don't destroy the loading bar here anymore, it should be destroyed by the caller g_gui.PopupDialog("Cleanup Complete", "Removed " + i2ws(removed_count) + " invalid tiles.", wxOK); } } void Map::convertHouseTiles(uint32_t fromId, uint32_t toId) { g_gui.CreateLoadBar("Converting house tiles..."); uint64_t tiles_done = 0; for (MapIterator miter = begin(); miter != end(); ++miter) { Tile* tile = (*miter)->get(); ASSERT(tile); uint32_t houseId = tile->getHouseID(); if (houseId == 0 || houseId != fromId) { continue; } tile->setHouseID(toId); ++tiles_done; if (tiles_done % 0x10000 == 0) { g_gui.SetLoadDone(int(tiles_done / double(getTileCount()) * 100.0)); } } g_gui.DestroyLoadBar(); } MapVersion Map::getVersion() const { return mapVersion; } bool Map::hasChanged() const { return has_changed; } bool Map::doChange() { bool doupdate = !has_changed; has_changed = true; return doupdate; } bool Map::clearChanges() { bool doupdate = has_changed; has_changed = false; return doupdate; } bool Map::hasFile() const { return filename != ""; } void Map::setWidth(int new_width) { if (new_width > 65000) { width = 65000; } else if (new_width < 64) { width = 64; } else { width = new_width; } } void Map::setHeight(int new_height) { if (new_height > 65000) { height = 65000; } else if (new_height < 64) { height = 64; } else { height = new_height; } } void Map::setMapDescription(const std::string& new_description) { description = new_description; } void Map::setHouseFilename(const std::string& new_housefile) { housefile = new_housefile; unnamed = false; } void Map::setSpawnFilename(const std::string& new_spawnfile) { spawnfile = new_spawnfile; unnamed = false; } bool Map::addSpawn(Tile* tile) { Spawn* spawn = tile->spawn; if (spawn) { int z = tile->getZ(); int start_x = tile->getX() - spawn->getSize(); int start_y = tile->getY() - spawn->getSize(); int end_x = tile->getX() + spawn->getSize(); int end_y = tile->getY() + spawn->getSize(); for (int y = start_y; y <= end_y; ++y) { for (int x = start_x; x <= end_x; ++x) { TileLocation* ctile_loc = createTileL(x, y, z); ctile_loc->increaseSpawnCount(); } } spawns.addSpawn(tile); return true; } return false; } void Map::removeSpawnInternal(Tile* tile) { Spawn* spawn = tile->spawn; ASSERT(spawn); int z = tile->getZ(); int start_x = tile->getX() - spawn->getSize(); int start_y = tile->getY() - spawn->getSize(); int end_x = tile->getX() + spawn->getSize(); int end_y = tile->getY() + spawn->getSize(); for (int y = start_y; y <= end_y; ++y) { for (int x = start_x; x <= end_x; ++x) { TileLocation* ctile_loc = getTileL(x, y, z); if (ctile_loc != nullptr && ctile_loc->getSpawnCount() > 0) { ctile_loc->decreaseSpawnCount(); } } } } void Map::removeSpawn(Tile* tile) { if (tile->spawn) { removeSpawnInternal(tile); spawns.removeSpawn(tile); } } SpawnList Map::getSpawnList(Tile* where) { SpawnList list; TileLocation* tile_loc = where->getLocation(); if (tile_loc) { if (tile_loc->getSpawnCount() > 0) { uint32_t found = 0; if (where->spawn) { ++found; list.push_back(where->spawn); } // Scans the border tiles in an expanding square around the original spawn int z = where->getZ(); int start_x = where->getX() - 1, end_x = where->getX() + 1; int start_y = where->getY() - 1, end_y = where->getY() + 1; while (found != tile_loc->getSpawnCount()) { for (int x = start_x; x <= end_x; ++x) { Tile* tile = getTile(x, start_y, z); if (tile && tile->spawn) { list.push_back(tile->spawn); ++found; } tile = getTile(x, end_y, z); if (tile && tile->spawn) { list.push_back(tile->spawn); ++found; } } for (int y = start_y + 1; y < end_y; ++y) { Tile* tile = getTile(start_x, y, z); if (tile && tile->spawn) { list.push_back(tile->spawn); ++found; } tile = getTile(end_x, y, z); if (tile && tile->spawn) { list.push_back(tile->spawn); ++found; } } --start_x, --start_y; ++end_x, ++end_y; } } } return list; } bool Map::exportMinimap(FileName filename, int floor, bool displaydialog) { uint8_t* pic = nullptr; try { const int MAX_AREA_SIZE = 2500; std::vector> initial_areas; std::map processed_tiles; // Helper function to get tile key auto getTileKey = [](int x, int y) -> uint32_t { return (uint32_t(x) << 16) | uint32_t(y); }; // Helper function to check if two areas can be merged within size limits auto canMergeAreas = [MAX_AREA_SIZE](const Position& min1, const Position& max1, const Position& min2, const Position& max2) -> bool { // Calculate combined bounds Position combined_min( std::min(min1.x, min2.x), std::min(min1.y, min2.y), min1.z ); Position combined_max( std::max(max1.x, max2.x), std::max(max1.y, max2.y), max1.z ); // Check if combined area is within limits return (combined_max.x - combined_min.x + 1 <= MAX_AREA_SIZE) && (combined_max.y - combined_min.y + 1 <= MAX_AREA_SIZE); }; // Helper function to check if areas are adjacent or close enough to merge auto areAreasNearby = [](const Position& max1, const Position& min2, const Position& min1, const Position& max2) -> bool { const int MERGE_DISTANCE = 1000; // Maximum distance to consider areas for merging // Check if areas are within merge distance of each other bool x_overlap = (min1.x <= max2.x + MERGE_DISTANCE) && (max1.x + MERGE_DISTANCE >= min2.x); bool y_overlap = (min1.y <= max2.y + MERGE_DISTANCE) && (max1.y + MERGE_DISTANCE >= min2.y); return x_overlap && y_overlap; }; // First pass - collect all initial areas using the existing flood fill std::vector tiles_on_floor; for (MapIterator mit = begin(); mit != end(); ++mit) { if ((*mit)->get() == nullptr || (*mit)->empty() || (*mit)->getZ() != floor) { continue; } tiles_on_floor.push_back((*mit)->getPosition()); } // Sort tiles for efficient processing std::sort(tiles_on_floor.begin(), tiles_on_floor.end(), [](const Position& a, const Position& b) { return a.x < b.x || (a.x == b.x && a.y < b.y); }); // Collect initial areas using flood fill for (const Position& pos : tiles_on_floor) { uint32_t tile_key = getTileKey(pos.x, pos.y); if (processed_tiles[tile_key]) { continue; } Position area_start = pos; Position area_end = pos; std::queue to_process; to_process.push(pos); processed_tiles[tile_key] = true; while (!to_process.empty()) { Position current = to_process.front(); to_process.pop(); if (area_end.x - area_start.x >= MAX_AREA_SIZE || area_end.y - area_start.y >= MAX_AREA_SIZE) { continue; } area_start.x = std::min(area_start.x, current.x); area_start.y = std::min(area_start.y, current.y); area_end.x = std::max(area_end.x, current.x); area_end.y = std::max(area_end.y, current.y); for (int dx = -1; dx <= 1; dx++) { for (int dy = -1; dy <= 1; dy++) { if (dx == 0 && dy == 0) continue; Position next(current.x + dx, current.y + dy, floor); uint32_t next_key = getTileKey(next.x, next.y); if (!processed_tiles[next_key]) { Tile* next_tile = getTile(next); if (next_tile && !next_tile->empty()) { to_process.push(next); processed_tiles[next_key] = true; } } } } } // Add the area if it contains content bool has_content = false; for (int y = area_start.y; y <= area_end.y && !has_content; y++) { for (int x = area_start.x; x <= area_end.x && !has_content; x++) { Tile* tile = getTile(x, y, floor); if (tile && !tile->empty()) { has_content = true; } } } if (has_content) { initial_areas.push_back(std::make_pair(area_start, area_end)); } } // Second pass - merge nearby areas into larger areas while respecting size limits std::vector> merged_areas; std::vector area_processed(initial_areas.size(), false); for (size_t i = 0; i < initial_areas.size(); i++) { if (area_processed[i]) continue; Position current_min = initial_areas[i].first; Position current_max = initial_areas[i].second; area_processed[i] = true; bool merged; do { merged = false; for (size_t j = 0; j < initial_areas.size(); j++) { if (area_processed[j]) continue; const auto& area2 = initial_areas[j]; if (canMergeAreas(current_min, current_max, area2.first, area2.second) && areAreasNearby(current_max, area2.first, current_min, area2.second)) { // Merge the areas current_min.x = std::min(current_min.x, area2.first.x); current_min.y = std::min(current_min.y, area2.first.y); current_max.x = std::max(current_max.x, area2.second.x); current_max.y = std::max(current_max.y, area2.second.y); area_processed[j] = true; merged = true; } } } while (merged); merged_areas.push_back(std::make_pair(current_min, current_max)); } // No areas found if (merged_areas.empty()) { return true; } // Export each merged area int area_count = 0; for (const auto& area : merged_areas) { Position min_pos = area.first; Position max_pos = area.second; // Add small padding but respect MAX_AREA_SIZE min_pos.x = std::max(0, min_pos.x - 5); min_pos.y = std::max(0, min_pos.y - 5); max_pos.x = std::min(65535, max_pos.x + 5); max_pos.y = std::min(65535, max_pos.y + 5); // Ensure we don't exceed MAX_AREA_SIZE if (max_pos.x - min_pos.x + 1 > MAX_AREA_SIZE) { max_pos.x = min_pos.x + MAX_AREA_SIZE - 1; } if (max_pos.y - min_pos.y + 1 > MAX_AREA_SIZE) { max_pos.y = min_pos.y + MAX_AREA_SIZE - 1; } // Calculate dimensions int minimap_width = max_pos.x - min_pos.x + 1; int minimap_height = max_pos.y - min_pos.y + 1; // Allocate memory for the bitmap pic = newd uint8_t[minimap_width * minimap_height]; memset(pic, 0, minimap_width * minimap_height); // Fill the bitmap for (int y = min_pos.y; y <= max_pos.y; y++) { for (int x = min_pos.x; x <= max_pos.x; x++) { Tile* tile = getTile(x, y, floor); if (!tile || tile->empty()) { continue; } uint32_t pixelpos = (y - min_pos.y) * minimap_width + (x - min_pos.x); uint8_t& pixel = pic[pixelpos]; // Get color from items for (ItemVector::const_reverse_iterator item_iter = tile->items.rbegin(); item_iter != tile->items.rend(); ++item_iter) { if ((*item_iter)->getMiniMapColor()) { pixel = (*item_iter)->getMiniMapColor(); break; } } // If no item color, check ground if (pixel == 0 && tile->hasGround()) { pixel = tile->ground->getMiniMapColor(); } } } // Generate filename for this area wxString base_name = filename.GetFullPath(); base_name = base_name.BeforeLast('.'); wxString area_filename = wxString::Format("%s_area%d.bmp", base_name, area_count++); // Write to file FileWriteHandle fh(nstr(area_filename)); if (!fh.isOpen()) { delete[] pic; return false; } // Store the magic number fh.addRAW("BM"); // Store the file size uint32_t file_size = 14 // header + 40 // image data header + 256 * 4 // color palette + ((minimap_width + 3) / 4 * 4) * minimap_height; // pixels fh.addU32(file_size); // Two values reserved, must always be 0. fh.addU16(0); fh.addU16(0); // Bitmapdata offset fh.addU32(14 + 40 + 256 * 4); // Header size fh.addU32(40); // Header width/height fh.addU32(minimap_width); fh.addU32(minimap_height); // Color planes fh.addU16(1); // bits per pixel, OT map format is 8 fh.addU16(8); // compression type, 0 is no compression fh.addU32(0); // image size, 0 is valid if we use no compression fh.addU32(0); // horizontal/vertical resolution in pixels / meter fh.addU32(4000); fh.addU32(4000); // Number of colors fh.addU32(256); // Important colors, 0 is all fh.addU32(0); // Write the color palette for (int i = 0; i < 256; ++i) { fh.addU32(uint32_t(minimap_color[i])); } // Bitmap width must be divisible by four, calculate how much padding we need int padding = ((minimap_width & 3) != 0 ? 4 - (minimap_width & 3) : 0); // Bitmap rows are saved in reverse order for (int y = minimap_height - 1; y >= 0; --y) { fh.addRAW(pic + y * minimap_width, minimap_width); for (int i = 0; i < padding; ++i) { fh.addU8(0); } if (displaydialog && y % 100 == 0) { g_gui.SetLoadDone(90 + int((minimap_height - y) / double(minimap_height) * 10.0)); } } delete[] pic; pic = nullptr; } return true; } catch (...) { delete[] pic; throw; } } uint32_t Map::cleanDuplicateItems(const std::vector>& ranges, const PropertyFlags& flags) { uint32_t duplicates_removed = 0; uint32_t tiles_affected = 0; // Helper function to check if item is in ranges auto isInRanges = [&ranges](uint16_t id) -> bool { if (ranges.empty()) return true; // If no ranges, check all items for (const auto& range : ranges) { if (id >= range.first && id <= range.second) return true; } return false; }; // Helper function to compare items considering property flags auto compareItems = [&flags](Item* item1, Item* item2) -> bool { if (item1->getID() != item2->getID()) return false; const ItemType& type1 = g_items[item1->getID()]; const ItemType& type2 = g_items[item2->getID()]; // If a flag is true (checked), we treat items with that property as different // even if they have the same value for that property if (flags.ignore_unpassable && (type1.unpassable || type2.unpassable)) return false; if (flags.ignore_unmovable && (type1.moveable || type2.moveable)) return false; if (flags.ignore_block_missiles && (type1.blockMissiles || type2.blockMissiles)) return false; if (flags.ignore_block_pathfinder && (type1.blockPathfinder || type2.blockPathfinder)) return false; if (flags.ignore_readable && (type1.canReadText || type2.canReadText)) return false; if (flags.ignore_writeable && (type1.canWriteText || type2.canWriteText)) return false; if (flags.ignore_pickupable && (type1.pickupable || type2.pickupable)) return false; if (flags.ignore_stackable && (type1.stackable || type2.stackable)) return false; if (flags.ignore_rotatable && (type1.rotable || type2.rotable)) return false; if (flags.ignore_hangable && (type1.isHangable || type2.isHangable)) return false; if (flags.ignore_hook_east && (type1.hookEast || type2.hookEast)) return false; if (flags.ignore_hook_south && (type1.hookSouth || type2.hookSouth)) return false; if (flags.ignore_elevation && (type1.hasElevation || type2.hasElevation)) return false; return true; }; for (MapIterator mit = begin(); mit != end(); ++mit) { Tile* tile = (*mit)->get(); if (!tile) continue; bool tile_modified = false; std::set kept_items; // Track first instances // First pass: identify items to keep for (Item* item : tile->items) { if (!isInRanges(item->getID())) continue; bool is_duplicate = false; for (Item* kept : kept_items) { if (compareItems(item, kept)) { is_duplicate = true; break; } } if (!is_duplicate) { kept_items.insert(item); } } // Second pass: remove duplicates auto iit = tile->items.begin(); while (iit != tile->items.end()) { Item* item = *iit; if (!isInRanges(item->getID())) { ++iit; continue; } bool should_remove = true; for (Item* kept : kept_items) { if (item == kept) { should_remove = false; break; } } if (should_remove) { delete item; iit = tile->items.erase(iit); duplicates_removed++; tile_modified = true; } else { ++iit; } } if (tile_modified) { tiles_affected++; } } return duplicates_removed; }