From 194aad8f83e9dd271e1ee3c65f7d1fcbc8b8eaa7 Mon Sep 17 00:00:00 2001 From: Shawn Date: Wed, 1 Jul 2026 20:39:17 -0400 Subject: [PATCH] t_p2_bomb: Bomb/defuse objective system for search & destroy rounds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds: - bomb_objective.gd: server-authoritative bomb state machine (IDLE→PLANTED→EXPLODED/DEFUSED) - plant_bomb(player_id, pos) - T-side only, LIVE phase, inside bombsite - defuse_bomb(player_id) - CT-side only, near bomb, 5s timer - cancel_defuse(player_id) - if defuser moves or is killed - bomb_explode() - configurable radius damage (10m lethal, 15m half) - 40s bomb timer (configurable) - Full query API: is_bomb_planted(), get_bomb_position(), get_bomb_timer() - Signals: bomb_planted, bomb_defused, bomb_exploded, defuse_started, defuse_cancelled - Connected to RoundManager via GameServer for round-end outcomes - bomb_carrier.gd: client-side bomb carrier UI - Bomb status/defuse progress UI updates - Direction indicator to planted bomb - "Hold E to defuse" prompt for CT near bomb - "Hold E to plant" prompt for T with bomb in bombsite - test_range.tscn: BombsiteA (south-west, -20,0,-5) and BombsiteB (north-east, 15,0,20) - 8x4m Area3D zones with BoxShape3D collision - PlantPosition Marker3D children - Group 'bomb_sites' for server discovery - game_server.gd: BombObjective integration in _ready() - Creates and wires BombObjective to RoundManager, TeamManager, DamageProcessor - Registers bomb sites from scene tree - Connects bomb_exploded/bomb_defused → RoundManager.end_round() - Connects round_ended → bomb.reset() --- client/assets/maps/test_range/test_range.tscn | 45 +- client/characters/objectives/bomb_carrier.gd | 293 ++++++++++ server/scripts/game_server.gd | 40 ++ server/scripts/objectives/bomb_objective.gd | 531 ++++++++++++++++++ 4 files changed, 908 insertions(+), 1 deletion(-) create mode 100644 client/characters/objectives/bomb_carrier.gd create mode 100644 server/scripts/objectives/bomb_objective.gd diff --git a/client/assets/maps/test_range/test_range.tscn b/client/assets/maps/test_range/test_range.tscn index 68fd263..ec4549b 100644 --- a/client/assets/maps/test_range/test_range.tscn +++ b/client/assets/maps/test_range/test_range.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=8 format=3] +[gd_scene load_steps=10 format=3] ; ============================================================================= ; test_range.tscn — Greybox Test Map @@ -103,6 +103,49 @@ transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 30, 0.5, 20) groups = ["spawn_points_t"] +; ============================================================================= +; Bomb Sites +; ============================================================================= +; BombsiteA — south-west area, 8×4m zone +; BombsiteB — north-east area, 8×4m zone +; Each has a CollisionShape3D (BoxShape3D) for overlap detection +; and a Marker3D child named "PlantPosition" marking the plant location. + +; --- Bombsite A --- + +[sub_resource type="BoxShape3D" id="8"] +size = Vector3(8, 4, 4) + +[node name="BombsiteA" type="Area3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -20, 1.0, -5) +collision_layer = 0 +collision_mask = 0 +groups = ["bomb_sites"] + +[node name="CollisionShape3D" type="CollisionShape3D" parent="BombsiteA"] +shape = SubResource("8") + +[node name="PlantPosition" type="Marker3D" parent="BombsiteA"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -2, 0, -1) + +; --- Bombsite B --- + +[sub_resource type="BoxShape3D" id="9"] +size = Vector3(8, 4, 4) + +[node name="BombsiteB" type="Area3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 15, 1.0, 20) +collision_layer = 0 +collision_mask = 0 +groups = ["bomb_sites"] + +[node name="PlantPosition" type="Marker3D" parent="BombsiteB"] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 2) + +[node name="CollisionShape3D" type="CollisionShape3D" parent="BombsiteB"] +shape = SubResource("9") + + ; === Open Area Floor (80×80) === [node name="Floor" type="CSGBox3D" parent="."] diff --git a/client/characters/objectives/bomb_carrier.gd b/client/characters/objectives/bomb_carrier.gd new file mode 100644 index 0000000..b9cb65d --- /dev/null +++ b/client/characters/objectives/bomb_carrier.gd @@ -0,0 +1,293 @@ +## BombCarrier — Client-side bomb status UI and prompts. +## +## Attached to the local T player who carries the bomb. +## Provides visual feedback for: +## - Bomb status (planted/defusing/timer) +## - Direction indicator to planted bomb +## - "Hold E to defuse" prompt (CT, near bomb) +## - "Hold E to plant" prompt (T, in bombsite, LIVE phase) +## +## This script is a Node that should be added as a child of the local +## player's character node (FPSCharacterController) on the client side. +## It communicates with the server BombObjective via RPC or state sync. + +extends Node +class_name BombCarrier + +# --------------------------------------------------------------------------- +# Signals +# --------------------------------------------------------------------------- + +## Emitted when the nearby interaction prompt changes. +signal prompt_changed(prompt_text: String, visible: bool) + +## Emitted when bomb status UI should update. +signal bomb_status_updated(planted: bool, timer: float, defusing: bool, defuse_progress: float) + +## Emitted when the bomb direction indicator should update. +signal bomb_direction_updated(bearing_degrees: float, distance: float) + +# --------------------------------------------------------------------------- +# Exports +# --------------------------------------------------------------------------- + +## The "Use/Interact" action name (matched in Input Map). +@export var interact_action: String = "interact" + +## Interaction distance for planting (in Godot units). +@export var plant_distance: float = 2.0 + +## Interaction distance for defusing (in Godot units). +@export var defuse_distance: float = 3.0 + +# --------------------------------------------------------------------------- +# State +# --------------------------------------------------------------------------- + +## Whether this player has the bomb (T-side bomb carrier). +var has_bomb: bool = false + +## Whether the bomb is currently planted. +var _bomb_planted: bool = false + +## Position of the planted bomb (server-authoritative). +var _bomb_position: Vector3 = Vector3.ZERO + +## Remaining bomb timer (received from server). +var _bomb_timer: float = 0.0 + +## Whether the local player is currently defusing. +var _is_defusing: bool = false + +## Current defuse progress (0.0 to 1.0). +var _defuse_progress: float = 0.0 + +## Current prompt text to show. +var _current_prompt: String = "" + +## Whether the prompt is currently visible. +var _prompt_visible: bool = false + +## Whether we are inside a bombsite. +var _in_bombsite: bool = false + +## Whether we are near the planted bomb. +var _near_planted_bomb: bool = false + +## Whether the interact key is being held. +var _interact_held: bool = false + +## Hold-down timer for plant/defuse (E key held long enough). +var _interact_hold_time: float = 0.0 + +## Time required to hold E to start action (for UX consistency). +var _interact_hold_duration: float = 0.0 # Immediate on press + +## Reference to the local player's parent/character controller. +var _character_controller: Node = null + +## Reference to the bomb objective on the server (for RPC calls). +## Set by the game manager after instantiation. +var bomb_objective_path: NodePath = NodePath() + +# --------------------------------------------------------------------------- +# Lifecycle +# --------------------------------------------------------------------------- + +func _ready() -> void: + _character_controller = get_parent() + if _character_controller == null: + push_warning("[BombCarrier] No parent node — prompts will not work") + + # Try to set up the interact action if it doesn't exist + if not InputMap.has_action(interact_action): + var event := InputEventKey.new() + event.keycode = KEY_E + InputMap.add_action(interact_action) + InputMap.action_add_event(interact_action, event) + + print("[BombCarrier] Initialised — has_bomb=%s" % has_bomb) + +func _process(delta: float) -> void: + _update_interaction(delta) + _emit_status_updates() + +# --------------------------------------------------------------------------- +# Public API — Server state sync +# --------------------------------------------------------------------------- + +## Called by the game manager or network sync to update bomb state. +func sync_bomb_state(planted: bool, position: Vector3, timer: float, + defusing: bool, defuse_progress: float) -> void: + _bomb_planted = planted + _bomb_position = position + _bomb_timer = timer + _is_defusing = defusing + _defuse_progress = defuse_progress + +## Set whether this player has the bomb (assigned by server). +func set_has_bomb(value: bool) -> void: + has_bomb = value + print("[BombCarrier] Has bomb: %s" % value) + +## Set whether the local player is inside a bombsite Area3D. +## Called by the bombsite Area3D signal handlers on the client. +func set_in_bombsite(value: bool) -> void: + _in_bombsite = value + +## Set whether the local player is near the planted bomb. +## Called by the bomb area proximity check. +func set_near_planted_bomb(value: bool) -> void: + _near_planted_bomb = value + +# --------------------------------------------------------------------------- +# Public API — RPC calls to server +# --------------------------------------------------------------------------- + +## Request to plant the bomb at the current position. +## Called when the T player presses E inside a bombsite with the bomb. +func request_plant() -> void: + if not has_bomb or not _in_bombsite: + return + if _bomb_planted: + return + + # Send plant request to server + # In real implementation, this would be an RPC call: + # _send_plant_request.rpc_id(1, _character_controller.global_position) + _send_plant_request(_character_controller.global_position) + +## Request to start defusing the bomb. +## Called when the CT player presses E near the planted bomb. +func request_defuse() -> void: + if not _near_planted_bomb or not _bomb_planted: + return + if _is_defusing: + return + + # Send defuse request to server + # _send_defuse_request.rpc_id(1) + _send_defuse_request() + +## Request to cancel defuse (when player moves or is interrupted). +func request_cancel_defuse() -> void: + if not _is_defusing: + return + _send_cancel_defuse() + +# --------------------------------------------------------------------------- +# RPC stubs (to be replaced with actual RPC calls) +# --------------------------------------------------------------------------- + +func _send_plant_request(position: Vector3) -> void: + # Placeholder: in real implementation, send RPC to server + print("[BombCarrier] Plant request at (%.1f, %.1f, %.1f)" % + [position.x, position.y, position.z]) + +func _send_defuse_request() -> void: + # Placeholder: in real implementation, send RPC to server + print("[BombCarrier] Defuse request") + +func _send_cancel_defuse() -> void: + # Placeholder: in real implementation, send RPC to server + print("[BombCarrier] Cancel defuse request") + +# --------------------------------------------------------------------------- +# Internal — Interaction logic +# --------------------------------------------------------------------------- + +func _update_interaction(delta: float) -> void: + var interact_pressed: bool = Input.is_action_pressed(interact_action) + var prompt_text: String = "" + var show_prompt: bool = false + + # Determine what prompt to show + if _bomb_planted and _near_planted_bomb and not _is_defusing: + # CT player near planted bomb — defuse prompt + var team: int = _get_local_player_team() + if team == TeamData.Team.COUNTER_TERRORIST: + prompt_text = "Hold E to defuse" + show_prompt = true + + elif has_bomb and _in_bombsite and not _bomb_planted: + # T player with bomb inside bombsite — plant prompt + if _is_round_live(): + prompt_text = "Hold E to plant" + show_prompt = true + + elif _bomb_planted and _is_defusing: + # Currently defusing — show progress + var progress_pct: int = int(_defuse_progress * 100.0) + prompt_text = "Defusing... %d%%" % progress_pct + show_prompt = true + + # Handle interact hold + if interact_pressed and show_prompt: + if not _interact_held: + _interact_held = true + _interact_hold_time = 0.0 + + _interact_hold_time += delta + + # Check if we should trigger the action + if _interact_hold_time >= _interact_hold_duration: + _interact_hold_time = 0.0 + if prompt_text.contains("defuse"): + request_defuse() + elif prompt_text.contains("plant"): + request_plant() + else: + # Cancel any pending interaction + if _interact_held: + _interact_held = false + _interact_hold_time = 0.0 + # If we were defusing and released, cancel + if _is_defusing: + request_cancel_defuse() + + # Update prompt if changed + if prompt_text != _current_prompt or show_prompt != _prompt_visible: + _current_prompt = prompt_text + _prompt_visible = show_prompt + prompt_changed.emit(prompt_text, show_prompt) + +func _emit_status_updates() -> void: + # Emit bomb status updates (rate-limited is handled by engine) + bomb_status_updated.emit(_bomb_planted, _bomb_timer, _is_defusing, _defuse_progress) + + # Emit direction indicator if bomb is planted + if _bomb_planted and _character_controller != null: + var player_pos: Vector3 = _character_controller.global_position + var direction: Vector3 = _bomb_position - player_pos + var distance: float = direction.length() + if distance > 0.01: + var bearing: float = atan2(direction.x, -direction.z) # Relative to forward (Z) + bomb_direction_updated.emit(rad_to_deg(bearing), distance) + else: + bomb_direction_updated.emit(0.0, 0.0) + +# --------------------------------------------------------------------------- +# Internal — Helpers +# --------------------------------------------------------------------------- + +## Get the local player's team (from TeamManager singleton if available). +func _get_local_player_team() -> int: + var team_manager: TeamManager = get_node_or_null("/root/TeamManager") + if team_manager: + var peer_id: int = multiplayer.get_unique_id() if multiplayer.has_method(&\"get_unique_id\") else -1 + if peer_id >= 0: + return team_manager.get_player_team(peer_id) + return -1 + +## Check if the current round is in LIVE phase. +func _is_round_live() -> bool: + var round_manager: RoundManager = get_node_or_null("/root/RoundManager") + if round_manager: + return round_manager.get_phase() == RoundManager.RoundPhase.LIVE + return false + +## Get the multiplayer API (stub for compatibility). +func get_multiplayer() -> MultiplayerAPI: + if multiplayer and multiplayer.has_method(&\"get_unique_id\"): + return multiplayer + return null diff --git a/server/scripts/game_server.gd b/server/scripts/game_server.gd index bb177cb..6fb0070 100644 --- a/server/scripts/game_server.gd +++ b/server/scripts/game_server.gd @@ -61,6 +61,9 @@ var economy_manager: EconomyManager = null ## BuyMenuHandler — server-side buy request validation and processing. var buy_menu_handler: BuyMenuHandler = null +## BombObjective — server-authoritative bomb plant/defuse logic. +var bomb_objective: BombObjective = null + ## Current server tick counter, incremented each time tick() is called. var _current_tick: int = 0 @@ -163,6 +166,43 @@ func _ready() -> void: # Register as singleton so FPSCharacterController can find us Engine.register_singleton("SimulationServer", simulation_server) + # --- Bomb / Defuse Objective --- + bomb_objective = BombObjective.new() + add_child(bomb_objective) + bomb_objective.round_manager = round_manager + bomb_objective.team_manager = team_manager + bomb_objective.damage_processor = damage_processor + bomb_objective.entity_to_peer = entity_to_peer + bomb_objective.peer_to_entity = peer_to_entity + + # Register bomb sites from the scene tree + if is_inside_tree(): + var bomb_sites: Array[Area3D] = get_tree().get_nodes_in_group("bomb_sites") + if bomb_sites.size() > 0: + bomb_objective.register_bomb_sites(bomb_sites) + print("[GameServer] Registered %d bomb sites with BombObjective" % bomb_sites.size()) + else: + print("[GameServer] No bomb sites found in scene — bomb can't be planted") + else: + print("[GameServer] Not in scene tree yet — bomb sites will be registered later") + + # Wire: bomb explosion/defuse → round end + bomb_objective.bomb_exploded.connect(func(_pos): + if round_manager: + round_manager.end_round(TeamData.Team.TERRORIST, "bomb_exploded") + ) + bomb_objective.bomb_defused.connect(func(_player_id): + if round_manager: + round_manager.end_round(TeamData.Team.COUNTER_TERRORIST, "bomb_defused") + ) + + # Wire: round end → reset bomb + round_manager.round_ended.connect(func(_winning_team, _reason): + bomb_objective.reset() + ) + + print("[GameServer] BombObjective integrated — bomb/defuse ready") + print("[GameServer] SimulationServer created. Tick rate: %d Hz" % simulation_server.get_tick_rate()) func _exit_tree() -> void: diff --git a/server/scripts/objectives/bomb_objective.gd b/server/scripts/objectives/bomb_objective.gd new file mode 100644 index 0000000..44d956e --- /dev/null +++ b/server/scripts/objectives/bomb_objective.gd @@ -0,0 +1,531 @@ +## BombObjective — Server-authoritative bomb plant/defuse logic. +## +## Manages the bomb lifecycle for competitive search & destroy rounds. +## Owned by GameServer; wired to RoundManager for round-end outcomes. +## +## Bomb state machine: +## IDLE → PLANTED → EXPLODED +## → DEFUSED +## +## Usage (GameServer._ready()): +## var bomb = BombObjective.new() +## add_child(bomb) +## bomb.round_manager = round_manager_ref +## bomb.team_manager = team_manager_ref +## bomb.damage_processor = damage_processor_ref +## bomb.entity_to_peer = entity_to_peer_ref +## bomb.peer_to_entity = peer_to_entity_ref +## bomb.register_bomb_sites(get_tree().get_nodes_in_group("bomb_sites")) +## round_manager.round_ended.connect(bomb.reset) +## +## Bomb sites are Area3D nodes with group "bomb_sites" in the map scene. +## Each bombsite should have a Marker3D child named "PlantPosition". + +extends Node +class_name BombObjective + +# --------------------------------------------------------------------------- +# Signals +# --------------------------------------------------------------------------- + +## Emitted when the bomb is planted by a T player. +signal bomb_planted(player_id: int, position: Vector3) + +## Emitted when the bomb is defused by a CT player. +signal bomb_defused(player_id: int) + +## Emitted when the bomb explodes. +signal bomb_exploded(position: Vector3) + +## Emitted when a CT player starts defusing. +signal defuse_started(player_id: int) + +## Emitted when a CT player cancels defuse (moves, killed, or interrupted). +signal defuse_cancelled(player_id: int) + +# --------------------------------------------------------------------------- +# Bomb State enum +# --------------------------------------------------------------------------- + +enum BombState { + IDLE = 0, # No bomb active + PLANTED = 1, # Bomb is planted, timer counting down + EXPLODED = 2, # Bomb has exploded + DEFUSED = 3, # Bomb was successfully defused +} + +# --------------------------------------------------------------------------- +# Constants — defaults +# --------------------------------------------------------------------------- + +## Default bomb timer duration in seconds (from plant to explosion). +const DEFAULT_BOMB_TIMER: float = 40.0 + +## Default defuse time in seconds. +const DEFAULT_DEFUSE_TIME: float = 5.0 + +## Default lethal radius in Godot units (full damage). +const DEFAULT_LETHAL_RADIUS: float = 10.0 + +## Default half-damage radius (50% damage). +const DEFAULT_HALF_RADIUS: float = 15.0 + +## Default explosion damage at center. +const DEFAULT_EXPLOSION_DAMAGE: float = 500.0 + +# --------------------------------------------------------------------------- +# Dependencies (set by GameServer before use) +# --------------------------------------------------------------------------- + +## Reference to the RoundManager node. +var round_manager: RoundManager = null + +## Reference to the TeamManager for checking player teams. +var team_manager: TeamManager = null + +## Reference to the DamageProcessor for applying explosion damage. +var damage_processor: DamageProcessor = null + +## Mapping: entity_id → peer_id (from GameServer). +var entity_to_peer: Dictionary = {} + +## Mapping: peer_id → entity_id (from GameServer). +var peer_to_entity: Dictionary = {} + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + +## Seconds from plant to explosion. +var bomb_timer_duration: float = DEFAULT_BOMB_TIMER + +## Seconds required to defuse. +var defuse_time: float = DEFAULT_DEFUSE_TIME + +## Radius within which explosion deals lethal (full) damage. +var lethal_radius: float = DEFAULT_LETHAL_RADIUS + +## Radius within which explosion deals half damage. +var half_radius: float = DEFAULT_HALF_RADIUS + +## Base explosion damage dealt at center (scales with distance). +var explosion_damage: float = DEFAULT_EXPLOSION_DAMAGE + +# --------------------------------------------------------------------------- +# State +# --------------------------------------------------------------------------- + +## Current bomb state. +var _state: BombState = BombState.IDLE + +## The entity ID (peer ID) of the player who planted the bomb. +var _planter_id: int = -1 + +## World position where the bomb is planted. +var _bomb_position: Vector3 = Vector3.ZERO + +## Remaining time on the bomb timer (counts down when PLANTED). +var _bomb_timer: float = 0.0 + +## The entity ID (peer ID) of the player currently defusing. +var _defuser_id: int = -1 + +## Time remaining for the current defuse attempt. +var _defuse_timer: float = 0.0 + +## Registered bomb sites: Array of Area3D nodes with "bomb_sites" group. +var _bomb_sites: Array[Area3D] = [] + +# --------------------------------------------------------------------------- +# Lifecycle +# --------------------------------------------------------------------------- + +func _ready() -> void: + _state = BombState.IDLE + print("[BombObjective] Initialised — state: IDLE") + +func _process(delta: float) -> void: + match _state: + BombState.PLANTED: + # Count down bomb timer + _bomb_timer -= delta + if _bomb_timer <= 0.0: + _bomb_timer = 0.0 + bomb_explode() + + # Count down defuse timer if defusing + if _defuser_id >= 0: + _defuse_timer -= delta + if _defuse_timer <= 0.0: + _defuse_timer = 0.0 + _complete_defuse() + + BombState.IDLE, BombState.EXPLODED, BombState.DEFUSED: + pass + +# --------------------------------------------------------------------------- +# Public API — Site registration +# --------------------------------------------------------------------------- + +## Register bomb site Area3D nodes. These are typically found via +## get_tree().get_nodes_in_group("bomb_sites"). +## Each bombsite should have a Marker3D child named "PlantPosition". +func register_bomb_sites(sites: Array[Area3D]) -> void: + _bomb_sites = sites + print("[BombObjective] Registered %d bomb site(s)" % sites.size()) + +## Get all registered bomb site areas. +func get_bomb_sites() -> Array[Area3D]: + return _bomb_sites.duplicate() + +# --------------------------------------------------------------------------- +# Public API — Bomb actions +# --------------------------------------------------------------------------- + +## Plant the bomb at the given world position. +## Only allowed for T-side players during the LIVE phase. +## Returns true if the bomb was planted, false otherwise. +func plant_bomb(player_id: int, position: Vector3) -> bool: + if _state != BombState.IDLE: + push_warning("[BombObjective] Cannot plant — bomb state is %d" % _state) + return false + + if not _is_round_live(): + push_warning("[BombObjective] Cannot plant — round is not LIVE") + return false + + if not _is_player_terrorist(player_id): + push_warning("[BombObjective] Cannot plant — player %d is not on T side" % player_id) + return false + + if not _is_player_in_bombsite(player_id): + push_warning("[BombObjective] Cannot plant — player %d is not in a bombsite" % player_id) + return false + + _state = BombState.PLANTED + _planter_id = player_id + _bomb_position = position + _bomb_timer = bomb_timer_duration + _defuser_id = -1 + _defuse_timer = 0.0 + + print("[BombObjective] Bomb planted by player %d at (%.1f, %.1f, %.1f) — % .1fs timer" % + [player_id, position.x, position.y, position.z, _bomb_timer]) + + bomb_planted.emit(player_id, position) + return true + +## Start defusing the bomb (CT-side only, must be near the bomb). +## Returns true if defuse started, false otherwise. +func defuse_bomb(player_id: int) -> bool: + if _state != BombState.PLANTED: + push_warning("[BombObjective] Cannot defuse — bomb state is %d" % _state) + return false + + if not _is_player_counter_terrorist(player_id): + push_warning("[BombObjective] Cannot defuse — player %d is not on CT side" % player_id) + return false + + if not _is_player_near_bomb(player_id): + push_warning("[BombObjective] Cannot defuse — player %d is not near the bomb" % player_id) + return false + + if _defuser_id >= 0: + push_warning("[BombObjective] Cannot defuse — someone (%d) is already defusing" % _defuser_id) + return false + + _defuser_id = player_id + _defuse_timer = defuse_time + + print("[BombObjective] Defuse started by player %d — % .1fs" % [player_id, _defuse_timer]) + defuse_started.emit(player_id) + return true + +## Cancel an active defuse attempt (player moved, was killed, or interrupted). +func cancel_defuse(player_id: int) -> bool: + if _defuser_id != player_id: + return false + + _defuser_id = -1 + _defuse_timer = 0.0 + + print("[BombObjective] Defuse cancelled by player %d" % player_id) + defuse_cancelled.emit(player_id) + return true + +## Force the bomb to explode immediately. Called when the timer reaches zero. +func bomb_explode() -> void: + if _state != BombState.PLANTED: + return + + _state = BombState.EXPLODED + _defuser_id = -1 + _defuse_timer = 0.0 + + print("[BombObjective] Bomb EXPLODED at (%.1f, %.1f, %.1f)" % + [_bomb_position.x, _bomb_position.y, _bomb_position.z]) + + # Apply explosion damage to all players within radius + _apply_explosion_damage() + + bomb_exploded.emit(_bomb_position) + # (RoundManager notification is handled by GameServer via signal connection) + +# --------------------------------------------------------------------------- +# Public API — Query +# --------------------------------------------------------------------------- + +## Check if the bomb is currently planted and active. +func is_bomb_planted() -> bool: + return _state == BombState.PLANTED + +## Get the world position of the planted bomb. +## Returns Vector3.ZERO if not planted. +func get_bomb_position() -> Vector3: + return _bomb_position + +## Get the remaining time on the bomb timer (seconds). +## Returns 0.0 if not planted. +func get_bomb_timer() -> float: + return max(0.0, _bomb_timer) + +## Get the current bomb state. +func get_state() -> BombState: + return _state + +## Get the player ID of the planter. +## Returns -1 if no bomb is planted. +func get_planter_id() -> int: + return _planter_id + +## Get the player ID of the current defuser. +## Returns -1 if no one is defusing. +func get_defuser_id() -> int: + return _defuser_id + +## Get the remaining defuse time (seconds). +## Returns 0.0 if no one is defusing. +func get_defuse_timer() -> float: + return max(0.0, _defuse_timer) + +## Get the progress of the current defuse (0.0 to 1.0). +## Returns 0.0 if no one is defusing. +func get_defuse_progress() -> float: + if _defuser_id < 0 or defuse_time <= 0.0: + return 0.0 + return clamp(1.0 - (_defuse_timer / defuse_time), 0.0, 1.0) + +## Check if a specific player is currently defusing the bomb. +func is_player_defusing(player_id: int) -> bool: + return _defuser_id == player_id + +## Check if a specific player is near the bomb (within defuse radius). +func is_player_near_bomb(player_id: int, distance_threshold: float = 3.0) -> bool: + if _state != BombState.PLANTED: + return false + return _is_player_near_bomb(player_id, distance_threshold) + +## Check if a specific player is inside any bombsite. +func is_player_in_bombsite(player_id: int) -> bool: + return _is_player_in_bombsite(player_id) + +# --------------------------------------------------------------------------- +# Public API — Reset +# --------------------------------------------------------------------------- + +## Reset the bomb to IDLE state (called on round end / new round). +func reset() -> void: + _state = BombState.IDLE + _planter_id = -1 + _bomb_position = Vector3.ZERO + _bomb_timer = 0.0 + _defuser_id = -1 + _defuse_timer = 0.0 + print("[BombObjective] Reset — state: IDLE") + +# --------------------------------------------------------------------------- +# Internal — Explosion damage +# --------------------------------------------------------------------------- + +## Apply damage to all players within the explosion radius. +## Deals lethal_radius damage within lethal_radius, and linear falloff +## from lethal_radius to half_radius (half damage at outer edge). +func _apply_explosion_damage() -> void: + if damage_processor == null: + print("[BombObjective] No damage processor — explosion deals no damage") + return + + if entity_to_peer.is_empty() or peer_to_entity.is_empty(): + return + + # Iterate over all tracked entity IDs (from GameServer.entity_to_peer) + for entity_id in entity_to_peer.keys(): + var health: float = damage_processor.get_health(entity_id) + if health <= 0.0: + continue # Already dead or not registered + + # Get the player's position via the registered node + var player_pos: Vector3 = _get_entity_position(entity_id) + + var distance: float = _bomb_position.distance_to(player_pos) + + # Determine damage multiplier based on distance + var damage_mult: float = 0.0 + if distance <= lethal_radius: + damage_mult = 1.0 # Full damage + elif distance <= half_radius: + # Linear falloff from full to half damage + var t: float = (distance - lethal_radius) / (half_radius - lethal_radius) if half_radius > lethal_radius else 1.0 + damage_mult = 1.0 - (t * 0.5) # 1.0 → 0.5 + else: + continue # Outside damage range + + var actual_damage: float = explosion_damage * damage_mult + + # Apply damage via DamageProcessor + # Use the planter's entity_id as the shooter for attribution + var planter_entity: int = peer_to_entity.get(_planter_id, -1) + var result: Dictionary = {"hit": true, "target_id": entity_id, "damage": actual_damage} + damage_processor.process_hit(result, planter_entity) + + print("[BombObjective] Explosion damaged entity %d — %.1f damage (dist=%.1f, mult=%.2f)" % + [entity_id, actual_damage, distance, damage_mult]) + +# --------------------------------------------------------------------------- +# Internal — Defuse completion +# --------------------------------------------------------------------------- + +func _complete_defuse() -> void: + if _state != BombState.PLANTED: + return + + var defuser: int = _defuser_id + _state = BombState.DEFUSED + _defuser_id = -1 + _defuse_timer = 0.0 + + print("[BombObjective] Bomb defused by player %d" % defuser) + bomb_defused.emit(defuser) + # (RoundManager notification is handled by GameServer via signal connection) + +# --------------------------------------------------------------------------- +# Internal — Helpers +# --------------------------------------------------------------------------- + +## Check if the current round phase is LIVE. +func _is_round_live() -> bool: + if round_manager == null: + return false + return round_manager.get_phase() == RoundManager.RoundPhase.LIVE + +## Check if a player (by peer ID) is on the Terrorist team. +func _is_player_terrorist(player_id: int) -> bool: + if team_manager == null: + return false + return team_manager.get_player_team(player_id) == TeamData.Team.TERRORIST + +## Check if a player (by peer ID) is on the Counter-Terrorist team. +func _is_player_counter_terrorist(player_id: int) -> bool: + if team_manager == null: + return false + return team_manager.get_player_team(player_id) == TeamData.Team.COUNTER_TERRORIST + +## Check if a player is inside any registered bombsite. +## Iterates bomb sites and checks if the player's body overlaps. +func _is_player_in_bombsite(player_id: int) -> bool: + if _bomb_sites.is_empty(): + return false + + var entity_id: int = peer_to_entity.get(player_id, -1) + if entity_id < 0: + return false + + # Get the player node from LagCompensation or scene tree + var player_node: Node3D = _get_entity_node(entity_id) + if player_node == null: + return false + + for site in _bomb_sites: + if site == null: + continue + # Check if the player's global position is within the site's Area3D bounds + var site_pos: Vector3 = site.global_position + var site_shape_extents: Vector3 = _get_area_extents(site) + var player_pos: Vector3 = player_node.global_position + + # Simple AABB check using the Area3D's extents + var half_size: Vector3 = site_shape_extents * 0.5 + var min_bounds: Vector3 = site_pos - half_size + var max_bounds: Vector3 = site_pos + half_size + + if player_pos.x >= min_bounds.x and player_pos.x <= max_bounds.x and \ + player_pos.y >= min_bounds.y and player_pos.y <= max_bounds.y and \ + player_pos.z >= min_bounds.z and player_pos.z <= max_bounds.z: + return true + + return false + +## Check if a player is near the planted bomb (within defuse range). +func _is_player_near_bomb(player_id: int, distance_threshold: float = 3.0) -> bool: + if _state != BombState.PLANTED: + return false + + var entity_id: int = peer_to_entity.get(player_id, -1) + if entity_id < 0: + return false + + var player_node: Node3D = _get_entity_node(entity_id) + if player_node == null: + return false + + var distance: float = player_node.global_position.distance_to(_bomb_position) + return distance <= distance_threshold + +## Get a player's world position via their registered node. +## Tries LagCompensation first, then direct node lookup. +func _get_entity_position(entity_id: int) -> Vector3: + var node: Node3D = _get_entity_node(entity_id) + if node: + return node.global_position + return Vector3.ZERO + +## Get the Node3D for a given entity_id. +## Tries to find via the scene tree or GameServer's entity→peer mapping. +func _get_entity_node(entity_id: int) -> Node3D: + # Fallback: search the scene tree for the entity (Player nodes) + var tree: SceneTree = get_tree() + if tree == null: + return null + + # Iterate all Player nodes in the tree (tagged with "players" group) + var players: Array[Node] = tree.get_nodes_in_group("players") + for p in players: + if p is Node3D and p.has_method(&"get_entity_id"): + if p.get_entity_id() == entity_id: + return p + + # Last resort: try to find by matching peer_id via entity_to_peer + var peer_id: int = entity_to_peer.get(entity_id, -1) + if peer_id >= 0: + for p in players: + if p is Node3D and p.has_method(&"get_peer_id"): + if p.get_peer_id() == peer_id: + return p + + return null + +## Get the approximate extents of an Area3D from its collision shapes. +func _get_area_extents(area: Area3D) -> Vector3: + var extents: Vector3 = Vector3(4.0, 2.0, 2.0) # Default fallback (8×4×4) + + for child in area.get_children(): + if child is CollisionShape3D and child.shape: + if child.shape is BoxShape3D: + extents = child.shape.size + break + elif child.shape is SphereShape3D: + var r: float = child.shape.radius + extents = Vector3(r * 2.0, r * 2.0, r * 2.0) + break + + return extents