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
+ )