From 191235da5456fe7268f844ad28a470bdd0adbfaa Mon Sep 17 00:00:00 2001 From: vaporvee Date: Fri, 17 Oct 2025 01:00:19 +0200 Subject: [PATCH] working keyboard and plugin for better tilemaps --- addons/TileMapDual/AtlasWatcher.gd | 70 ++++ addons/TileMapDual/AtlasWatcher.gd.uid | 1 + addons/TileMapDual/CursorDual.gd | 41 +++ addons/TileMapDual/CursorDual.gd.uid | 1 + addons/TileMapDual/CursorDual.svg | 49 +++ addons/TileMapDual/CursorDual.svg.import | 43 +++ addons/TileMapDual/Display.gd | 151 ++++++++ addons/TileMapDual/Display.gd.uid | 1 + addons/TileMapDual/DisplayLayer.gd | 102 ++++++ addons/TileMapDual/DisplayLayer.gd.uid | 1 + addons/TileMapDual/Set.gd | 76 ++++ addons/TileMapDual/Set.gd.uid | 1 + addons/TileMapDual/TerrainDual.gd | 194 ++++++++++ addons/TileMapDual/TerrainDual.gd.uid | 1 + addons/TileMapDual/TerrainLayer.gd | 128 +++++++ addons/TileMapDual/TerrainLayer.gd.uid | 1 + addons/TileMapDual/TerrainPreset.gd | 322 +++++++++++++++++ addons/TileMapDual/TerrainPreset.gd.uid | 1 + addons/TileMapDual/TileCache.gd | 74 ++++ addons/TileMapDual/TileCache.gd.uid | 1 + addons/TileMapDual/TileMapDual.gd | 160 +++++++++ addons/TileMapDual/TileMapDual.gd.uid | 1 + addons/TileMapDual/TileMapDual.svg | 38 ++ addons/TileMapDual/TileMapDual.svg.import | 43 +++ addons/TileMapDual/TileMapDualLegacy.gd | 333 ++++++++++++++++++ addons/TileMapDual/TileMapDualLegacy.gd.uid | 1 + addons/TileMapDual/TileSetWatcher.gd | 181 ++++++++++ addons/TileMapDual/TileSetWatcher.gd.uid | 1 + addons/TileMapDual/Util.gd | 62 ++++ addons/TileMapDual/Util.gd.uid | 1 + addons/TileMapDual/ghost.gdshader | 6 + addons/TileMapDual/ghost.gdshader.uid | 1 + addons/TileMapDual/ghost_material.tres | 6 + addons/TileMapDual/plugin.cfg | 7 + addons/TileMapDual/plugin.gd | 63 ++++ addons/TileMapDual/plugin.gd.uid | 1 + assets/textures/1bit 16px icons part-2.png | Bin 0 -> 46465 bytes .../1bit 16px icons part-2.png.import | 40 +++ project.godot | 8 +- scenes/menus/main_menu.tscn | 1 - scenes/menus/util/keyboard.tscn | 40 ++- scripts/menus/util/keyboard.gd | 32 ++ 42 files changed, 2274 insertions(+), 11 deletions(-) create mode 100644 addons/TileMapDual/AtlasWatcher.gd create mode 100644 addons/TileMapDual/AtlasWatcher.gd.uid create mode 100644 addons/TileMapDual/CursorDual.gd create mode 100644 addons/TileMapDual/CursorDual.gd.uid create mode 100644 addons/TileMapDual/CursorDual.svg create mode 100644 addons/TileMapDual/CursorDual.svg.import create mode 100644 addons/TileMapDual/Display.gd create mode 100644 addons/TileMapDual/Display.gd.uid create mode 100644 addons/TileMapDual/DisplayLayer.gd create mode 100644 addons/TileMapDual/DisplayLayer.gd.uid create mode 100644 addons/TileMapDual/Set.gd create mode 100644 addons/TileMapDual/Set.gd.uid create mode 100644 addons/TileMapDual/TerrainDual.gd create mode 100644 addons/TileMapDual/TerrainDual.gd.uid create mode 100644 addons/TileMapDual/TerrainLayer.gd create mode 100644 addons/TileMapDual/TerrainLayer.gd.uid create mode 100644 addons/TileMapDual/TerrainPreset.gd create mode 100644 addons/TileMapDual/TerrainPreset.gd.uid create mode 100644 addons/TileMapDual/TileCache.gd create mode 100644 addons/TileMapDual/TileCache.gd.uid create mode 100644 addons/TileMapDual/TileMapDual.gd create mode 100644 addons/TileMapDual/TileMapDual.gd.uid create mode 100644 addons/TileMapDual/TileMapDual.svg create mode 100644 addons/TileMapDual/TileMapDual.svg.import create mode 100644 addons/TileMapDual/TileMapDualLegacy.gd create mode 100644 addons/TileMapDual/TileMapDualLegacy.gd.uid create mode 100644 addons/TileMapDual/TileSetWatcher.gd create mode 100644 addons/TileMapDual/TileSetWatcher.gd.uid create mode 100644 addons/TileMapDual/Util.gd create mode 100644 addons/TileMapDual/Util.gd.uid create mode 100644 addons/TileMapDual/ghost.gdshader create mode 100644 addons/TileMapDual/ghost.gdshader.uid create mode 100644 addons/TileMapDual/ghost_material.tres create mode 100644 addons/TileMapDual/plugin.cfg create mode 100644 addons/TileMapDual/plugin.gd create mode 100644 addons/TileMapDual/plugin.gd.uid create mode 100644 assets/textures/1bit 16px icons part-2.png create mode 100644 assets/textures/1bit 16px icons part-2.png.import 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 0000000000000000000000000000000000000000..945da6ec3f7d49d42b157621d2acb2a8a298bcc5 GIT binary patch literal 46465 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 literal 0 HcmV?d00001 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 + )