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:
@@ -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="."]
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user