extends Node2D @onready var sprite = $Sprite2D @onready var animation_player = $AnimationPlayer @onready var activation_area = $ActivationArea @onready var disarm_area = $DisarmArea @onready var detection_area = $DetectionArea # Trap state var is_detected: bool = false # Becomes true when any player detects it var is_disarmed: bool = false # True if trap has been disarmed var is_active: bool = false # True when trap is currently triggering var has_cooldown: bool = false # Some traps can reset var cooldown_time: float = 5.0 # Time until trap can re-activate var cooldown_timer: float = 0.0 # Trap properties var trap_damage: float = 15.0 var trap_type: String = "Floor_Lance" # Per-player detection tracking (Dictionary: peer_id -> has_attempted_detection) var player_detection_attempts: Dictionary = {} # Disarm tracking var disarming_player: Node = null var disarm_progress: float = 0.0 var disarm_duration: float = 1.0 var disarm_label: Label = null func _ready() -> void: # Add to trap group for detection by players add_to_group("trap") # Randomize trap visual based on dungeon seed var highbox_seed = 0 var world = get_tree().get_first_node_in_group("game_world") if world and "dungeon_seed" in world: highbox_seed = world.dungeon_seed highbox_seed += int(global_position.x) * 1000 + int(global_position.y) var rng = RandomNumberGenerator.new() rng.seed = highbox_seed var index = rng.randi() % 2 if index == 0: sprite.texture = load("res://assets/gfx/traps/Floor_Lance.png") trap_type = "Floor_Lance" has_cooldown = true # Lance traps can reset # Start hidden (invisible until detected) sprite.modulate.a = 0.0 # Setup detection area to check for players if detection_area: detection_area.body_entered.connect(_on_detection_area_body_entered) detection_area.body_exited.connect(_on_detection_area_body_exited) # Setup disarm area to show "DISARM" text for dwarves if disarm_area: disarm_area.body_entered.connect(_on_disarm_area_body_entered) disarm_area.body_exited.connect(_on_disarm_area_body_exited) func _process(delta: float) -> void: # Handle cooldown timer for resetting traps if has_cooldown and not is_active and cooldown_timer > 0: cooldown_timer -= delta if cooldown_timer <= 0: # Trap has cooled down - ready to trigger again pass # Handle disarm progress if disarming_player and is_instance_valid(disarming_player): # Check if player is still holding grab button if disarming_player.is_multiplayer_authority() and Input.is_action_pressed("grab"): # Play disarming sound (only if not already playing) if $SfxDisarming.playing == false: $SfxDisarming.play() disarm_progress += delta _update_disarm_ui() if disarm_progress >= disarm_duration: # Disarm complete! _complete_disarm() else: # Player released grab - cancel disarm _cancel_disarm() func _on_detection_area_body_entered(body: Node) -> void: # When a player enters detection range, roll perception check (once per player per game) if not body.is_in_group("player"): return if is_detected or is_disarmed: return # Already detected or disarmed # Get player peer ID var peer_id = body.get_multiplayer_authority() # Check if this player has already attempted detection if player_detection_attempts.has(peer_id): return # Already tried once this game # Mark that this player has attempted detection player_detection_attempts[peer_id] = true # Roll perception check (only on server/authority) if multiplayer.is_server() or not multiplayer.has_multiplayer_peer(): _roll_perception_check(body) func _on_detection_area_body_exited(_body: Node) -> void: pass # Detection is permanent once attempted func _roll_perception_check(player: Node) -> void: # Roll perception check for player if not player or not player.character_stats: return var per_stat = player.character_stats.baseStats.per + player.character_stats.get_pass("per") # Perception roll: d20 + PER modifier # Target DC: 15 (medium difficulty) var roll = randi() % 20 + 1 # 1d20 var total = roll + int(per_stat / 2) - 5 # PER modifier: (PER - 10) / 2 var dc = 15 print(player.name, " rolls Perception: ", roll, " + ", int(per_stat / 2) - 5, " = ", total, " vs DC ", dc) if total >= dc: # Success! Player detects the trap _detect_trap(player) else: # Failure - trap remains hidden to this player print(player.name, " failed to detect trap") func _detect_trap(detecting_player: Node) -> void: # Trap is detected - make it visible to ALL players is_detected = true # Make trap visible sprite.modulate.a = 1.0 # Notify the detecting player to show alert and play sound if detecting_player and is_instance_valid(detecting_player) and detecting_player.has_method("_on_trap_detected"): detecting_player._on_trap_detected() # Sync detection to all clients (including server with call_local) # CRITICAL: Validate trap is still valid before sending RPC # Use GameWorld RPC to avoid node path issues if multiplayer.has_multiplayer_peer() and is_inside_tree() and is_instance_valid(self): if multiplayer.is_server(): # Use GameWorld RPC with trap name instead of path var game_world = get_tree().get_first_node_in_group("game_world") if game_world and game_world.has_method("_sync_trap_state_by_name"): game_world._sync_trap_state_by_name.rpc(name, true, false) # detected=true, disarmed=false print(detecting_player.name, " detected trap at ", global_position) @rpc("authority", "call_local", "reliable") func _sync_trap_detected() -> void: # Client receives trap detection notification # CRITICAL: Validate trap is still valid before processing if not is_instance_valid(self) or not is_inside_tree(): return is_detected = true if sprite: sprite.modulate.a = 1.0 func _on_disarm_area_body_entered(body: Node) -> void: # Show "DISARM" text if player is Dwarf and trap is detected if not body.is_in_group("player"): return if not is_detected or is_disarmed: return # CRITICAL: Only show disarm text for LOCAL players who are Dwarves # Check if this player is the local player (has authority matching local peer ID) var is_local = false if body.has_method("is_multiplayer_authority") and body.is_multiplayer_authority(): # This player is controlled by the local client is_local = true elif multiplayer.has_multiplayer_peer(): # Check if this player's authority matches our local peer ID var player_authority = body.get_multiplayer_authority() var local_peer_id = multiplayer.get_unique_id() if player_authority == local_peer_id: is_local = true if is_local: # Check if player is Dwarf if body.character_stats and body.character_stats.race == "Dwarf": _show_disarm_text(body) func _on_disarm_area_body_exited(body: Node) -> void: # Hide disarm text when player leaves area if body == disarming_player: _cancel_disarm() _hide_disarm_text(body) func _show_disarm_text(_player: Node) -> void: # Create "DISARM" label above trap if disarm_label: return # Already showing disarm_label = Label.new() disarm_label.text = "DISARM" disarm_label.add_theme_font_size_override("font_size", 16) disarm_label.add_theme_color_override("font_color", Color.YELLOW) disarm_label.add_theme_color_override("font_outline_color", Color.BLACK) disarm_label.add_theme_constant_override("outline_size", 2) disarm_label.position = Vector2(-25, -30) disarm_label.z_index = 100 add_child(disarm_label) func _hide_disarm_text(_player: Node) -> void: if disarm_label: disarm_label.queue_free() disarm_label = null func _update_disarm_ui() -> void: # Update disarm progress (could show radial timer here) if disarm_label: var progress_percent = int((disarm_progress / disarm_duration) * 100) disarm_label.text = "DISARM (" + str(progress_percent) + "%)" func _cancel_disarm() -> void: if disarming_player and disarming_player.has_method("set") and "is_disarming" in disarming_player: disarming_player.is_disarming = false disarming_player = null disarm_progress = 0.0 # Stop disarming sound if $SfxDisarming.playing: $SfxDisarming.stop() if disarm_label: disarm_label.text = "DISARM" func _complete_disarm() -> void: # Trap successfully disarmed! is_disarmed = true if disarming_player and disarming_player.has_method("set") and "is_disarming" in disarming_player: disarming_player.is_disarming = false disarming_player = null disarm_progress = 0.0 # Stop disarming sound if $SfxDisarming.playing: $SfxDisarming.stop() # Hide disarm text _hide_disarm_text(null) # Disable activation area if activation_area: activation_area.monitoring = false # Show "TRAP DISARMED" in chat var chat_ui = get_tree().get_first_node_in_group("chat_ui") if chat_ui and chat_ui.has_method("send_system_message"): chat_ui.send_system_message("Trap disarmed by Dwarf!") # Show floating text "TRAP DISARMED" _show_floating_text("TRAP DISARMED", Color.YELLOW) # Change trap visual to show it's disarmed (optional - could fade out or change color) sprite.modulate = Color(0.5, 0.5, 0.5, 0.5) # Sync disarm to all clients (including host when joiner disarms) if multiplayer.has_multiplayer_peer() and is_inside_tree() and is_instance_valid(self): var game_world = get_tree().get_first_node_in_group("game_world") if game_world: if multiplayer.is_server(): # Host disarmed: broadcast to clients if game_world.has_method("_sync_trap_state_by_name"): game_world._sync_trap_state_by_name.rpc(name, true, true) # detected=true, disarmed=true else: # Joiner disarmed: request host to apply locally and broadcast to all if game_world.has_method("_request_trap_disarm"): game_world._request_trap_disarm.rpc_id(1, name) print("Trap disarmed!") @rpc("authority", "call_local", "reliable") func _sync_trap_disarmed() -> void: # Client receives trap disarm notification # CRITICAL: Validate trap is still valid before processing if not is_instance_valid(self) or not is_inside_tree(): return is_disarmed = true if sprite: sprite.modulate = Color(0.5, 0.5, 0.5, 0.5) if activation_area: activation_area.monitoring = false func _show_floating_text(text: String, color: Color) -> void: var floating_text_scene = preload("res://scenes/floating_text.tscn") if floating_text_scene: var floating_text = floating_text_scene.instantiate() var parent = get_parent() if parent: parent.add_child(floating_text) floating_text.global_position = Vector2(global_position.x, global_position.y - 20) floating_text.setup(text, color, 0.5, 0.5, null, 1, 1, 0) func _on_activation_area_body_shape_entered(_body_rid: RID, body: Node2D, _body_shape_index: int, _local_shape_index: int) -> void: # Trap triggered! if not body.is_in_group("player"): return if is_disarmed or is_active: return # Can't trigger if disarmed or already active if has_cooldown and cooldown_timer > 0: return # Still on cooldown # Trigger trap is_active = true $SfxActivate.play() animation_player.play("activate") # Trap is now visible to all players (once triggered) if not is_detected: is_detected = true sprite.modulate.a = 1.0 # CRITICAL: Validate trap is still valid before sending RPC # Use GameWorld RPC to avoid node path issues if multiplayer.has_multiplayer_peer() and is_inside_tree() and is_instance_valid(self): if multiplayer.is_server(): # Use GameWorld RPC with trap name instead of path var game_world = get_tree().get_first_node_in_group("game_world") if game_world and game_world.has_method("_sync_trap_state_by_name"): game_world._sync_trap_state_by_name.rpc(name, true, false) # detected=true, disarmed=false # Deal damage to player (with luck-based avoidance) _deal_trap_damage(body) # Start cooldown if applicable if has_cooldown: cooldown_timer = cooldown_time await animation_player.animation_finished animation_player.play("reset") await animation_player.animation_finished is_active = false else: # One-time trap - stays triggered pass func _deal_trap_damage(player: Node) -> void: if not player or not player.character_stats: return # Luck-based avoidance check var luck_stat = player.character_stats.baseStats.lck + player.character_stats.get_pass("lck") var avoid_chance = luck_stat * 0.02 # 2% per luck point (e.g., 10 luck = 20% avoid) var avoid_roll = randf() if avoid_roll < avoid_chance: $SfxAvoid.play() # Player avoided trap damage! print(player.name, " avoided trap damage! (", avoid_chance * 100, "% chance)") _show_floating_text("AVOIDED", Color.GREEN) return # Apply trap damage (affected by player's defense) var final_damage = player.character_stats.calculate_damage(trap_damage, false, false) if player.has_method("rpc_take_damage"): player.rpc_take_damage(trap_damage, global_position) print(player.name, " took ", final_damage, " trap damage")