@tool extends Node ## A [TileMapLayer] terrain / auto-tiling system. ## ## This is a drop-in replacement for Godot 4's tilemap terrain system, offering ## more versatile and straightforward autotiling. It can be used with any ## existing [TileMapLayer] or [TileSet], either through the editor plugin, or ## directly via code. ## [br][br] ## The [b]BetterTerrain[/b] class contains only static functions, each of which ## either takes a [TileMapLayer], a [TileSet], and sometimes a [TileData]. ## Meta-data is embedded inside the [TileSet] and the [TileData] types to store ## the terrain information. See [method Object.get_meta] for information. ## [br][br] ## Once terrain is set up, it can be written to the tilemap using [method set_cells]. ## Similar to Godot 3.x, setting the cells does not run the terrain solver, so once ## the cells have been set, you need to call an update function such as [method update_terrain_cells]. ## The meta-data key used to store terrain information. const TERRAIN_META = &"_better_terrain" ## The current version. Used to handle future upgrades. const TERRAIN_SYSTEM_VERSION = "0.2" var _tile_cache = {} var rng = RandomNumberGenerator.new() var use_seed := true ## A helper class that provides functions detailing valid peering bits and ## polygons for different tile types. var data := load("res://addons/better-terrain/BetterTerrainData.gd"): get: return data enum TerrainType { MATCH_TILES, ## Selects tiles by matching against adjacent tiles. MATCH_VERTICES, ## Select tiles by analysing vertices, similar to wang-style tiles. CATEGORY, ## Declares a matching type for more sophisticated rules. DECORATION, ## Fills empty tiles by matching adjacent tiles MAX, } enum TileCategory { EMPTY = -1, ## An empty cell, or a tile marked as decoration NON_TERRAIN = -2, ## A non-empty cell that does not contain a terrain tile ERROR = -3 } enum SymmetryType { NONE, MIRROR, ## Horizontally mirror FLIP, ## Vertically flip REFLECT, ## All four reflections ROTATE_CLOCKWISE, ROTATE_COUNTER_CLOCKWISE, ROTATE_180, ROTATE_ALL, ## All four rotated forms ALL ## All rotated and reflected forms } func _intersect(first: Array, second: Array) -> bool: if first.size() > second.size(): return _intersect(second, first) # Array 'has' is fast compared to gdscript loop for f in first: if second.has(f): return true return false # Meta-data functions func _get_terrain_meta(ts: TileSet) -> Dictionary: return ts.get_meta(TERRAIN_META) if ts and ts.has_meta(TERRAIN_META) else { terrains = [], decoration = ["Decoration", Color.DIM_GRAY, TerrainType.DECORATION, [], {path = "res://addons/better-terrain/icons/Decoration.svg"}], version = TERRAIN_SYSTEM_VERSION } func _set_terrain_meta(ts: TileSet, meta : Dictionary) -> void: ts.set_meta(TERRAIN_META, meta) ts.emit_changed() func _get_tile_meta(td: TileData) -> Dictionary: return td.get_meta(TERRAIN_META) if td.has_meta(TERRAIN_META) else { type = TileCategory.NON_TERRAIN } func _set_tile_meta(ts: TileSet, td: TileData, meta) -> void: td.set_meta(TERRAIN_META, meta) ts.emit_changed() func _get_cache(ts: TileSet) -> Array: if _tile_cache.has(ts): return _tile_cache[ts] var cache := [] if !ts: return cache _tile_cache[ts] = cache var watcher = Node.new() watcher.set_script(load("res://addons/better-terrain/Watcher.gd")) watcher.tileset = ts watcher.trigger.connect(_purge_cache.bind(ts)) add_child(watcher) ts.changed.connect(watcher.activate) var types = {} var ts_meta := _get_terrain_meta(ts) for t in ts_meta.terrains.size(): var terrain = ts_meta.terrains[t] var bits = terrain[3].duplicate() bits.push_back(t) types[t] = bits cache.push_back([]) # Decoration types[-1] = [TileCategory.EMPTY] cache.push_back([[-1, Vector2.ZERO, -1, {}, 1.0]]) for s in ts.get_source_count(): var source_id := ts.get_source_id(s) var source := ts.get_source(source_id) as TileSetAtlasSource if !source: continue source.changed.connect(watcher.activate) for c in source.get_tiles_count(): var coord := source.get_tile_id(c) for a in source.get_alternative_tiles_count(coord): var alternate := source.get_alternative_tile_id(coord, a) var td := source.get_tile_data(coord, alternate) var td_meta := _get_tile_meta(td) if td_meta.type < TileCategory.EMPTY or td_meta.type >= cache.size(): continue td.changed.connect(watcher.activate) var peering := {} for key in td_meta.keys(): if !(key is int): continue var targets := [] for k in types: if _intersect(types[k], td_meta[key]): targets.push_back(k) peering[key] = targets # Decoration tiles without peering are skipped if td_meta.type == TileCategory.EMPTY and !peering: continue var symmetry = td_meta.get("symmetry", SymmetryType.NONE) # Branch out no symmetry tiles early if symmetry == SymmetryType.NONE: cache[td_meta.type].push_back([source_id, coord, alternate, peering, td.probability]) continue # calculate the symmetry order for this tile var symmetry_order := 0 for flags in data.symmetry_mapping[symmetry]: var symmetric_peering = data.peering_bits_after_symmetry(peering, flags) if symmetric_peering == peering: symmetry_order += 1 var adjusted_probability = td.probability / symmetry_order for flags in data.symmetry_mapping[symmetry]: var symmetric_peering = data.peering_bits_after_symmetry(peering, flags) cache[td_meta.type].push_back([source_id, coord, alternate | flags, symmetric_peering, adjusted_probability]) return cache func _get_cache_terrain(ts_meta : Dictionary, index: int) -> Array: # the cache and the terrains in ts_meta don't line up because # decorations are cached too if index < 0 or index >= ts_meta.terrains.size(): return ts_meta.decoration return ts_meta.terrains[index] func _purge_cache(ts: TileSet) -> void: _tile_cache.erase(ts) for c in get_children(): if c.tileset == ts: c.tidy() break func _clear_invalid_peering_types(ts: TileSet) -> void: var ts_meta := _get_terrain_meta(ts) var cache := _get_cache(ts) for t in cache.size(): var type = _get_cache_terrain(ts_meta, t)[2] var valid_peering_types = data.get_terrain_peering_cells(ts, type) for c in cache[t]: if c[0] < 0: continue var source := ts.get_source(c[0]) as TileSetAtlasSource if !source: continue var td := source.get_tile_data(c[1], c[2]) var td_meta := _get_tile_meta(td) for peering in c[3].keys(): if valid_peering_types.has(peering): continue td_meta.erase(peering) _set_tile_meta(ts, td, td_meta) # Not strictly necessary _purge_cache(ts) func _has_invalid_peering_types(ts: TileSet) -> bool: var ts_meta := _get_terrain_meta(ts) var cache := _get_cache(ts) for t in cache.size(): var type = _get_cache_terrain(ts_meta, t)[2] var valid_peering_types = data.get_terrain_peering_cells(ts, type) for c in cache[t]: for peering in c[3].keys(): if !valid_peering_types.has(peering): return true return false func _update_terrain_data(ts: TileSet) -> void: var ts_meta = _get_terrain_meta(ts) var previous_version = ts_meta.get("version") # First release: no version info if !ts_meta.has("version"): ts_meta["version"] = "0.0" # 0.0 -> 0.1: add categories if ts_meta.version == "0.0": for t in ts_meta.terrains: if t.size() == 3: t.push_back([]) ts_meta.version = "0.1" # 0.1 -> 0.2: add decoration tiles and terrain icons if ts_meta.version == "0.1": # Add terrain icon containers for t in ts_meta.terrains: if t.size() == 4: t.push_back({}) # Add default decoration data ts_meta["decoration"] = ["Decoration", Color.DIM_GRAY, TerrainType.DECORATION, [], {path = "res://addons/better-terrain/icons/Decoration.svg"}] ts_meta.version = "0.2" if previous_version != ts_meta.version: _set_terrain_meta(ts, ts_meta) func _weighted_selection(choices: Array, apply_empty_probability: bool): if choices.is_empty(): return null var weight = choices.reduce(func(a, c): return a + c[4], 0.0) if apply_empty_probability and weight < 1.0 and rng.randf() > weight: return [-1, Vector2.ZERO, -1, null, 1.0] if choices.size() == 1: return choices[0] if weight == 0.0: return choices[rng.randi() % choices.size()] var pick = rng.randf() * weight for c in choices: if pick < c[4]: return c pick -= c[4] return choices.back() func _weighted_selection_seeded(choices: Array, coord: Vector2i, apply_empty_probability: bool): if use_seed: rng.seed = hash(coord) return _weighted_selection(choices, apply_empty_probability) func _update_tile_tiles(tm: TileMapLayer, coord: Vector2i, types: Dictionary, cache: Array, apply_empty_probability: bool): var type = types[coord] const reward := 3 var penalty := -2000 if apply_empty_probability else -10 var best_score := -1000 # Impossibly bad score var best := [] for t in cache[type]: var score := 0 for peering in t[3]: score += reward if t[3][peering].has(types[tm.get_neighbor_cell(coord, peering)]) else penalty if score > best_score: best_score = score best = [t] elif score == best_score: best.append(t) return _weighted_selection_seeded(best, coord, apply_empty_probability) func _probe(tm: TileMapLayer, coord: Vector2i, peering: int, type: int, types: Dictionary) -> int: var targets = data.associated_vertex_cells(tm, coord, peering) targets = targets.map(func(c): return types[c]) var first = targets[0] if targets.all(func(t): return t == first): return first # if different, use the lowest non-same targets = targets.filter(func(t): return t != type) return targets.reduce(func(a, t): return min(a, t)) func _update_tile_vertices(tm: TileMapLayer, coord: Vector2i, types: Dictionary, cache: Array): var type = types[coord] const reward := 3 const penalty := -10 var best_score := -1000 # Impossibly bad score var best := [] for t in cache[type]: var score := 0 for peering in t[3]: score += reward if _probe(tm, coord, peering, type, types) in t[3][peering] else penalty if score > best_score: best_score = score best = [t] elif score == best_score: best.append(t) return _weighted_selection_seeded(best, coord, false) func _update_tile_immediate(tm: TileMapLayer, coord: Vector2i, ts_meta: Dictionary, types: Dictionary, cache: Array) -> void: var type = types[coord] if type < TileCategory.EMPTY or type >= ts_meta.terrains.size(): return var placement var terrain = _get_cache_terrain(ts_meta, type) if terrain[2] in [TerrainType.MATCH_TILES, TerrainType.DECORATION]: placement = _update_tile_tiles(tm, coord, types, cache, true) elif terrain[2] == TerrainType.MATCH_VERTICES: placement = _update_tile_vertices(tm, coord, types, cache) else: return if placement: tm.set_cell(coord, placement[0], placement[1], placement[2]) func _update_tile_deferred(tm: TileMapLayer, coord: Vector2i, ts_meta: Dictionary, types: Dictionary, cache: Array): var type = types[coord] if type >= TileCategory.EMPTY and type < ts_meta.terrains.size(): var terrain = _get_cache_terrain(ts_meta, type) if terrain[2] in [TerrainType.MATCH_TILES, TerrainType.DECORATION]: return _update_tile_tiles(tm, coord, types, cache, terrain[2] == TerrainType.DECORATION) elif terrain[2] == TerrainType.MATCH_VERTICES: return _update_tile_vertices(tm, coord, types, cache) return null func _widen(tm: TileMapLayer, coords: Array) -> Array: var result := {} var peering_neighbors = data.get_terrain_peering_cells(tm.tile_set, TerrainType.MATCH_TILES) for c in coords: result[c] = true var neighbors = data.neighboring_coords(tm, c, peering_neighbors) for t in neighbors: result[t] = true return result.keys() func _widen_with_exclusion(tm: TileMapLayer, coords: Array, exclusion: Rect2i) -> Array: var result := {} var peering_neighbors = data.get_terrain_peering_cells(tm.tile_set, TerrainType.MATCH_TILES) for c in coords: if !exclusion.has_point(c): result[c] = true var neighbors = data.neighboring_coords(tm, c, peering_neighbors) for t in neighbors: if !exclusion.has_point(t): result[t] = true return result.keys() # Terrains ## Returns an [Array] of categories. These are the terrains in the [TileSet] which ## are marked with [enum TerrainType] of [code]CATEGORY[/code]. Each entry in the ## array is a [Dictionary] with [code]name[/code], [code]color[/code], and [code]id[/code]. func get_terrain_categories(ts: TileSet) -> Array: var result := [] if !ts: return result var ts_meta := _get_terrain_meta(ts) for id in ts_meta.terrains.size(): var t = ts_meta.terrains[id] if t[2] == TerrainType.CATEGORY: result.push_back({name = t[0], color = t[1], id = id}) return result ## Adds a new terrain to the [TileSet]. Returns [code]true[/code] if this is successful. ## [br][br] ## [code]type[/code] must be one of [enum TerrainType].[br] ## [code]categories[/code] is an indexed list of terrain categories that this terrain ## can match as. The indexes must be valid terrains of the CATEGORY type. ## [code]icon[/code] is a [Dictionary] with either a [code]path[/code] string pointing ## to a resource, or a [code]source_id[/code] [int] and a [code]coord[/code] [Vector2i]. ## The former takes priority if both are present. func add_terrain(ts: TileSet, name: String, color: Color, type: int, categories: Array = [], icon: Dictionary = {}) -> bool: if !ts or name.is_empty() or type < 0 or type == TerrainType.DECORATION or type >= TerrainType.MAX: return false var ts_meta := _get_terrain_meta(ts) # check categories if type == TerrainType.CATEGORY and !categories.is_empty(): return false for c in categories: if c < 0 or c >= ts_meta.terrains.size() or ts_meta.terrains[c][2] != TerrainType.CATEGORY: return false if icon and not (icon.has("path") or (icon.has("source_id") and icon.has("coord"))): return false ts_meta.terrains.push_back([name, color, type, categories, icon]) _set_terrain_meta(ts, ts_meta) _purge_cache(ts) return true ## Removes the terrain at [code]index[/code] from the [TileSet]. Returns [code]true[/code] ## if the deletion is successful. func remove_terrain(ts: TileSet, index: int) -> bool: if !ts or index < 0: return false var ts_meta := _get_terrain_meta(ts) if index >= ts_meta.terrains.size(): return false if ts_meta.terrains[index][2] == TerrainType.CATEGORY: for t in ts_meta.terrains: t[3].erase(index) for s in ts.get_source_count(): var source := ts.get_source(ts.get_source_id(s)) as TileSetAtlasSource if !source: continue for t in source.get_tiles_count(): var coord := source.get_tile_id(t) for a in source.get_alternative_tiles_count(coord): var alternate := source.get_alternative_tile_id(coord, a) var td := source.get_tile_data(coord, alternate) var td_meta := _get_tile_meta(td) if td_meta.type == TileCategory.NON_TERRAIN: continue if td_meta.type == index: _set_tile_meta(ts, td, null) continue if td_meta.type > index: td_meta.type -= 1 for peering in td_meta.keys(): if !(peering is int): continue var fixed_peering = [] for p in td_meta[peering]: if p < index: fixed_peering.append(p) elif p > index: fixed_peering.append(p - 1) if fixed_peering.is_empty(): td_meta.erase(peering) else: td_meta[peering] = fixed_peering _set_tile_meta(ts, td, td_meta) ts_meta.terrains.remove_at(index) _set_terrain_meta(ts, ts_meta) _purge_cache(ts) return true ## Returns the number of terrains in the [TileSet]. func terrain_count(ts: TileSet) -> int: if !ts: return 0 var ts_meta := _get_terrain_meta(ts) return ts_meta.terrains.size() ## Retrieves information about the terrain at [code]index[/code] in the [TileSet]. ## [br][br] ## Returns a [Dictionary] describing the terrain. If it succeeds, the key [code]valid[/code] ## will be set to [code]true[/code]. Other keys are [code]name[/code], [code]color[/code], ## [code]type[/code] (a [enum TerrainType]), [code]categories[/code] which is ## an [Array] of category type terrains that this terrain matches as, and ## [code]icon[/code] which is a [Dictionary] with a [code]path[/code] [String] or ## a [code]source_id[/code] [int] and [code]coord[/code] [Vector2i] func get_terrain(ts: TileSet, index: int) -> Dictionary: if !ts or index < TileCategory.EMPTY: return {valid = false} var ts_meta := _get_terrain_meta(ts) if index >= ts_meta.terrains.size(): return {valid = false} var terrain := _get_cache_terrain(ts_meta, index) return { id = index, name = terrain[0], color = terrain[1], type = terrain[2], categories = terrain[3].duplicate(), icon = terrain[4].duplicate(), valid = true } ## Updates the details of the terrain at [code]index[/code] in [TileSet]. Returns ## [code]true[/code] if this succeeds. ## [br][br] ## If supplied, the [code]categories[/code] must be a list of indexes to other [code]CATEGORY[/code] ## type terrains. ## [code]icon[/code] is a [Dictionary] with either a [code]path[/code] string pointing ## to a resource, or a [code]source_id[/code] [int] and a [code]coord[/code] [Vector2i]. func set_terrain(ts: TileSet, index: int, name: String, color: Color, type: int, categories: Array = [], icon: Dictionary = {valid = false}) -> bool: if !ts or name.is_empty() or index < 0 or type < 0 or type == TerrainType.DECORATION or type >= TerrainType.MAX: return false var ts_meta := _get_terrain_meta(ts) if index >= ts_meta.terrains.size(): return false if type == TerrainType.CATEGORY and !categories.is_empty(): return false for c in categories: if c < 0 or c == index or c >= ts_meta.terrains.size() or ts_meta.terrains[c][2] != TerrainType.CATEGORY: return false var icon_valid = icon.get("valid", "true") if icon_valid: match icon: {}, {"path"}, {"source_id", "coord"}: pass _: return false if type != TerrainType.CATEGORY: for t in ts_meta.terrains: t[3].erase(index) ts_meta.terrains[index] = [name, color, type, categories, icon] _set_terrain_meta(ts, ts_meta) _clear_invalid_peering_types(ts) _purge_cache(ts) return true ## Swaps the terrains at [code]index1[/code] and [code]index2[/code] in [TileSet]. func swap_terrains(ts: TileSet, index1: int, index2: int) -> bool: if !ts or index1 < 0 or index2 < 0 or index1 == index2: return false var ts_meta := _get_terrain_meta(ts) if index1 >= ts_meta.terrains.size() or index2 >= ts_meta.terrains.size(): return false for t in ts_meta.terrains: var has1 = t[3].has(index1) var has2 = t[3].has(index2) if has1 and !has2: t[3].erase(index1) t[3].push_back(index2) elif has2 and !has1: t[3].erase(index2) t[3].push_back(index1) for s in ts.get_source_count(): var source := ts.get_source(ts.get_source_id(s)) as TileSetAtlasSource if !source: continue for t in source.get_tiles_count(): var coord := source.get_tile_id(t) for a in source.get_alternative_tiles_count(coord): var alternate := source.get_alternative_tile_id(coord, a) var td := source.get_tile_data(coord, alternate) var td_meta := _get_tile_meta(td) if td_meta.type == TileCategory.NON_TERRAIN: continue if td_meta.type == index1: td_meta.type = index2 elif td_meta.type == index2: td_meta.type = index1 for peering in td_meta.keys(): if !(peering is int): continue var fixed_peering = [] for p in td_meta[peering]: if p == index1: fixed_peering.append(index2) elif p == index2: fixed_peering.append(index1) else: fixed_peering.append(p) td_meta[peering] = fixed_peering _set_tile_meta(ts, td, td_meta) var temp = ts_meta.terrains[index1] ts_meta.terrains[index1] = ts_meta.terrains[index2] ts_meta.terrains[index2] = temp _set_terrain_meta(ts, ts_meta) _purge_cache(ts) return true # Terrain tile data ## For a tile in a [TileSet] as specified by [TileData], set the terrain associated ## with that tile to [code]type[/code], which is an index of an existing terrain. ## Returns [code]true[/code] on success. func set_tile_terrain_type(ts: TileSet, td: TileData, type: int) -> bool: if !ts or !td or type < TileCategory.NON_TERRAIN: return false var td_meta = _get_tile_meta(td) td_meta.type = type if type == TileCategory.NON_TERRAIN: td_meta = null _set_tile_meta(ts, td, td_meta) _clear_invalid_peering_types(ts) _purge_cache(ts) return true ## Returns the terrain type associated with tile specified by [TileData]. Returns ## -1 if the tile has no associated terrain. func get_tile_terrain_type(td: TileData) -> int: if !td: return TileCategory.ERROR var td_meta := _get_tile_meta(td) return td_meta.type ## For a tile represented by [TileData] [code]td[/code] in [TileSet] ## [code]ts[/code], sets [enum SymmetryType] [code]type[/code]. This controls ## how the tile is rotated/mirrored during placement. func set_tile_symmetry_type(ts: TileSet, td: TileData, type: int) -> bool: if !ts or !td or type < SymmetryType.NONE or type > SymmetryType.ALL: return false var td_meta := _get_tile_meta(td) if td_meta.type == TileCategory.NON_TERRAIN: return false td_meta.symmetry = type _set_tile_meta(ts, td, td_meta) _purge_cache(ts) return true ## For a tile [code]td[/code], returns the [enum SymmetryType] which that ## tile uses. func get_tile_symmetry_type(td: TileData) -> int: if !td: return SymmetryType.NONE var td_meta := _get_tile_meta(td) return td_meta.get("symmetry", SymmetryType.NONE) ## Returns an Array of all [TileData] tiles included in the specified ## terrain [code]type[/code] for the [TileSet] [code]ts[/code] func get_tiles_in_terrain(ts: TileSet, type: int) -> Array[TileData]: var result:Array[TileData] = [] if !ts or type < TileCategory.EMPTY: return result var cache := _get_cache(ts) if type > cache.size(): return result var tiles = cache[type] if !tiles: return result for c in tiles: if c[0] < 0: continue var source := ts.get_source(c[0]) as TileSetAtlasSource var td := source.get_tile_data(c[1], c[2]) result.push_back(td) return result ## Returns an [Array] of [Dictionary] items including information about each ## tile included in the specified terrain [code]type[/code] for ## the [TileSet] [code]ts[/code]. Each Dictionary item includes ## [TileSetAtlasSource] [code]source[/code], [TileData] [code]td[/code], ## [Vector2i] [code]coord[/code], and [int] [code]alt_id[/code]. func get_tile_sources_in_terrain(ts: TileSet, type: int) -> Array[Dictionary]: var result:Array[Dictionary] = [] var cache := _get_cache(ts) var tiles = cache[type] if !tiles: return result for c in tiles: if c[0] < 0: continue var source := ts.get_source(c[0]) as TileSetAtlasSource if not source: continue var td := source.get_tile_data(c[1], c[2]) result.push_back({ source = source, td = td, coord = c[1], alt_id = c[2] }) return result ## For a [TileSet]'s tile, specified by [TileData], add terrain [code]type[/code] ## (an index of a terrain) to match this tile in direction [code]peering[/code], ## which is of type [enum TileSet.CellNeighbor]. Returns [code]true[/code] on success. func add_tile_peering_type(ts: TileSet, td: TileData, peering: int, type: int) -> bool: if !ts or !td or peering < 0 or peering > 15 or type < TileCategory.EMPTY: return false var ts_meta := _get_terrain_meta(ts) var td_meta := _get_tile_meta(td) if td_meta.type < TileCategory.EMPTY or td_meta.type >= ts_meta.terrains.size(): return false if !td_meta.has(peering): td_meta[peering] = [type] elif !td_meta[peering].has(type): td_meta[peering].append(type) else: return false _set_tile_meta(ts, td, td_meta) _purge_cache(ts) return true ## For a [TileSet]'s tile, specified by [TileData], remove terrain [code]type[/code] ## from matching in direction [code]peering[/code], which is of type [enum TileSet.CellNeighbor]. ## Returns [code]true[/code] on success. func remove_tile_peering_type(ts: TileSet, td: TileData, peering: int, type: int) -> bool: if !ts or !td or peering < 0 or peering > 15 or type < TileCategory.EMPTY: return false var td_meta := _get_tile_meta(td) if !td_meta.has(peering): return false if !td_meta[peering].has(type): return false td_meta[peering].erase(type) if td_meta[peering].is_empty(): td_meta.erase(peering) _set_tile_meta(ts, td, td_meta) _purge_cache(ts) return true ## For the tile specified by [TileData], return an [Array] of peering directions ## for which terrain matching is set up. These will be of type [enum TileSet.CellNeighbor]. func tile_peering_keys(td: TileData) -> Array: if !td: return [] var td_meta := _get_tile_meta(td) var result := [] for k in td_meta: if k is int: result.append(k) return result ## For the tile specified by [TileData], return the [Array] of terrains that match ## for the direction [code]peering[/code] which should be of type [enum TileSet.CellNeighbor]. func tile_peering_types(td: TileData, peering: int) -> Array: if !td or peering < 0 or peering > 15: return [] var td_meta := _get_tile_meta(td) return td_meta[peering].duplicate() if td_meta.has(peering) else [] ## For the tile specified by [TileData], return the [Array] of peering directions ## for the specified terrain type [code]type[/code]. func tile_peering_for_type(td: TileData, type: int) -> Array: if !td: return [] var td_meta := _get_tile_meta(td) var result := [] var sides := tile_peering_keys(td) for side in sides: if td_meta[side].has(type): result.push_back(side) result.sort() return result # Painting ## Applies the terrain [code]type[/code] to the [TileMapLayer] for the [Vector2i] ## [code]coord[/code]. Returns [code]true[/code] if it succeeds. Use [method set_cells] ## to change multiple tiles at once. ## [br][br] ## Use terrain type -1 to erase cells. func set_cell(tm: TileMapLayer, coord: Vector2i, type: int) -> bool: if !tm or !tm.tile_set or type < TileCategory.EMPTY: return false if type == TileCategory.EMPTY: tm.erase_cell(coord) return true var cache := _get_cache(tm.tile_set) if type >= cache.size(): return false if cache[type].is_empty(): return false var tile = cache[type].front() tm.set_cell(coord, tile[0], tile[1], tile[2]) return true ## Applies the terrain [code]type[/code] to the [TileMapLayer] for the ## [Vector2i] [code]coords[/code]. Returns [code]true[/code] if it succeeds. ## [br][br] ## Note that this does not cause the terrain solver to run, so this will just place ## an arbitrary terrain-associated tile in the given position. To run the solver, ## you must set the require cells, and then call either [method update_terrain_cell], ## [method update_terrain_cels], or [method update_terrain_area]. ## [br][br] ## If you want to prepare changes to the tiles in advance, you can use [method create_terrain_changeset] ## and the associated functions. ## [br][br] ## Use terrain type -1 to erase cells. func set_cells(tm: TileMapLayer, coords: Array, type: int) -> bool: if !tm or !tm.tile_set or type < TileCategory.EMPTY: return false if type == TileCategory.EMPTY: for c in coords: tm.erase_cell(c) return true var cache := _get_cache(tm.tile_set) if type >= cache.size(): return false if cache[type].is_empty(): return false var tile = cache[type].front() for c in coords: tm.set_cell(c, tile[0], tile[1], tile[2]) return true ## Replaces an existing tile on the [TileMapLayer] for the [Vector2i] ## [code]coord[/code] with a new tile in the provided terrain [code]type[/code] ## *only if* there is a tile with a matching set of peering sides in this terrain. ## Returns [code]true[/code] if any tiles were changed. Use [method replace_cells] ## to replace multiple tiles at once. func replace_cell(tm: TileMapLayer, coord: Vector2i, type: int) -> bool: if !tm or !tm.tile_set or type < 0: return false var cache := _get_cache(tm.tile_set) if type >= cache.size(): return false if cache[type].is_empty(): return false var td = tm.get_cell_tile_data(coord) if !td: return false var ts_meta := _get_terrain_meta(tm.tile_set) var categories = ts_meta.terrains[type][3] var check_types = [type] + categories for check_type in check_types: var placed_peering = tile_peering_for_type(td, check_type) for pt in get_tiles_in_terrain(tm.tile_set, type): var check_peering := tile_peering_for_type(pt, check_type) if placed_peering == check_peering: var tile = cache[type].front() tm.set_cell(coord, tile[0], tile[1], tile[2]) return true return false ## Replaces existing tiles on the [TileMapLayer] for the [Vector2i] ## [code]coords[/code] with new tiles in the provided terrain [code]type[/code] ## *only if* there is a tile with a matching set of peering sides in this terrain ## for each tile. ## Returns [code]true[/code] if any tiles were changed. func replace_cells(tm: TileMapLayer, coords: Array, type: int) -> bool: if !tm or !tm.tile_set or type < 0: return false var cache := _get_cache(tm.tile_set) if type >= cache.size(): return false if cache[type].is_empty(): return false var ts_meta := _get_terrain_meta(tm.tile_set) var categories = ts_meta.terrains[type][3] var check_types = [type] + categories var changed = false var potential_tiles = get_tiles_in_terrain(tm.tile_set, type) for c in coords: var found = false var td = tm.get_cell_tile_data(c) if !td: continue for check_type in check_types: var placed_peering = tile_peering_for_type(td, check_type) for pt in potential_tiles: var check_peering = tile_peering_for_type(pt, check_type) if placed_peering == check_peering: var tile = cache[type].front() tm.set_cell(c, tile[0], tile[1], tile[2]) changed = true found = true break if found: break return changed ## Returns the terrain type detected in the [TileMapLayer] at specified [Vector2i] ## [code]coord[/code]. Returns -1 if tile is not valid or does not contain a ## tile associated with a terrain. func get_cell(tm: TileMapLayer, coord: Vector2i) -> int: if !tm or !tm.tile_set: return TileCategory.ERROR if tm.get_cell_source_id(coord) == -1: return TileCategory.EMPTY var t := tm.get_cell_tile_data(coord) if !t: return TileCategory.NON_TERRAIN return _get_tile_meta(t).type ## Runs the tile solving algorithm on the [TileMapLayer] for the given ## [Vector2i] coordinates in the [code]cells[/code] parameter. By default, ## the surrounding cells are also solved, but this can be adjusted by passing [code]false[/code] ## to the [code]and_surrounding_cells[/code] parameter. ## [br][br] ## See also [method update_terrain_area] and [method update_terrain_cell]. func update_terrain_cells(tm: TileMapLayer, cells: Array, and_surrounding_cells := true) -> void: if !tm or !tm.tile_set: return if and_surrounding_cells: cells = _widen(tm, cells) var needed_cells := _widen(tm, cells) var types := {} for c in needed_cells: types[c] = get_cell(tm, c) var ts_meta := _get_terrain_meta(tm.tile_set) var cache := _get_cache(tm.tile_set) for c in cells: _update_tile_immediate(tm, c, ts_meta, types, cache) ## Runs the tile solving algorithm on the [TileMapLayer] for the given [Vector2i] ## [code]cell[/code]. By default, the surrounding cells are also solved, but ## this can be adjusted by passing [code]false[/code] to the [code]and_surrounding_cells[/code] ## parameter. This calls through to [method update_terrain_cells]. func update_terrain_cell(tm: TileMapLayer, cell: Vector2i, and_surrounding_cells := true) -> void: update_terrain_cells(tm, [cell], and_surrounding_cells) ## Runs the tile solving algorithm on the [TileMapLayer] for the given [Rect2i] ## [code]area[/code]. By default, the surrounding cells are also solved, but ## this can be adjusted by passing [code]false[/code] to the [code]and_surrounding_cells[/code] ## parameter. ## [br][br] ## See also [method update_terrain_cells]. func update_terrain_area(tm: TileMapLayer, area: Rect2i, and_surrounding_cells := true) -> void: if !tm or !tm.tile_set: return # Normalize area and extend so tiles cover inclusive space area = area.abs() area.size += Vector2i.ONE var edges = [] for x in range(area.position.x, area.end.x): edges.append(Vector2i(x, area.position.y)) edges.append(Vector2i(x, area.end.y - 1)) for y in range(area.position.y + 1, area.end.y - 1): edges.append(Vector2i(area.position.x, y)) edges.append(Vector2i(area.end.x - 1, y)) var additional_cells := [] var needed_cells := _widen_with_exclusion(tm, edges, area) if and_surrounding_cells: additional_cells = needed_cells needed_cells = _widen_with_exclusion(tm, needed_cells, area) var types := {} for y in range(area.position.y, area.end.y): for x in range(area.position.x, area.end.x): var coord = Vector2i(x, y) types[coord] = get_cell(tm, coord) for c in needed_cells: types[c] = get_cell(tm, c) var ts_meta := _get_terrain_meta(tm.tile_set) var cache := _get_cache(tm.tile_set) for y in range(area.position.y, area.end.y): for x in range(area.position.x, area.end.x): var coord := Vector2i(x, y) _update_tile_immediate(tm, coord, ts_meta, types, cache) for c in additional_cells: _update_tile_immediate(tm, c, ts_meta, types, cache) ## For a [TileMapLayer], create a changeset that will ## be calculated via a [WorkerThreadPool], so it will not delay processing the current ## frame or affect the framerate. ## [br][br] ## The [code]paint[/code] parameter must be a [Dictionary] with keys of type [Vector2i] ## representing map coordinates, and integer values representing terrain types. ## [br][br] ## Returns a [Dictionary] with internal details. See also [method is_terrain_changeset_ready], ## [method apply_terrain_changeset], and [method wait_for_terrain_changeset]. func create_terrain_changeset(tm: TileMapLayer, paint: Dictionary) -> Dictionary: # Force cache rebuild if required var _cache := _get_cache(tm.tile_set) var cells := paint.keys() var needed_cells := _widen(tm, cells) var types := {} for c in needed_cells: types[c] = paint[c] if paint.has(c) else get_cell(tm, c) var placements := [] placements.resize(cells.size()) var ts_meta := _get_terrain_meta(tm.tile_set) var work := func(n: int): placements[n] = _update_tile_deferred(tm, cells[n], ts_meta, types, _cache) return { "valid": true, "tilemap": tm, "cells": cells, "placements": placements, "group_id": WorkerThreadPool.add_group_task(work, cells.size(), -1, false, "BetterTerrain") } ## Returns [code]true[/code] if a changeset created by [method create_terrain_changeset] ## has finished the threaded calculation and is ready to be applied by [method apply_terrain_changeset]. ## See also [method wait_for_terrain_changeset]. func is_terrain_changeset_ready(change: Dictionary) -> bool: if !change.has("group_id"): return false return WorkerThreadPool.is_group_task_completed(change.group_id) ## Blocks until a changeset created by [method create_terrain_changeset] finishes. ## This is useful to tidy up threaded work in the event that a node is to be removed ## whilst still waiting on threads. ## [br][br] ## Usage example: ## [codeblock] ## func _exit_tree(): ## if changeset.valid: ## BetterTerrain.wait_for_terrain_changeset(changeset) ## [/codeblock] func wait_for_terrain_changeset(change: Dictionary) -> void: if change.has("group_id"): WorkerThreadPool.wait_for_group_task_completion(change.group_id) ## Apply the changes in a changeset created by [method create_terrain_changeset] ## once it is confirmed by [method is_terrain_changeset_ready]. The changes will ## be applied to the [TileMapLayer] that the changeset was initialized with. ## [br][br] ## Completed changesets can be applied multiple times, and stored for as long as ## needed once calculated. func apply_terrain_changeset(change: Dictionary) -> void: for n in change.cells.size(): var placement = change.placements[n] if placement: change.tilemap.set_cell(change.cells[n], placement[0], placement[1], placement[2])