370 lines
13 KiB
GDScript
370 lines
13 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)
|
|
|
|
# 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")
|