extends Area3D class_name CombatArea ## The play volume. Leaving it starts a five second countdown with an on-screen ## warning and a reddening overlay; reaching zero kills the player. ## ## ── Every map needs one ────────────────────────────────────────────────────── ## ## This existed for a long while and only dust2 installed it, which meant that ## on every other map a player who got over a boundary wall — and this game has ## a grapple and a dash — simply fell out of the world and kept falling, with no ## warning, no death and no way back. `add_to()` exists so wiring it up is one ## line at the bottom of a level builder rather than fifteen, because the reason ## it was missing everywhere was that it was fiddly to add. ## ## Size it GENEROUSLY: the volume is the point at which a player is told they ## have left, not the edge of the level geometry. A box tight to the boundary ## walls fires the warning while someone is still standing on a legitimate ## rooftop. static func add_to(level: Node, size: Vector3, centre: Vector3 = Vector3.ZERO, node_name: String = "CombatArea") -> CombatArea: if level.has_node(NodePath(node_name)): return level.get_node(NodePath(node_name)) as CombatArea var area := CombatArea.new() area.name = node_name var shape := CollisionShape3D.new() var box := BoxShape3D.new() box.size = size shape.shape = box area.add_child(shape) area.position = centre level.add_child(area) return area var out_of_bounds_players: Dictionary = {} # body -> { time_left: 5.0 } var recovery_players: Dictionary = {} # body -> { time_safe: 0.0, time_left: float } var _ui_layer: CanvasLayer = null var _ui_label: Label = null var _ui_overlay: ColorRect = null var _is_local_player_out: bool = false var _local_player_body: Node3D = null func _ready() -> void: collision_layer = 0 collision_mask = 4294967295 # All layers body_exited.connect(_on_body_exited) body_entered.connect(_on_body_entered) # Own the warning UI for the same lifetime as the combat volume. Creating it # from body_exited races level teardown: removing the player from physics can # emit that signal while the scene is already being freed, leaving the # deferred overlay orphaned. _create_ui() _ui_layer.visible = false set_process(false) func _process(delta: float) -> void: # Process players who are actively out of bounds var to_kill: Array = [] for body in out_of_bounds_players.keys(): if "is_dead" in body and body.is_dead: out_of_bounds_players.erase(body) if body == _local_player_body: _remove_ui() continue var data = out_of_bounds_players[body] data.time_left -= delta if data.time_left <= 0.0: data.time_left = 0.0 to_kill.append(body) if body == _local_player_body and _is_local_player_out and _ui_label and _ui_overlay: _ui_label.text = "WARNING: RETURN TO COMBAT AREA\n%.1f" % data.time_left _ui_overlay.color = Color(1.0, 0.0, 0.0, 0.8 * (1.0 - (data.time_left / 5.0))) for body in to_kill: _kill_player(body) # Process players who returned but are recovering var recovered: Array = [] for body in recovery_players.keys(): if "is_dead" in body and body.is_dead: recovered.append(body) continue var data = recovery_players[body] data.time_safe += delta if data.time_safe >= 10.0: recovered.append(body) for body in recovered: recovery_players.erase(body) if out_of_bounds_players.is_empty() and recovery_players.is_empty(): set_process(false) func _on_body_exited(body: Node3D) -> void: if body is CharacterBody3D and body.has_method("take_damage") and str(body.name).is_valid_int(): if "is_dead" in body and body.is_dead: return if body.is_queued_for_deletion() or not is_inside_tree(): return var start_time = 5.0 if recovery_players.has(body): start_time = recovery_players[body].time_left recovery_players.erase(body) out_of_bounds_players[body] = { "time_left": start_time } # Singleplayer goes through OfflineMultiplayerPeer, so it takes the # branch below and works. This default covers the case with NO peer at # all — a level opened directly, without NetworkManager having started a # match, which is how the debug and capture tools run it. There is one # player then and they are the local one; without this they would get # the silent countdown and no warning. var is_local := true if multiplayer.has_multiplayer_peer(): is_local = body.get_multiplayer_authority() == multiplayer.get_unique_id() if is_local: _is_local_player_out = true _local_player_body = body _create_ui() set_process(true) func _on_body_entered(body: Node3D) -> void: if out_of_bounds_players.has(body): var time_left_val = out_of_bounds_players[body].time_left out_of_bounds_players.erase(body) if time_left_val < 5.0: recovery_players[body] = { "time_left": time_left_val, "time_safe": 0.0 } set_process(true) if body == _local_player_body and _is_local_player_out: _remove_ui() _is_local_player_out = false _local_player_body = null func _create_ui() -> void: if _ui_layer: _ui_layer.visible = true return _ui_layer = CanvasLayer.new() _ui_layer.layer = 100 # Put it on top of everything _ui_overlay = ColorRect.new() _ui_overlay.set_anchors_preset(Control.PRESET_FULL_RECT) _ui_overlay.color = Color(1.0, 0.0, 0.0, 0.0) _ui_overlay.mouse_filter = Control.MOUSE_FILTER_IGNORE _ui_layer.add_child(_ui_overlay) _ui_label = Label.new() _ui_label.set_anchors_preset(Control.PRESET_CENTER) _ui_label.grow_horizontal = Control.GROW_DIRECTION_BOTH _ui_label.grow_vertical = Control.GROW_DIRECTION_BOTH _ui_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER _ui_label.add_theme_font_size_override("font_size", 48) _ui_label.add_theme_color_override("font_color", Color(1, 0.2, 0.2)) _ui_label.add_theme_color_override("font_outline_color", Color(0, 0, 0)) _ui_label.add_theme_constant_override("outline_size", 8) var display_time = 5.0 if out_of_bounds_players.has(_local_player_body): display_time = out_of_bounds_players[_local_player_body].time_left _ui_label.text = "WARNING: RETURN TO COMBAT AREA\n%.1f" % display_time _ui_layer.add_child(_ui_label) add_child(_ui_layer) func _remove_ui() -> void: if _ui_layer: _ui_layer.visible = false func _kill_player(body: Node3D) -> void: if body: # Damage is the server's call. With no peer there is no server to defer # to and `is_server()` answers false, so without this a level opened # outside a match would count down to zero and then quietly drop the # player from its own list without ever killing them. var authoritative := not multiplayer.has_multiplayer_peer() \ or multiplayer.is_server() if authoritative and body.has_method("take_damage"): body.take_damage(9999, Vector3.ZERO, null, Vector3.ZERO) out_of_bounds_players.erase(body) if body == _local_player_body and _is_local_player_out: _remove_ui() _is_local_player_out = false _local_player_body = null