1027 lines
37 KiB
GDScript
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.
|