424 lines
19 KiB
GDScript
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"]
|