extends Node2D # Staff Projectile - Travels away from player and deals damage (magic ball) @export var damage: float = 20.0 @export var initial_speed: float = 300.0 # Faster than sword projectile @export var deceleration: float = 600.0 # Slower deceleration (travels further) @export var lifetime: float = 0.8 # Longer lifetime @export var max_distance: float = 200.0 # Travels further var current_speed: float = 0.0 var travel_direction: Vector2 = Vector2.RIGHT var elapsed_time: float = 0.0 var distance_traveled: float = 0.0 var player_owner: Node = null var hit_targets = {} # Track what we've already hit (Dictionary for O(1) lookup) var color_replacements: Array = [] # Color replacements from staff item @onready var sprite = $Sprite2D @onready var hit_area = $Area2D func _ready(): # Apply color replacements if available _apply_color_replacements() $SfxSwosh.play() $AnimationPlayer.play("flying") # Connect area signals (only if not already connected) if hit_area and not hit_area.body_entered.is_connected(_on_body_entered): hit_area.body_entered.connect(_on_body_entered) func setup(direction: Vector2, owner_player: Node, damage_value: float = 20.0, staff_item: Item = null): travel_direction = direction.normalized() player_owner = owner_player damage = damage_value # Set damage from player current_speed = initial_speed # Store color replacements from staff if staff_item and staff_item.colorReplacements: color_replacements = staff_item.colorReplacements # Rotate sprite to face travel direction rotation = direction.angle() # Apply color replacements after setup (in case sprite wasn't ready yet) _apply_color_replacements() func _apply_color_replacements(): # Apply color replacements to projectile sprite using shader parameters if not sprite or not sprite.material or not sprite.material is ShaderMaterial: return if color_replacements.size() == 0: return var shader_material = sprite.material as ShaderMaterial # Filter for "magic" colors only (RGB 174,39,30; RGB 109,29,32; RGB 246,57,48) # These are the colors that should be replaced on the projectile var magic_colors = [ Color(174/255.0, 39/255.0, 30/255.0), Color(109/255.0, 29/255.0, 32/255.0), Color(246/255.0, 57/255.0, 48/255.0) ] var replacement_index = 0 for color_replacement in color_replacements: if color_replacement.has("original") and color_replacement.has("replace"): var original_color = color_replacement["original"] as Color # Only apply replacements for magic colors for magic_color in magic_colors: # Check if this replacement matches a magic color (with some tolerance) if _colors_similar(original_color, magic_color, 0.1): var replace_color = color_replacement["replace"] as Color shader_material.set_shader_parameter("original_" + str(replacement_index), original_color) shader_material.set_shader_parameter("replace_" + str(replacement_index), replace_color) replacement_index += 1 break # Found match, move to next replacement func _colors_similar(color1: Color, color2: Color, tolerance: float = 0.1) -> bool: # Check if two colors are similar within tolerance var r_diff = abs(color1.r - color2.r) var g_diff = abs(color1.g - color2.g) var b_diff = abs(color1.b - color2.b) return r_diff <= tolerance and g_diff <= tolerance and b_diff <= tolerance func _physics_process(delta): elapsed_time += delta # Check lifetime if elapsed_time >= lifetime or distance_traveled >= max_distance: $Area2D.set_deferred("monitoring", false) self.visible = false if $SfxImpactWall.playing: await $SfxImpactWall.finished if $SfxImpact.playing: await $SfxImpact.finished queue_free() return # Decelerate current_speed -= deceleration * delta current_speed = max(0.0, current_speed) # Don't go negative # Move in travel direction var movement = travel_direction * current_speed * delta global_position += movement distance_traveled += movement.length() # Fade out (based on speed) var alpha = current_speed / initial_speed # 1.0 at start, 0.0 when stopped if sprite: sprite.modulate.a = alpha func _on_body_entered(body): # Don't hit the owner if body == player_owner: return # Don't hit the same target twice - use Dictionary for O(1) lookup to prevent race conditions if body in hit_targets: return # CRITICAL: Only the projectile owner (authority) should deal damage if player_owner and not player_owner.is_multiplayer_authority(): return # Only the authority (creator) of the projectile can deal damage # Add to hit_targets IMMEDIATELY to prevent multiple hits (mark as hit before processing) hit_targets[body] = true # Deal damage to players - call RPC to let victim apply damage on their client if body.is_in_group("player") and body.has_method("rpc_take_damage"): $SfxImpact.play() var attacker_pos = player_owner.global_position if player_owner else global_position var player_peer_id = body.get_multiplayer_authority() if player_peer_id != 0: if multiplayer.is_server() and player_peer_id == multiplayer.get_unique_id(): body.rpc_take_damage(damage, attacker_pos) else: body.rpc_take_damage.rpc_id(player_peer_id, damage, attacker_pos) else: body.rpc_take_damage.rpc(damage, attacker_pos) print("Staff projectile hit player: ", body.name, " for ", damage, " damage!") # Deal damage to enemies - only authority (creator) deals damage elif body.is_in_group("enemy") and body.has_method("rpc_take_damage"): var attacker_pos = player_owner.global_position if player_owner else global_position var is_crit = get_meta("is_crit") if has_meta("is_crit") else false # Check hit chance (based on player's DEX stat) var hit_roll = randf() var hit_chance = 0.95 # Base hit chance if player_owner and player_owner.character_stats: hit_chance = player_owner.character_stats.hit_chance var is_miss = hit_roll >= hit_chance if is_miss: # Attack missed print("Player MISSED enemy: ", body.name, "! (hit chance: ", hit_chance * 100.0, "%)") if body.has_method("_show_damage_number"): body._show_damage_number(0.0, attacker_pos, false, true, false) # is_miss = true return # Hit successful $SfxImpact.play() # Use game_world to route damage request var game_world = get_tree().get_first_node_in_group("game_world") var enemy_name = body.name var enemy_index = body.get_meta("enemy_index") if body.has_meta("enemy_index") else -1 if game_world and game_world.has_method("_request_enemy_damage"): if multiplayer.is_server(): game_world._request_enemy_damage(enemy_name, enemy_index, damage, attacker_pos, is_crit) else: game_world._request_enemy_damage.rpc_id(1, enemy_name, enemy_index, damage, attacker_pos, is_crit) else: # Fallback var enemy_peer_id = body.get_multiplayer_authority() if enemy_peer_id != 0: if multiplayer.is_server() and enemy_peer_id == multiplayer.get_unique_id(): body.rpc_take_damage(damage, attacker_pos, is_crit) else: body.rpc_take_damage.rpc_id(enemy_peer_id, damage, attacker_pos, is_crit) else: body.rpc_take_damage.rpc(damage, attacker_pos, is_crit) var owner_name: String = "none" var is_authority: bool = false if player_owner: owner_name = str(player_owner.name) is_authority = player_owner.is_multiplayer_authority() print("Staff projectile hit enemy: ", body.name, " for ", damage, " damage! (owner: ", owner_name, " is_authority: ", is_authority, ")") return # Deal damage to boxes or other damageable objects elif "health" in body: body.health -= damage $SfxImpact.play() if body.health <= 0: # Get object identifier var obj_name = body.name var obj_index = -1 if body.has_meta("object_index"): obj_index = body.get_meta("object_index") if obj_index >= 0: obj_name = "InteractableObject_%d" % obj_index if not obj_name.begins_with("InteractableObject_") and obj_index < 0: print("Staff projectile: Warning - object ", body.name, " doesn't have consistent naming!") # Sync break to server if multiplayer.has_multiplayer_peer(): var game_world = get_tree().get_first_node_in_group("game_world") if game_world and is_instance_valid(game_world) and game_world.is_inside_tree() and game_world.has_method("_sync_object_break"): if multiplayer.is_server(): if game_world.has_method("_rpc_to_ready_peers"): game_world._rpc_to_ready_peers("_sync_object_break", [obj_name]) print("Staff projectile synced box break to all clients: ", obj_name) else: game_world._sync_object_break.rpc_id(1, obj_name) print("Staff projectile requested box break on server: ", obj_name, " (index: ", obj_index, ")") else: print("Staff projectile: GameWorld not ready, skipping box break sync for ", obj_name) # Break locally AFTER syncing if body.has_method("_break_into_pieces"): body._break_into_pieces() print("Staff projectile broke box locally: ", body.name) print("Staff projectile hit object: ", body.name) # Push the hit target away slightly (only for non-enemies) if body is CharacterBody2D and not body.is_in_group("enemy"): var knockback_dir = (body.global_position - global_position).normalized() if body.is_in_group("player") and "is_dead" in body and body.is_dead: const CORPSE_KNOCKBACK: float = 50.0 var pid = body.get_multiplayer_authority() if pid == multiplayer.get_unique_id(): body.rpc_apply_corpse_knockback(knockback_dir.x, knockback_dir.y, CORPSE_KNOCKBACK) elif pid != 0: body.rpc_apply_corpse_knockback.rpc_id(pid, knockback_dir.x, knockback_dir.y, CORPSE_KNOCKBACK) else: body.rpc_apply_corpse_knockback.rpc(knockback_dir.x, knockback_dir.y, CORPSE_KNOCKBACK) else: body.velocity = knockback_dir * 200.0