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