diff --git a/addons/TileMapDual/AtlasWatcher.gd b/addons/TileMapDual/AtlasWatcher.gd new file mode 100644 index 0000000..dced698 --- /dev/null +++ b/addons/TileMapDual/AtlasWatcher.gd @@ -0,0 +1,70 @@ +##[br] Watches a TileSetAtlasSource for changes. +##[br] Causes its 'parent' TileSetWatcher to emit terrains_changed when the atlas changes. +##[br] Also emits parent.atlas_autotiled when it thinks the user auto-generated atlas tiles. +class_name AtlasWatcher + +## Prevents the number of seen atlases from extending to infinity. +const UNDO_LIMIT = 1024 +## Stores all of the atlas instance id's that have been seen before, to prevent autogen on redo. +static var _registered_atlases := [] + +## The TileSetWatcher that created this AtlasWatcher. Used to send signals back. +var parent: TileSetWatcher + +## The Source ID of `self.atlas`. +var sid: int + +## The atlas to be watched for changes. +var atlas: TileSetAtlasSource + +func _init(parent: TileSetWatcher, sid: int, atlas: TileSetAtlasSource) -> void: + self.parent = parent + self.sid = sid + self.atlas = atlas + atlas.changed.connect(_atlas_changed, ConnectFlags.CONNECT_DEFERRED) + var id := atlas.get_instance_id() + # should not autogen if atlas was created through redo, i.e. its instance id already existed + if _atlas_is_empty() and id not in _registered_atlases: + _registered_atlases.push_back(id) + if _registered_atlases.size() > UNDO_LIMIT: + _registered_atlases.pop_front() + atlas.changed.connect(_detect_autogen, ConnectFlags.CONNECT_DEFERRED | ConnectFlags.CONNECT_ONE_SHOT) + + +func _atlas_is_empty() -> bool: + return atlas.get_tiles_count() == 0 + + +## Returns true if the texture has any opaque pixels in the specified tile coordinates. +func _is_opaque_tile(image: Image, tile: Vector2i, p_threshold: float = 0.1) -> bool: + # We cannot use atlas.get_tile_texture_region(tile) as it fails on unregistered tiles. + var region := Rect2i(tile * atlas.texture_region_size, atlas.texture_region_size) + var sprite := image.get_region(region) + if sprite.is_invisible(): + return false + # We're still not sure if the tile is empty or not. + # Godot's auto-gen considers 0.1 opacity as "transparent" but not "invisible". + for y in range(region.position.y, region.end.y): + for x in range(region.position.x, region.end.x): + if image.get_pixel(x, y).a > p_threshold: + return true + return false + + +##[br] HACK: literally just tries to guess which tiles the terrain autogen system would make +##[br] Called once, and only once, at the end of the first frame that a texture is created. +func _detect_autogen() -> void: + var size := Vector2i(atlas.texture.get_size()) / atlas.texture_region_size + var image := atlas.texture.get_image() + var expected_tiles := [] + for y in size.y: + for x in size.x: + var tile := Vector2i(x, y) + if atlas.has_tile(tile) != _is_opaque_tile(image, tile): + return + parent.atlas_autotiled.emit(sid, atlas) + + +## Called every time the atlas changes. Simply flags that terrains have changed. +func _atlas_changed() -> void: + parent._flag_terrains_changed = true diff --git a/addons/TileMapDual/AtlasWatcher.gd.uid b/addons/TileMapDual/AtlasWatcher.gd.uid new file mode 100644 index 0000000..72dd5d9 --- /dev/null +++ b/addons/TileMapDual/AtlasWatcher.gd.uid @@ -0,0 +1 @@ +uid://cfhiw77a85x8w diff --git a/addons/TileMapDual/CursorDual.gd b/addons/TileMapDual/CursorDual.gd new file mode 100644 index 0000000..d126df0 --- /dev/null +++ b/addons/TileMapDual/CursorDual.gd @@ -0,0 +1,41 @@ +@icon('CursorDual.svg') +class_name CursorDual +extends Sprite2D + +# TODO: instead of setting the target tilemap manually, +# just add the CursorDual as a child of the target tilemap +@export var tilemap_dual: TileMapDual = null + +var cell: Vector2i +var tile_size: Vector2 +var sprite_size: Vector2 +var terrain := 1 + + +func _ready() -> void: + if tilemap_dual != null: + tile_size = tilemap_dual.tile_set.tile_size + sprite_size = self.texture.get_size() + scale = Vector2(tile_size.y, tile_size.y) / sprite_size + self.set_scale(scale) + + +func _process(_delta: float) -> void: + if tilemap_dual == null: + return + cell = tilemap_dual.local_to_map(tilemap_dual.get_local_mouse_position()) + global_position = tilemap_dual.to_global(tilemap_dual.map_to_local(cell)) + # Clicking the 1 key activates the first terrain + if Input.is_action_pressed("quick_action_1"): + terrain = 1 + # Clicking the 2 key activates the second terrain + if Input.is_action_pressed("quick_action_2"): + terrain = 2 + # Clicking the 0 key activates the background terrain + if Input.is_action_pressed("quick_action_0"): + terrain = 0 + + if Input.is_action_pressed("left_click"): + tilemap_dual.draw_cell(cell, terrain) + elif Input.is_action_pressed("right_click"): + tilemap_dual.draw_cell(cell, 0) diff --git a/addons/TileMapDual/CursorDual.gd.uid b/addons/TileMapDual/CursorDual.gd.uid new file mode 100644 index 0000000..5e04ecf --- /dev/null +++ b/addons/TileMapDual/CursorDual.gd.uid @@ -0,0 +1 @@ +uid://c6m630v880okx diff --git a/addons/TileMapDual/CursorDual.svg b/addons/TileMapDual/CursorDual.svg new file mode 100644 index 0000000..c69dd53 --- /dev/null +++ b/addons/TileMapDual/CursorDual.svg @@ -0,0 +1,49 @@ + + + + + + + + + diff --git a/addons/TileMapDual/CursorDual.svg.import b/addons/TileMapDual/CursorDual.svg.import new file mode 100644 index 0000000..b3707aa --- /dev/null +++ b/addons/TileMapDual/CursorDual.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bth8306ui4dcx" +path="res://.godot/imported/CursorDual.svg-af24e4bad2d22f0cc5627c83d35ae307.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/TileMapDual/CursorDual.svg" +dest_files=["res://.godot/imported/CursorDual.svg-af24e4bad2d22f0cc5627c83d35ae307.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/addons/TileMapDual/Display.gd b/addons/TileMapDual/Display.gd new file mode 100644 index 0000000..609e2a6 --- /dev/null +++ b/addons/TileMapDual/Display.gd @@ -0,0 +1,151 @@ +##[br] A Node designed to hold and manage up to 2 DisplayLayer children. +##[br] See DisplayLayer.gd for details. +class_name Display +extends Node2D + + +## See TerrainDual.gd +var terrain: TerrainDual +## See TileSetWatcher.gd +var _tileset_watcher: TileSetWatcher +## The parent TileMapDual to base the terrains off of. +@export var world: TileMapDual +## Creates a new Display that updates when the TileSet updates. +func _init(world: TileMapDual, tileset_watcher: TileSetWatcher) -> void: + #print('initializing Display...') + self.world = world + _tileset_watcher = tileset_watcher + terrain = TerrainDual.new(tileset_watcher) + terrain.changed.connect(_terrain_changed, 1) + world_tiles_changed.connect(_world_tiles_changed, 1) + # let parent materal through to the displaylayers + use_parent_material = true + + +## Activates when the TerrainDual changes. +func _terrain_changed() -> void: + cached_cells.update(world) + _delete_layers() + if _tileset_watcher.tile_set != null: + _create_layers() + + +## Emitted when the tiles in the map have been edited. +signal world_tiles_changed(changed: Array) +func _world_tiles_changed(changed: Array) -> void: + #print('SIGNAL EMITTED: world_tiles_changed(%s)' % {'changed': changed}) + for child in get_children(true): + child.update_tiles(cached_cells, changed) + + +## Initializes and configures new DisplayLayers according to the grid shape. +func _create_layers() -> void: + #print('GRID SHAPE: %s' % _tileset_watcher.grid_shape) + var grid: Array = GRIDS[_tileset_watcher.grid_shape] + for i in grid.size(): + var layer_config: Dictionary = grid[i] + #print('layer_config: %s' % layer_config) + var layer := DisplayLayer.new(world, _tileset_watcher, layer_config, terrain.layers[i]) + add_child(layer) + layer.update_tiles_all(cached_cells) + + +## Deletes all of the DisplayLayers. +func _delete_layers() -> void: + for child in get_children(true): + child.queue_free() + + +## The TileCache computed from the last time update() was called. +var cached_cells := TileCache.new() +## Updates the display based on the cells changed in the world TileMapDual. +func update(updated: Array) -> void: + if _tileset_watcher.tile_set == null: + return + _update_properties() + if not updated.is_empty(): + cached_cells.update(world, updated) + world_tiles_changed.emit(updated) + + +## Activates when the properties of the parent TileMapDual have been edited. +func _update_properties() -> void: + for child in get_children(true): + child.update_properties(world) + + +# TODO: phase out GridShape and simply transpose everything when the offset axis is vertical +##[br] Returns what kind of grid a TileSet is. +##[br] Will default to SQUARE if Godot decides to add a new TileShape. +static func tileset_gridshape(tile_set: TileSet) -> GridShape: + var hori: bool = tile_set.tile_offset_axis == TileSet.TILE_OFFSET_AXIS_HORIZONTAL + match tile_set.tile_shape: + TileSet.TileShape.TILE_SHAPE_SQUARE: + return GridShape.SQUARE + TileSet.TileShape.TILE_SHAPE_ISOMETRIC: + return GridShape.ISO + TileSet.TileShape.TILE_SHAPE_HALF_OFFSET_SQUARE: + return GridShape.HALF_OFF_HORI if hori else GridShape.HALF_OFF_VERT + TileSet.TileShape.TILE_SHAPE_HEXAGON: + return GridShape.HEX_HORI if hori else GridShape.HEX_VERT + _: + return GridShape.SQUARE + + +## Every meaningfully different TileSet.tile_shape * TileSet.tile_offset_axis combination. +enum GridShape { + SQUARE, + ISO, + HALF_OFF_HORI, + HALF_OFF_VERT, + HEX_HORI, + HEX_VERT, +} + + +##[br] How to deal with every available GridShape. +##[br] See DisplayLayer.gd for more information about these fields. +const GRIDS: Dictionary = { + GridShape.SQUARE: [ + { # [] + 'offset': Vector2(-0.5, -0.5), + } + ], + GridShape.ISO: [ + { # <> + 'offset': Vector2(0, -0.5), + } + ], + GridShape.HALF_OFF_HORI: [ + { # v + 'offset': Vector2(0.0, -0.5), + }, + { # ^ + 'offset': Vector2(-0.5, -0.5), + }, + ], + GridShape.HALF_OFF_VERT: [ + { # > + 'offset': Vector2(-0.5, 0.0), + }, + { # < + 'offset': Vector2(-0.5, -0.5), + }, + ], + GridShape.HEX_HORI: [ + { # v + 'offset': Vector2(0.0, -3.0 / 8.0), + }, + { # ^ + 'offset': Vector2(-0.5, -3.0 / 8.0), + }, + ], + GridShape.HEX_VERT: [ + { # > + 'offset': Vector2(-3.0 / 8.0, 0.0), + }, + { # < + 'offset': Vector2(-3.0 / 8.0, -0.5), + }, + ], +} diff --git a/addons/TileMapDual/Display.gd.uid b/addons/TileMapDual/Display.gd.uid new file mode 100644 index 0000000..4aeea62 --- /dev/null +++ b/addons/TileMapDual/Display.gd.uid @@ -0,0 +1 @@ +uid://cacp8xe0jaxln diff --git a/addons/TileMapDual/DisplayLayer.gd b/addons/TileMapDual/DisplayLayer.gd new file mode 100644 index 0000000..608233d --- /dev/null +++ b/addons/TileMapDual/DisplayLayer.gd @@ -0,0 +1,102 @@ +##[br] A single TileMapLayer whose purpose is to display tiles to maintain the Dual Grid illusion. +##[br] Its contents are automatically computed and updated based on: +##[br] - the contents of the parent TileMapDual +##[br] - the rules set in its assigned TerrainLayer +class_name DisplayLayer +extends TileMapLayer + + +##[br] How much to offset this DisplayLayer relative to the main TileMapDual grid. +##[br] This is independent of tile size. +var offset: Vector2 + +## See TileSetWatcher.gd +var _tileset_watcher: TileSetWatcher + +## See TerrainDual.gd +var _terrain: TerrainLayer + +func _init( + world: TileMapDual, + tileset_watcher: TileSetWatcher, + fields: Dictionary, + layer: TerrainLayer +) -> void: + #print('initializing Layer...') + update_properties(world) + offset = fields.offset + _tileset_watcher = tileset_watcher + _terrain = layer + tile_set = tileset_watcher.tile_set + tileset_watcher.tileset_resized.connect(reposition, 1) + reposition() + + +## Adjusts the position of this DisplayLayer based on the tile set's tile_size +func reposition() -> void: + position = offset * Vector2(_tileset_watcher.tile_size) + + +## Copies properties from parent TileMapDual to child display tilemap +func update_properties(parent: TileMapDual) -> void: + # Both tilemaps must be the same, so we copy all relevant properties + # Tilemap + # already covered by parent._tileset_watcher + # Rendering + self.y_sort_origin = parent.y_sort_origin + self.x_draw_order_reversed = parent.x_draw_order_reversed + self.rendering_quadrant_size = parent.rendering_quadrant_size + # Physics + self.collision_enabled = parent.collision_enabled + self.use_kinematic_bodies = parent.use_kinematic_bodies + self.collision_visibility_mode = parent.collision_visibility_mode + # Navigation + self.navigation_enabled = parent.navigation_enabled + self.navigation_visibility_mode = parent.navigation_visibility_mode + # Canvas item properties + self.show_behind_parent = parent.show_behind_parent + self.top_level = parent.top_level + self.light_mask = parent.light_mask + self.visibility_layer = parent.visibility_layer + self.y_sort_enabled = parent.y_sort_enabled + self.modulate = parent.modulate + self.self_modulate = parent.self_modulate + # NOTE: parent material takes priority over the current shaders, causing the world tiles to show up + self.use_parent_material = parent.use_parent_material + + # Save any manually introduced Material change: + self.material = parent.display_material + + +## Updates all display tiles to reflect the current changes. +func update_tiles_all(cache: TileCache) -> void: + update_tiles(cache, cache.cells.keys()) + + +## Update all display tiles affected by the world cells +func update_tiles(cache: TileCache, updated_world_cells: Array) -> void: + #push_warning('updating tiles') + var already_updated := Set.new() + for path: Array in _terrain.display_to_world_neighborhood: + path = path.map(Util.reverse_neighbor) + for world_cell: Vector2i in updated_world_cells: + var display_cell := follow_path(world_cell, path) + if already_updated.insert(display_cell): + update_tile(cache, display_cell) + + +## Updates a specific world cell. +func update_tile(cache: TileCache, cell: Vector2i) -> void: + var get_cell_at_path := func(path): return cache.get_terrain_at(follow_path(cell, path)) + var terrain_neighbors := _terrain.display_to_world_neighborhood.map(get_cell_at_path) + var mapping: Dictionary = _terrain.apply_rule(terrain_neighbors, cell) + var sid: int = mapping.sid + var tile: Vector2i = mapping.tile + set_cell(cell, sid, tile) + + +## Finds the neighbor of a given cell by following a path of CellNeighbors +func follow_path(cell: Vector2i, path: Array) -> Vector2i: + for neighbor: TileSet.CellNeighbor in path: + cell = get_neighbor_cell(cell, neighbor) + return cell diff --git a/addons/TileMapDual/DisplayLayer.gd.uid b/addons/TileMapDual/DisplayLayer.gd.uid new file mode 100644 index 0000000..8a994ac --- /dev/null +++ b/addons/TileMapDual/DisplayLayer.gd.uid @@ -0,0 +1 @@ +uid://bon5inags0mqy diff --git a/addons/TileMapDual/Set.gd b/addons/TileMapDual/Set.gd new file mode 100644 index 0000000..9a8b47e --- /dev/null +++ b/addons/TileMapDual/Set.gd @@ -0,0 +1,76 @@ +##[br] Real sets don't exist yet. +##[br] https://github.com/godotengine/godot/pull/94399 +class_name Set +extends Resource + + +## The internal Dictionary that holds this Set's items as keys. +var data: Dictionary = {} + +func _init(initial_data: Variant = []) -> void: + union_in_place(initial_data) + +## Returns true if the item exists in this Set. +func has(item: Variant) -> bool: + return item in data + + +## A dummy value to put in a slot. +const DUMMY = null +## Returns true if the item was not previously in the Set. +func insert(item: Variant) -> bool: + var out := not has(item) + data[item] = DUMMY + return out + + +## Returns true if the item was previously in the Set. +func remove(item: Variant) -> bool: + return data.erase(item) + + +## Deletes all items in this Set. +func clear() -> void: + data = {} + + +## Merges an Array's items or Dict's keys into the Set. +func union_in_place(other: Variant): + for item in other: + insert(item) + + +## Returns a new Set with the items of both self and other. +func union(other: Set) -> Set: + var out = self.duplicate() + out.union_in_place(other.data) + return out + + +## Removes an Array's items or Dict's keys from the Set. +func diff_in_place(other: Variant): + for item in other: + remove(item) + + +## Returns a new Set with all items in self that are not present in other. +func diff(other: Set) -> Set: + var out = self.duplicate() + out.diff_in_place(other.data) + return out + + +## Inserts elements that are in other but not in self, and removes elements found in both. +func xor_in_place(other: Variant): + for item in other: + if has(item): + remove(item) + else: + insert(item) + + +## Returns a new Set where each item is either in self or other, but not both. +func xor(other: Set) -> Set: + var out = self.duplicate() + out.xor_in_place(other.data) + return out diff --git a/addons/TileMapDual/Set.gd.uid b/addons/TileMapDual/Set.gd.uid new file mode 100644 index 0000000..904dc7c --- /dev/null +++ b/addons/TileMapDual/Set.gd.uid @@ -0,0 +1 @@ +uid://ds8j47h4vi6gx diff --git a/addons/TileMapDual/TerrainDual.gd b/addons/TileMapDual/TerrainDual.gd new file mode 100644 index 0000000..8fc85bb --- /dev/null +++ b/addons/TileMapDual/TerrainDual.gd @@ -0,0 +1,194 @@ +##[br] Reads a TileSet and dictates which tiles in the display map +##[br] match up with its neighbors in the world map +class_name TerrainDual +extends Resource + + +# Functions are ordered top to bottom in the transformation pipeline + +## Maps a TileSet to a Neighborhood. +static func tileset_neighborhood(tile_set: TileSet) -> Neighborhood: + return GRID_NEIGHBORHOODS[Display.tileset_gridshape(tile_set)] + + +## Maps a GridShape to a Neighborhood. +const GRID_NEIGHBORHOODS = { + Display.GridShape.SQUARE: Neighborhood.SQUARE, + Display.GridShape.ISO: Neighborhood.ISOMETRIC, + Display.GridShape.HALF_OFF_HORI: Neighborhood.TRIANGLE_HORIZONTAL, + Display.GridShape.HALF_OFF_VERT: Neighborhood.TRIANGLE_VERTICAL, + Display.GridShape.HEX_HORI: Neighborhood.TRIANGLE_HORIZONTAL, + Display.GridShape.HEX_VERT: Neighborhood.TRIANGLE_VERTICAL, +} + + +## A specific neighborhood that the Display tiles will look at. +enum Neighborhood { + SQUARE, + ISOMETRIC, + TRIANGLE_HORIZONTAL, + TRIANGLE_VERTICAL, +} + + +## Maps a Neighborhood to a set of atlas terrain neighbors. +const NEIGHBORHOOD_LAYERS := { + Neighborhood.SQUARE: [ + { # [] + 'terrain_neighborhood': [ + TileSet.CELL_NEIGHBOR_TOP_LEFT_CORNER, + TileSet.CELL_NEIGHBOR_TOP_RIGHT_CORNER, + TileSet.CELL_NEIGHBOR_BOTTOM_LEFT_CORNER, + TileSet.CELL_NEIGHBOR_BOTTOM_RIGHT_CORNER, + ], + 'display_to_world_neighborhood': [ + [TileSet.CELL_NEIGHBOR_TOP_LEFT_CORNER], + [TileSet.CELL_NEIGHBOR_TOP_SIDE], + [TileSet.CELL_NEIGHBOR_LEFT_SIDE], + [], + ], + }, + ], + Neighborhood.ISOMETRIC: [ + { # <> + 'terrain_neighborhood': [ + TileSet.CELL_NEIGHBOR_TOP_CORNER, + TileSet.CELL_NEIGHBOR_RIGHT_CORNER, + TileSet.CELL_NEIGHBOR_LEFT_CORNER, + TileSet.CELL_NEIGHBOR_BOTTOM_CORNER, + ], + 'display_to_world_neighborhood': [ + [TileSet.CELL_NEIGHBOR_TOP_CORNER], + [TileSet.CELL_NEIGHBOR_TOP_RIGHT_SIDE], + [TileSet.CELL_NEIGHBOR_TOP_LEFT_SIDE], + [], + ], + }, + ], + Neighborhood.TRIANGLE_HORIZONTAL: [ + { # v + 'terrain_neighborhood': [ + TileSet.CELL_NEIGHBOR_BOTTOM_CORNER, + TileSet.CELL_NEIGHBOR_TOP_LEFT_CORNER, + TileSet.CELL_NEIGHBOR_TOP_RIGHT_CORNER, + ], + 'display_to_world_neighborhood': [ + [], + [TileSet.CELL_NEIGHBOR_TOP_LEFT_SIDE], + [TileSet.CELL_NEIGHBOR_TOP_RIGHT_SIDE], + ], + }, + { # ^ + 'terrain_neighborhood': [ + TileSet.CELL_NEIGHBOR_TOP_CORNER, + TileSet.CELL_NEIGHBOR_BOTTOM_LEFT_CORNER, + TileSet.CELL_NEIGHBOR_BOTTOM_RIGHT_CORNER, + ], + 'display_to_world_neighborhood': [ + [TileSet.CELL_NEIGHBOR_TOP_LEFT_SIDE], + [TileSet.CELL_NEIGHBOR_LEFT_SIDE], + [], + ], + }, + ], + # TODO: this is just TRIANGLE_HORIZONTAL but transposed. this can be refactored. + Neighborhood.TRIANGLE_VERTICAL: [ + { # > + 'terrain_neighborhood': [ + TileSet.CELL_NEIGHBOR_RIGHT_CORNER, + TileSet.CELL_NEIGHBOR_TOP_LEFT_CORNER, + TileSet.CELL_NEIGHBOR_BOTTOM_LEFT_CORNER, + ], + 'display_to_world_neighborhood': [ + [], + [TileSet.CELL_NEIGHBOR_TOP_LEFT_SIDE], + [TileSet.CELL_NEIGHBOR_BOTTOM_LEFT_SIDE], + ], + }, + { # < + 'terrain_neighborhood': [ + TileSet.CELL_NEIGHBOR_LEFT_CORNER, + TileSet.CELL_NEIGHBOR_TOP_RIGHT_CORNER, + TileSet.CELL_NEIGHBOR_BOTTOM_RIGHT_CORNER, + ], + 'display_to_world_neighborhood': [ + [TileSet.CELL_NEIGHBOR_TOP_LEFT_SIDE], + [TileSet.CELL_NEIGHBOR_TOP_SIDE], + [], + ], + }, + ], +} + + +## The Neighborhood type of this TerrainDual. +var neighborhood: Neighborhood + +## Maps a terrain type to its sprite as registered in the TerrainDual. +var terrains: Dictionary + +## The TerrainLayers for this TerrainDual. +var layers: Array +var _tileset_watcher: TileSetWatcher +func _init(tileset_watcher: TileSetWatcher) -> void: + _tileset_watcher = tileset_watcher + _tileset_watcher.terrains_changed.connect(_changed, 1) + _changed() + + +##[br] Emitted when any of the terrains change. +##[br] NOTE: Prefer connecting to TerrainDual.changed instead of TileSetWatcher.terrains_changed. +func _changed() -> void: + #print('SIGNAL EMITTED: changed(%s)' % {}) + read_tileset(_tileset_watcher.tile_set) + emit_changed() + + +## Create rules for every atlas in a TileSet. +func read_tileset(tile_set: TileSet) -> void: + terrains = {} + layers = [] + neighborhood = Neighborhood.SQUARE # default + if tile_set == null: + return + neighborhood = tileset_neighborhood(tile_set) + layers = NEIGHBORHOOD_LAYERS[neighborhood].map(TerrainLayer.new) + for i in tile_set.get_source_count(): + var sid := tile_set.get_source_id(i) + var src := tile_set.get_source(sid) + if src is not TileSetAtlasSource: + continue + read_atlas(src, sid) + + +## Create rules for every tile in an atlas. +func read_atlas(atlas: TileSetAtlasSource, sid: int) -> void: + var size = atlas.get_atlas_grid_size() + for y in size.y: + for x in size.x: + var tile := Vector2i(x, y) + # Take only existing tiles + if not atlas.has_tile(tile): + continue + read_tile(atlas, sid, tile) + + +## Add a new rule for a specific tile in an atlas. +func read_tile(atlas: TileSetAtlasSource, sid: int, tile: Vector2i) -> void: + var data := atlas.get_tile_data(tile, 0) + var mapping := {'sid': sid, 'tile': tile} + var terrain_set := data.terrain_set + if terrain_set != 0: + push_warning( + "The tile at %s has a terrain set of %d. Only terrain set 0 is supported." % [mapping, terrain_set] + ) + return + var terrain := data.terrain + if terrain != -1: + if not terrain in terrains: + terrains[terrain] = mapping + + var filters = NEIGHBORHOOD_LAYERS[neighborhood] + for i in layers.size(): + var layer: TerrainLayer = layers[i] + layer._register_tile(data, mapping) diff --git a/addons/TileMapDual/TerrainDual.gd.uid b/addons/TileMapDual/TerrainDual.gd.uid new file mode 100644 index 0000000..1657e80 --- /dev/null +++ b/addons/TileMapDual/TerrainDual.gd.uid @@ -0,0 +1 @@ +uid://qa8xnqphuk68 diff --git a/addons/TileMapDual/TerrainLayer.gd b/addons/TileMapDual/TerrainLayer.gd new file mode 100644 index 0000000..b6432f8 --- /dev/null +++ b/addons/TileMapDual/TerrainLayer.gd @@ -0,0 +1,128 @@ +## A set of _rules usable by a single DisplayLayer. +class_name TerrainLayer +extends Resource + + +## A list of which CellNeighbors to care about during terrain checking. +var terrain_neighborhood: Array = [] + +##[br] When a cell in a DisplayLayer needs to be recomputed, +## the TerrainLayer needs to know which tiles surround it. +##[br] This Array stores the paths from the affected cell to the neighboring world cells. +var display_to_world_neighborhood: Array + +# TODO: change Mapping to support https://github.com/pablogila/TileMapDual/issues/13 +##[br] The rules that dictate which tile matches a given set of neighbors. +##[br] Used by register_rule() and apply_rule() +##[codeblock] +## _rules: TrieNode = # The actual type of _rules +## +## TrieNode: Dictionary{ +## key: int = # The terrain value of this neighbor +## value: TrieNode | TrieLeaf = # The next branch of this trie +## } +## +## TrieLeaf: Dictionary{ +## 'mapping': Mapping = # The tile that this should now become +## } +## +## Mapping: Dictionary{ +## 'sid': int = # The source_id of this tile +## 'tile': Vector2i = # The Atlas Coordinates of this tile +## } +##[/codeblock] +##[br] Internally a decision "trie": +##[br] - each node branch represents a terrain neighbor +##[br] - each leaf node represents the terrain that a tile +## with the given terrain neighborhood should become +##[br] +##[br] How the trie is searched: +##[br] - check the next neighbor in terrain_neighbors +##[br] - check if there is a branch corresponding to the terrain of that neighbor +##[br] - if there is a branch, search again under that branch +##[br] - else pretend that neighbor is empty and try again +##[br] - if there really isn't a branch, no rules exist so just return empty +##[br] - once at a leaf node, its mapping should tell us what terrain to become +##[br] +##[br] See apply_rule() for more details. +var _rules: Dictionary = {} + +## Generator for random placement of tiles +var rand : RandomNumberGenerator +## Random seed used to generate tiles deterministically +var global_seed : int = 707 + +func _init(fields: Dictionary) -> void: + self.terrain_neighborhood = fields.terrain_neighborhood + self.display_to_world_neighborhood = fields.display_to_world_neighborhood + self.rand = RandomNumberGenerator.new() + + +## Register a new rule for a specific tile in an atlas. +func _register_tile(data: TileData, mapping: Dictionary) -> void: + if data.terrain_set != 0: + # This was already handled as an error in the parent TerrainDual + return + var terrain_neighbors := terrain_neighborhood.map(data.get_terrain_peering_bit) + # Skip tiles with no peering bits in this filter + # They might be used for a different layer, + # or may have no peering bits at all, which will just be ignored by all layers + if terrain_neighbors.any(func(neighbor): return neighbor == -1): + if terrain_neighbors.any(func(neighbor): return neighbor != -1): + push_warning( + "Invalid Tile Neighborhood at %s.\n" % [mapping] + + "Expected neighborhood: %s" % [terrain_neighborhood.map(Util.neighbor_name)] + ) + return + mapping["prob"] = data.probability + _register_rule(terrain_neighbors, mapping) + + +## Register a new rule for a set of surrounding terrain neighbors +func _register_rule(terrain_neighbors: Array, mapping: Dictionary) -> void: + var node := _rules + for terrain in terrain_neighbors: + if terrain not in node: + node[terrain] = {} + node = node[terrain] + if not 'mappings' in node: + node.mappings = [] + node.mappings.append(mapping) + + +const TILE_EMPTY: Dictionary = {'sid': - 1, 'tile': Vector2i(-1, -1)} +## Returns the tile that should be used based on the surrounding terrain neighbors +func apply_rule(terrain_neighbors: Array, cell: Vector2i) -> Dictionary: + var is_empty := terrain_neighbors.all(func(terrain): return terrain == -1) + if is_empty: + return TILE_EMPTY + var normalized_neighbors = terrain_neighbors.map(normalize_terrain) + + var node := _rules + for terrain in normalized_neighbors: + if terrain not in node: + terrain = 0 + if terrain not in node: + return TILE_EMPTY + node = node[terrain] + if 'mappings' not in node: + return TILE_EMPTY + + #random tile selection + var weights: Array= [] + var index: int = 0 + for mapping in node.mappings: + weights.append(mapping.prob) + rand.seed = hash(str(cell)+str(global_seed)) + index = rand.rand_weighted(weights) + return node.mappings[index] + + +## Coerces all empty tiles to have a terrain of 0. +static func normalize_terrain(terrain): + return terrain if terrain != -1 else 0 + + +## Utility function for easier printing +func _neighbors_to_dict(terrain_neighbors: Array) -> Dictionary: + return Util.arrays_to_dict(terrain_neighborhood.map(Util.neighbor_name), terrain_neighbors) diff --git a/addons/TileMapDual/TerrainLayer.gd.uid b/addons/TileMapDual/TerrainLayer.gd.uid new file mode 100644 index 0000000..a3e4752 --- /dev/null +++ b/addons/TileMapDual/TerrainLayer.gd.uid @@ -0,0 +1 @@ +uid://ks3fxwahkm6j diff --git a/addons/TileMapDual/TerrainPreset.gd b/addons/TileMapDual/TerrainPreset.gd new file mode 100644 index 0000000..a7da9b7 --- /dev/null +++ b/addons/TileMapDual/TerrainPreset.gd @@ -0,0 +1,322 @@ +## Functions for automatically generating terrains for an atlas. +class_name TerrainPreset + + +## Maps a Neighborhood to a Topology. +const NEIGHBORHOOD_TOPOLOGIES := { + TerrainDual.Neighborhood.SQUARE: Topology.SQUARE, + TerrainDual.Neighborhood.ISOMETRIC: Topology.SQUARE, + TerrainDual.Neighborhood.TRIANGLE_HORIZONTAL: Topology.TRIANGLE, + TerrainDual.Neighborhood.TRIANGLE_VERTICAL: Topology.TRIANGLE, +} + + +## Determines the available Terrain presets for a certain Atlas. +enum Topology { + SQUARE, + TRIANGLE, +} + + +## Maps a Neighborhood to a preset of the specified name. +static func neighborhood_preset( + neighborhood: TerrainDual.Neighborhood, + preset_name: String = 'Standard' +) -> Dictionary: + var topology: Topology = NEIGHBORHOOD_TOPOLOGIES[neighborhood] + # TODO: test when the preset doesn't exist + var available_presets = PRESETS[topology] + if preset_name not in available_presets: + return {'size': Vector2i.ONE, 'layers': []} + var out: Dictionary = available_presets[preset_name].duplicate(true) + # All Horizontal neighborhoods can be transposed to Vertical + if neighborhood == TerrainDual.Neighborhood.TRIANGLE_VERTICAL: + out.size = Util.transpose_vec(out.size) + out.fg = Util.transpose_vec(out.fg) + out.bg = Util.transpose_vec(out.bg) + for seq in out.layers: + for i in seq.size(): + seq[i] = Util.transpose_vec(seq[i]) + return out + + +## Contains all of the builtin Terrain presets for each topology +const PRESETS := { + Topology.SQUARE: { + 'Standard': { + 'size': Vector2i(4, 4), + 'bg': Vector2i(0, 3), + 'fg': Vector2i(2, 1), + 'layers': [ + [ # [] + Vector2i(0, 3), + Vector2i(3, 3), + Vector2i(0, 2), + Vector2i(1, 2), + Vector2i(0, 0), + Vector2i(3, 2), + Vector2i(2, 3), + Vector2i(3, 1), + Vector2i(1, 3), + Vector2i(0, 1), + Vector2i(1, 0), + Vector2i(2, 2), + Vector2i(3, 0), + Vector2i(2, 0), + Vector2i(1, 1), + Vector2i(2, 1), + ], + ], + }, + }, + Topology.TRIANGLE: { + 'Standard': { + 'size': Vector2i(4, 4), + 'bg': Vector2i(0, 0), + 'fg': Vector2i(0, 2), + 'layers': [ + [ # v + Vector2i(0, 1), + Vector2i(2, 3), + Vector2i(3, 1), + Vector2i(1, 3), + Vector2i(1, 1), + Vector2i(3, 3), + Vector2i(2, 1), + Vector2i(0, 3), + ], + [ # ^ + Vector2i(0, 0), + Vector2i(2, 2), + Vector2i(3, 0), + Vector2i(1, 2), + Vector2i(1, 0), + Vector2i(3, 2), + Vector2i(2, 0), + Vector2i(0, 2), + ], + ] + }, + # Old template. + # a bit inconvenient to use for Brick (Half-Off Square) tilesets. + 'Winged': { + 'size': Vector2i(4, 4), + 'bg': Vector2i(0, 0), + 'fg': Vector2i(0, 2), + 'layers': [ + [ # v + Vector2i(0, 1), + Vector2i(2, 1), + Vector2i(3, 1), + Vector2i(1, 3), + Vector2i(1, 1), + Vector2i(3, 3), + Vector2i(2, 3), + Vector2i(0, 3), + ], + [ # ^ + Vector2i(0, 0), + Vector2i(2, 0), + Vector2i(3, 0), + Vector2i(1, 2), + Vector2i(1, 0), + Vector2i(3, 2), + Vector2i(2, 2), + Vector2i(0, 2), + ], + ] + }, + # Old template. + # The gaps between triangles made them harder to align. + 'Alternating': { + 'size': Vector2i(4, 4), + 'bg': Vector2i(0, 0), + 'fg': Vector2i(0, 2), + 'layers': [ + [ # v + Vector2i(0, 0), + Vector2i(2, 0), + Vector2i(3, 1), + Vector2i(1, 3), + Vector2i(1, 1), + Vector2i(3, 3), + Vector2i(2, 2), + Vector2i(0, 2), + ], + [ # ^ + Vector2i(0, 1), + Vector2i(2, 1), + Vector2i(3, 0), + Vector2i(1, 2), + Vector2i(1, 0), + Vector2i(3, 2), + Vector2i(2, 3), + Vector2i(0, 3), + ], + ], + }, + }, +} + +##[br] Would you like to automatically create tiles in the atlas? +##[br] +##[br] NOTE: Assumes urm.create_action() was called. Does not actually do anything until urm.commit_action() is called. +##[br] NOTE: Assumes atlas only has auto-generated tiles. Does not save peering bit information or anything else for undo/redo. +static func write_default_preset(urm: EditorUndoRedoManager, tile_set: TileSet, atlas: TileSetAtlasSource) -> void: + #print('writing default') + var neighborhood := TerrainDual.tileset_neighborhood(tile_set) + var terrain := new_terrain( + urm, + tile_set, + atlas.texture.resource_path.get_file() + ) + write_preset( + urm, + atlas, + neighborhood, + terrain, + ) + + +##[br] Creates terrain set 0 (the primary terrain set) and terrain 0 (the 'any' terrain) +##[br] +##[br] NOTE: Assumes urm.create_action() was called. Does not actually do anything until urm.commit_action() is called. +static func init_terrains(urm: EditorUndoRedoManager, tile_set: TileSet) -> void: + urm.add_do_method(TerrainPreset, "_do_init_terrains", tile_set) + urm.add_undo_method(TerrainPreset, "_undo_init_terrains", tile_set) + +static func _do_init_terrains(tile_set: TileSet) -> void: + tile_set.add_terrain_set() + tile_set.set_terrain_set_mode(0, TileSet.TERRAIN_MODE_MATCH_CORNERS) + tile_set.add_terrain(0) + tile_set.set_terrain_name(0, 0, "") + tile_set.set_terrain_color(0, 0, Color.VIOLET) + +static func _undo_init_terrains(tile_set: TileSet) -> void: + tile_set.remove_terrain_set(0) + + +##[br] Adds a new terrain type to terrain set 0 for the sprites to use. +##[br] +##[br] NOTE: Assumes urm.create_action() was called. Does not actually do anything until urm.commit_action() is called. +static func new_terrain(urm: EditorUndoRedoManager, tile_set: TileSet, terrain_name: String) -> int: + var terrain: int + if tile_set.get_terrain_sets_count() == 0: + init_terrains(urm, tile_set) + terrain = 1 + else: + terrain = tile_set.get_terrains_count(0) + urm.add_do_method(TerrainPreset, "_do_new_terrain", tile_set, terrain_name) + urm.add_undo_method(TerrainPreset, "_undo_new_terrain", tile_set) + return terrain + +static func _do_new_terrain(tile_set: TileSet, terrain_name: String) -> void: + tile_set.add_terrain(0) + var terrain := tile_set.get_terrains_count(0) - 1 + tile_set.set_terrain_name(0, terrain, "FG -%s" % terrain_name) + +static func _undo_new_terrain(tile_set: TileSet) -> void: + var terrain := tile_set.get_terrains_count(0) - 1 + tile_set.remove_terrain(0, terrain) + + +##[br] Takes a preset and writes it onto the given atlas, replacing the previous configuration. +##[br] ARGUMENTS: +##[br] - atlas: the atlas source to apply the preset to. +##[br] - filters: the neighborhood filter +##[br] +##[br] NOTE: Assumes urm.create_action() was called. Does not actually do anything until urm.commit_action() is called. +##[br] NOTE: Assumes atlas only has auto-generated tiles. Does not save peering bit information or anything else for undo/redo. +static func write_preset( + urm: EditorUndoRedoManager, + atlas: TileSetAtlasSource, + neighborhood: TerrainDual.Neighborhood, + terrain_foreground: int, + terrain_background: int = 0, + preset: Dictionary = neighborhood_preset(neighborhood), +) -> void: + clear_and_divide_atlas(urm, atlas, preset.size) + urm.add_do_method(TerrainPreset, '_do_write_preset', atlas, neighborhood, terrain_foreground, terrain_background, preset) + +static func _do_write_preset( + atlas: TileSetAtlasSource, + neighborhood: TerrainDual.Neighborhood, + terrain_foreground: int, + terrain_background: int, + preset: Dictionary, +) -> void: + var layers: Array = TerrainDual.NEIGHBORHOOD_LAYERS[neighborhood] + # Set peering bits + var sequences: Array = preset.layers + for j in layers.size(): + var terrain_neighborhood = layers[j].terrain_neighborhood + var sequence: Array = sequences[j] + for i in sequence.size(): + var tile: Vector2i = sequence[i] + atlas.create_tile(tile) + var data := atlas.get_tile_data(tile, 0) + data.terrain_set = 0 + for neighbor in terrain_neighborhood: + data.set_terrain_peering_bit( + neighbor, + [terrain_background, terrain_foreground][i & 1] + ) + i >>= 1 + # Set terrains + atlas.get_tile_data(preset.bg, 0).terrain = terrain_background + atlas.get_tile_data(preset.fg, 0).terrain = terrain_foreground + +##[br] Unregisters all the tiles in an atlas and changes the size of the individual sprites. +##[br] +##[br] NOTE: Assumes urm.create_action() was called. Does not actually do anything until urm.commit_action() is called. +##[br] NOTE: Assumes atlas only has auto-generated tiles. Does not save peering bit information or anything else for undo/redo. +static func clear_and_resize_atlas(urm: EditorUndoRedoManager, atlas: TileSetAtlasSource, size: Vector2) -> void: + var atlas_data := _save_atlas_data(atlas) + urm.add_do_method(TerrainPreset, '_do_clear_and_resize_atlas', atlas, size) + urm.add_undo_method(TerrainPreset, '_undo_clear_and_resize_atlas', atlas, atlas_data) + +static func _do_clear_and_resize_atlas(atlas: TileSetAtlasSource, size: Vector2) -> void: + # Clear all tiles + atlas.texture_region_size = atlas.texture.get_size() + Vector2.ONE + atlas.clear_tiles_outside_texture() + # Resize the tiles + atlas.texture_region_size = size + +static func _undo_clear_and_resize_atlas(atlas: TileSetAtlasSource, atlas_data: Dictionary) -> void: + _load_atlas_data(atlas, atlas_data) + +## NOTE: Assumes atlas only has auto-generated tiles. Does not save peering bit information or anything else. +static func _save_atlas_data(atlas: TileSetAtlasSource) -> Dictionary: + var size_img := atlas.texture.get_size() + var size_sprite := atlas.texture_region_size + var size_dims := Vector2i(size_img) / size_sprite + var tiles := [] + for y in size_dims.y: + var row := [] + for x in size_dims.x: + var tile := Vector2i(x, y) + var exists := atlas.has_tile(tile) + row.push_back(exists) + tiles.push_back(row) + return { + 'size_sprite': size_sprite, + 'size_dims': size_dims, + 'tiles': tiles, + } + +static func _load_atlas_data(atlas: TileSetAtlasSource, atlas_data: Dictionary) -> void: + _do_clear_and_resize_atlas(atlas, atlas_data.size_sprite) + for y in atlas_data.size_dims.y: + for x in atlas_data.size_dims.x: + if atlas_data.tiles[y][x]: + var tile := Vector2i(x, y) + atlas.create_tile(tile) + + +##[br] Unregisters all the tiles in an atlas and changes the size of the +## individual sprites to accomodate a divisions.x by divisions.y grid of sprites. +##[br] +##[br] NOTE: Assumes urm.create_action() was called. Does not actually do anything until urm.commit_action() is called. +##[br] NOTE: Assumes atlas only has auto-generated tiles. Does not save peering bit information or anything else for undo/redo. +static func clear_and_divide_atlas(urm: EditorUndoRedoManager, atlas: TileSetAtlasSource, divisions: Vector2i) -> void: + clear_and_resize_atlas(urm, atlas, atlas.texture.get_size() / Vector2(divisions)) diff --git a/addons/TileMapDual/TerrainPreset.gd.uid b/addons/TileMapDual/TerrainPreset.gd.uid new file mode 100644 index 0000000..de8ff19 --- /dev/null +++ b/addons/TileMapDual/TerrainPreset.gd.uid @@ -0,0 +1 @@ +uid://cdsxbtu57wews diff --git a/addons/TileMapDual/TileCache.gd b/addons/TileMapDual/TileCache.gd new file mode 100644 index 0000000..98550f3 --- /dev/null +++ b/addons/TileMapDual/TileCache.gd @@ -0,0 +1,74 @@ +## Caches the sprite location and terrains of each tile in the TileMapDual world grid. +class_name TileCache +extends Resource + + +##[br] Maps a cell coordinate to the stored tile data. +##[codeblock] +## Dictionary{ +## key: Vector2i = # The coordinates of this tile in the world grid. +## value: Dictionary{ +## 'sid': int = # The Source ID of this tile. +## 'tile': Vector2i = # The coordinates of this tile in its Atlas. +## 'terrain': int = # The terrain assigned to this tile. +## } = # The data stored at this tile. +## } +##[/codeblock] +var cells := {} +func _init() -> void: + pass + +##[br] Updates specific cells of the TileCache based on the current layer data at those points. +##[br] Makes corrections in case the user accidentally places invalid tiles. +func update(world: TileMapLayer, edited: Array = cells.keys()) -> void: + var tile_set := world.tile_set + if tile_set == null: + push_error('Attempted to update TileCache while tile set was null') + return + for cell in edited: + # Invalid cells will be treated as empty and ignored + var sid := world.get_cell_source_id(cell) + if sid == -1: + cells.erase(cell) + continue + if not tile_set.has_source(sid): + continue + var src = tile_set.get_source(sid) + var tile := world.get_cell_atlas_coords(cell) + if not src.has_tile(tile): + continue + var data := world.get_cell_tile_data(cell) + if data == null: + continue + # Accidental cells should be reset to their previous value + # They will be treated as unchanged + if data.terrain == -1 or data.terrain_set != 0: + if cell not in cells: + world.erase_cell(cell) + continue + var cached: Dictionary = cells[cell] + sid = cached.sid + tile = cached.tile + world.set_cell(cell, sid, tile) + data = world.get_cell_tile_data(cell) + cells[cell] = {'sid': sid, 'tile': tile, 'terrain': data.terrain} + + +## Returns the symmetric difference (xor) of two tile caches. +func xor(other: TileCache) -> Array[Vector2i]: + var out: Array[Vector2i] = [] + for key in cells: + if key not in other.cells or cells[key].terrain != other.cells[key].terrain: + out.push_back(key) + for key in other.cells: + if key not in cells: + out.push_back(key) + return out + + +##[br] Returns the terrain value of the tile at the given cell coordinates. +##[br] Empty cells have a terrain of -1. +func get_terrain_at(cell: Vector2i) -> int: + if cell not in cells: + return -1 + return cells[cell].terrain diff --git a/addons/TileMapDual/TileCache.gd.uid b/addons/TileMapDual/TileCache.gd.uid new file mode 100644 index 0000000..d488b17 --- /dev/null +++ b/addons/TileMapDual/TileCache.gd.uid @@ -0,0 +1 @@ +uid://dpuc70ypq7wf8 diff --git a/addons/TileMapDual/TileMapDual.gd b/addons/TileMapDual/TileMapDual.gd new file mode 100644 index 0000000..91c23d8 --- /dev/null +++ b/addons/TileMapDual/TileMapDual.gd @@ -0,0 +1,160 @@ +@tool +@icon('TileMapDual.svg') +class_name TileMapDual +extends TileMapLayer + + +## An invisible material used to hide the world grid so only the display layers show up. +## Currently implemented as a shader that sets all pixels to 0 alpha. +var _ghost_material: Material = preload("res://addons/TileMapDual/ghost_material.tres") + + +# === External functions that don't exist once exported === +# HACK: this uses some sort of "Dynamic Linking" technique because these features don't exist right now +# - conditional compilation +# - static signals +static func _editor_only(name: String): + push_error('Attempt to call Editor-Only function "' + name + '"') +static var autotile: Callable = _editor_only.bind('autotile').unbind(3) +static var popup: Callable = _editor_only.bind('popup').unbind(2) + + +## Material for the display tilemap. +@export_custom(PROPERTY_HINT_RESOURCE_TYPE, "ShaderMaterial, CanvasItemMaterial") +var display_material: Material: + get: + return display_material + set(new_material): # Custom setter so that it gets copied + display_material = new_material + changed.emit() + +var _tileset_watcher: TileSetWatcher +var _display: Display +func _ready() -> void: + _tileset_watcher = TileSetWatcher.new(tile_set) + _display = Display.new(self, _tileset_watcher) + add_child(_display) + _make_self_invisible(true) + if Engine.is_editor_hint(): + _tileset_watcher.atlas_autotiled.connect(_atlas_autotiled) + set_process(true) + else: # Run in-game using signals for better performance + changed.connect(_changed, 1) + set_process(false) + # Update full tileset on first instance + await get_tree().process_frame + _changed() + + +## Automatically generate terrains when the atlas is initialized. +func _atlas_autotiled(source_id: int, atlas: TileSetAtlasSource): + autotile.call(source_id, atlas, tile_set) + + +## Keeps track of use_parent_material to see when it turns on or off. +var _cached_use_parent_material = null +##[br] Makes the main world grid invisible. +##[br] The main tiles don't need to be seen. Only the DisplayLayers should be visible. +##[br] Called every frame, and functions a lot like TileSetWatcher. +func _make_self_invisible(startup: bool = false) -> void: + # If user has set a material in the original slot, inform the user + if material != _ghost_material: + if not startup and Engine.is_editor_hint(): + popup.call( + "Warning! Direct material edit detected.", + "Don't manually edit the real material in the editor! Instead edit the custom 'Display Material' property.\n" + + "(Resetting the material to an invisible shader material... this is to keep the 'World Layer' invisible)\n" + + "* This warning is only given when the material is set in the Godot editor.\n" + + "* In-game scripts may set the material directly. It will be copied over to display_material automatically." + ) + else: + # copy over the material if it was edited by script + display_material = material + material = _ghost_material # Force TileMapDual's material to become invisible + + # check if use_parent_material is set + if ( + Engine.is_editor_hint() + and use_parent_material != _cached_use_parent_material + and _cached_use_parent_material == false # cache may be null + ): + popup.call( + "Warning: Using Parent Material.", + "The parent material will override any other materials used by the TileMapDual,\n" + + "including the 'ghost shader' that the world tiles use to hide themselves.\n" + + "This will cause the world tiles to show themselves in-game.\n" + + "\n" + + "* Recommendation: Turn this setting off. Don't use parent material.\n" + + "* Workaround: Set your world tiles to custom sprites that are entirely transparent.\n" + + "(see 'res://addons/TileMapDual/docs/custom_drawing_sprites.mp4' for a non-transparent example)" + ) + _cached_use_parent_material = use_parent_material + + +## HACK: How long to wait before processing another "frame". +## Mainly matters when [godot_4_3_compatibility] is active. +@export_range(0.0, 0.1) var refresh_time: float = 0.02 +var _timer: float = 0.0 +func _process(delta: float) -> void: # Only used inside the editor + if refresh_time < 0.0: + return + if _timer > 0: + _timer -= delta + return + _timer = refresh_time + call_deferred('_changed') + +## When toggled on, double-checks ALL cells in the grid every change. +## Only use this when running Godot 4.3 and below, +## where TileMapLayer could not detect changes properly. +@export var godot_4_3_compatibility: bool = _godot_is_below_4_4() + +## Detects if godot is below v4.4. +## Only used to detect whether the _update_cells() function is usable. +func _godot_is_below_4_4(): + var version := Engine.get_version_info() + return version.major < 4 or version.major == 4 and version.minor < 4 + +## Called by signals when the tileset changes, +## or by _process inside the editor. +func _changed() -> void: + _tileset_watcher.update(tile_set) + + var updated_cells := [] + # HACK: double check all tiles every refresh + if godot_4_3_compatibility and tile_set != null: + var current_cells := TileCache.new() + current_cells.update(self, get_used_cells()) + updated_cells = current_cells.xor(_display.cached_cells) + + _display.update(updated_cells) + _make_self_invisible() + + +## Called when the user draws on the map or presses undo/redo. +func _update_cells(coords: Array[Vector2i], forced_cleanup: bool) -> void: + if is_instance_valid(_display): + _display.update(coords) + + +##[br] Public method to add and remove tiles. +##[br] +##[br] - 'cell' is a vector with the cell position. +##[br] - 'terrain' is which terrain type to draw. +##[br] - terrain -1 completely removes the tile, +##[br] - and by default, terrain 0 is the empty tile. +func draw_cell(cell: Vector2i, terrain: int = 1) -> void: + var terrains := _display.terrain.terrains + if terrain not in terrains: + erase_cell(cell) + changed.emit() + return + var tile_to_use: Dictionary = terrains[terrain] + var sid: int = tile_to_use.sid + var tile: Vector2i = tile_to_use.tile + set_cell(cell, sid, tile) + changed.emit() + +## Public method to get the terrain at a specific coordinate. +func get_cell(cell: Vector2i) -> int: + return get_cell_tile_data(cell).terrain diff --git a/addons/TileMapDual/TileMapDual.gd.uid b/addons/TileMapDual/TileMapDual.gd.uid new file mode 100644 index 0000000..34a6b7f --- /dev/null +++ b/addons/TileMapDual/TileMapDual.gd.uid @@ -0,0 +1 @@ +uid://cjk8nronimk5r diff --git a/addons/TileMapDual/TileMapDual.svg b/addons/TileMapDual/TileMapDual.svg new file mode 100644 index 0000000..0c387e4 --- /dev/null +++ b/addons/TileMapDual/TileMapDual.svg @@ -0,0 +1,38 @@ + + + + + + diff --git a/addons/TileMapDual/TileMapDual.svg.import b/addons/TileMapDual/TileMapDual.svg.import new file mode 100644 index 0000000..a777d0c --- /dev/null +++ b/addons/TileMapDual/TileMapDual.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bqu55yxko3ofm" +path="res://.godot/imported/TileMapDual.svg-74ed91a3d196dbe51dce1133c9b2d6f7.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/TileMapDual/TileMapDual.svg" +dest_files=["res://.godot/imported/TileMapDual.svg-74ed91a3d196dbe51dce1133c9b2d6f7.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/addons/TileMapDual/TileMapDualLegacy.gd b/addons/TileMapDual/TileMapDualLegacy.gd new file mode 100644 index 0000000..867aa8a --- /dev/null +++ b/addons/TileMapDual/TileMapDualLegacy.gd @@ -0,0 +1,333 @@ +@tool +@icon('TileMapDual.svg') +class_name TileMapDualLegacy +extends TileMapLayer + +## Material for the display tilemap. +@export_custom(PROPERTY_HINT_RESOURCE_TYPE, "ShaderMaterial,CanvasItemMaterial") +var display_material: Material: + get: + return display_material + set(new_material): # Custom setter so that it gets copied + display_material = new_material + if display_tilemap: # Copy over, only if display tilemap is initiated + display_tilemap.material = display_material + +var display_tilemap: TileMapLayer = null +var _filled_cells: Dictionary = {} +var _emptied_cells: Dictionary = {} +var _tile_shape: TileSet.TileShape = TileSet.TileShape.TILE_SHAPE_SQUARE +var _tile_size: Vector2i = Vector2i(16, 16) +## Coordinates for the fully-filled tile in the Atlas that +## will be used to sketch in the World grid. +## Only this tile will be considered for autotiling. +var full_tile: Vector2i = Vector2i(2,1) +## The opposed of full_tile. Used to erase sketched tiles. +var empty_tile: Vector2i = Vector2i(0,3) +var _should_check_cells: bool = false +## Prevents checking the cells more than once when the entire tileset +## is being updated, which is indicated by `_should_check_cells`. +var _checked_cells: Dictionary = {} +var is_isometric: bool = false +var _atlas_id: int + +## We will use a bit-wise logic, so that a summation over all sketched +## neighbours provides a unique key, assigned to the corresponding +## tile from the Atlas through the NEIGHBOURS_TO_ATLAS dictionary. +enum _location { + TOP_LEFT = 1, + LOW_LEFT = 2, + TOP_RIGHT = 4, + LOW_RIGHT = 8 + } + +enum _direction { + TOP, + LEFT, + BOTTOM, + RIGHT, + BOTTOM_LEFT, + BOTTOM_RIGHT, + TOP_LEFT, + TOP_RIGHT, + } + +## Overlapping tiles from the World grid +## that a tile from the Dual grid has. +const _NEIGHBORS := { + _direction.TOP : TileSet.CellNeighbor.CELL_NEIGHBOR_TOP_SIDE, + _direction.LEFT : TileSet.CellNeighbor.CELL_NEIGHBOR_LEFT_SIDE, + _direction.RIGHT : TileSet.CellNeighbor.CELL_NEIGHBOR_RIGHT_SIDE, + _direction.BOTTOM : TileSet.CellNeighbor.CELL_NEIGHBOR_BOTTOM_SIDE, + _direction.TOP_LEFT : TileSet.CellNeighbor.CELL_NEIGHBOR_TOP_LEFT_CORNER, + _direction.TOP_RIGHT : TileSet.CellNeighbor.CELL_NEIGHBOR_TOP_RIGHT_CORNER, + _direction.BOTTOM_LEFT : TileSet.CellNeighbor.CELL_NEIGHBOR_BOTTOM_LEFT_CORNER, + _direction.BOTTOM_RIGHT : TileSet.CellNeighbor.CELL_NEIGHBOR_BOTTOM_RIGHT_CORNER + } + +## Overlapping tiles from the World grid +## that a tile from the Dual grid has. +## To be used ONLY with isometric tilesets. +## CellNighbors are literal, even for Isometric +const _NEIGHBORS_ISOMETRIC := { + _direction.TOP : TileSet.CellNeighbor.CELL_NEIGHBOR_TOP_RIGHT_SIDE, + _direction.LEFT : TileSet.CellNeighbor.CELL_NEIGHBOR_TOP_LEFT_SIDE, + _direction.RIGHT : TileSet.CellNeighbor.CELL_NEIGHBOR_BOTTOM_RIGHT_SIDE, + _direction.BOTTOM : TileSet.CellNeighbor.CELL_NEIGHBOR_BOTTOM_LEFT_SIDE, + _direction.TOP_LEFT : TileSet.CellNeighbor.CELL_NEIGHBOR_TOP_CORNER, + _direction.TOP_RIGHT : TileSet.CellNeighbor.CELL_NEIGHBOR_RIGHT_CORNER, + _direction.BOTTOM_LEFT : TileSet.CellNeighbor.CELL_NEIGHBOR_LEFT_CORNER, + _direction.BOTTOM_RIGHT : TileSet.CellNeighbor.CELL_NEIGHBOR_BOTTOM_CORNER + } + +## Dict to assign the Atlas coordinates from the +## summation over all sketched NEIGHBOURS. +## Follows the official 2x2 template. +## Works for isometric as well. +const _NEIGHBORS_TO_ATLAS: Dictionary = { + 0: Vector2i(0,3), + 1: Vector2i(3,3), + 2: Vector2i(0,0), + 3: Vector2i(3,2), + 4: Vector2i(0,2), + 5: Vector2i(1,2), + 6: Vector2i(2,3), + 7: Vector2i(3,1), + 8: Vector2i(1,3), + 9: Vector2i(0,1), + 10: Vector2i(3,0), + 11: Vector2i(2,0), + 12: Vector2i(1,0), + 13: Vector2i(2,2), + 14: Vector2i(1,1), + 15: Vector2i(2,1) + } + + +func _ready() -> void: + if Engine.is_editor_hint(): + set_process(true) + else: # Run in-game using signals for better performance + set_process(false) + self.changed.connect(_update_tileset, 1) + update_full_tileset() + # Fire copy upon any property change. More specific than _update_tileset + self.changed.connect(copy_properties, 1) + +func _process(_delta): # Only used inside the editor + if not self.tile_set: + return + call_deferred('_update_tileset') + +## Copies properties from parent TileMapDual to child display tilemap +func copy_properties() -> void: + # Both tilemaps must be the same, so we copy all relevant properties + # Tilemap + display_tilemap.tile_set = self.tile_set + # Rendering + display_tilemap.y_sort_origin = self.y_sort_origin + display_tilemap.x_draw_order_reversed = self.x_draw_order_reversed + display_tilemap.rendering_quadrant_size = self.rendering_quadrant_size + # Physics + display_tilemap.collision_enabled = self.collision_enabled + display_tilemap.use_kinematic_bodies = self.use_kinematic_bodies + display_tilemap.collision_visibility_mode = self.collision_visibility_mode + # Navigation + display_tilemap.navigation_enabled = self.navigation_enabled + display_tilemap.navigation_visibility_mode = self.navigation_visibility_mode + # Canvas item properties + display_tilemap.show_behind_parent = self.show_behind_parent + display_tilemap.top_level = self.top_level + display_tilemap.light_mask = self.light_mask + display_tilemap.visibility_layer = self.visibility_layer + display_tilemap.y_sort_enabled = self.y_sort_enabled + display_tilemap.modulate = self.modulate + + # If user has set a material in the original slot, copy it over for redundancy + # Helps both migration to new version, and prevents user mistakes + if self.material: + display_material = self.material + + # Set material for first time + display_tilemap.material = display_material + + # Save any manually introduced alpha modulation: + if self.self_modulate.a != 0.0: + display_tilemap.self_modulate = self.self_modulate + self.self_modulate.a = 0.0 # Override modulation to prevent render bugs with certain shaders + + self.material = null # Unset TileMapDual's material, to prevent render of it + +## Set the dual grid as a child of TileMapDual. +func _set_display_tilemap() -> void: + if not self.tile_set: + return + + # Add the display TileMapLayer, if it doesn't already exist + if not get_node_or_null("WorldTileMap"): + display_tilemap = TileMapLayer.new() + display_tilemap.name = "WorldTileMap" + add_child(display_tilemap) + + copy_properties() # Copy properties from TileMapDual to displayed tilemap + + update_geometry() + display_tilemap.clear() + +## Update the size and shape of the tileset, displacing the display TileMapLayer accordingly. +func update_geometry() -> void: + is_isometric = self.tile_set.tile_shape == TileSet.TileShape.TILE_SHAPE_ISOMETRIC + var offset := Vector2(self.tile_set.tile_size) * -0.5 + if is_isometric: + offset.x = 0 + display_tilemap.position = offset + _tile_size = self.tile_set.tile_size + _tile_shape = self.tile_set.tile_shape + + +## Update the entire tileset, processing all the cells in the map. +func update_full_tileset() -> void: + if display_tilemap == null: + _set_display_tilemap() + elif display_tilemap.tile_set != self.tile_set: # TO-DO: merge with the above + _set_display_tilemap() + _should_check_cells = true + for _cell in self.get_used_cells(): + if _is_world_tile_sketched(_cell) == 1 or _is_world_tile_sketched(_cell) == 0: + update_tile(_cell) + _should_check_cells = false + _checked_cells = {} + # _checked_cells is only used when updating + # the whole tilemap to avoid repeating checks. + # This is skipped when updating tiles individually. + +## Update only the very specific tiles that have changed. +## Much more efficient than update_full_tileset. +## Called by signals when the tileset changes, +## or by _process inside the editor. +func _update_tileset() -> void: + if display_tilemap == null: + update_full_tileset() + return + elif display_tilemap.tile_set != self.tile_set: # TO-DO: merge with the above + update_full_tileset() + return + elif _tile_size != self.tile_set.tile_size or _tile_shape != self.tile_set.tile_shape: + update_geometry() + return + + var _new_emptied_cells: Dictionary = array_to_dict(get_used_cells_by_id(-1, empty_tile)) + var _new_filled_cells: Dictionary = array_to_dict(get_used_cells_by_id(-1, full_tile)) + var _changed_cells: Dictionary = exclude_dicts(_emptied_cells, _new_emptied_cells).merged(exclude_dicts(_filled_cells, _new_filled_cells)) + + _emptied_cells = _new_emptied_cells + _filled_cells = _new_filled_cells + for _cell in _changed_cells: + update_tile(_cell) + +func array_to_dict(array: Array) -> Dictionary: + var dict: Dictionary = {} + for item in array: + dict[item] = true + return dict + +## Return the values that are not shared between the arrays +func exclude_dicts(a: Dictionary, b: Dictionary) -> Dictionary: + var result = a.duplicate() + for item in b: + if result.has(item): + result.erase(item) + else: + result[item] = true + return result + +## Takes a cell, and updates the overlapping tiles from the dual grid accordingly. +func update_tile(world_cell: Vector2i, recurse: bool = true) -> void: + _atlas_id = self.get_cell_source_id(world_cell) + + # to not fall in a recursive loop because of a large space of emptiness in the map + if (!recurse and _atlas_id == -1): + return + + var __NEIGHBORS = _NEIGHBORS_ISOMETRIC if is_isometric else _NEIGHBORS + var _top_left = world_cell + var _low_left = display_tilemap.get_neighbor_cell(world_cell, __NEIGHBORS[_direction.BOTTOM]) + var _top_right = display_tilemap.get_neighbor_cell(world_cell, __NEIGHBORS[_direction.RIGHT]) + var _low_right = display_tilemap.get_neighbor_cell(world_cell, __NEIGHBORS[_direction.BOTTOM_RIGHT]) + _update_displayed_tile(_top_left) + _update_displayed_tile(_low_left) + _update_displayed_tile(_top_right) + _update_displayed_tile(_low_right) + + # if atlas id is -1 the tile is empty, so to have a good rendering we need to update surroundings + if (_atlas_id == -1): + update_tile(self.get_neighbor_cell(world_cell, __NEIGHBORS[_direction.LEFT]), false) + update_tile(self.get_neighbor_cell(world_cell, __NEIGHBORS[_direction.TOP_LEFT]), false) + update_tile(self.get_neighbor_cell(world_cell, __NEIGHBORS[_direction.TOP]), false) + update_tile(self.get_neighbor_cell(world_cell, __NEIGHBORS[_direction.TOP_RIGHT]), false) + update_tile(self.get_neighbor_cell(world_cell, __NEIGHBORS[_direction.RIGHT]), false) + update_tile(self.get_neighbor_cell(world_cell, __NEIGHBORS[_direction.BOTTOM_RIGHT]), false) + update_tile(self.get_neighbor_cell(world_cell, __NEIGHBORS[_direction.BOTTOM]), false) + update_tile(self.get_neighbor_cell(world_cell, __NEIGHBORS[_direction.BOTTOM_LEFT]), false) + + +func _update_displayed_tile(_display_cell: Vector2i) -> void: + # Avoid updating cells more than necessary + if _should_check_cells: + if _display_cell in _checked_cells: + return + _checked_cells[_display_cell] = true + + var __NEIGHBORS = _NEIGHBORS_ISOMETRIC if is_isometric else _NEIGHBORS + var _top_left = display_tilemap.get_neighbor_cell(_display_cell, __NEIGHBORS[_direction.TOP_LEFT]) + var _low_left = display_tilemap.get_neighbor_cell(_display_cell, __NEIGHBORS[_direction.LEFT]) + var _top_right = display_tilemap.get_neighbor_cell(_display_cell, __NEIGHBORS[_direction.TOP]) + var _low_right = _display_cell + + # We perform a bitwise summation over the sketched neighbours + var _tile_key: int = 0 + if _is_world_tile_sketched(_top_left) == 1: + _tile_key += _location.TOP_LEFT + if _is_world_tile_sketched(_low_left) == 1: + _tile_key += _location.LOW_LEFT + if _is_world_tile_sketched(_top_right) == 1: + _tile_key += _location.TOP_RIGHT + if _is_world_tile_sketched(_low_right) == 1: + _tile_key += _location.LOW_RIGHT + + var _coords_atlas: Vector2i = _NEIGHBORS_TO_ATLAS[_tile_key] + display_tilemap.set_cell(_display_cell, _atlas_id, _coords_atlas) + + +## Return -1 if the cell is empty, 0 if sketched with the empty tile, +## and 1 if it is sketched with the fully-filled tile. +func _is_world_tile_sketched(_world_cell: Vector2i): + var _atlas_coords = get_cell_atlas_coords(_world_cell) + if _atlas_coords == full_tile: + return 1 + elif _atlas_coords == empty_tile: + return 0 + return -1 + + +## Public method to add and remove tiles, as +## TileMapDual.draw(cell, tile, atlas_id). +## 'cell' is a vector with the cell position. +## 'tile' is 1 to draw the full tile (default), 0 to draw the empty tile, +## and -1 to completely remove the tile. +## 'atlas_id' is the atlas id of the tileset to modify, 0 by default. +## This method replaces the deprecated 'fill_tile' and 'erase_tile' methods. +func draw(cell: Vector2i, tile: int = 1, atlas_id: int = 0) -> void: + # Prevents a crash if this is called on the first frame + if display_tilemap == null: + update_full_tileset() + var tile_to_use: Vector2i + if tile == 1: + tile_to_use = full_tile + if tile == 0: + tile_to_use = empty_tile + if tile == -1: + tile_to_use = Vector2i(-1, -1) + atlas_id = -1 + set_cell(cell, atlas_id, tile_to_use) + update_tile(cell) diff --git a/addons/TileMapDual/TileMapDualLegacy.gd.uid b/addons/TileMapDual/TileMapDualLegacy.gd.uid new file mode 100644 index 0000000..af1118d --- /dev/null +++ b/addons/TileMapDual/TileMapDualLegacy.gd.uid @@ -0,0 +1 @@ +uid://dlyqj0u8ckhb0 diff --git a/addons/TileMapDual/TileSetWatcher.gd b/addons/TileMapDual/TileSetWatcher.gd new file mode 100644 index 0000000..4b5f7ca --- /dev/null +++ b/addons/TileMapDual/TileSetWatcher.gd @@ -0,0 +1,181 @@ +## Provides information about a TileSet and sends signals when it changes. +class_name TileSetWatcher +extends Resource + +## Caches the previous tile_set to see when it changes. +var tile_set: TileSet +## Caches the previous tile_size to see when it changes. +var tile_size: Vector2i +## caches the previous result of display.tileset_grid_shape(tile_set) to see when it changes. +var grid_shape: Display.GridShape + +func _init(tile_set: TileSet) -> void: + # tileset_deleted.connect(func(): print('tileset_deleted'), 1) + # tileset_created.connect(func(): print('tileset_created'), 1) + # tileset_resized.connect(func(): print('tileset_resized'), 1) + # tileset_reshaped.connect(func(): print('tileset_reshaped'), 1) + atlas_added.connect(_atlas_added, 1) + update(tile_set) + + +var _flag_tileset_deleted := false +## Emitted when the parent TileMapDual's tile_set is cleared or replaced. +signal tileset_deleted + +var _flag_tileset_created := false +## Emitted when the parent TileMapDual's tile_set is created or replaced. +signal tileset_created + +var _flag_tileset_resized := false +## Emitted when tile_set.tile_size is changed. +signal tileset_resized + +var _flag_tileset_reshaped := false +## Emitted when the GridShape of the TileSet would be different. +signal tileset_reshaped + +var _flag_atlas_added := false +## Emitted when a new Atlas is added to this TileSet. +## Does not react to Scenes being added to the TileSet. +signal atlas_added(source_id: int, atlas: TileSetAtlasSource) +func _atlas_added(source_id: int, atlas: TileSetAtlasSource) -> void: + _flag_atlas_added = true + #print('SIGNAL EMITTED: atlas_added(%s)' % {'source_id': source_id, 'atlas': atlas}) + + +## Emitted when the watcher thinks that "Yes" was clicked for: +## 'Would you like to automatically create tiles in the atlas?' +signal atlas_autotiled(source_id: int, atlas: TileSetAtlasSource) + + +var _flag_terrains_changed := false +## Emitted when an atlas is added or removed, +## or when the terrains change in one of the Atlases. +## NOTE: Prefer connecting to TerrainDual.changed instead of TileSetWatcher.terrains_changed. +signal terrains_changed + + +## Checks if anything about the concerned TileMapDual's tile_set changed. +## Must be called by the TileMapDual every frame. +func update(tile_set: TileSet) -> void: + check_tile_set(tile_set) + check_flags() + + +## Emit update signals if the corresponding flags were set. +## Must only be run once per frame. +func check_flags() -> void: + if _flag_tileset_changed: + _flag_tileset_changed = false + _update_tileset() + if _flag_tileset_deleted: + _flag_tileset_deleted = false + _flag_tileset_reshaped = true + tileset_deleted.emit() + if _flag_tileset_created: + _flag_tileset_created = false + _flag_tileset_reshaped = true + tileset_created.emit() + if _flag_tileset_resized: + _flag_tileset_resized = false + tileset_resized.emit() + if _flag_tileset_reshaped: + _flag_tileset_reshaped = false + _flag_terrains_changed = true + tileset_reshaped.emit() + if _flag_atlas_added: + _flag_atlas_added = false + _flag_terrains_changed = true + if _flag_terrains_changed: + _flag_terrains_changed = false + terrains_changed.emit() + + +## Check if tile_set has been added, replaced, or deleted. +func check_tile_set(tile_set: TileSet) -> void: + if tile_set == self.tile_set: + return + if self.tile_set != null: + self.tile_set.changed.disconnect(_set_tileset_changed) + _cached_source_count = 0 + _cached_sids.clear() + _flag_tileset_deleted = true + self.tile_set = tile_set + if self.tile_set != null: + self.tile_set.changed.connect(_set_tileset_changed, 1) + self.tile_set.emit_changed() + _flag_tileset_created = true + emit_changed() + + +var _flag_tileset_changed := false +## Helper method to be called when the tile_set detects a change. +## Must be disconnected when the tile_set is changed. +func _set_tileset_changed() -> void: + _flag_tileset_changed = true + + +## Called when _flag_tileset_changed. +## Provides more detail about what changed. +func _update_tileset() -> void: + var tile_size = tile_set.tile_size + if self.tile_size != tile_size: + self.tile_size = tile_size + _flag_tileset_resized = true + var grid_shape = Display.tileset_gridshape(tile_set) + if self.grid_shape != grid_shape: + self.grid_shape = grid_shape + _flag_tileset_reshaped = true + _update_tileset_atlases() + + +# Cached variables from the previous frame +# These are used to compare what changed between frames +var _cached_source_count: int = 0 +var _cached_sids := {} +# TODO: detect automatic tile creation +## Checks if new atlases have been added. +## Does not check which ones were deleted. +func _update_tileset_atlases() -> void: + # Update all tileset sources + var source_count := tile_set.get_source_count() + + # Only if an asset was added or removed + # FIXME?: may break on add+remove in 1 frame + if _cached_source_count == source_count: + return + _cached_source_count = source_count + + # Process the new atlases in the TileSet + var sids := {} + for i in source_count: + var sid: int = tile_set.get_source_id(i) + if sid in _cached_sids: + sids[sid] = _cached_sids[sid] + continue + var source: TileSetSource = tile_set.get_source(sid) + if source is not TileSetAtlasSource: + push_warning( + "Non-Atlas TileSet found at index %i, source id %i.\n" % [i, source] + + "Dual Grids only support Atlas TileSets." + ) + sids[sid] = null + continue + var atlas: TileSetAtlasSource = source + # FIXME?: check if this needs to be disconnected + # SETUP: + # - add logging to check which Watcher's flag was changed + # - add a TileSet with an atlas to 2 TileMapDuals + # - remove the TileSet + # - modify the terrains on one of the atlases + # - check how many watchers were flagged: + # - if 2 watchers were flagged, this is bad. + # try to repeatedly add and remove the tileset. + # this could either cause the flag to happen multiple times, + # or it could stay at 2 watchers. + # - if 1 watcher was flagged, that is ok + sids[sid] = AtlasWatcher.new(self, sid, atlas) + atlas_added.emit(sid, atlas) + _flag_terrains_changed = true + # FIXME?: find which sid's were deleted + _cached_sids = sids diff --git a/addons/TileMapDual/TileSetWatcher.gd.uid b/addons/TileMapDual/TileSetWatcher.gd.uid new file mode 100644 index 0000000..34b0195 --- /dev/null +++ b/addons/TileMapDual/TileSetWatcher.gd.uid @@ -0,0 +1 @@ +uid://bjmo6oy8a4k2k diff --git a/addons/TileMapDual/Util.gd b/addons/TileMapDual/Util.gd new file mode 100644 index 0000000..1762156 --- /dev/null +++ b/addons/TileMapDual/Util.gd @@ -0,0 +1,62 @@ +## Utility functions. +class_name Util + + +## Merges an Array of keys and an Array of values into a Dictionary. +static func arrays_to_dict(keys: Array, values: Array) -> Dictionary: + var out := {} + for i in keys.size(): + out[keys[i]] = values[i] + return out + + +## Swaps the X and Y axes of a Vector2i. +static func transpose_vec(v: Vector2i) -> Vector2i: + return Vector2i(v.y, v.x) + + +# TODO: transposed(TileSet.CellNeighbor) -> Tileset.CellNeighbor + + +## Reverses the direction of a CellNeighbor. +static func reverse_neighbor(neighbor: TileSet.CellNeighbor) -> TileSet.CellNeighbor: + return (neighbor + 8) % 16 + + +## Returns a shorthand name for a CellNeighbor. +static func neighbor_name(neighbor: TileSet.CellNeighbor) -> String: + const DIRECTIONS := ['E', 'SE', 'S', 'SW', 'W', 'NW', 'N', 'NE'] + return DIRECTIONS[neighbor >> 1] + + +## Returns a pretty-printable neighborhood. +static func neighborhood_str(neighborhood: Array) -> String: + var neighbors := array_of(-1, 16) + for i in neighborhood.size(): + neighbors[neighborhood[i]] = i + + var get := func(neighbor: TileSet.CellNeighbor) -> String: + var terrain = neighbors[neighbor] + return '-' if terrain == -1 else str(terrain) + + var nw = get.call(TileSet.CELL_NEIGHBOR_TOP_LEFT_CORNER) + var n = get.call(TileSet.CELL_NEIGHBOR_TOP_CORNER) + var ne = get.call(TileSet.CELL_NEIGHBOR_TOP_RIGHT_CORNER) + var w = get.call(TileSet.CELL_NEIGHBOR_LEFT_CORNER) + var e = get.call(TileSet.CELL_NEIGHBOR_RIGHT_CORNER) + var sw = get.call(TileSet.CELL_NEIGHBOR_BOTTOM_LEFT_CORNER) + var s = get.call(TileSet.CELL_NEIGHBOR_BOTTOM_CORNER) + var se = get.call(TileSet.CELL_NEIGHBOR_BOTTOM_RIGHT_CORNER) + + return ( + "%2s %2s %2s\n" % [nw, n, ne] + + "%2s C %2s\n" % [w, e] + + "%2s %2s %2s\n" % [sw, s, se] + ) + +## Returns an Array of the given size, all filled with the given value. +static func array_of(value: Variant, size: int) -> Array[Variant]: + var out := [] + out.resize(size) + out.fill(value) + return out diff --git a/addons/TileMapDual/Util.gd.uid b/addons/TileMapDual/Util.gd.uid new file mode 100644 index 0000000..400f1ff --- /dev/null +++ b/addons/TileMapDual/Util.gd.uid @@ -0,0 +1 @@ +uid://bfbksxcjuwdjt diff --git a/addons/TileMapDual/ghost.gdshader b/addons/TileMapDual/ghost.gdshader new file mode 100644 index 0000000..103a708 --- /dev/null +++ b/addons/TileMapDual/ghost.gdshader @@ -0,0 +1,6 @@ +// A shader that sets all pixels to 0 alpha, making the object invisible. + +shader_type canvas_item; +void fragment() { + COLOR = vec4(0); +} \ No newline at end of file diff --git a/addons/TileMapDual/ghost.gdshader.uid b/addons/TileMapDual/ghost.gdshader.uid new file mode 100644 index 0000000..de92f1a --- /dev/null +++ b/addons/TileMapDual/ghost.gdshader.uid @@ -0,0 +1 @@ +uid://44652e0wv1ve diff --git a/addons/TileMapDual/ghost_material.tres b/addons/TileMapDual/ghost_material.tres new file mode 100644 index 0000000..5efd897 --- /dev/null +++ b/addons/TileMapDual/ghost_material.tres @@ -0,0 +1,6 @@ +[gd_resource type="ShaderMaterial" load_steps=2 format=3 uid="uid://cmbcfxlkxxnwq"] + +[ext_resource type="Shader" uid="uid://44652e0wv1ve" path="res://addons/TileMapDual/ghost.gdshader" id="1_gvngp"] + +[resource] +shader = ExtResource("1_gvngp") diff --git a/addons/TileMapDual/plugin.cfg b/addons/TileMapDual/plugin.cfg new file mode 100644 index 0000000..f1e8fbf --- /dev/null +++ b/addons/TileMapDual/plugin.cfg @@ -0,0 +1,7 @@ +[plugin] + +name="TileMapDual" +description="An automatic, real-time dual-grid tileset system for Godot!" +author="@GilaPixel" +version="5.0.0rc3" +script="plugin.gd" diff --git a/addons/TileMapDual/plugin.gd b/addons/TileMapDual/plugin.gd new file mode 100644 index 0000000..f25643b --- /dev/null +++ b/addons/TileMapDual/plugin.gd @@ -0,0 +1,63 @@ +@tool +class_name TileMapDualEditorPlugin +extends EditorPlugin + +static var instance: TileMapDualEditorPlugin = null + + +# TODO: create a message queue that groups warnings, errors, and messages into categories +# so that we don't get 300 lines of the same warnings pushed to console every time we undo/redo + + +func _enter_tree() -> void: + # assign singleton instance + instance = self + # register custom nodes + add_custom_type("TileMapDual", "TileMapLayer", preload("TileMapDual.gd"), preload("TileMapDual.svg")) + add_custom_type("CursorDual", "Sprite2D", preload("CursorDual.gd"), preload("CursorDual.svg")) + add_custom_type("TileMapDualLegacy", "TileMapLayer", preload("TileMapDualLegacy.gd"), preload("TileMapDual.svg")) + # load editor-only functions + TileMapDual.autotile = autotile + TileMapDual.popup = popup + # finish + print("plugin TileMapDual loaded") + + +func _exit_tree() -> void: + # disable editor-only functions + TileMapDual.popup = TileMapDual._editor_only.bind('popup').unbind(2) + TileMapDual.autotile = TileMapDual._editor_only.bind('autotile').unbind(3) + # remove custom nodes + remove_custom_type("TileMapDualLegacy") + remove_custom_type("CursorDual") + remove_custom_type("TileMapDual") + # unassign singleton instance + instance = null + # finish + print("plugin TileMapDual unloaded") + + +# HACK: functions that reference EditorPlugin, directly or indirectly, cannot be in the publicly exported scripts +# or else they simply won't work when exported + +## Shows a popup with a title bar, a message, and an "Ok" button in the middle of the screen. +func popup(title: String, message: String) -> void: + var popup := AcceptDialog.new() + get_editor_interface().get_base_control().add_child(popup) + popup.name = 'TileMapDualPopup' + popup.title = title + popup.dialog_text = message + popup.popup_centered() + await popup.confirmed + popup.queue_free() + + +## Automatically generate terrains when the atlas is initialized. +func autotile(source_id: int, atlas: TileSetAtlasSource, tile_set: TileSet): + print_stack() + var urm := get_undo_redo() + urm.create_action("Create tiles in non-transparent texture regions", UndoRedo.MergeMode.MERGE_ALL, self, true) + # NOTE: commit_action() is called immediately after. + # NOTE: Atlas is guaranteed to have only been auto-generated with no extra peering bit information. + TerrainPreset.write_default_preset(urm, tile_set, atlas) + urm.commit_action() \ No newline at end of file diff --git a/addons/TileMapDual/plugin.gd.uid b/addons/TileMapDual/plugin.gd.uid new file mode 100644 index 0000000..e18ee30 --- /dev/null +++ b/addons/TileMapDual/plugin.gd.uid @@ -0,0 +1 @@ +uid://irrn0ous8tet diff --git a/assets/textures/1bit 16px icons part-2.png b/assets/textures/1bit 16px icons part-2.png new file mode 100644 index 0000000..945da6e Binary files /dev/null and b/assets/textures/1bit 16px icons part-2.png differ diff --git a/assets/textures/1bit 16px icons part-2.png.import b/assets/textures/1bit 16px icons part-2.png.import new file mode 100644 index 0000000..ffff431 --- /dev/null +++ b/assets/textures/1bit 16px icons part-2.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cfsvkp6w82tgh" +path="res://.godot/imported/1bit 16px icons part-2.png-964581ea1d15a6f7999a5835abf4d89f.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/textures/1bit 16px icons part-2.png" +dest_files=["res://.godot/imported/1bit 16px icons part-2.png-964581ea1d15a6f7999a5835abf4d89f.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/project.godot b/project.godot index bfb721c..6ae5e4d 100644 --- a/project.godot +++ b/project.godot @@ -40,13 +40,13 @@ window/size/mode.release=4 [editor_plugins] -enabled=PackedStringArray("res://addons/dialogue_manager/plugin.cfg") +enabled=PackedStringArray("res://addons/TileMapDual/plugin.cfg", "res://addons/dialogue_manager/plugin.cfg") [gui] -fonts/dynamic_fonts/use_oversampling=false theme/default_font_antialiasing=0 theme/default_font_subpixel_positioning=0 +theme/lcd_subpixel_layout=0 theme/custom_font="uid://s0mghd0bccm0" [input] @@ -110,10 +110,6 @@ common/physics_interpolation=true textures/canvas_textures/default_texture_filter=0 textures/default_filters/use_nearest_mipmap_filter=true -anti_aliasing/quality/msaa_2d=3 -anti_aliasing/quality/screen_space_aa=2 -anti_aliasing/quality/use_taa=true -anti_aliasing/quality/use_debanding=true 2d/snap/snap_2d_transforms_to_pixel=true 2d/snap/snap_2d_vertices_to_pixel=true environment/defaults/default_clear_color.release=Color(0, 0, 0, 1) diff --git a/scenes/menus/main_menu.tscn b/scenes/menus/main_menu.tscn index 0fbadd6..766cc8e 100644 --- a/scenes/menus/main_menu.tscn +++ b/scenes/menus/main_menu.tscn @@ -43,7 +43,6 @@ grow_vertical = 2 [node name="Button" type="Button" parent="TabContainer/Main/VBoxContainer"] layout_mode = 2 -theme_override_font_sizes/font_size = 8 text = "Start" [node name="ProfileCreator" parent="TabContainer" instance=ExtResource("2_ovrgc")] diff --git a/scenes/menus/util/keyboard.tscn b/scenes/menus/util/keyboard.tscn index 8938a06..3096042 100644 --- a/scenes/menus/util/keyboard.tscn +++ b/scenes/menus/util/keyboard.tscn @@ -1,18 +1,40 @@ -[gd_scene load_steps=2 format=3 uid="uid://dn0lr1sbir3d3"] +[gd_scene load_steps=4 format=3 uid="uid://dn0lr1sbir3d3"] [ext_resource type="Script" uid="uid://bxquk4wo56r22" path="res://scripts/menus/util/keyboard.gd" id="1_xrtsm"] +[ext_resource type="Texture2D" uid="uid://cfsvkp6w82tgh" path="res://assets/textures/1bit 16px icons part-2.png" id="2_v4hro"] + +[sub_resource type="AtlasTexture" id="AtlasTexture_l3m3g"] +atlas = ExtResource("2_v4hro") +region = Rect2(296.5, 82, 13, 13) [node name="Keyboard" type="Control"] layout_mode = 3 anchors_preset = 15 anchor_right = 1.0 anchor_bottom = 1.0 +offset_left = 3.0 +offset_top = 1.0 +offset_right = 3.0 +offset_bottom = 1.0 grow_horizontal = 2 grow_vertical = 2 size_flags_horizontal = 4 size_flags_vertical = 4 script = ExtResource("1_xrtsm") +[node name="TextLabel" type="Label" parent="."] +layout_mode = 1 +anchors_preset = -1 +anchor_left = 0.5 +anchor_top = 0.202 +anchor_right = 0.5 +anchor_bottom = 0.202 +offset_left = -125.0 +offset_top = -6.4000015 +offset_right = 126.0 +offset_bottom = 6.5999985 +grow_horizontal = 2 + [node name="MainKeys" type="HFlowContainer" parent="."] custom_minimum_size = Vector2(230, 100) layout_mode = 1 @@ -22,9 +44,9 @@ anchor_top = 0.5 anchor_right = 0.5 anchor_bottom = 0.5 offset_left = -115.0 -offset_top = -50.0 -offset_right = 115.0 -offset_bottom = 50.0 +offset_top = -50.5 +offset_right = 154.0 +offset_bottom = 50.5 grow_horizontal = 2 grow_vertical = 2 @@ -73,6 +95,11 @@ custom_minimum_size = Vector2(22, 22) layout_mode = 2 text = "I" +[node name="UShift" type="Button" parent="MainKeys"] +custom_minimum_size = Vector2(22, 22) +layout_mode = 2 +icon = SubResource("AtlasTexture_l3m3g") + [node name="Button10" type="Button" parent="MainKeys"] custom_minimum_size = Vector2(22, 22) layout_mode = 2 @@ -143,6 +170,11 @@ custom_minimum_size = Vector2(22, 22) layout_mode = 2 text = "W" +[node name="USpace" type="Button" parent="MainKeys"] +custom_minimum_size = Vector2(74, 0) +layout_mode = 2 +text = "Space" + [node name="Button24" type="Button" parent="MainKeys"] custom_minimum_size = Vector2(22, 22) layout_mode = 2 diff --git a/scripts/menus/util/keyboard.gd b/scripts/menus/util/keyboard.gd index e07a0bf..0f62342 100644 --- a/scripts/menus/util/keyboard.gd +++ b/scripts/menus/util/keyboard.gd @@ -1 +1,33 @@ extends Control + +signal text_changed +signal key_pressed + +@onready var main_keys: HFlowContainer = $MainKeys +@onready var text_label: Label = $TextLabel + +@export var text: String = "": + set(value): + text = value + text_label.text = text + text_changed.emit() + +var shifting: bool = true + +func _ready() -> void: + for key: Button in main_keys.get_children(): + key.connect("pressed", func(): + key_pressed.emit() + match key.name: + "UShift": + shifting = !shifting + for i_key: Button in main_keys.get_children(): + if shifting: + i_key.text = i_key.text.to_upper() + else: + i_key.text = i_key.text.to_lower() + "USpace": + text += " " + _: + text += key.text + )