Skip to content

Instantly share code, notes, and snippets.

@levidavidmurray
Created February 22, 2025 14:12

Revisions

  1. levidavidmurray created this gist Feb 22, 2025.
    345 changes: 345 additions & 0 deletions procedural_bridge.gd
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,345 @@
    @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)