Files
GameJamDungeon/addons/SimpleDungeons/DungeonRoom3D.gd

424 lines
19 KiB
GDScript

@tool
class_name DungeonRoom3D
extends Node3D
signal dungeon_done_generating()
var dungeon_generator : DungeonGenerator3D :
get:
if is_inside_tree() and get_parent() is DungeonGenerator3D:
return get_parent()
else: return dungeon_generator
@export var size_in_voxels := Vector3i(1,1,1) :
set(v):
size_in_voxels = v.clamp(Vector3i(1,1,1),Vector3i(9999,9999,9999))
@export var voxel_scale := Vector3(10,10,10) :
set(v):
voxel_scale = v.clamp(Vector3(0.0001,0.0001,0.0001),Vector3(9999,9999,9999))
@export var min_count : int = 2
@export var max_count : int = 5
## Stair rooms are used in the floor connection stage and will have their min/max counts ignored.
## For the dungeon to generate, you must mark at least 1 room as a stair and have its doors span multiple floors.
@export var is_stair_room := false
## Preplaced rooms are immovable rooms you can place at a preset position in the dungeon
@export_group("Pre-placed room options")
## Editor button to align the room's position & scale with the dungeon's voxel grid.
## Will be called before generation to ensure room is aligned with grid.
@export var force_align_with_grid_button := false :
set(_v):
virtual_transform = self.transform
snap_room_to_dungeon_grid()
func _validate_property(property: Dictionary):
if property.name in ["force_align_with_grid_button"] and not get_parent() is DungeonGenerator3D:
property.usage = PROPERTY_USAGE_NO_EDITOR
@export_group("Debug view")
@export var show_debug_in_editor : bool = true
@export var show_debug_in_game : bool = false
## For internal debug use only
@export var show_grid_aabb_with_doors : bool = false
var was_preplaced = false :
get:
if Engine.is_editor_hint():
return is_inside_tree() and get_parent() is DungeonGenerator3D
else: return was_preplaced
## Number of 90 degree rotations around the y axis
var room_rotations : int :
set(v): virtual_transform.basis = Basis.from_euler(Vector3(0,wrapi(v, 0, 4) * deg_to_rad(90.0),0)).scaled(virtual_transform.basis.get_scale())
get: return round(wrapi(round(virtual_transform.basis.get_euler().y / deg_to_rad(90.0)), 0, 4))
# For performance, we should not spawn/instantiate many dungeon rooms.
# This is because the dungeon generator may have to restart multiple times.
# We don't want to have to delete and restart all nodes/models/etc we instantiated
# Also, we cannot access the scene tree from other threads.
# So instead, we will create virtual DungeonRoom3Ds which reference the single original we create.
# Then, we can still encapsulate all the logic into this one class & improve performance.
var virtualized_from : DungeonRoom3D = null
# To be called instead of self on anything that requires transform or node access
# I think just in getting door nodes is the only part we need this.
var virtual_self : DungeonRoom3D = self :
get: return virtualized_from if virtualized_from else self
# Cannot access the transform in threads at all so just saving it here and copying on unvirtualize.
var virtual_transform : Transform3D = Transform3D() :
set(v):
virtual_transform = v
if is_inside_tree() and OS.get_main_thread_id() == OS.get_thread_caller_id():
self.transform = v
var original_ready_func_called := false # For error checking. Ensure noone inherits this class w/o calling _ready.
func _ready():
original_ready_func_called = true
if not virtualized_from:
add_debug_view_if_not_exist()
# When spawning a room, make sure to set its transform to whatever its virtual/out of tree transform was set to.
if virtual_transform != Transform3D():
self.transform = virtual_transform
elif self.transform != Transform3D():
# For preplaced rooms:
virtual_transform = self.transform
# Mostly for debug and pre placed rooms:
if get_parent() is Node3D:
if get_parent() is DungeonGenerator3D:
dungeon_generator = get_parent()
if get_parent().get_parent() is DungeonGenerator3D:
dungeon_generator = get_parent().get_parent()
if Engine.is_editor_hint():
return
func _process(delta):
if Engine.is_editor_hint():
return
const _dungeon_room_export_props_names = ["size_in_voxels", "voxel_scale", "min_count", "max_count", "is_stair_room", "show_debug_in_editor", "show_debug_in_game", "show_grid_aabb_with_doors"]
func copy_all_props(from : DungeonRoom3D, to : DungeonRoom3D) -> void:
for prop in _dungeon_room_export_props_names:
if from.get(prop) != to.get(prop):
to.set(prop, from.get(prop))
to.name = from.name
to.dungeon_generator = from.dungeon_generator
func get_original_packed_scene() -> PackedScene:
# Try to find the room packed scene in the DungeonGenerator3D so we don't have to load()
if dungeon_generator:
if dungeon_generator.corridor_room_scene.resource_path == virtual_self.scene_file_path:
return dungeon_generator.corridor_room_scene
for scene in dungeon_generator.room_scenes:
if scene.resource_path == virtual_self.scene_file_path:
return scene
# Fall back to just getting it from the scene path just incase
if virtual_self.scene_file_path:
return load(virtual_self.scene_file_path)
printerr(self.name+" Could not find DungeonRoom3D's original packed scene. This shouldn't happen. Are you manually spawning rooms?")
return null
func create_clone_and_make_virtual_unless_visualizing() -> DungeonRoom3D:
var make_clone_virtual : bool = true
if dungeon_generator and dungeon_generator.visualize_generation_progress:
make_clone_virtual = false
var _clone
if make_clone_virtual:
#if not self.virtual_self._doors_cache:
# printerr("Cloning dungeon room without doors cached!!! Make sure to call .get_doors() at least once for all rooms.")
_clone = DungeonRoom3D.new()
_clone.virtualized_from = self.virtual_self
# Can't access door nodes on threads, also if it's a clone, won't have any door nodes to check door pos/dir.
#_clone._doors_cache = []
for d in _doors_cache:
_clone._doors_cache.push_back(Door.new(d.local_pos, d.dir, d.optional, _clone, d.door_node))
else: _clone = get_original_packed_scene().instantiate()
copy_all_props(virtual_self, _clone)
_clone.dungeon_generator = self.dungeon_generator
var name = virtual_self.name
if dungeon_generator:
name = name + "_" + str(len(dungeon_generator._rooms_placed))
_clone.name = name
return _clone
# Spawn the real room into the DungeonGenerator
func unvirtualize_and_free_clone_if_needed(into_parent : Node3D) -> DungeonRoom3D:
if not virtualized_from:
#if self.get_parent() != into_parent:
#if self.get_parent() != null:
#self.get_parent().remove_child(self)
#into_parent.add_child(self)
return self
# Can't do this threaded anyway for virtualized so it's implied there will be no parent
#var parent = self.get_parent()
#parent.remove_child(self)
self.queue_free()
var inst : DungeonRoom3D = get_original_packed_scene().instantiate()
copy_all_props(self, inst)
inst.transform = self.virtual_transform
into_parent.add_child(inst)
inst.owner = into_parent.owner
return inst
var _debug_view = null
func add_debug_view_if_not_exist():
if not _debug_view:
_debug_view = preload("res://addons/SimpleDungeons/debug_visuals/DungeonRoom3DDebugView.gd").new()
add_child(_debug_view)
###########
## DOORS ##
###########
## A class that represent a door on a dungeon room
## TODO probably remove this 'dir' variable as it is just confusing now that we have rotations
class Door:
var local_pos : Vector3i
var grid_pos : Vector3i :
get: return room.local_grid_pos_to_dungeon_grid_pos(local_pos)
var exit_pos_local : Vector3i :
get: return local_pos + Vector3i(DungeonUtils.DIRECTION_VECTORS[dir])
var exit_pos_grid : Vector3i :
get: return room.local_grid_pos_to_dungeon_grid_pos(exit_pos_local)
var dir : DungeonUtils.Direction
var optional : bool
var room : DungeonRoom3D
var door_node : Node3D
func _init(local_pos : Vector3, dir : DungeonUtils.Direction, optional : bool, room : DungeonRoom3D, door_node : Node3D):
self.local_pos = Vector3i(local_pos.round())
self.dir = dir
self.optional = optional
self.room = room
self.door_node = door_node
func fits_other_door(other_room_door : Door) -> bool:
return other_room_door.exit_pos_grid == grid_pos and other_room_door.grid_pos == exit_pos_grid
func find_duplicates() -> Array:
return room.get_doors().filter(func (d : Door): return d.exit_pos_local == exit_pos_local and d.local_pos == local_pos)
func validate_door() -> bool:
if not AABBi.new(Vector3i(), room.size_in_voxels).contains_point(local_pos):
return false
if AABBi.new(Vector3i(), room.size_in_voxels).contains_point(exit_pos_local):
return false
if find_duplicates().size() > 1:
return false
return true
func get_room_leads_to() -> DungeonRoom3D:
var other_room = room.dungeon_generator.get_room_at_pos(exit_pos_grid)
if other_room == null: return null
for door in other_room.get_doors():
if fits_other_door(door):
return other_room
return null
func get_door_nodes() -> Array[Node3D]:
var doors : Array[Node3D] = [] # .assign typecast workaround https://github.com/godotengine/godot/issues/72566
doors.assign(virtual_self.find_children("DOOR*", "Node3D"))
return doors
func get_door_by_node(node : Node) -> Door:
for door in get_doors():
if door.door_node == node:
return door
return null
# For calling on other threads/for virtualized rooms
func get_doors_cached() -> Array:
return self._doors_cache
func ensure_doors_and_or_transform_cached_for_threads_and_virtualized_rooms() -> void:
if is_inside_tree(): # transform cache only applies to preplaced rooms
virtual_transform = self.transform
get_doors()
# For some reason this mutex is required or I get crashes all over the place on threads.
# I never access _doors_cache from main thread so maybe it's overly sensitive thread guards.
var _thread_fix_mutex := Mutex.new()
var _doors_cache : Array = [] :
set(v):
_thread_fix_mutex.lock()
_doors_cache = v
_thread_fix_mutex.unlock()
get:
_thread_fix_mutex.lock()
var tmp = _doors_cache
_thread_fix_mutex.unlock()
return tmp
func get_doors() -> Array:
if OS.get_thread_caller_id() != OS.get_main_thread_id() or virtualized_from != null:
# Ensure using get_doors_cached() when dealing with virtual rooms/threads.
return _doors_cache
var real_aabb_local = get_local_aabb()
var room_doors = []
for door in get_door_nodes():
# Get door pos from min corner of aabb, then divide by the full aabb size.
var door_pos_pct_across = (get_transform_rel_to(door, self).origin - real_aabb_local.position) / real_aabb_local.size
# Snap door pos to the grid square it's in
var door_pos_grid = (door_pos_pct_across * Vector3(size_in_voxels)).floor()
door_pos_grid = door_pos_grid.clamp(Vector3(0,0,0), Vector3(size_in_voxels) - Vector3(1,1,1))
var grid_center_pct_across = (door_pos_grid + Vector3(0.5,0.5,0.5)) / Vector3(size_in_voxels)
# Find the door direction by the its vector from the grid square's center point
var door_dir := DungeonUtils.vec3_to_direction(door_pos_pct_across - grid_center_pct_across)
var door_obj := Door.new(door_pos_grid, door_dir, door.name.begins_with("DOOR?"), self, door)
room_doors.push_back(door_obj)
_doors_cache = room_doors
return room_doors
#######################
## UTILITY FUNCTIONS ##
#######################
func push_away_from_and_stay_within_bounds(other_room : DungeonRoom3D) -> void:
var diff := other_room.virtual_transform.origin - self.virtual_transform.origin
var move := Vector3i(
-1 if diff.x > 0 else 1,
0,
-1 if diff.z > 0 else 1)
var dpos = get_grid_aabbi(true)
var able_to_move = dpos.translated(move).push_within(dungeon_generator.get_grid_aabbi(), true).position - dpos.position
if able_to_move.x != 0 or able_to_move.z != 0:
set_position_by_grid_pos(get_grid_pos() + able_to_move)
func overlaps_room(other_room : DungeonRoom3D) -> bool:
var aabbis = { self: self.get_grid_aabbi(false), other_room: other_room.get_grid_aabbi(false) }
if aabbis[self].intersects(aabbis[other_room]): return true
# Separate with a margin for doors, but allow if 2 opposing doors fit together
var door_intersects = (func(door : Door, room : DungeonRoom3D):
# Optional doors can intersect but not if stair room
if door.optional and not door.room.is_stair_room: return false
if not aabbis[room].contains_point(door.exit_pos_grid): return false
return not room.get_doors().any(func(_d): return _d.fits_other_door(door)))
if other_room.get_doors().any(door_intersects.bind(self)): return true
if get_doors().any(door_intersects.bind(other_room)): return true
return false
func snap_room_to_dungeon_grid() -> void:
if not dungeon_generator:
return
snap_rotation_and_scale_to_dungeon_grid()
set_position_by_grid_pos()
constrain_room_to_bounds_with_doors()
func constrain_room_to_bounds_with_doors():
# For stair rooms, also ensure optional doors can be reached. So stairs don't get their path blocked against wall
var aabbi_with_doors := get_grid_aabbi(true)
var aabbi_with_doors_constrained := aabbi_with_doors.push_within(dungeon_generator.get_grid_aabbi(), false)
set_position_by_grid_pos(get_grid_pos() + (aabbi_with_doors_constrained.position - aabbi_with_doors.position))
## Room must be scaled so voxel scale matches the DungeonGenerator3D's voxel scale.
func snap_rotation_and_scale_to_dungeon_grid() -> void:
virtual_transform = Transform3D(Basis().rotated(Vector3(0,1,0), self.room_rotations * deg_to_rad(90.0)).scaled(dungeon_generator.voxel_scale / voxel_scale), virtual_transform.origin)
## Returns room pos from corner (min) of AABB on dungeon grid
func get_grid_pos() -> Vector3i:
return get_grid_aabbi(false).position
## Set position of room from corner (min) of AABB on dungeon grid
func set_position_by_grid_pos(new_grid_pos : Vector3i = get_grid_pos()) -> void:
if not dungeon_generator: printerr("set_position_by_grid_pos: No dungeon_generator set on DungeonRoom3D")
var cur_aabb := xform_aabb(get_local_aabb(), get_xform_to(SPACE.LOCAL_SPACE, SPACE.DUNGEON_GRID))
cur_aabb.position = Vector3(new_grid_pos)
cur_aabb = xform_aabb(cur_aabb, get_xform_to(SPACE.DUNGEON_GRID, SPACE.DUNGEON_SPACE))
virtual_transform.origin = cur_aabb.get_center()
# Probably a good idea to have this in. Hopping between local/dungeon space, grid, and editor positions
enum SPACE { LOCAL_GRID = 0, LOCAL_SPACE = 1, DUNGEON_SPACE = 2, DUNGEON_GRID = 3 }
func get_xform_to(from : SPACE, to : SPACE) -> Transform3D:
var t = Transform3D()
var inv := to < from
if inv:
var tmp = to; to = from; from = tmp
if from <= SPACE.LOCAL_GRID and to >= SPACE.LOCAL_SPACE:
t = Transform3D(Basis().scaled(voxel_scale), self.get_local_aabb().position) * t
if from <= SPACE.LOCAL_SPACE and to >= SPACE.DUNGEON_SPACE:
t = virtual_transform * t
if from <= SPACE.DUNGEON_SPACE and to >= SPACE.DUNGEON_GRID and dungeon_generator:
t = Transform3D(Basis().scaled(dungeon_generator.voxel_scale.inverse()), Vector3(dungeon_generator.dungeon_size)/2.0) * t
return t.affine_inverse() if inv else t
## Different behavior than Godot's transform * AABB. This properly scales too. Regular just does position & rotation.
func xform_aabb(aabb : AABB, xform : Transform3D) -> AABB:
var pos := xform * aabb.position
var end := xform * aabb.end
return AABB(pos, end - pos).abs()
func xform_aabbi(aabbi : AABBi, xform : Transform3D) -> AABBi:
return AABBi.from_AABB_rounded(xform_aabb(aabbi.to_AABB(), xform))
func get_local_aabb() -> AABB:
var size := Vector3(size_in_voxels) * voxel_scale
return AABB(-size/2.0, size)
func get_grid_aabbi(include_doors : bool) -> AABBi:
# For stair rooms, all doors are counted for AABB. Generally even if they are optional, want to guarantee able to walk out of them to connect floors
var grid_aabbi = AABBi.from_AABB_rounded(xform_aabb(get_local_aabb(), get_xform_to(SPACE.LOCAL_SPACE, SPACE.DUNGEON_GRID)))
if include_doors: # Include doors after to keep position the same
for door in get_doors().filter(func(d : Door): return !d.optional or self.is_stair_room):
grid_aabbi = grid_aabbi.expand_to_include(door.exit_pos_grid)
return grid_aabbi
func local_grid_pos_to_dungeon_grid_pos(local_pos : Vector3i) -> Vector3i:
# It will be rounded in wrong direction when rotated 180 going to dungeon space.
# So use middle of local grid and .floor() instead of .round() at end.
var transformed_middle := get_xform_to(SPACE.LOCAL_GRID, SPACE.DUNGEON_GRID) * (Vector3(local_pos) + Vector3(0.5,0.5,0.5))
return Vector3i(transformed_middle.floor())
# Can't use global_transform when it's not actually in the scene tree.
# Just using this for doors.
func get_transform_rel_to(child_node : Node3D, parent_node : Node3D) -> Transform3D:
var transform = Transform3D()
while child_node != parent_node:
transform = child_node.transform * transform
if child_node.get_parent() is Node3D: child_node = child_node.get_parent()
else: break
return transform
################
## VALIDATION ##
################
# printerr() and push_warning() eat my outputs a lot. Regular prints are more reliable.
func _printerr(str : String, str2 : String = "", str3 : String = "", str4 : String = ""):
print_rich("[color=#FF3531]"+(str+str2+str3+str4)+"[/color]")
func _printwarning(str : String, str2 : String = "", str3 : String = "", str4 : String = ""):
print_rich("[color=#FFF831]"+(str+str2+str3+str4)+"[/color]")
# Returns true if no errors found before generating.
# Calls callbacks with warning/error string if any.
func validate_room(error_callback = null, warning_callback = null) -> bool:
if not warning_callback is Callable: warning_callback = (func(str): _printwarning(str))
if not error_callback is Callable: error_callback = (func(str): _printerr(str))
var any_errors := {"err": false} # So lambda closure captures
error_callback = func(str): any_errors["err"] = true; error_callback.call(str)
var doors = get_doors()
if doors.size() == 0:
warning_callback.call("Room "+self.name+" has no doors defined. Room will be unreachable. Add doors by creating Node3Ds with names prefixed with DOOR or DOOR? for optional doors.")
if not doors.all(func(d : Door): return d.validate_door()):
error_callback.call("Room "+self.name+" has one or more invalid doors.")
if doors.any(func(d : Door): return d.find_duplicates().size() > 1):
error_callback.call("Room "+self.name+" has one or more overlapping/duplicate doors.")
var unique_door_y = doors.reduce(func(acc : Dictionary, d : Door):
acc[d.local_pos.y] = true
return acc, {})
if is_stair_room and unique_door_y.keys().size() < 2:
error_callback.call("Room "+self.name+" is set as is_stair_room but does not have doors leading to 2 or more floors.")
# Post instantiate/place checks:
if not dungeon_generator:
return not any_errors["err"]
if not self.scale.is_equal_approx(Vector3(1,1,1)):
warning_callback.call("Room "+self.name+"'s root node scale should be set to Vector3(1,1,1). Will be scaled during generation.")
if voxel_scale != dungeon_generator.voxel_scale:
warning_callback.call("Room "+self.name+"'s voxel scale does not match DungeonGenerator3D voxel size. Room will be scaled to match.")
if size_in_voxels != size_in_voxels.clamp(Vector3i(0,0,0), dungeon_generator.dungeon_size):
error_callback.call("Room "+self.name+" is too big for the DungeonGenerator3D!")
return not any_errors["err"]