extends CharacterBody2D var tileParticleScene = preload("res://scenes/tile_particle.tscn") # Interactable Object - Can be grabbed, pushed, pulled, lifted, and thrown @export var is_grabbable: bool = true @export var can_be_pushed: bool = true @export var is_destroyable: bool = true @export var is_liftable: bool = true @export var weight: float = 1.0 # Affects throw distance and friction @export var health: float = 1.0 # How many hits before breaking const BASE_SCALE = Vector2(0.25, 0.25) # Base scale for box sprites var is_being_held: bool = false var held_by_player = null var is_frozen: bool = false var thrown_by_player = null # Track who threw this box var is_broken: bool = false var has_dealt_damage: bool = false # Track if this thrown object has already damaged something # Physics for thrown objects var throw_velocity: Vector2 = Vector2.ZERO var friction: float = 0.92 # Deceleration when sliding # Z-axis simulation (for throwing arc) var position_z: float = 0.0 var velocity_z: float = 0.0 var gravity_z: float = 500.0 # Gravity pulls down (scaled for 1x scale) var is_airborne: bool = false @onready var sprite = $Sprite2D @onready var sprite_above = $Sprite2DAbove @onready var shadow = $Shadow # Object type tracking var object_type: String = "" var chest_closed_frame: int = -1 var chest_opened_frame: int = -1 var is_chest_opened: bool = false # Network sync timer var sync_timer: float = 0.0 var sync_interval: float = 0.05 # Sync 20 times per second func _ready(): # Make sure it's on the interactable layer collision_layer = 2 # Layer 2 for objects collision_mask = 1 | 2 | 4 # Collide with players, other objects, and walls # Ensure deterministic name for network sync if has_meta("object_index") and not name.begins_with("InteractableObject_"): name = "InteractableObject_%d" % get_meta("object_index") elif name.begins_with("InteractableObject_"): # Ensure meta matches name if it already has a consistent name var index_str = name.substr(20) if index_str.is_valid_int(): var name_index = index_str.to_int() if not has_meta("object_index") or get_meta("object_index") != name_index: set_meta("object_index", name_index) # No gravity in top-down motion_mode = MOTION_MODE_FLOATING # Setup shadow if shadow: shadow.modulate = Color(0, 0, 0, 0.5) shadow.z_index = -1 func _physics_process(delta): # All clients simulate physics locally for smooth visuals # Initial throw state is synced via player's _sync_throw RPC # Don't update physics if being held (player controls position) if is_being_held: return if not is_frozen: # Z-axis physics for airborne boxes if is_airborne: # Apply gravity to Z velocity velocity_z -= gravity_z * delta position_z += velocity_z * delta # Update sprite position and scale based on height if sprite: sprite.position.y = - position_z * 0.5 var height_scale = 1.0 - (position_z / 50.0) * 0.2 # Scaled down for smaller Z values sprite.scale = Vector2(1.0, 1.0) * max(0.8, height_scale) # Update shadow based on height if shadow: var shadow_scale = 1.0 - (position_z / 75.0) * 0.5 # Scaled down for smaller Z values shadow.scale = Vector2(1.0, 1.0) * max(0.5, shadow_scale) shadow.modulate.a = 0.5 - (position_z / 100.0) * 0.3 # Apply throw velocity (NO friction while airborne, just like players) velocity = throw_velocity # Check if landed if position_z <= 0: _land() else: # Ground physics - apply friction when on ground if shadow: shadow.scale = Vector2(1.0, 1.0) # Set to 1,1 scale instead of BASE_SCALE shadow.modulate.a = 0.5 # Apply throw velocity velocity = throw_velocity # Apply friction only on ground throw_velocity = throw_velocity * friction # Stop if very slow if throw_velocity.length() < 5.0: throw_velocity = Vector2.ZERO var _collision = move_and_slide() # Check collisions while airborne (only check if moving fast enough) if is_airborne and throw_velocity.length() > 50.0 and get_slide_collision_count() > 0: _handle_air_collision() func _land(): is_airborne = false position_z = 0.0 velocity_z = 0.0 is_frozen = false is_being_held = false # Make sure it can be grabbed again held_by_player = null thrown_by_player = null # Clear who threw it has_dealt_damage = false # Reset damage flag for next throw # Re-enable collision when landing set_collision_layer_value(2, true) set_collision_mask_value(1, true) set_collision_mask_value(2, true) # Reset sprite if sprite: sprite.position.y = 0 sprite.scale = Vector2(1.0, 1.0) # Set to 1,1 scale instead of BASE_SCALE # Reset shadow if shadow: shadow.scale = Vector2(1.0, 1.0) # Set to 1,1 scale instead of BASE_SCALE shadow.modulate.a = 0.5 # Landing squash effect if sprite: var tween = create_tween() tween.tween_property(sprite, "scale", Vector2(1.2, 0.8), 0.1) tween.tween_property(sprite, "scale", Vector2(1.0, 1.0), 0.1) print(name, " landed!") $SfxLand.play() $DragParticles.emitting = true $DragParticles/TimerSmokeParticles.start() func _handle_air_collision(): # Handle collision while airborne # CRITICAL: Only allow ONE damage event per throw if has_dealt_damage: return for i in get_slide_collision_count(): var collision = get_slide_collision(i) var collider = collision.get_collider() # Break on wall collision (pots and boxes) if (object_type == "Pot" or object_type == "Box") and _is_wall_collider(collider): # Only process on server to prevent duplicates if not multiplayer.is_server(): continue has_dealt_damage = true # Mark as dealt damage (wall hit counts) if is_destroyable: if multiplayer.has_multiplayer_peer(): var game_world = get_tree().get_first_node_in_group("game_world") if game_world and game_world.has_method("_rpc_to_ready_peers"): game_world._rpc_to_ready_peers("_sync_object_break", [name]) _break_into_pieces() return # Hit an enemy! Damage them if collider.is_in_group("enemy"): # Only process collision on server to prevent duplicates if not multiplayer.is_server(): continue has_dealt_damage = true # Mark as dealt damage - can't damage anything else now # Damage enemy (pots deal less damage than boxes) # Enemy's take_damage() already handles defense calculation if collider.has_method("take_damage"): var attacker_pos = thrown_by_player.global_position if thrown_by_player and is_instance_valid(thrown_by_player) else global_position var base_damage = 10.0 if object_type == "Pot" else 15.0 collider.take_damage(base_damage, attacker_pos) print(name, " hit enemy ", collider.name, " with thrown object (", base_damage, " base damage, defense will reduce)!") # Box breaks (only if destroyable) if is_destroyable: # Sync break to OTHER clients via RPC BEFORE breaking locally # Use game_world to route the RPC to avoid node path resolution issues if multiplayer.has_multiplayer_peer() and multiplayer.is_server(): var game_world = get_tree().get_first_node_in_group("game_world") if game_world and game_world.has_method("_rpc_to_ready_peers"): game_world._rpc_to_ready_peers("_sync_object_break", [name]) _break_into_pieces() return if collider.is_in_group("player"): # Ignore collision with the player who threw this box if collider == thrown_by_player: continue # Only process collision on server to prevent duplicates if not multiplayer.is_server(): continue has_dealt_damage = true # Mark as dealt damage - can't damage anything else now # Hit a player! Break locally and sync to others (only if destroyable) if is_destroyable: # Sync break to OTHER clients via RPC BEFORE breaking locally # Use game_world to route the RPC to avoid node path resolution issues if multiplayer.has_multiplayer_peer() and multiplayer.is_server(): var game_world = get_tree().get_first_node_in_group("game_world") if game_world and game_world.has_method("_rpc_to_ready_peers"): game_world._rpc_to_ready_peers("_sync_object_break", [name]) _break_into_pieces() # Damage and knockback player using RPC (pots deal less damage than boxes) # Player's take_damage() already handles defense calculation # Pass the thrower's position for accurate direction if collider.has_method("rpc_take_damage"): var attacker_pos = thrown_by_player.global_position if thrown_by_player and is_instance_valid(thrown_by_player) else global_position var base_damage = 7.0 if object_type == "Pot" else 10.0 var player_peer_id = collider.get_multiplayer_authority() if player_peer_id != 0: # If target peer is the same as server (us), call directly # rpc_id() might not execute locally when called to same peer if multiplayer.is_server() and player_peer_id == multiplayer.get_unique_id(): # Call directly on the same peer collider.rpc_take_damage(base_damage, attacker_pos) else: # Send RPC to remote peer collider.rpc_take_damage.rpc_id(player_peer_id, base_damage, attacker_pos) else: # Fallback: broadcast if we can't get peer_id collider.rpc_take_damage.rpc(base_damage, attacker_pos) print(name, " hit player ", collider.name, "!") return elif "throw_velocity" in collider and "is_grabbable" in collider: # Another box # Only process collision on server to prevent duplicates if not multiplayer.is_server(): continue has_dealt_damage = true # Mark as dealt damage - can't damage anything else now # Hit another box! Break both locally (only if destroyable) if is_destroyable: # Sync break to OTHER clients via RPC BEFORE breaking locally # Use game_world to route the RPC to avoid node path resolution issues if multiplayer.has_multiplayer_peer() and multiplayer.is_server(): var game_world = get_tree().get_first_node_in_group("game_world") if game_world and game_world.has_method("_rpc_to_ready_peers"): game_world._rpc_to_ready_peers("_sync_object_break", [name]) # Tell the other box to break too if collider.has_method("can_be_destroyed") and collider.can_be_destroyed(): game_world._rpc_to_ready_peers("_sync_object_break", [collider.name]) _break_into_pieces() if collider.has_method("_break_into_pieces") and collider.has_method("can_be_destroyed") and collider.can_be_destroyed(): collider._break_into_pieces() print(name, " hit another box!") return func _break_into_pieces(silent: bool = false): # Only break if destroyable if not is_destroyable or is_broken: return is_broken = true var sprite_texture = $Sprite2D.texture var frame_width = sprite_texture.get_width() / $Sprite2D.hframes var frame_height = sprite_texture.get_height() / $Sprite2D.vframes var frame_x = ($Sprite2D.frame % $Sprite2D.hframes) * frame_width var frame_y = ($Sprite2D.frame / $Sprite2D.hframes) * frame_height # Create 4 particles with different directions and different parts of the texture var directions = [ Vector2(-1, -1).normalized(), # Top-left Vector2(1, -1).normalized(), # Top-right Vector2(-1, 1).normalized(), # Bottom-left Vector2(1, 1).normalized() # Bottom-right ] var regions = [ Rect2(frame_x, frame_y, frame_width / 2, frame_height / 2), # Top-left Rect2(frame_x + frame_width / 2, frame_y, frame_width / 2, frame_height / 2), # Top-right Rect2(frame_x, frame_y + frame_height / 2, frame_width / 2, frame_height / 2), # Bottom-left Rect2(frame_x + frame_width / 2, frame_y + frame_height / 2, frame_width / 2, frame_height / 2) # Bottom-right ] if not silent: for i in range(4): var tp = tileParticleScene.instantiate() as CharacterBody2D var spr2D = tp.get_node("Sprite2D") as Sprite2D tp.global_position = global_position # Set up the sprite's texture and region spr2D.texture = sprite_texture spr2D.region_enabled = true spr2D.region_rect = regions[i] # Add some randomness to the velocity var speed = randf_range(170, 200) var dir = directions[i] + Vector2(randf_range(-0.2, 0.2), randf_range(-0.2, 0.2)) tp.velocity = dir * speed # Add some rotation tp.angular_velocity = randf_range(-7, 7) get_parent().call_deferred("add_child", tp) play_destroy_sound() self.set_deferred("collision_layer", 0) $Shadow.visible = false $Sprite2DAbove.visible = false $Sprite2D.visible = false # Spawn item loot when breaking (30% chance) if is_multiplayer_authority(): var drop_chance = randf() if drop_chance < 0.3: # 30% chance to drop item var item = ItemDatabase.get_random_container_item() if item: var entities_node = get_parent() var game_world = get_tree().get_first_node_in_group("game_world") if entities_node and game_world: ItemLootHelper.spawn_item_loot(item, global_position, entities_node, game_world) print(name, " dropped item: ", item.item_name, " when broken") if not silent: if ($SfxShatter.playing): await $SfxShatter.finished if ($SfxBreakCrate.playing): await $SfxBreakCrate.finished # Remove self queue_free() func _is_wall_collider(collider) -> bool: if not collider: return false # TileMapLayer collisions if collider is TileMapLayer: return true # Any CollisionObject2D with wall layer (7) enabled if collider is CollisionObject2D and collider.get_collision_layer_value(7): return true return false func can_be_grabbed() -> bool: return is_grabbable and not is_being_held func _get_configured_object_type() -> String: # Prefer the configured type from dungeon data if available var idx = -1 if name.begins_with("InteractableObject_"): var index_str = name.substr(20) if index_str.is_valid_int(): idx = index_str.to_int() elif has_meta("object_index"): idx = get_meta("object_index") if idx >= 0: var game_world = get_tree().get_first_node_in_group("game_world") if game_world and "dungeon_data" in game_world and game_world.dungeon_data.has("interactable_objects"): var objects = game_world.dungeon_data.interactable_objects if idx < objects.size(): var obj_data = objects[idx] if obj_data is Dictionary and obj_data.has("type"): return obj_data.type return object_type func can_be_lifted() -> bool: # Can be lifted if it's liftable (being held is OK - we're checking if it CAN be lifted) var resolved_type = object_type if resolved_type == "": resolved_type = _get_configured_object_type() if resolved_type in ["Box", "Pot", "LiftableBarrel"]: return true if resolved_type in ["Chest", "Pillar", "PushableBarrel", "PushableHighBox"]: return false return is_liftable func can_be_thrown() -> bool: # Can be thrown if it's liftable (being held is OK - we're checking if it CAN be thrown) return is_liftable func can_be_destroyed() -> bool: return is_destroyable func _is_wooden_burnable() -> bool: var t = object_type if object_type != "" else _get_configured_object_type() return t in ["Box", "Pot", "LiftableBarrel", "PushableBarrel", "PushableHighBox"] func take_fire_damage(amount: float, _attacker_position: Vector2) -> void: if not is_destroyable or is_broken or not _is_wooden_burnable(): return health -= amount if health > 0: return var game_world = get_tree().get_first_node_in_group("game_world") if multiplayer.has_multiplayer_peer(): if multiplayer.is_server(): if game_world and game_world.has_method("_rpc_to_ready_peers"): game_world._rpc_to_ready_peers("_sync_object_break", [name]) _break_into_pieces() else: if game_world and game_world.has_method("_sync_object_break"): game_world._sync_object_break.rpc_id(1, name) else: _break_into_pieces() func take_damage(amount: float, _from_position: Vector2) -> void: """Generic damage from bomb, frost spike, etc. Any destroyable object.""" if not is_destroyable or is_broken: return health -= amount if health > 0: return var game_world = get_tree().get_first_node_in_group("game_world") if multiplayer.has_multiplayer_peer(): if multiplayer.is_server(): if game_world and game_world.has_method("_rpc_to_ready_peers"): game_world._rpc_to_ready_peers("_sync_object_break", [name]) _break_into_pieces() else: if game_world and game_world.has_method("_sync_object_break"): game_world._sync_object_break.rpc_id(1, name) else: _break_into_pieces() func on_grabbed(by_player): # Special handling for chests - open instead of grab if object_type == "Chest" and not is_chest_opened: # In multiplayer, send RPC to server if client is opening if multiplayer.has_multiplayer_peer() and not is_multiplayer_authority(): # Client - send request to server if by_player: var player_peer_id = by_player.get_multiplayer_authority() # Use consistent object name based on object_index to avoid NodePath issues var chest_name = name if has_meta("object_index"): chest_name = "InteractableObject_%d" % get_meta("object_index") print("Chest: Client sending RPC to open chest, player_peer_id: ", player_peer_id, " chest_name: ", chest_name) var game_world = get_tree().get_first_node_in_group("game_world") if game_world and game_world.has_method("_request_chest_open_by_name"): game_world._request_chest_open_by_name.rpc_id(1, chest_name, player_peer_id) else: push_warning("Chest: GameWorld not ready, cannot send chest open request for " + chest_name) else: # Server or single player - open directly _open_chest(by_player) return # CRITICAL: Return early to prevent normal grab behavior is_being_held = true held_by_player = by_player has_dealt_damage = false # Reset damage flag when picked up print(name, " grabbed by ", by_player.name) func on_lifted(by_player): # Called when object is lifted above head # Note: The check for is_liftable is done in can_be_lifted(), not here # This function is called after the check passes, so we can proceed is_frozen = true throw_velocity = Vector2.ZERO print(name, " lifted by ", by_player.name) func on_released(by_player): is_being_held = false held_by_player = null is_frozen = false is_airborne = false position_z = 0.0 velocity_z = 0.0 throw_velocity = Vector2.ZERO has_dealt_damage = false # Reset damage flag when released # Re-enable collision (in case it was disabled) set_collision_layer_value(2, true) set_collision_mask_value(1, true) set_collision_mask_value(2, true) # Reset sprite and shadow visuals if sprite: sprite.position.y = 0 sprite.scale = Vector2(1.0, 1.0) # Set to 1,1 scale instead of BASE_SCALE if shadow: shadow.scale = Vector2(1.0, 1.0) # Set to 1,1 scale instead of BASE_SCALE shadow.modulate.a = 0.5 print(name, " released by ", by_player.name) func on_thrown(by_player, force: Vector2): # Special handling for bombs - convert to projectile when thrown if object_type == "Bomb": _convert_to_bomb_projectile(by_player, force) return # Only allow throwing if object is liftable if not is_liftable: return is_being_held = false held_by_player = null thrown_by_player = by_player # Remember who threw this is_frozen = false has_dealt_damage = false # Reset damage flag - this throw can deal damage to ONE target # Set throw velocity (affected by weight) - increased for longer arc throw_velocity = force / weight # Make airborne with same physics as players is_airborne = true position_z = 2.5 velocity_z = 100.0 # Scaled down for 1x scale print(name, " thrown with velocity ", throw_velocity) func _convert_to_bomb_projectile(by_player, force: Vector2): # Convert bomb object to projectile bomb when thrown var attack_bomb_scene = load("res://scenes/attack_bomb.tscn") if not attack_bomb_scene: push_error("ERROR: Could not load attack_bomb scene!") return # Only authority can spawn bombs if not is_multiplayer_authority(): return # Store current position before freeing var current_pos = global_position # Spawn bomb projectile at current position var bomb = attack_bomb_scene.instantiate() bomb.name = "ThrownBomb_" + name get_parent().add_child(bomb) bomb.global_position = current_pos # Use current position, not target # Set multiplayer authority if multiplayer.has_multiplayer_peer(): bomb.set_multiplayer_authority(by_player.get_multiplayer_authority()) # Setup bomb with throw physics (pass force as throw_velocity) # The bomb will use throw_velocity for movement bomb.setup(current_pos, by_player, force, true) # true = is_thrown, force is throw_velocity # Make sure bomb sprite is visible if bomb.has_node("Sprite2D"): bomb.get_node("Sprite2D").visible = true # Sync bomb throw to other clients (pass our name so they can free the lifted bomb) if multiplayer.has_multiplayer_peer() and by_player.is_multiplayer_authority() and by_player.has_method("_rpc_to_ready_peers") and by_player.is_inside_tree(): by_player._rpc_to_ready_peers("_sync_throw_bomb", [name, current_pos, force]) # Remove the interactable object queue_free() print("Bomb object converted to projectile and thrown!") @rpc("authority", "unreliable") func _sync_box_state(pos: Vector2, vel: Vector2, z_pos: float, z_vel: float, airborne: bool): # Only update on clients (server already has correct state) if not multiplayer.is_server(): # Only update if we're not holding this box if not is_being_held: global_position = pos throw_velocity = vel position_z = z_pos velocity_z = z_vel is_airborne = airborne @rpc("any_peer", "reliable") func _sync_break(silent: bool = false): # Sync break to all clients including server (called by whoever breaks the box) if not is_queued_for_deletion() and not is_broken: _break_into_pieces(silent) # Object type setup functions func setup_pot(): object_type = "Pot" is_grabbable = true can_be_pushed = true is_destroyable = true is_liftable = true weight = 0.8 # Pots are very light and easy to throw far! var pot_frames = [1, 2, 3, 20, 21, 22, 58] if sprite: var box_seed = 0 var game_world = get_tree().get_first_node_in_group("game_world") if game_world and "dungeon_seed" in game_world: box_seed = game_world.dungeon_seed # Add position and object_index to seed to make each box unique but deterministic box_seed += int(global_position.x) * 1000 + int(global_position.y) if has_meta("object_index"): box_seed += get_meta("object_index") * 10000 var rng = RandomNumberGenerator.new() rng.seed = box_seed var index = rng.randi() % pot_frames.size() sprite.frame = pot_frames[index] func setup_liftable_barrel(): object_type = "LiftableBarrel" is_grabbable = true can_be_pushed = true is_destroyable = true is_liftable = true weight = 1.0 var barrel_frames = [4, 23] if sprite: var box_seed = 0 var game_world = get_tree().get_first_node_in_group("game_world") if game_world and "dungeon_seed" in game_world: box_seed = game_world.dungeon_seed # Add position and object_index to seed to make each box unique but deterministic box_seed += int(global_position.x) * 1000 + int(global_position.y) if has_meta("object_index"): box_seed += get_meta("object_index") * 10000 var rng = RandomNumberGenerator.new() rng.seed = box_seed var index = rng.randi() % barrel_frames.size() sprite.frame = barrel_frames[index] func setup_pushable_barrel(): object_type = "PushableBarrel" is_grabbable = true can_be_pushed = true is_destroyable = true is_liftable = false weight = 1.0 if sprite: sprite.frame = 42 func setup_box(): object_type = "Box" is_grabbable = true can_be_pushed = true is_destroyable = true is_liftable = true weight = 1.5 # Boxes are heavier than pots var box_frames = [7, 26] if sprite: # Use deterministic randomness based on dungeon seed and position # This ensures host and clients get the same box variant var box_seed = 0 var game_world = get_tree().get_first_node_in_group("game_world") if game_world and "dungeon_seed" in game_world: box_seed = game_world.dungeon_seed # Add position and object_index to seed to make each box unique but deterministic box_seed += int(global_position.x) * 1000 + int(global_position.y) if has_meta("object_index"): box_seed += get_meta("object_index") * 10000 var rng = RandomNumberGenerator.new() rng.seed = box_seed var index = rng.randi() % box_frames.size() sprite.frame = box_frames[index] func setup_chest(): object_type = "Chest" is_grabbable = true can_be_pushed = false is_destroyable = false is_liftable = false weight = 1.0 var chest_frames = [12, 31] var opened_frames = [13, 32] # Use deterministic randomness based on dungeon seed and position # This ensures host and clients get the same chest variant var chest_seed = 0 var game_world = get_tree().get_first_node_in_group("game_world") if game_world and "dungeon_seed" in game_world: chest_seed = game_world.dungeon_seed # Add position to seed to make each chest unique but deterministic chest_seed += int(global_position.x) * 1000 + int(global_position.y) var rng = RandomNumberGenerator.new() rng.seed = chest_seed var index = rng.randi() % chest_frames.size() chest_closed_frame = chest_frames[index] chest_opened_frame = opened_frames[index] if sprite: sprite.frame = chest_closed_frame func setup_pillar(): object_type = "Pillar" is_grabbable = true can_be_pushed = true is_destroyable = false is_liftable = false weight = 5.0 if sprite: sprite.frame = 30 if sprite_above: sprite_above.frame = 11 func setup_pushable_high_box(): object_type = "PushableHighBox" is_grabbable = true can_be_pushed = true is_destroyable = true is_liftable = false weight = 1.0 var bottom_frames = [24, 25] var top_frames = [5, 6] # Use deterministic randomness based on dungeon seed and position # This ensures host and clients get the same chest variant 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 # Add position to seed to make each chest unique but deterministic highbox_seed += int(global_position.x) * 1000 + int(global_position.y) var rng = RandomNumberGenerator.new() rng.seed = highbox_seed var index = rng.randi() % bottom_frames.size() if sprite: sprite.frame = bottom_frames[index] if sprite_above: sprite_above.frame = top_frames[index] func setup_bomb(): object_type = "Bomb" is_grabbable = true can_be_pushed = false is_destroyable = false # Bombs don't break, they explode is_liftable = true weight = 0.5 # Light weight for easy throwing # Set bomb sprite (frame 199 from items_n_shit.png) if sprite: sprite.texture = load("res://assets/gfx/pickups/items_n_shit.png") sprite.hframes = 20 sprite.vframes = 14 sprite.frame = 199 func _open_chest(by_player: Node = null): if is_chest_opened: return # Only process on server (authority) if multiplayer.has_multiplayer_peer() and not is_multiplayer_authority(): return $SfxOpenChest.play() is_chest_opened = true # Track opened chest for syncing to new clients if multiplayer.has_multiplayer_peer() and multiplayer.is_server(): var world = get_tree().get_first_node_in_group("game_world") if world and has_meta("object_index"): var obj_index = get_meta("object_index") world.opened_chests[obj_index] = true LogManager.log("Chest: Tracked opened chest with index " + str(obj_index), LogManager.CATEGORY_NETWORK) if sprite and chest_opened_frame >= 0: sprite.frame = chest_opened_frame # Get random item from entire item database (using chest rarity weights) # Use deterministic randomness based on dungeon seed and chest position var chest_seed = 0 var game_world = get_tree().get_first_node_in_group("game_world") if game_world and "dungeon_seed" in game_world: chest_seed = game_world.dungeon_seed # Add position to seed to make each chest unique but deterministic chest_seed += int(global_position.x) * 1000 + int(global_position.y) # Create deterministic RNG for this chest var chest_rng = RandomNumberGenerator.new() chest_rng.seed = chest_seed # Get random item using deterministic RNG # We need to manually select by rarity since get_random_chest_item() uses global randi() var rarity_roll = chest_rng.randf() var rarity: ItemDatabase.ItemRarity if rarity_roll < 0.4: rarity = ItemDatabase.ItemRarity.COMMON elif rarity_roll < 0.75: rarity = ItemDatabase.ItemRarity.UNCOMMON elif rarity_roll < 0.95: rarity = ItemDatabase.ItemRarity.RARE else: rarity = ItemDatabase.ItemRarity.EPIC if chest_rng.randf() < 0.5 else ItemDatabase.ItemRarity.CONSUMABLE # Get candidates for this rarity using deterministic RNG ItemDatabase._initialize() var candidates = [] # Access static item_definitions directly for item_id in ItemDatabase.item_definitions.keys(): var item_data = ItemDatabase.item_definitions[item_id] if item_data.has("rarity") and item_data["rarity"] == rarity: candidates.append(item_id) # Fallback to common if no candidates if candidates.is_empty(): for item_id in ItemDatabase.item_definitions.keys(): var item_data = ItemDatabase.item_definitions[item_id] if item_data.has("rarity") and item_data["rarity"] == ItemDatabase.ItemRarity.COMMON: candidates.append(item_id) # Select random item from candidates using deterministic RNG var random_item_id = candidates[chest_rng.randi() % candidates.size()] if not candidates.is_empty() else null var chest_item = ItemDatabase.create_item(random_item_id) if random_item_id else null # CRITICAL: Instantly give item to player instead of spawning loot object if by_player and is_instance_valid(by_player) and by_player.is_in_group("player") and chest_item: # Add item to player inventory if by_player.character_stats: var was_encumbered = by_player.character_stats.is_over_encumbered() by_player.character_stats.add_item(chest_item) if not was_encumbered and by_player.character_stats.is_over_encumbered(): if by_player.has_method("show_floating_status"): by_player.show_floating_status("Encumbered!", Color(1.0, 0.5, 0.2)) # Show pickup notification var items_texture = load(chest_item.spritePath) if chest_item.spritePath != "" else null var display_text = chest_item.item_name.to_upper() var item_color = Color.WHITE # Determine color based on item type/rarity if chest_item.item_type == Item.ItemType.Restoration: item_color = Color.GREEN elif chest_item.item_type == Item.ItemType.Equippable: item_color = Color.CYAN # Cyan for equipment (matches loot pickup color) else: item_color = Color.WHITE # Show notification with item sprite (pass chest_item for ItemSprite colorization) if items_texture: _show_item_pickup_notification(by_player, display_text, item_color, items_texture, chest_item.spriteFrames.x, chest_item.spriteFrames.y, chest_item.spriteFrame, chest_item) else: _show_item_pickup_notification(by_player, display_text, item_color, null, 0, 0, 0, chest_item) # Play chest open sound if has_node("SfxChestOpen"): $SfxChestOpen.play() print(name, " opened by ", by_player.name, "! Item given: ", chest_item.item_name) # Sync chest opening visual to all clients (item already given on server) if multiplayer.has_multiplayer_peer(): var player_peer_id = by_player.get_multiplayer_authority() if by_player else 0 # Reuse game_world from earlier in the function if game_world and game_world.has_method("_rpc_to_ready_peers"): var chest_name = name if has_meta("object_index"): chest_name = "InteractableObject_%d" % get_meta("object_index") # Sync chest open visual with item_data so clients can show the floating text var item_data = chest_item.save() if chest_item else {} game_world._rpc_to_ready_peers("_sync_chest_open_by_name", [chest_name, "item", player_peer_id, item_data]) # Sync inventory+equipment to joiner (server added item; joiner's client must apply) if multiplayer.is_server(): var owner_id = by_player.get_multiplayer_authority() if owner_id != 1 and owner_id != multiplayer.get_unique_id(): var inv_data: Array = [] for inv_item in by_player.character_stats.inventory: inv_data.append(inv_item.save() if inv_item else null) var equip_data: Dictionary = {} for slot_name in by_player.character_stats.equipment.keys(): var eq = by_player.character_stats.equipment[slot_name] equip_data[slot_name] = eq.save() if eq else null if by_player.has_method("_apply_inventory_and_equipment_from_server"): by_player._apply_inventory_and_equipment_from_server.rpc_id(owner_id, inv_data, equip_data) else: push_error("Chest: ERROR - No valid player to give item to!") @rpc("any_peer", "reliable") func _request_chest_open(player_peer_id: int): # Server receives chest open request from client if not multiplayer.is_server(): return print("Chest: Server received RPC to open chest, player_peer_id: ", player_peer_id) if is_chest_opened: print("Chest: Chest already opened, ignoring request") return # Find the 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: push_error("Chest: ERROR - Could not find player with peer_id ", player_peer_id, " for chest opening!") return print("Chest: Found player ", player.name, " for peer_id ", player_peer_id, ", opening chest") # Open chest on server (this will give item to player) _open_chest(player) @rpc("any_peer", "reliable") func _sync_chest_open(loot_type_str: String = "coin", player_peer_id: int = 0, item_data: Dictionary = {}): # Sync chest opening to all clients (only visual - item already given on server) if not is_chest_opened and sprite and chest_opened_frame >= 0: is_chest_opened = true sprite.frame = chest_opened_frame # Play chest open sound on clients if has_node("SfxChestOpen"): $SfxChestOpen.play() # Show pickup notification on client side if player_peer_id > 0: var players = get_tree().get_nodes_in_group("player") var player = null for p in players: if p.get_multiplayer_authority() == player_peer_id: player = p break if player and is_instance_valid(player): # If item_data is provided, use it to show item notification if not item_data.is_empty(): var chest_item = Item.new(item_data) var items_texture = load(chest_item.spritePath) if chest_item.spritePath != "" else null var display_text = chest_item.item_name.to_upper() var item_color = Color.WHITE if chest_item.item_type == Item.ItemType.Restoration: item_color = Color.GREEN elif chest_item.item_type == Item.ItemType.Equippable: item_color = Color.CYAN # Cyan for equipment (matches loot pickup color) if items_texture: _show_item_pickup_notification(player, display_text, item_color, items_texture, chest_item.spriteFrames.x, chest_item.spriteFrames.y, chest_item.spriteFrame, chest_item) else: _show_item_pickup_notification(player, display_text, item_color, null, 0, 0, 0, chest_item) else: # Fallback to old loot type system (for backwards compatibility) match loot_type_str: "coin": var coin_texture = load("res://assets/gfx/pickups/gold_coin.png") _show_item_pickup_notification(player, "+1 COIN", Color(1.0, 0.84, 0.0), coin_texture, 6, 1, 0) "apple": var heal_amount = 20.0 var items_texture = load("res://assets/gfx/pickups/items_n_shit.png") _show_item_pickup_notification(player, "+" + str(int(heal_amount)) + " HP", Color.GREEN, items_texture, 20, 14, (8 * 20) + 10) "banana": var heal_amount = 20.0 var items_texture = load("res://assets/gfx/pickups/items_n_shit.png") _show_item_pickup_notification(player, "+" + str(int(heal_amount)) + " HP", Color.YELLOW, items_texture, 20, 14, (8 * 20) + 11) "cherry": var heal_amount = 20.0 var items_texture = load("res://assets/gfx/pickups/items_n_shit.png") _show_item_pickup_notification(player, "+" + str(int(heal_amount)) + " HP", Color.RED, items_texture, 20, 14, (8 * 20) + 12) "key": var items_texture = load("res://assets/gfx/pickups/items_n_shit.png") _show_item_pickup_notification(player, "+1 KEY", Color.YELLOW, items_texture, 20, 14, (13 * 20) + 10) func _show_item_pickup_notification(player: Node, text: String, text_color: Color, item_texture: Texture2D = null, sprite_hframes: int = 1, sprite_vframes: int = 1, sprite_frame: int = 0, item = null): # Show item graphic and text above player's head for 0.5s, then fade out over 0.5s 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) # Position at player.position.y - 20 (just above head) floating_text.global_position = Vector2(player.global_position.x, player.global_position.y - 20) floating_text.setup(text, text_color, 0.5, 0.5, item_texture, sprite_hframes, sprite_vframes, sprite_frame, item) func play_destroy_sound(): if object_type == "Pot": $SfxShatter.play() else: $SfxBreakCrate.play() $DragParticles.emitting = true $DragParticles/TimerSmokeParticles.start() pass func play_drag_sound(): if object_type == "Pot": if !$SfxDrag.playing: $SfxDrag.play() else: if !$SfxDragRock.playing: $SfxDragRock.play() $DragParticles.emitting = true pass func stop_drag_sound(): $SfxDrag.stop() $SfxDragRock.stop() $DragParticles.emitting = false pass func _on_timer_smoke_particles_timeout() -> void: $DragParticles.emitting = false pass # Replace with function body.