working keyboard and plugin for better tilemaps

This commit is contained in:
2025-10-17 01:00:19 +02:00
parent 69c3e6f396
commit 191235da54
42 changed files with 2274 additions and 11 deletions

View File

@@ -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

View File

@@ -0,0 +1 @@
uid://cfhiw77a85x8w

View File

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

View File

@@ -0,0 +1 @@
uid://c6m630v880okx

View File

@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="16"
height="16"
version="1.1"
id="svg1"
sodipodi:docname="CursorDual.svg"
inkscape:version="1.4 (e7c3feb100, 2024-10-09)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs1" />
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#999999"
borderopacity="1"
inkscape:showpageshadow="2"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="35.5"
inkscape:cx="9.0845069"
inkscape:cy="8.112676"
inkscape:window-width="1366"
inkscape:window-height="736"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg1" />
<path
d="M 8.9882813,2.0703125 V 4.046875 h 2.9648437 v 2.9648437 h 1.976563 V 2.0703125 Z"
style="fill:#ffb273;stroke:#000000;stroke-width:0;stroke-linecap:round;stroke-linejoin:round"
id="path10" />
<path
d="m 8.9882813,11.953125 v 1.976563 H 13.929688 V 8.9882813 h -1.976563 v 2.9648437 z"
style="fill:#ffb273;stroke:#000000;stroke-width:0;stroke-linecap:round;stroke-linejoin:round"
id="path9" />
<path
d="M 7.0117187,13.929688 V 11.953125 H 4.046875 V 8.9882813 H 2.0703125 v 4.9414067 z"
style="fill:#ffb273;stroke:#000000;stroke-width:0;stroke-linecap:round;stroke-linejoin:round"
id="path8" />
<path
d="M 2.0703125,7.0117187 H 4.046875 V 4.046875 H 7.0117187 V 2.0703125 H 2.0703125 Z"
style="fill:#ffb273;stroke:#000000;stroke-width:0;stroke-linecap:round;stroke-linejoin:round"
id="path7" />
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -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

View File

@@ -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),
},
],
}

View File

@@ -0,0 +1 @@
uid://cacp8xe0jaxln

View File

@@ -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

View File

@@ -0,0 +1 @@
uid://bon5inags0mqy

76
addons/TileMapDual/Set.gd Normal file
View File

@@ -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

View File

@@ -0,0 +1 @@
uid://ds8j47h4vi6gx

View File

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

View File

@@ -0,0 +1 @@
uid://qa8xnqphuk68

View File

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

View File

@@ -0,0 +1 @@
uid://ks3fxwahkm6j

View File

@@ -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, "<any>")
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))

View File

@@ -0,0 +1 @@
uid://cdsxbtu57wews

View File

@@ -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

View File

@@ -0,0 +1 @@
uid://dpuc70ypq7wf8

View File

@@ -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

View File

@@ -0,0 +1 @@
uid://cjk8nronimk5r

View File

@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="16"
height="16"
version="1.1"
id="svg1"
sodipodi:docname="TileMapDual.svg"
inkscape:version="1.4 (e7c3feb100, 2024-10-09)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs1" />
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#999999"
borderopacity="1"
inkscape:showpageshadow="2"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="35.5"
inkscape:cx="8"
inkscape:cy="8.028169"
inkscape:window-width="1366"
inkscape:window-height="736"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg1" />
<path
fill="#8da5f3"
d="M8 2 6.25 3.375 8 4.75l1.75-1.375zm2.624 2.062-1.75 1.375 1.75 1.375 1.75-1.375Zm2.626 2.063L11.5 7.5l1.75 1.375L15 7.5ZM5.376 4.062l-1.75 1.375 1.75 1.375 1.75-1.375zM8 6.124 6.25 7.499 8 8.874l1.75-1.375zm2.626 2.063-1.75 1.375 1.75 1.375 1.75-1.375ZM2.75 6.125 1 7.5l1.75 1.375L4.5 7.5Zm2.624 2.062-1.75 1.375 1.75 1.375 1.75-1.375ZM8 10.25l-1.75 1.375L8 13l1.75-1.375z"
id="path1"
style="fill:#ffb273;fill-opacity:1" />
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -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

View File

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

View File

@@ -0,0 +1 @@
uid://dlyqj0u8ckhb0

View File

@@ -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

View File

@@ -0,0 +1 @@
uid://bjmo6oy8a4k2k

View File

@@ -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

View File

@@ -0,0 +1 @@
uid://bfbksxcjuwdjt

View File

@@ -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);
}

View File

@@ -0,0 +1 @@
uid://44652e0wv1ve

View File

@@ -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")

View File

@@ -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"

View File

@@ -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()

View File

@@ -0,0 +1 @@
uid://irrn0ous8tet

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

View File

@@ -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

View File

@@ -40,13 +40,13 @@ window/size/mode.release=4
[editor_plugins] [editor_plugins]
enabled=PackedStringArray("res://addons/dialogue_manager/plugin.cfg") enabled=PackedStringArray("res://addons/TileMapDual/plugin.cfg", "res://addons/dialogue_manager/plugin.cfg")
[gui] [gui]
fonts/dynamic_fonts/use_oversampling=false
theme/default_font_antialiasing=0 theme/default_font_antialiasing=0
theme/default_font_subpixel_positioning=0 theme/default_font_subpixel_positioning=0
theme/lcd_subpixel_layout=0
theme/custom_font="uid://s0mghd0bccm0" theme/custom_font="uid://s0mghd0bccm0"
[input] [input]
@@ -110,10 +110,6 @@ common/physics_interpolation=true
textures/canvas_textures/default_texture_filter=0 textures/canvas_textures/default_texture_filter=0
textures/default_filters/use_nearest_mipmap_filter=true 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_transforms_to_pixel=true
2d/snap/snap_2d_vertices_to_pixel=true 2d/snap/snap_2d_vertices_to_pixel=true
environment/defaults/default_clear_color.release=Color(0, 0, 0, 1) environment/defaults/default_clear_color.release=Color(0, 0, 0, 1)

View File

@@ -43,7 +43,6 @@ grow_vertical = 2
[node name="Button" type="Button" parent="TabContainer/Main/VBoxContainer"] [node name="Button" type="Button" parent="TabContainer/Main/VBoxContainer"]
layout_mode = 2 layout_mode = 2
theme_override_font_sizes/font_size = 8
text = "Start" text = "Start"
[node name="ProfileCreator" parent="TabContainer" instance=ExtResource("2_ovrgc")] [node name="ProfileCreator" parent="TabContainer" instance=ExtResource("2_ovrgc")]

View File

@@ -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="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"] [node name="Keyboard" type="Control"]
layout_mode = 3 layout_mode = 3
anchors_preset = 15 anchors_preset = 15
anchor_right = 1.0 anchor_right = 1.0
anchor_bottom = 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_horizontal = 2
grow_vertical = 2 grow_vertical = 2
size_flags_horizontal = 4 size_flags_horizontal = 4
size_flags_vertical = 4 size_flags_vertical = 4
script = ExtResource("1_xrtsm") 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="."] [node name="MainKeys" type="HFlowContainer" parent="."]
custom_minimum_size = Vector2(230, 100) custom_minimum_size = Vector2(230, 100)
layout_mode = 1 layout_mode = 1
@@ -22,9 +44,9 @@ anchor_top = 0.5
anchor_right = 0.5 anchor_right = 0.5
anchor_bottom = 0.5 anchor_bottom = 0.5
offset_left = -115.0 offset_left = -115.0
offset_top = -50.0 offset_top = -50.5
offset_right = 115.0 offset_right = 154.0
offset_bottom = 50.0 offset_bottom = 50.5
grow_horizontal = 2 grow_horizontal = 2
grow_vertical = 2 grow_vertical = 2
@@ -73,6 +95,11 @@ custom_minimum_size = Vector2(22, 22)
layout_mode = 2 layout_mode = 2
text = "I" 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"] [node name="Button10" type="Button" parent="MainKeys"]
custom_minimum_size = Vector2(22, 22) custom_minimum_size = Vector2(22, 22)
layout_mode = 2 layout_mode = 2
@@ -143,6 +170,11 @@ custom_minimum_size = Vector2(22, 22)
layout_mode = 2 layout_mode = 2
text = "W" 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"] [node name="Button24" type="Button" parent="MainKeys"]
custom_minimum_size = Vector2(22, 22) custom_minimum_size = Vector2(22, 22)
layout_mode = 2 layout_mode = 2

View File

@@ -1 +1,33 @@
extends Control 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
)