Files
DungeonsOfKharadum/src/scripts/interactable_object.gd
2026-01-25 21:31:33 +01:00

1027 lines
37 KiB
GDScript

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.