Files
DungeonsOfKharadum/src/scripts/trap.gd

434 lines
16 KiB
GDScript

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)
# Grant EXP to all players for disarming trap (only on server)
# CRITICAL: Only server should grant EXP to avoid duplicates
if multiplayer.has_multiplayer_peer() and multiplayer.is_server():
var trap_exp_reward = 8.0 # EXP reward for disarming a trap
var all_players = get_tree().get_nodes_in_group("player")
var valid_players = []
for player in all_players:
if is_instance_valid(player) and player.character_stats:
valid_players.append(player)
if valid_players.size() > 0:
# Split EXP evenly among all players
var exp_per_player = trap_exp_reward / valid_players.size()
for player in valid_players:
player.character_stats.add_xp(exp_per_player)
LogManager.log("Trap disarmed: granted " + str(exp_per_player) + " EXP to " + str(player.name) + " (shared from " + str(trap_exp_reward) + " total)", LogManager.CATEGORY_ENEMY)
# Sync EXP to client if this player belongs to a client
var player_peer_id = player.get_multiplayer_authority()
if player_peer_id != 0 and player_peer_id != multiplayer.get_unique_id() and player.has_method("_sync_stats_update"):
var coins = player.character_stats.coin if "coin" in player.character_stats else 0
var xp = player.character_stats.xp if "xp" in player.character_stats else 0.0
player._sync_stats_update.rpc_id(player_peer_id, player.character_stats.kills, coins, xp)
# Show floating EXP text at trap position and sync to all clients
# Show locally first
_show_exp_number(exp_per_player, global_position)
# Sync to all clients via game_world
var game_world = get_tree().get_first_node_in_group("game_world")
if game_world and game_world.has_method("_sync_exp_text_at_position") and multiplayer.has_multiplayer_peer():
game_world._sync_exp_text_at_position.rpc(exp_per_player, global_position)
# 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_exp_number(amount: float, exp_pos: Vector2):
# Show EXP number (green, using dmg_numbers.png font) at position
var damage_number_scene = preload("res://scenes/damage_number.tscn")
if not damage_number_scene:
return
var exp_label = damage_number_scene.instantiate()
if not exp_label:
return
# Set text and color for EXP (green)
exp_label.label = "+" + str(int(amount)) + " EXP"
exp_label.color = Color(0.4, 1.0, 0.4) # Bright green
exp_label.z_index = 5
# Direction is straight up
exp_label.direction = Vector2(0, -1)
# Position at the specified location
var game_world = get_tree().get_first_node_in_group("game_world")
if game_world:
var entities_node = game_world.get_node_or_null("Entities")
if entities_node:
entities_node.add_child(exp_label)
exp_label.global_position = exp_pos + Vector2(0, -20) # Slightly above
else:
get_tree().current_scene.add_child(exp_label)
exp_label.global_position = exp_pos + Vector2(0, -20)
else:
get_tree().current_scene.add_child(exp_label)
exp_label.global_position = exp_pos + Vector2(0, -20)
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")