@tool class_name ProceduralBridge extends Node3D ############################## ## EXPORT VARIABLES ############################## @export var physics_server: bool = false: set(value): physics_server = value PhysicsServer3D.set_active(value) @export var bridge_length: float = 0.0: set(value): bridge_length = value _update_supports() @export var bridge_path_length: float = 0.0: set(value): bridge_path_length = value _update_path() @export var max_path_gap: float = 0.0: set(value): max_path_gap = value _update_path() @export var support_gap: float = 1.0: set(value): support_gap = value _update_support_transforms() @export var support_y_offset: float = 0.0: set(value): support_y_offset = value _update_support_transforms() @export_category("Control Nodes") @export var length_control: Node3D @export var support_control: Marker3D @export_category("Resources") @export var catwalk_material: Material @export var path_plank_meshes: Array[ArrayMesh] @export var support_plank_meshes: Array[ArrayMesh] @export var sfx_plank_add: AudioStream @export var sfx_plank_remove: AudioStream ############################## ## VARIABLES ############################## var support_plank_length: float var path_container: Node3D var support_container: Node3D var current_path_length: float = 0.0 var last_sfx_plank_add_time: float = 0.0 var last_sfx_plank_remove_time: float = 0.0 # { "action": "add"/"remove", "plank": MeshInstance3D, "index": int } var tween_queue: Array[Dictionary] var tween_timer: float = 0.0 ############################## ## LIFECYCLE METHODS ############################## func _ready(): _ensure_containers() func _process(delta): if length_control: bridge_path_length = -length_control.position.z if support_control: support_gap = abs(support_control.position.x) * 2.0 support_y_offset = support_control.position.y _check_queue(delta) func _physics_process(delta): if Engine.get_physics_frames() % 4 == 0: _check_bridge_gaps() ############################## ## PRIVATE METHODS ############################## func _check_queue(delta: float): # Accumulate time tween_timer += delta # When enough time has passed and there is an action waiting, dequeue one and execute it var queue_size = tween_queue.size() if queue_size == 0: return var next = tween_queue.front() var timer_interval = remap(queue_size, 0, 20, 0.03, 0.008) if next.action == "add": timer_interval = 0.0 if tween_timer >= timer_interval: tween_timer = 0.0 var entry = tween_queue.pop_front() match entry.action: "add": var index = entry.index var add_delay = lerp(0.05, 0.3, index / 10.0) _add_plank_tween(entry.plank, add_delay) "remove": _remove_plank_tween(entry.plank) func _check_bridge_gaps(): _ensure_path_container() var collision_shapes: Array[CollisionShape3D] var last_shape: Shape3D = null for child in get_children(): if child is CollisionShape3D: if child.shape == last_shape: child.shape = last_shape.duplicate() collision_shapes.append(child as CollisionShape3D) last_shape = child.shape for plank in path_container.get_children(): plank = plank as MeshInstance3D if not plank.mesh: continue var plank_aabb = plank.mesh.get_aabb() var plank_visible = true for col in collision_shapes: # check if plank is within collision shape var shape = col.shape as BoxShape3D var plank_shape_aabb = col.global_transform.affine_inverse() * plank.global_transform * plank_aabb var shape_aabb = shape.get_debug_mesh().get_aabb() if shape_aabb.intersects(plank_shape_aabb): plank_visible = false break plank.visible = plank_visible func _queue_tween(action: String, plank: MeshInstance3D): var action_count = 0 for entry in tween_queue: if entry.action == action: action_count += 1 plank.set_meta("queued_action", action) tween_queue.append({"action": action, "plank": plank, "index": action_count}) func _add_plank_tween(plank: MeshInstance3D, delay: float = 0.0): plank.transparency = 0.0 plank.position.y = 0.6 await G.wait(delay) if G.get_time() - last_sfx_plank_add_time > 0.1: G.wait(0.075).connect(func(): if plank.visible: G.play_sound_at(sfx_plank_add, plank.global_position) ) last_sfx_plank_add_time = G.get_time() var tween = create_tween() tween.tween_property(plank, "position:y", 0.0, 0.15) tween.tween_property(plank, "position:y", 0.1, 0.05) tween.tween_property(plank, "position:y", 0.0, 0.05) tween.tween_property(plank, "position:y", 0.1, 0.05) tween.tween_property(plank, "position:y", 0.0, 0.05) tween.finished.connect(func(): plank.remove_meta("tween") plank.remove_meta("queued_action") var scale_tween = create_tween() scale_tween.tween_property(plank, "scale", Vector3(1.0, 0.8, 0.9), 0.05) scale_tween.tween_property(plank, "scale", Vector3.ONE, 0.1) ) plank.set_meta("tween", tween) func _remove_plank_tween(plank: MeshInstance3D): if plank.has_meta("tween") and plank.get_meta("tween") is Tween: plank.get_meta("tween").kill() var direction: int = 1 if randf() < 0.5: direction = -1 if G.get_time() - last_sfx_plank_remove_time > 0.075 and plank.visible: G.wait(0.075).connect(func(): G.play_sound_at(sfx_plank_remove, plank.global_position, -5) ) last_sfx_plank_remove_time = G.get_time() var dir_pos_x = plank.position.x dir_pos_x += randf_range(1.0, 3.0) * direction var dir_pos_y = randf_range(0.3, 3.0) var dir_pos_z = plank.position.z + randf_range(-1.0, 1.0) var dir_pos = Vector3(dir_pos_x, dir_pos_y, dir_pos_z) var force_dir = plank.global_position.direction_to(to_global(dir_pos)) var rb = RigidBody3D.new() var col = CollisionShape3D.new() var shape = BoxShape3D.new() shape.size = plank.mesh.get_aabb().size col.shape = shape add_child(rb) rb.add_child(col) rb.global_transform = plank.global_transform plank.reparent(rb) var time = G.randf_range(1.75, 2.5) rb.gravity_scale = 2.0 var force = force_dir * randf_range(5.0, 10.0) rb.continuous_cd = true rb.apply_impulse(force, Vector3(-0.75, 0, 0)) var ang = randf_range(PI * 2.0, PI * 4.0) rb.apply_torque_impulse(Vector3(0, 0, ang * direction)) var tween = create_tween() tween.tween_property(plank, "transparency", 1.0, time * 0.2).set_delay(time * 0.8) G.wait(time).connect(rb.free) func _update_path(): _ensure_path_container() if path_plank_meshes.size() == 0: return var total_length: float = 0.0 var last_plank: MeshInstance3D if path_container.get_child_count() > 0: last_plank = path_container.get_child(path_container.get_child_count() - 1) as MeshInstance3D total_length = last_plank.get_meta("length_at_plank") if bridge_path_length < current_path_length: # loop over path children, remove any that are out of bounds for plank in path_container.get_children(): plank = plank as MeshInstance3D var mesh = plank.mesh as ArrayMesh var aabb = mesh.get_aabb() var plank_length = aabb.size.x var z_pos = -plank.position.z # use plank_length as a buffer to remove planks that are out of bounds if z_pos > bridge_path_length + plank_length: # plank.free() if plank.has_meta("queued_action") and plank.get_meta("queued_action") == "add": var index = tween_queue.find_custom(func(e): return e.plank == plank) if index >= 0: tween_queue.remove_at(index) _queue_tween("remove", plank) elif not plank.has_meta("queued_action"): _queue_tween("remove", plank) current_path_length = bridge_path_length else: var i = 0 while total_length < bridge_path_length: var plank = MeshInstance3D.new() var mesh = G.pick_random(path_plank_meshes) as ArrayMesh plank.mesh = mesh plank.set_surface_override_material(0, catwalk_material) path_container.add_child(plank) var aabb = mesh.get_aabb() var plank_length = aabb.size.x var gap = G.randf_range(0.0, max_path_gap) var z_pos = total_length + gap plank.position = Vector3(0.0, 0.0, -z_pos) plank.rotation_degrees = Vector3(0, 90, 0) total_length += plank_length + gap plank.transparency = 1.0 _queue_tween("add", plank) plank.set_meta("length_at_plank", total_length) i += 1 if i > 1000: printerr("Infinite loop") break current_path_length = total_length func _update_support_transforms(): _ensure_support_container() if support_plank_length == 0: _calculate_support_length() var support_count = support_container.get_child_count() for i in range(0, support_count, 2): var left_support = support_container.get_child(i) as MeshInstance3D var right_support = support_container.get_child(i + 1) as MeshInstance3D var z_pos = left_support.position.z left_support.position = Vector3(-support_gap / 2.0, support_y_offset, z_pos) right_support.position = Vector3(support_gap / 2.0, support_y_offset, z_pos) func _update_supports(): _ensure_support_container() if support_plank_length == 0: _calculate_support_length() for child in support_container.get_children(): child.free() var support_count = int(bridge_length / support_plank_length) for i in range(support_count): var left_support = MeshInstance3D.new() left_support.mesh = support_plank_meshes[0] left_support.set_surface_override_material(0, catwalk_material) var z_pos = (i * support_plank_length) + (support_plank_length / 2.0) var right_support = left_support.duplicate() support_container.add_child(left_support) support_container.add_child(right_support) var half_gap = support_gap / 2.0 left_support.position = Vector3(-half_gap, support_y_offset, -z_pos) right_support.position = Vector3(half_gap, support_y_offset, -z_pos) func _calculate_support_length(): if support_plank_meshes.size() == 0: return var mesh = support_plank_meshes[0] var aabb = mesh.get_aabb() print(aabb.size) support_plank_length = aabb.size.z func _ensure_containers(): _ensure_path_container() _ensure_support_container() func _ensure_path_container(): if path_container: return if has_node("PathContainer"): path_container = get_node("PathContainer") else: path_container = Node3D.new() path_container.name = "PathContainer" add_child(path_container) func _ensure_support_container(): if support_container: return if has_node("SupportContainer"): support_container = get_node("SupportContainer") else: support_container = Node3D.new() support_container.name = "SupportContainer" add_child(support_container)