extends CharacterBody2D # Loot Item - Coins and food items that drop from enemies enum LootType { COIN, APPLE, BANANA, CHERRY, KEY, ITEM # Item instance (equipment, consumables, etc.) } @export var loot_type: LootType = LootType.COIN # Z-axis physics (like boxes and players) var position_z: float = 0.0 var velocity_z: float = 0.0 var acceleration_z: float = 0.0 var is_airborne: bool = true var velocity_set_by_spawner: bool = false # Track if velocity was set externally # Bounce physics var bounce_restitution: float = 0.6 # How much bounce energy is retained (0-1) - matches old code var min_bounce_velocity: float = 40.0 # Minimum velocity needed to bounce var friction: float = 8.0 # Friction when on ground (lower = more gradual slowdown, matches old code) var bounce_timer: float = 0.0 # Prevent rapid bounce sounds # Multiplayer sync and prediction var sync_timer: float = 0.0 # Timer for periodic position/velocity sync var sync_interval: float = 0.05 # Sync every 0.05 seconds (20 times per second) for smoother sync var last_sync_time: float = 0.0 # Track last server sync time for reconciliation var server_position: Vector2 = Vector2.ZERO # Last server-authoritative position var server_velocity: Vector2 = Vector2.ZERO # Last server-authoritative velocity var server_position_z: float = 0.0 # Last server-authoritative Z position var server_velocity_z: float = 0.0 # Last server-authoritative Z velocity var prediction_error_threshold: float = 10.0 # Distance threshold before correcting (pixels) var correction_smoothing: float = 0.3 # Lerp factor for smooth correction (0-1, lower = smoother) # Loot properties var coin_value: int = 1 var heal_amount: float = 20.0 var collected: bool = false var item: Item = null # Item instance (for LootType.ITEM) @onready var sprite = $Sprite2D @onready var shadow = $Shadow @onready var pickup_area = $PickupArea @onready var collision_shape = $CollisionShape2D @onready var sfx_coin_bounce = $SfxCoinBounce @onready var sfx_coin_collect = $SfxCoinCollect @onready var sfx_loot_collect = $SfxLootCollect @onready var sfx_potion_collect = $SfxPotionCollect @onready var sfx_banana_collect = $SfxBananaCollect @onready var sfx_key_collect = $SfxKeyCollect # Quantity badge for items with quantity > 1 var quantity_badge: Label = null func _ready(): add_to_group("loot") # Setup shadow if shadow: shadow.modulate = Color(0, 0, 0, 0.5) shadow.z_index = -1 # Setup pickup area if pickup_area: pickup_area.body_entered.connect(_on_pickup_area_body_entered) # Set collision mask to detect players (layer 1) pickup_area.collision_mask = 1 # Top-down physics motion_mode = MOTION_MODE_FLOATING # Initial velocity is set by spawner (server) or synced via RPC (clients) # If not set externally, use defaults (shouldn't happen in normal flow) if not velocity_set_by_spawner: velocity_z = randf_range(80.0, 120.0) var random_angle = randf() * PI * 2 var random_force = randf_range(50.0, 100.0) velocity = Vector2(cos(random_angle), sin(random_angle)) * random_force is_airborne = true # Setup sprite based on loot type (call after all properties are set) call_deferred("_setup_sprite") # Setup collision shape based on loot type call_deferred("_setup_collision_shape") # Adjust bounce properties based on loot type if loot_type == LootType.COIN: bounce_restitution = 0.6 # Matches old code - more bouncy min_bounce_velocity = 40.0 friction = 8.0 # Lower friction for coins - more gradual slowdown else: bounce_restitution = 0.3 # Lower bounce for food items min_bounce_velocity = 60.0 friction = 12.0 # Slightly higher friction for food items func _setup_sprite(): if not sprite: return match loot_type: LootType.COIN: # Load coin texture var coin_texture = load("res://assets/gfx/pickups/gold_coin.png") if coin_texture and sprite: sprite.texture = coin_texture sprite.hframes = 6 sprite.vframes = 1 sprite.frame = 0 print("Coin sprite setup: texture=", coin_texture != null, " hframes=", sprite.hframes, " vframes=", sprite.vframes, " frame=", sprite.frame) else: print("ERROR: Coin texture or sprite is null! texture=", coin_texture, " sprite=", sprite) LootType.APPLE: var items_texture = load("res://assets/gfx/pickups/items_n_shit.png") if items_texture: sprite.texture = items_texture sprite.hframes = 20 sprite.vframes = 14 sprite.frame = (8 * 20) + 10 LootType.BANANA: var items_texture = load("res://assets/gfx/pickups/items_n_shit.png") if items_texture: sprite.texture = items_texture sprite.hframes = 20 sprite.vframes = 14 sprite.frame = (8 * 20) + 11 LootType.CHERRY: var items_texture = load("res://assets/gfx/pickups/items_n_shit.png") if items_texture: sprite.texture = items_texture sprite.hframes = 20 sprite.vframes = 14 sprite.frame = (8 * 20) + 12 LootType.KEY: var items_texture = load("res://assets/gfx/pickups/items_n_shit.png") if items_texture: sprite.texture = items_texture sprite.hframes = 20 sprite.vframes = 14 sprite.frame = (13 * 20) + 10 LootType.ITEM: # Item instance - use item's spritePath and spriteFrame if item and item.spritePath != "": var items_texture = load(item.spritePath) if items_texture: sprite.texture = items_texture sprite.hframes = item.spriteFrames.x if item.spriteFrames.x > 0 else 20 sprite.vframes = item.spriteFrames.y if item.spriteFrames.y > 0 else 14 sprite.frame = item.spriteFrame ItemDatabase.apply_item_colors_to_sprite(sprite, item) print("Loot: Set up item sprite for ", item.item_name, " frame=", sprite.frame) # Add quantity badge if quantity > 1 if item.quantity > 1: _create_quantity_badge(item.quantity) else: print("Loot: ERROR - Could not load texture from spritePath: ", item.spritePath) else: print("Loot: ERROR - Item loot has no item instance or spritePath! item=", item) func _setup_collision_shape(): if not collision_shape: # Try to find existing collision shape collision_shape = get_node_or_null("CollisionShape2D") if not collision_shape: # Create collision shape if it doesn't exist collision_shape = CollisionShape2D.new() add_child(collision_shape) # Get or create circle shape var circle_shape: CircleShape2D = null if collision_shape.shape and collision_shape.shape is CircleShape2D: circle_shape = collision_shape.shape as CircleShape2D else: circle_shape = CircleShape2D.new() # Set collision size based on loot type if loot_type == LootType.COIN: circle_shape.radius = 3.0 # 6x6 pixels (diameter) else: circle_shape.radius = 7.0 # 14x14 pixels (diameter) collision_shape.shape = circle_shape func _create_quantity_badge(quantity: int): # Create a label to show the quantity quantity_badge = Label.new() quantity_badge.text = str(quantity) quantity_badge.add_theme_font_size_override("font_size", 8) quantity_badge.add_theme_color_override("font_color", Color.WHITE) quantity_badge.add_theme_color_override("font_outline_color", Color.BLACK) quantity_badge.add_theme_constant_override("outline_size", 2) quantity_badge.z_index = 100 # Above the sprite quantity_badge.position = Vector2(6, -8) # Bottom right of sprite add_child(quantity_badge) func _physics_process(delta): # Stop all physics processing if collected if collected: return var is_client = multiplayer.has_multiplayer_peer() and not is_multiplayer_authority() var is_server = not multiplayer.has_multiplayer_peer() or is_multiplayer_authority() # Server (authority): Run physics normally if is_server: # Update bounce timer if bounce_timer > 0.0: bounce_timer -= delta if bounce_timer < 0: bounce_timer = 0.0 # Update Z-axis physics if is_airborne: # Apply gravity to Z-axis (matches old code) acceleration_z = -300.0 # Gravity velocity_z += acceleration_z * delta position_z += velocity_z * delta # Ground collision and bounce (matches old code - simpler, no aggressive damping) if position_z <= 0.0: position_z = 0.0 # Apply friction ONLY when on ground (matches old code behavior) velocity = velocity.lerp(Vector2.ZERO, 1.0 - exp(-friction * delta)) # Check if we should bounce (simpler logic matching old code) if not collected and abs(velocity_z) > min_bounce_velocity: # Play bounce sound for coins (matches old code volume formula) if loot_type == LootType.COIN and bounce_timer == 0.0 and velocity_z < 0.0: if sfx_coin_bounce: # Old code formula: -1 + (-10 - (velocityZ * 0.1)) # Adjusted for negative velocity_z sfx_coin_bounce.volume_db = -1.0 + (-10.0 - (abs(velocity_z) * 0.1)) sfx_coin_bounce.play() bounce_timer = 0.08 # Matches old code timing # Simple bounce (matches old code) velocity_z = - velocity_z * bounce_restitution is_airborne = true # Still bouncing else: # Velocity too small or collected - stop bouncing velocity_z = 0.0 is_airborne = false else: is_airborne = false # Ensure velocity_z is zero when on ground velocity_z = 0.0 # Apply friction when on ground (matches old code) velocity = velocity.lerp(Vector2.ZERO, 1.0 - exp(-friction * delta)) # Move and check for collisions move_and_slide() # Check for wall collisions (skip if collected to prevent bounce sounds) # Matches old code behavior - simpler wall bounce without aggressive velocity reduction if not collected: for i in get_slide_collision_count(): var collision = get_slide_collision(i) if collision: var collider = collision.get_collider() # Only bounce off walls, not players (players are detected via PickupArea) if collider and not collider.is_in_group("player"): # Check if velocity is too small before bouncing (prevent infinite micro-bounces) var velocity_magnitude = velocity.length() if velocity_magnitude < 15.0: # If velocity is very small, stop bouncing velocity = Vector2.ZERO continue # Skip bounce and sound # Bounce off walls (matches old code - no aggressive velocity reduction) var normal = collision.get_normal() velocity = velocity.bounce(normal) # Old code didn't reduce velocity here # Play bounce sound for coins hitting walls (matches old code) if loot_type == LootType.COIN and bounce_timer == 0.0: if sfx_coin_bounce: sfx_coin_bounce.volume_db = -5.0 sfx_coin_bounce.play() bounce_timer = 0.08 # Matches old code timing # Update visual position based on Z _update_visuals() # Animate coin rotation (always animate, even when not airborne) if loot_type == LootType.COIN: _animate_coin(delta) # Server: Periodically sync position/velocity to clients (sync more frequently when airborne) sync_timer += delta # Sync more frequently when airborne (bouncing), less when settled var current_interval = sync_interval if is_airborne else sync_interval * 2.0 if sync_timer >= current_interval: sync_timer = 0.0 var game_world = get_tree().get_first_node_in_group("game_world") if game_world and game_world.has_method("_sync_loot_physics"): var loot_id = get_meta("loot_id") if has_meta("loot_id") else -1 if loot_id >= 0: game_world._rpc_to_ready_peers("_sync_loot_physics", [loot_id, global_position, velocity, position_z, velocity_z]) # Client (prediction): Run physics locally for smooth movement, then reconcile with server elif is_client: # Run physics locally (client-side prediction) - same logic as server # Update bounce timer if bounce_timer > 0.0: bounce_timer -= delta if bounce_timer < 0: bounce_timer = 0.0 # Update Z-axis physics if is_airborne: # Apply gravity to Z-axis (matches server) acceleration_z = -300.0 velocity_z += acceleration_z * delta position_z += velocity_z * delta # Ground collision and bounce (matches server logic) if position_z <= 0.0: position_z = 0.0 velocity = velocity.lerp(Vector2.ZERO, 1.0 - exp(-friction * delta)) if not collected and abs(velocity_z) > min_bounce_velocity: # Play bounce sound for coins if loot_type == LootType.COIN and bounce_timer == 0.0 and velocity_z < 0.0: if sfx_coin_bounce: sfx_coin_bounce.volume_db = -1.0 + (-10.0 - (abs(velocity_z) * 0.1)) sfx_coin_bounce.play() bounce_timer = 0.08 velocity_z = - velocity_z * bounce_restitution is_airborne = true else: velocity_z = 0.0 is_airborne = false else: is_airborne = false velocity_z = 0.0 velocity = velocity.lerp(Vector2.ZERO, 1.0 - exp(-friction * delta)) # Move and check for collisions move_and_slide() # Check for wall collisions if not collected: for i in get_slide_collision_count(): var collision = get_slide_collision(i) if collision: var collider = collision.get_collider() if collider and not collider.is_in_group("player"): var velocity_magnitude = velocity.length() if velocity_magnitude < 15.0: velocity = Vector2.ZERO continue var normal = collision.get_normal() velocity = velocity.bounce(normal) if loot_type == LootType.COIN and bounce_timer == 0.0: if sfx_coin_bounce: sfx_coin_bounce.volume_db = -5.0 sfx_coin_bounce.play() bounce_timer = 0.08 # Update visuals _update_visuals() # Animate coin rotation if loot_type == LootType.COIN: _animate_coin(delta) # Reconcile with server state if available (called from game_world._sync_loot_physics) # Server state is stored in server_position, server_velocity, etc. variables # Reconciliation happens in game_world._reconcile_loot_state() func _update_z_physics(delta): position_z += velocity_z * delta func _update_visuals(): # Update sprite position based on Z if sprite: sprite.position.y = - position_z * 0.5 # Update shadow scale and opacity based on Z if shadow: var shadow_scale = 1.0 - (position_z / 50.0) * 0.5 shadow.scale = Vector2.ONE * max(0.3, shadow_scale) shadow.modulate.a = 0.5 - (position_z / 50.0) * 0.2 var coin_anim_time: float = 0.0 func _animate_coin(delta): # Rotate coin animation if sprite and loot_type == LootType.COIN: # Make sure sprite is set up correctly (in case _setup_sprite hasn't run yet) if sprite.hframes < 6 or sprite.texture == null: _setup_sprite() # Animate coin rotation coin_anim_time += delta * 10.0 # Animation speed var frame = int(coin_anim_time) % 6 sprite.frame = frame func _on_pickup_area_body_entered(body): if body and body.is_in_group("player") and not body.is_dead: # Check if this item was dropped by this player recently (5 second cooldown) if has_meta("dropped_by_peer_id") and has_meta("drop_time"): var dropped_by_peer_id = get_meta("dropped_by_peer_id") var drop_time = get_meta("drop_time") var current_time = Time.get_ticks_msec() var time_since_drop = (current_time - drop_time) / 1000.0 # Convert to seconds # Check if this player dropped the item and cooldown hasn't expired if body.has_method("get_multiplayer_authority"): var player_peer_id = body.get_multiplayer_authority() if player_peer_id == dropped_by_peer_id and time_since_drop < 5.0: # Player can't pick up their own dropped item for 5 seconds print("Loot: Player ", body.name, " cannot pick up item they dropped (", time_since_drop, "s ago, need 5s cooldown)") return print("Loot: Pickup area entered by player: ", body.name, " is_local: ", body.is_local_player if "is_local_player" in body else "unknown", " is_server: ", multiplayer.is_server()) _pickup(body) func _pickup(player: Node): # Prevent multiple pickups if collected: print("Loot: Already collected, ignoring pickup") return var player_auth_str = "N/A" if "get_multiplayer_authority" in player: player_auth_str = str(player.get_multiplayer_authority()) print("Loot: _pickup called by player: ", player.name, " is_server: ", multiplayer.is_server(), " has_peer: ", multiplayer.has_multiplayer_peer(), " player_authority: ", player_auth_str) # In multiplayer, only process on server or if player has authority # If client player picks it up, send RPC to server if multiplayer.has_multiplayer_peer(): if not multiplayer.is_server(): # Client: send pickup request to server if player.is_multiplayer_authority(): # This is the local player, send request to server var player_peer_id = player.get_multiplayer_authority() print("Loot: Client sending pickup request to server for player peer_id: ", player_peer_id) # Route through game_world to avoid node path issues var game_world = get_tree().get_first_node_in_group("game_world") var loot_id = get_meta("loot_id") if has_meta("loot_id") else -1 if game_world and game_world.has_method("_request_loot_pickup"): game_world._request_loot_pickup.rpc_id(1, loot_id, global_position, player_peer_id) else: # Fallback: try direct RPC rpc_id(1, "_request_pickup", player_peer_id) else: print("Loot: Client player does not have authority, cannot pickup") return else: # Server: If player doesn't have authority, this is a client player # The client will send _request_pickup RPC, so we should ignore this Area2D signal # to avoid duplicate processing and errors if not player.is_multiplayer_authority(): # Client player - they will send RPC, so ignore this signal completely # This prevents race conditions where server's Area2D fires before client's RPC arrives # Don't log as error since this is expected behavior return # Process the pickup (common code for both server and single-player) _process_pickup_on_server(player) func _process_pickup_on_server(player: Node): # Internal function to process pickup on server (called from _request_pickup RPC) # This skips the authority check since we've already validated the request # Mark as collected immediately to prevent duplicate pickups # (Note: This may already be set by _request_pickup, but set it here too for safety) if not collected: collected = true # Disable pickup area immediately to prevent further collisions # Use set_deferred() because we're in a signal callback if pickup_area: pickup_area.set_deferred("monitoring", false) pickup_area.set_deferred("monitorable", false) # Sync removal to all clients FIRST (before processing pickup) # This ensures clients remove the loot even if host processes it # Use game_world to route removal sync instead of direct RPC to avoid node path issues if multiplayer.has_multiplayer_peer() and is_inside_tree(): var loot_id = get_meta("loot_id") if has_meta("loot_id") else -1 var game_world = get_tree().get_first_node_in_group("game_world") if game_world and game_world.has_method("_sync_loot_remove"): print("Loot: Server syncing removal of loot id=", loot_id, " at ", global_position) game_world._rpc_to_ready_peers("_sync_loot_remove", [loot_id, global_position]) else: # If GameWorld isn't ready, skip removal sync to avoid node path RPC errors print("Loot: GameWorld not ready, skipping removal sync for loot id=", loot_id) match loot_type: LootType.COIN: if sfx_coin_collect: sfx_coin_collect.play() # Give player coin if player.has_method("add_coins"): player.add_coins(coin_value) # Show floating text with item graphic and text var coin_texture = load("res://assets/gfx/pickups/gold_coin.png") _show_floating_text(player, "+" + str(coin_value) + " COIN", Color(1.0, 0.84, 0.0), 0.5, 0.5, coin_texture, 6, 1, 0) # Sync floating text to client via GameWorld to avoid loot node path RPCs if multiplayer.has_multiplayer_peer() and player.get_multiplayer_authority() != 1: var game_world = get_tree().get_first_node_in_group("game_world") if game_world and game_world.has_method("_sync_loot_floating_text"): game_world._sync_loot_floating_text.rpc_id(player.get_multiplayer_authority(), loot_type, "+" + str(coin_value) + " COIN", Color(1.0, 0.84, 0.0), 0, player.get_multiplayer_authority()) self.visible = false # Wait for sound to finish before removing if sfx_coin_collect and sfx_coin_collect.playing: await sfx_coin_collect.finished queue_free() LootType.APPLE: if sfx_potion_collect: sfx_potion_collect.play() # Heal player var actual_heal = 0.0 if player.has_method("heal"): actual_heal = heal_amount player.heal(heal_amount) # Show floating text with item graphic and heal amount var items_texture = load("res://assets/gfx/pickups/items_n_shit.png") _show_floating_text(player, "+" + str(int(actual_heal)) + " HP", Color.GREEN, 0.5, 0.5, items_texture, 20, 14, (8 * 20) + 10) # Sync floating text to client via GameWorld to avoid loot node path RPCs if multiplayer.has_multiplayer_peer() and player.get_multiplayer_authority() != 1: var game_world = get_tree().get_first_node_in_group("game_world") if game_world and game_world.has_method("_sync_loot_floating_text"): game_world._sync_loot_floating_text.rpc_id(player.get_multiplayer_authority(), loot_type, "+" + str(int(actual_heal)) + " HP", Color.GREEN, (8 * 20) + 10, player.get_multiplayer_authority()) self.visible = false # Wait for sound to finish before removing if sfx_potion_collect and sfx_potion_collect.playing: await sfx_potion_collect.finished queue_free() LootType.BANANA: if sfx_banana_collect: sfx_banana_collect.play() # Heal player var actual_heal = 0.0 if player.has_method("heal"): actual_heal = heal_amount player.heal(heal_amount) # Show floating text with item graphic and heal amount var items_texture = load("res://assets/gfx/pickups/items_n_shit.png") _show_floating_text(player, "+" + str(int(actual_heal)) + " HP", Color.GREEN, 0.5, 0.5, items_texture, 20, 14, (8 * 20) + 11) # Sync floating text to client via GameWorld to avoid loot node path RPCs if multiplayer.has_multiplayer_peer() and player.get_multiplayer_authority() != 1: var game_world = get_tree().get_first_node_in_group("game_world") if game_world and game_world.has_method("_sync_loot_floating_text"): game_world._sync_loot_floating_text.rpc_id(player.get_multiplayer_authority(), loot_type, "+" + str(int(actual_heal)) + " HP", Color.GREEN, (8 * 20) + 11, player.get_multiplayer_authority()) self.visible = false # Wait for sound to finish before removing if sfx_banana_collect and sfx_banana_collect.playing: await sfx_banana_collect.finished queue_free() LootType.CHERRY: if sfx_banana_collect: sfx_banana_collect.play() # Heal player var actual_heal = 0.0 if player.has_method("heal"): actual_heal = heal_amount player.heal(heal_amount) # Show floating text with item graphic and heal amount var items_texture = load("res://assets/gfx/pickups/items_n_shit.png") _show_floating_text(player, "+" + str(int(actual_heal)) + " HP", Color.GREEN, 0.5, 0.5, items_texture, 20, 14, (8 * 20) + 12) # Sync floating text to client via GameWorld to avoid loot node path RPCs if multiplayer.has_multiplayer_peer() and player.get_multiplayer_authority() != 1: var game_world = get_tree().get_first_node_in_group("game_world") if game_world and game_world.has_method("_sync_loot_floating_text"): game_world._sync_loot_floating_text.rpc_id(player.get_multiplayer_authority(), loot_type, "+" + str(int(actual_heal)) + " HP", Color.GREEN, (8 * 20) + 12, player.get_multiplayer_authority()) self.visible = false # Wait for sound to finish before removing if sfx_banana_collect and sfx_banana_collect.playing: await sfx_banana_collect.finished queue_free() LootType.KEY: if sfx_key_collect: sfx_key_collect.play() # Give player key in inventory if player.has_method("add_key"): player.add_key(1) # Show floating text with item graphic and text var items_texture = load("res://assets/gfx/pickups/items_n_shit.png") _show_floating_text(player, "+1 KEY", Color.YELLOW, 0.5, 0.5, items_texture, 20, 14, (13 * 20) + 10) # Sync floating text to client via GameWorld to avoid loot node path RPCs if multiplayer.has_multiplayer_peer() and player.get_multiplayer_authority() != 1: var game_world = get_tree().get_first_node_in_group("game_world") if game_world and game_world.has_method("_sync_loot_floating_text"): game_world._sync_loot_floating_text.rpc_id(player.get_multiplayer_authority(), loot_type, "+1 KEY", Color.YELLOW, (13 * 20) + 10, player.get_multiplayer_authority()) self.visible = false # Wait for sound to finish before removing if sfx_key_collect and sfx_key_collect.playing: await $SfxKeyCollect.finished queue_free() LootType.ITEM: # Item instance pickup if not item: print("Loot: ERROR - Item loot has no item instance!") queue_free() return if sfx_loot_collect: sfx_loot_collect.play() # Handle item pickup based on type var was_encumbered = player.character_stats.is_over_encumbered() if player.character_stats else false if item.item_type == Item.ItemType.Equippable: # Equippable item - add to inventory if player.character_stats: player.character_stats.add_item(item) print(name, " picked up item: ", item.item_name, " (added to inventory)") elif item.item_type == Item.ItemType.Restoration: # Consumable item - add to inventory (use with F key in inventory) if player.character_stats: player.character_stats.add_item(item) print(name, " picked up item: ", item.item_name, " (added to inventory)") if player.character_stats and not was_encumbered and player.character_stats.is_over_encumbered(): if player.has_method("show_floating_status"): player.show_floating_status("Encumbered!", Color(1.0, 0.5, 0.2)) # Sync inventory+equipment to joiner (server added to their copy; joiner's client must apply) if multiplayer.has_multiplayer_peer() and multiplayer.is_server(): var owner_id = player.get_multiplayer_authority() if owner_id != 1 and owner_id != multiplayer.get_unique_id(): var inv_data: Array = [] for inv_item in player.character_stats.inventory: inv_data.append(inv_item.save() if inv_item else null) var equip_data: Dictionary = {} for slot_name in player.character_stats.equipment.keys(): var eq = player.character_stats.equipment[slot_name] equip_data[slot_name] = eq.save() if eq else null if player.has_method("_apply_inventory_and_equipment_from_server"): player._apply_inventory_and_equipment_from_server.rpc_id(owner_id, inv_data, equip_data) # Show floating text with item name (uppercase) var items_texture = load(item.spritePath) var display_text = item.item_name.to_upper() # Always uppercase var text_color = Color.WHITE # Color code based on item type if item.item_type == Item.ItemType.Equippable: text_color = Color.CYAN # Cyan for equipment elif item.item_type == Item.ItemType.Restoration: text_color = Color.GREEN # Green for consumables _show_floating_text(player, display_text, text_color, 0.5, 0.5, items_texture, item.spriteFrames.x, item.spriteFrames.y, item.spriteFrame, item) # Sync floating text to client via GameWorld to avoid loot node path RPCs if multiplayer.has_multiplayer_peer() and player.get_multiplayer_authority() != 1: var game_world = get_tree().get_first_node_in_group("game_world") if game_world and game_world.has_method("_sync_loot_floating_text"): game_world._sync_loot_floating_text.rpc_id(player.get_multiplayer_authority(), loot_type, display_text, text_color, item.spriteFrame, player.get_multiplayer_authority()) self.visible = false # Wait for sound to finish before removing if sfx_loot_collect and sfx_loot_collect.playing: await sfx_loot_collect.finished queue_free() var processing_pickup: bool = false # Mutex to prevent concurrent pickup processing @rpc("any_peer", "reliable") func _request_pickup(player_peer_id: int): # Server receives pickup request from client print("Loot: _request_pickup RPC received on server for player_peer_id: ", player_peer_id, " is_server: ", multiplayer.is_server()) if not multiplayer.is_server(): print("Loot: _request_pickup called on non-server, ignoring") return # Check cooldown: prevent player from picking up their own dropped item for 5 seconds if has_meta("dropped_by_peer_id") and has_meta("drop_time"): var dropped_by_peer_id = get_meta("dropped_by_peer_id") var drop_time = get_meta("drop_time") var current_time = Time.get_ticks_msec() var time_since_drop = (current_time - drop_time) / 1000.0 # Convert to seconds if player_peer_id == dropped_by_peer_id and time_since_drop < 5.0: # Player can't pick up their own dropped item for 5 seconds print("Loot: Player ", player_peer_id, " cannot pick up item they dropped (", time_since_drop, "s ago, need 5s cooldown)") return # Use mutex to prevent concurrent processing (race condition protection) if processing_pickup: print("Loot: Pickup already being processed, ignoring duplicate request") return # Check if already collected (this prevents race conditions with Area2D signals) if collected: print("Loot: Already collected (collected=", collected, "), ignoring pickup request") return # Set mutex and mark as collected IMMEDIATELY to prevent any race conditions processing_pickup = true collected = true print("Loot: Marked as collected=true and set processing_pickup=true at start of _request_pickup") # Find the player by peer ID var player = null var players = get_tree().get_nodes_in_group("player") print("Loot: Searching for player with peer_id ", player_peer_id, " among ", players.size(), " players") for p in players: var p_authority = p.get_multiplayer_authority() if "get_multiplayer_authority" in p else -1 print("Loot: Checking player ", p.name, " authority: ", p_authority) if p_authority == player_peer_id: player = p break if not player: print("Loot: ERROR - Could not find player with peer_id ", player_peer_id, " for pickup") # Clear mutex and reset collected if player not found (allow retry) processing_pickup = false collected = false return print("Loot: Found player ", player.name, " processing pickup on server") # Process pickup on server directly (skip authority check since we've already validated the request came from the right client) # Don't call _pickup() because it will check authority and reject client players _process_pickup_on_server(player) # Clear mutex after processing completes processing_pickup = false # This function can be called directly (not just via RPC) when game_world routes the update func _sync_remove(): # Clients remove loot when any player picks it up # Only process if we're not the authority (i.e., we're a client) if multiplayer.is_server(): return # Server ignores its own updates print("Loot: Client received removal sync for loot at ", global_position) # Mark as collected immediately to stop physics and sounds collected = true # Play pickup sound on client so they can hear it match loot_type: LootType.COIN: if sfx_coin_collect: sfx_coin_collect.play() LootType.APPLE, LootType.BANANA, LootType.CHERRY, LootType.ITEM: if sfx_loot_collect: sfx_loot_collect.play() # Disable pickup area to prevent further collisions if pickup_area: pickup_area.monitoring = false pickup_area.monitorable = false # Hide immediately visible = false # Wait for sound to finish before removing (if sound is playing) var _sound_playing = false if loot_type == LootType.COIN and sfx_coin_collect and sfx_coin_collect.playing: _sound_playing = true await sfx_coin_collect.finished elif loot_type in [LootType.APPLE, LootType.BANANA, LootType.CHERRY, LootType.ITEM] and sfx_loot_collect and sfx_loot_collect.playing: _sound_playing = true await sfx_loot_collect.finished # Remove from scene if not is_queued_for_deletion(): queue_free() @rpc("any_peer", "reliable") func _sync_show_floating_text(loot_type_value: int, text: String, color_value: Color, _value: int, sprite_frame_value: int, player_peer_id: int): # Client receives floating text sync from server if multiplayer.is_server(): return # Server ignores this (it's the sender) # Find player by peer ID var player = null var players = get_tree().get_nodes_in_group("player") for p in players: if p.get_multiplayer_authority() == player_peer_id: player = p break if not player or not is_instance_valid(player): return # Can't find player # Determine texture and parameters based on loot type var item_texture: Texture2D = null var sprite_hframes: int = 1 var sprite_vframes: int = 1 match loot_type_value: LootType.COIN: item_texture = load("res://assets/gfx/pickups/gold_coin.png") sprite_hframes = 6 sprite_vframes = 1 LootType.APPLE, LootType.BANANA, LootType.CHERRY, LootType.KEY: item_texture = load("res://assets/gfx/pickups/items_n_shit.png") sprite_hframes = 20 sprite_vframes = 14 LootType.ITEM: # Item instance - use item's sprite path # Note: item data is not available on client in this sync, so we use default # The actual item sprite is set when the loot is created item_texture = load("res://assets/gfx/pickups/items_n_shit.png") sprite_hframes = 20 sprite_vframes = 14 # Show floating text on client _show_floating_text(player, text, color_value, 0.5, 0.5, item_texture, sprite_hframes, sprite_vframes, sprite_frame_value) func _show_floating_text(player: Node, text: String, color: Color, show_time: float = 0.5, fade_time: float = 0.5, item_texture: Texture2D = null, sprite_hframes: int = 1, sprite_vframes: int = 1, sprite_frame: int = 0, item = null): # Create floating text and item graphic above player's head # Shows for show_time seconds, then fades out over fade_time seconds var floating_text_scene = preload("res://scenes/floating_text.tscn") if floating_text_scene and player and is_instance_valid(player): var floating_text = floating_text_scene.instantiate() var parent = player.get_parent() if parent: parent.add_child(floating_text) floating_text.global_position = Vector2(player.global_position.x, player.global_position.y - 20) floating_text.setup(text, color, show_time, fade_time, item_texture, sprite_hframes, sprite_vframes, sprite_frame, item)