## 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 # Preload for headless compat const _td = preload("res://scripts/teams/team_data.gd") # --------------------------------------------------------------------------- # 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 = null ## Reference to the TeamManager for checking player teams. var team_manager = null ## Reference to the DamageProcessor for applying explosion damage. var damage_processor = null # DamageProcessor — untyped for headless compat ## 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) == _td.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) == _td.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