196 lines
7.0 KiB
GDScript
196 lines
7.0 KiB
GDScript
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
|