t_p2_bomb: Bomb/defuse objective system for search & destroy rounds

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()
This commit is contained in:
2026-07-01 20:39:17 -04:00
parent 222dcaebb3
commit 194aad8f83
4 changed files with 908 additions and 1 deletions
+531
View File
@@ -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