Files
tactical-shooter/server/scripts/objectives/bomb_objective.gd
T
shawn 194aad8f83 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()
2026-07-01 20:39:17 -04:00

532 lines
18 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## 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