Files
project-hood/addons/TileMapDual/TileMapDual.gd

161 lines
5.9 KiB
GDScript

@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