extends CharacterBody2D # Player Character - Top-down movement and interaction # Character stats system var character_stats: CharacterStats var appearance_rng: RandomNumberGenerator # Deterministic RNG for appearance/stats @export var move_speed: float = 100.0 @export var grab_range: float = 20.0 @export var throw_force: float = 150.0 # Network identity var peer_id: int = 1 var local_player_index: int = 0 var is_local_player: bool = false var can_send_rpcs: bool = false # Flag to prevent RPCs until player is fully initialized var all_clients_ready: bool = false # Server only: true when all clients have notified they're ready var all_clients_ready_time: float = 0.0 # Server only: time when all_clients_ready was set to true var teleported_this_frame: bool = false # Flag to prevent position sync from overriding teleportation # Input device (for local multiplayer) var input_device: int = -1 # -1 for keyboard, 0+ for gamepad index # Interaction var held_object = null var grab_offset = Vector2.ZERO var can_grab = true var is_lifting = false # True when object is lifted above head var is_pushing = false # True when holding button to push/pull var grab_button_pressed_time = 0.0 var just_grabbed_this_frame = false # Prevents immediate release bug - persists across frames var grab_start_time = 0.0 # Time when we first grabbed (to detect quick tap) var grab_tap_threshold = 0.2 # Seconds to distinguish tap from hold var last_movement_direction = Vector2.DOWN # Track last direction for placing objects var push_axis = Vector2.ZERO # Locked axis for pushing/pulling var push_direction_locked: int = Direction.DOWN # Locked facing direction when pushing var initial_grab_position = Vector2.ZERO # Position of grabbed object when first grabbed var initial_player_position = Vector2.ZERO # Position of player when first grabbed var object_blocked_by_wall = false # True if pushed object is blocked by a wall var was_dragging_last_frame = false # Track if we were dragging last frame to detect start/stop # Level complete state var controls_disabled: bool = false # True when player has reached exit and controls should be disabled # Being held state var being_held_by: Node = null var struggle_time: float = 0.0 var struggle_threshold: float = 0.8 # Seconds to break free var struggle_direction: Vector2 = Vector2.ZERO # Knockback state var is_knocked_back: bool = false var knockback_time: float = 0.0 var knockback_duration: float = 0.3 # How long knockback lasts # Attack/Combat var can_attack: bool = true var attack_cooldown: float = 0.0 # No cooldown - instant attacks! var is_attacking: bool = false var sword_slash_scene = preload("res://scenes/sword_slash.tscn") # Old rotating version (kept for reference) var sword_projectile_scene = preload("res://scenes/sword_projectile.tscn") # New projectile version var blood_scene = preload("res://scenes/blood_clot.tscn") # Simulated Z-axis for height (when thrown) var position_z: float = 0.0 var velocity_z: float = 0.0 var gravity_z: float = 500.0 # Gravity pulling down (scaled for 1x scale) var is_airborne: bool = false # Components # @onready var sprite = $Sprite2D # REMOVED: Now using layered sprites @onready var shadow = $Shadow @onready var collision_shape = $CollisionShape2D @onready var grab_area = $GrabArea @onready var interaction_indicator = $InteractionIndicator # Audio @onready var sfx_walk = $SfxWalk @onready var timer_walk = $SfxWalk/TimerWalk @onready var sfx_take_damage = $SfxTakeDamage @onready var sfx_die = $SfxDie # Character sprite layers @onready var sprite_body = $Sprite2DBody @onready var sprite_boots = $Sprite2DBoots @onready var sprite_armour = $Sprite2DArmour @onready var sprite_facial_hair = $Sprite2DFacialHair @onready var sprite_hair = $Sprite2DHair @onready var sprite_eyes = $Sprite2DEyes @onready var sprite_eyelashes = $Sprite2DEyeLashes @onready var sprite_addons = $Sprite2DAddons @onready var sprite_headgear = $Sprite2DHeadgear @onready var sprite_weapon = $Sprite2DWeapon # Player stats (legacy - now using character_stats) var max_health: float: get: return character_stats.maxhp if character_stats else 100.0 var current_health: float: get: return character_stats.hp if character_stats else 100.0 set(value): if character_stats: character_stats.hp = value var is_dead: bool = false var is_processing_death: bool = false # Prevent multiple death sequences var respawn_point: Vector2 = Vector2.ZERO var coins: int: get: return character_stats.coin if character_stats else 0 set(value): if character_stats: character_stats.coin = value # Key inventory var keys: int = 0 # Number of keys the player has # Animation system enum Direction { DOWN = 0, DOWN_RIGHT = 1, RIGHT = 2, UP_RIGHT = 3, UP = 4, UP_LEFT = 5, LEFT = 6, DOWN_LEFT = 7 } const ANIMATIONS = { "IDLE": { "frames": [0, 1], "frameDurations": [500, 500], "loop": true, "nextAnimation": null }, "RUN": { "frames": [3, 2, 3, 4], "frameDurations": [140, 140, 140, 140], "loop": true, "nextAnimation": null }, "SWORD": { "frames": [5, 6, 7, 8], "frameDurations": [40, 60, 90, 80], "loop": false, "nextAnimation": "IDLE" }, "AXE": { "frames": [5, 6, 7, 8], "frameDurations": [50, 70, 100, 90], "loop": false, "nextAnimation": "IDLE" }, "PUNCH": { "frames": [16, 17, 18], "frameDurations": [50, 70, 100], "loop": false, "nextAnimation": "IDLE" }, "BOW": { "frames": [9, 10, 11, 12], "frameDurations": [80, 110, 110, 80], "loop": false, "nextAnimation": "IDLE" }, "STAFF": { "frames": [13, 14, 15], "frameDurations": [200, 200, 400], "loop": false, "nextAnimation": "IDLE" }, "THROW": { "frames": [16, 17, 18], "frameDurations": [80, 80, 300], "loop": false, "nextAnimation": "IDLE" }, "CONJURE": { "frames": [19], "frameDurations": [400], "loop": false, "nextAnimation": "IDLE" }, "DAMAGE": { "frames": [20, 21], "frameDurations": [150, 150], "loop": false, "nextAnimation": "IDLE" }, "DIE": { "frames": [21, 22, 23, 24], "frameDurations": [200, 200, 200, 800], "loop": false, "nextAnimation": null }, "IDLE_HOLD": { "frames": [25], "frameDurations": [500], "loop": true, "nextAnimation": null }, "RUN_HOLD": { "frames": [25, 26, 25, 27], "frameDurations": [150, 150, 150, 150], "loop": true, "nextAnimation": null }, "JUMP": { "frames": [25, 26, 27, 28], "frameDurations": [80, 80, 80, 800], "loop": false, "nextAnimation": "IDLE" }, "LIFT": { "frames": [19, 30], "frameDurations": [70, 70], "loop": false, "nextAnimation": "IDLE_HOLD" }, "IDLE_PUSH": { "frames": [30], "frameDurations": [10], "loop": true, "nextAnimation": null }, "RUN_PUSH": { "frames": [30, 29, 30, 31], "frameDurations": [260, 260, 260, 260], "loop": true, "nextAnimation": null } } var current_animation = "IDLE" var current_frame = 0 var current_direction = Direction.DOWN var time_since_last_frame = 0.0 func _ready(): # Add to player group for easy identification add_to_group("player") # Set respawn point to starting position respawn_point = global_position # Set up input device based on local player index if is_local_player: if local_player_index == 0: input_device = -1 # Keyboard for first local player else: input_device = local_player_index - 1 # Gamepad for others # Initialize character stats system _initialize_character_stats() # Set up player appearance (randomized based on stats) _setup_player_appearance() # Authority is set by player_manager after adding to scene # Just log it here print("Player ", name, " ready. Authority: ", get_multiplayer_authority(), " Is local: ", is_local_player) # Hide interaction indicator by default if interaction_indicator: interaction_indicator.visible = false # Wait before allowing RPCs to ensure player is fully spawned on all clients # This prevents "Node not found" errors when RPCs try to resolve node paths if multiplayer.is_server(): # Server: wait for all clients to be ready # First wait a bit for initial setup await get_tree().process_frame await get_tree().process_frame # Notify server that this player is ready (if we're a client-controlled player) # Actually, server players don't need to notify - only clients do # But we need to wait for all clients to be ready var game_world = get_tree().get_first_node_in_group("game_world") if game_world: # Wait for all connected clients to be ready var max_wait_time = 5.0 # Maximum wait time (5 seconds) var check_interval = 0.1 # Check every 0.1 seconds var waited = 0.0 var all_ready = false # Declare outside loop while waited < max_wait_time: all_ready = true var connected_peers = multiplayer.get_peers() # Check if all connected peers (except server) are ready for connected_peer_id in connected_peers: if connected_peer_id != multiplayer.get_unique_id(): # Skip server if not game_world.clients_ready.has(connected_peer_id) or not game_world.clients_ready[connected_peer_id]: all_ready = false break # If no peers, we can start sending RPCs (no clients to wait for) # But we'll still check in _physics_process in case clients connect later if connected_peers.size() == 0: all_ready = true # Note: We set all_ready = true, but if clients connect later, # _reset_server_players_ready_flag will reset all_clients_ready if all_ready: break await get_tree().create_timer(check_interval).timeout waited += check_interval if all_ready: all_clients_ready = true var connected_peers = multiplayer.get_peers() # Get peers again for logging print("Player ", name, " (server) - all clients ready: ", game_world.clients_ready, " connected_peers: ", connected_peers) else: # Not all clients ready yet, but we'll keep checking var connected_peers = multiplayer.get_peers() # Get peers again for logging print("Player ", name, " (server) - waiting for clients, ready: ", game_world.clients_ready, " connected_peers: ", connected_peers) # Set up a signal to re-check when clients become ready # We'll check again in _physics_process all_clients_ready = false else: # Client: wait until ALL players have been spawned before notifying server # This ensures Player_1_0 and other players exist before server starts sending RPCs await get_tree().process_frame await get_tree().process_frame await get_tree().process_frame # Wait for all players to be spawned var game_world = get_tree().get_first_node_in_group("game_world") var network_manager = get_node("/root/NetworkManager") if game_world and network_manager: # Check if all players from players_info have been spawned var max_wait = 3.0 # Maximum 3 seconds var check_interval = 0.1 var waited = 0.0 var all_players_spawned = false while waited < max_wait: all_players_spawned = true var player_manager = game_world.get_node_or_null("PlayerManager") if player_manager: # Check if all players from players_info exist for check_peer_id in network_manager.players_info.keys(): var info = network_manager.players_info[check_peer_id] for local_idx in range(info.local_player_count): var unique_id = "%d_%d" % [check_peer_id, local_idx] # Check if player exists in player_manager if not player_manager.players.has(unique_id): all_players_spawned = false break if all_players_spawned: break await get_tree().create_timer(check_interval).timeout waited += check_interval if all_players_spawned: # Wait a bit more after all players are spawned to ensure they're fully in scene tree await get_tree().create_timer(0.3).timeout var my_peer_id = multiplayer.get_unique_id() game_world._notify_client_ready.rpc_id(1, my_peer_id) # Send to server (peer 1) print("Player ", name, " (client) - notified server we're ready (all players spawned)") else: print("Player ", name, " (client) - timed out waiting for all players, notifying anyway") # Wait a bit even on timeout to ensure players are in scene tree await get_tree().create_timer(0.3).timeout var my_peer_id = multiplayer.get_unique_id() game_world._notify_client_ready.rpc_id(1, my_peer_id) # Send anyway after timeout can_send_rpcs = true print("Player ", name, " is now ready to send RPCs (is_server: ", multiplayer.is_server(), ")") func _initialize_character_stats(): # Create deterministic RNG based on peer_id and local_index for sync across clients appearance_rng = RandomNumberGenerator.new() var seed_value = hash(str(peer_id) + "_" + str(local_player_index)) appearance_rng.seed = seed_value print(name, " appearance/stats seed: ", seed_value, " (peer_id: ", peer_id, ", local_index: ", local_player_index, ")") # Create character stats character_stats = CharacterStats.new() character_stats.character_type = "player" character_stats.character_name = "Player_" + str(peer_id) + "_" + str(local_player_index) # Randomize base stats (deterministic) _randomize_stats() # Initialize health/mana from stats character_stats.hp = character_stats.maxhp character_stats.mp = character_stats.maxmp # Connect signals if character_stats: character_stats.level_up_stats.connect(_on_level_up_stats) character_stats.character_changed.connect(_on_character_changed) func _randomize_stats(): # Randomize base stats within reasonable ranges # Using deterministic RNG so all clients generate the same values character_stats.baseStats.str = appearance_rng.randi_range(8, 12) character_stats.baseStats.dex = appearance_rng.randi_range(8, 12) character_stats.baseStats.int = appearance_rng.randi_range(8, 12) character_stats.baseStats.end = appearance_rng.randi_range(8, 12) character_stats.baseStats.wis = appearance_rng.randi_range(8, 12) character_stats.baseStats.cha = appearance_rng.randi_range(8, 12) character_stats.baseStats.lck = appearance_rng.randi_range(8, 12) print(name, " randomized stats: STR=", character_stats.baseStats.str, " DEX=", character_stats.baseStats.dex, " INT=", character_stats.baseStats.int, " END=", character_stats.baseStats.end, " WIS=", character_stats.baseStats.wis, " CHA=", character_stats.baseStats.cha, " LCK=", character_stats.baseStats.lck) func _setup_player_appearance(): # Randomize appearance - players spawn "bare" (naked, no equipment) # But with randomized hair, facial hair, eyes, etc. # Randomize skin (human only for players) var skin_index = appearance_rng.randi_range(0, 6) # 0-6 for Human1-Human7 character_stats.setSkin(skin_index) # Randomize hairstyle (0 = none, 1-12 = various styles) var hair_style = appearance_rng.randi_range(0, 12) character_stats.setHair(hair_style) # Randomize hair color var hair_colors = [ Color.WHITE, Color.BLACK, Color(0.4, 0.2, 0.1), # Brown Color(0.8, 0.6, 0.4), # Blonde Color(0.6, 0.3, 0.1), # Dark brown Color(0.9, 0.7, 0.5), # Light blonde Color(0.2, 0.2, 0.2), # Dark gray Color(0.5, 0.5, 0.5) # Gray ] character_stats.setHairColor(hair_colors[appearance_rng.randi() % hair_colors.size()]) # Randomize facial hair (0 = none, 1-3 = beard/mustache styles) var facial_hair_style = appearance_rng.randi_range(0, 3) character_stats.setFacialHair(facial_hair_style) # Randomize facial hair color (usually matches hair) if facial_hair_style > 0: character_stats.setFacialHairColor(character_stats.hair_color) # Randomize eyes (0 = none, 1-14 = various eye colors) var eye_style = appearance_rng.randi_range(1, 14) # Always have eyes character_stats.setEyes(eye_style) # Randomize eyelashes (0 = none, 1-8 = various styles) var eyelash_style = appearance_rng.randi_range(0, 8) character_stats.setEyeLashes(eyelash_style) # Randomize ears/addons (0 = none, 1-7 = elf ears) var ear_style = appearance_rng.randi_range(0, 7) if appearance_rng.randf() < 0.2: # 20% chance for elf ears character_stats.setEars(ear_style) else: character_stats.setEars(0) # No ears # Apply appearance to sprite layers _apply_appearance_to_sprites() func _apply_appearance_to_sprites(): # Apply character_stats appearance to sprite layers if not character_stats: return # Body/Skin if sprite_body and character_stats.skin != "": var body_texture = load(character_stats.skin) if body_texture: sprite_body.texture = body_texture sprite_body.hframes = 35 sprite_body.vframes = 8 sprite_body.modulate = Color.WHITE # Remove old color tint # Boots if sprite_boots: var equipped_boots = character_stats.equipment["boots"] if equipped_boots and equipped_boots.equipmentPath != "": var boots_texture = load(equipped_boots.equipmentPath) if boots_texture: sprite_boots.texture = boots_texture sprite_boots.hframes = 35 sprite_boots.vframes = 8 else: sprite_boots.texture = null else: sprite_boots.texture = null # Armour if sprite_armour: var equipped_armour = character_stats.equipment["armour"] if equipped_armour and equipped_armour.equipmentPath != "": var armour_texture = load(equipped_armour.equipmentPath) if armour_texture: sprite_armour.texture = armour_texture sprite_armour.hframes = 35 sprite_armour.vframes = 8 else: sprite_armour.texture = null else: sprite_armour.texture = null # Facial Hair if sprite_facial_hair: if character_stats.facial_hair != "": var facial_hair_texture = load(character_stats.facial_hair) if facial_hair_texture: sprite_facial_hair.texture = facial_hair_texture sprite_facial_hair.hframes = 35 sprite_facial_hair.vframes = 8 sprite_facial_hair.modulate = character_stats.facial_hair_color else: sprite_facial_hair.texture = null else: sprite_facial_hair.texture = null # Hair if sprite_hair: if character_stats.hairstyle != "": var hair_texture = load(character_stats.hairstyle) if hair_texture: sprite_hair.texture = hair_texture sprite_hair.hframes = 35 sprite_hair.vframes = 8 sprite_hair.modulate = character_stats.hair_color else: sprite_hair.texture = null else: sprite_hair.texture = null # Eyes if sprite_eyes: if character_stats.eyes != "": var eyes_texture = load(character_stats.eyes) if eyes_texture: sprite_eyes.texture = eyes_texture sprite_eyes.hframes = 35 sprite_eyes.vframes = 8 else: sprite_eyes.texture = null else: sprite_eyes.texture = null # Eyelashes if sprite_eyelashes: if character_stats.eye_lashes != "": var eyelash_texture = load(character_stats.eye_lashes) if eyelash_texture: sprite_eyelashes.texture = eyelash_texture sprite_eyelashes.hframes = 35 sprite_eyelashes.vframes = 8 else: sprite_eyelashes.texture = null else: sprite_eyelashes.texture = null # Addons (ears, etc.) if sprite_addons: if character_stats.add_on != "": var addon_texture = load(character_stats.add_on) if addon_texture: sprite_addons.texture = addon_texture sprite_addons.hframes = 35 sprite_addons.vframes = 8 else: sprite_addons.texture = null else: sprite_addons.texture = null # Headgear if sprite_headgear: var equipped_headgear = character_stats.equipment["headgear"] if equipped_headgear and equipped_headgear.equipmentPath != "": var headgear_texture = load(equipped_headgear.equipmentPath) if headgear_texture: sprite_headgear.texture = headgear_texture sprite_headgear.hframes = 35 sprite_headgear.vframes = 8 else: sprite_headgear.texture = null else: sprite_headgear.texture = null # Weapon (Mainhand) # NOTE: Weapons should NEVER use equipmentPath - they don't have character sprite sheets # Weapons are only displayed as inventory icons (spritePath), not as character sprite layers if sprite_weapon: sprite_weapon.texture = null # Weapons don't use character sprite layers print(name, " appearance applied: skin=", character_stats.skin, " hair=", character_stats.hairstyle, " facial_hair=", character_stats.facial_hair, " eyes=", character_stats.eyes) func _on_character_changed(_char: CharacterStats): # Update appearance when character stats change (e.g., equipment) _apply_appearance_to_sprites() func _get_player_color() -> Color: # Legacy function - now returns white (no color tint) return Color.WHITE # Helper function to check if object is a box (interactable object) func _is_box(obj) -> bool: # Check if it's an interactable object by checking for specific properties return "throw_velocity" in obj and "is_grabbable" in obj # Helper function to check if object is a player func _is_player(obj) -> bool: # Check if it's a player by looking for player-specific properties return obj.is_in_group("player") or ("is_local_player" in obj and "peer_id" in obj) func _update_animation(delta): # Update animation frame timing time_since_last_frame += delta if time_since_last_frame >= ANIMATIONS[current_animation]["frameDurations"][current_frame] / 1000.0: current_frame += 1 if current_frame >= len(ANIMATIONS[current_animation]["frames"]): current_frame -= 1 # Prevent out of bounds if ANIMATIONS[current_animation]["loop"]: current_frame = 0 if ANIMATIONS[current_animation]["nextAnimation"] != null: current_frame = 0 current_animation = ANIMATIONS[current_animation]["nextAnimation"] time_since_last_frame = 0.0 # Calculate frame index var frame_index = current_direction * 35 + ANIMATIONS[current_animation]["frames"][current_frame] # Update all sprite layers if sprite_body: sprite_body.frame = frame_index if sprite_boots: sprite_boots.frame = frame_index if sprite_armour: sprite_armour.frame = frame_index if sprite_facial_hair: sprite_facial_hair.frame = frame_index if sprite_hair: sprite_hair.frame = frame_index if sprite_eyes: sprite_eyes.frame = frame_index if sprite_eyelashes: sprite_eyelashes.frame = frame_index if sprite_addons: sprite_addons.frame = frame_index if sprite_headgear: sprite_headgear.frame = frame_index if sprite_weapon: sprite_weapon.frame = frame_index func _get_direction_from_vector(vec: Vector2) -> int: if vec.length() < 0.1: return current_direction var angle = vec.angle() var deg = rad_to_deg(angle) # Normalize to 0-360 if deg < 0: deg += 360 # Map to 8 directions if deg >= 337.5 or deg < 22.5: return Direction.RIGHT elif deg >= 22.5 and deg < 67.5: return Direction.DOWN_RIGHT elif deg >= 67.5 and deg < 112.5: return Direction.DOWN elif deg >= 112.5 and deg < 157.5: return Direction.DOWN_LEFT elif deg >= 157.5 and deg < 202.5: return Direction.LEFT elif deg >= 202.5 and deg < 247.5: return Direction.UP_LEFT elif deg >= 247.5 and deg < 292.5: return Direction.UP else: # 292.5 to 337.5 return Direction.UP_RIGHT func _set_animation(anim_name: String): if current_animation != anim_name: current_animation = anim_name current_frame = 0 time_since_last_frame = 0.0 # Helper function to snap direction to 8-way directions func _snap_to_8_directions(direction: Vector2) -> Vector2: if direction.length() < 0.1: return Vector2.DOWN # 8 cardinal and diagonal directions var directions = [ Vector2(0, -1), # Up Vector2(1, -1).normalized(), # Up-Right Vector2(1, 0), # Right Vector2(1, 1).normalized(), # Down-Right Vector2(0, 1), # Down Vector2(-1, 1).normalized(), # Down-Left Vector2(-1, 0), # Left Vector2(-1, -1).normalized() # Up-Left ] # Find closest direction var best_dir = directions[0] var best_dot = direction.normalized().dot(best_dir) for dir in directions: var dot = direction.normalized().dot(dir) if dot > best_dot: best_dot = dot best_dir = dir return best_dir func _update_z_physics(delta): # Apply gravity velocity_z -= gravity_z * delta # Update Z position position_z += velocity_z * delta # Check if landed if position_z <= 0.0: position_z = 0.0 velocity_z = 0.0 is_airborne = false # Stop horizontal movement on landing (with some friction) velocity = velocity * 0.3 # Visual effect when landing (removed - using layered sprites now) print(name, " landed!") # Update visual offset based on height for all sprite layers var y_offset = - position_z * 0.5 # Visual height is half of z for perspective var height_scale = 1.0 # Base scale if position_z > 0: height_scale = 1.0 * (1.0 - (position_z / 50.0) * 0.2) # Scaled down for smaller Z values height_scale = max(0.8, height_scale) # Apply to all sprite layers for sprite_layer in [sprite_body, sprite_boots, sprite_armour, sprite_facial_hair, sprite_hair, sprite_eyes, sprite_eyelashes, sprite_addons, sprite_headgear, sprite_weapon]: if sprite_layer: sprite_layer.position.y = y_offset if position_z > 0: sprite_layer.scale = Vector2.ONE * height_scale else: # Spring back to normal when landed sprite_layer.scale = sprite_layer.scale.lerp(Vector2.ONE, delta * 10.0) # Update shadow based on height if shadow: if position_z > 0: var shadow_scale = 1.0 - (position_z / 75.0) * 0.5 # Scaled down for smaller Z values shadow.scale = Vector2.ONE * max(0.5, shadow_scale) shadow.modulate.a = 0.5 - (position_z / 100.0) * 0.3 else: shadow.scale = Vector2.ONE shadow.modulate.a = 0.5 func _physics_process(delta): # Reset teleport flag at start of frame teleported_this_frame = false # Update animations _update_animation(delta) # Update Z-axis physics (height simulation) if is_airborne: _update_z_physics(delta) if is_local_player and is_multiplayer_authority(): # Skip all input and logic if dead if is_dead: return # Skip input if controls are disabled (e.g., when player reached exit) if controls_disabled: velocity = velocity.lerp(Vector2.ZERO, delta * 8.0) # Slow down movement return # Handle knockback timer if is_knocked_back: knockback_time += delta if knockback_time >= knockback_duration: is_knocked_back = false knockback_time = 0.0 # Check if being held by someone var being_held_by_someone = false for other_player in get_tree().get_nodes_in_group("player"): if other_player != self and other_player.held_object == self: being_held_by_someone = true being_held_by = other_player break if being_held_by_someone: # Handle struggle mechanic _handle_struggle(delta) elif is_knocked_back: # During knockback, no input control - just let velocity carry the player # Apply friction to slow down knockback velocity = velocity.lerp(Vector2.ZERO, delta * 8.0) elif not is_airborne: # Normal input handling struggle_time = 0.0 # Reset struggle timer struggle_direction = Vector2.ZERO _handle_input() _handle_movement(delta) _handle_interactions() else: # Reset struggle when airborne struggle_time = 0.0 struggle_direction = Vector2.ZERO # Update held object positions if is_lifting: _update_lifted_object() elif is_pushing: _update_pushed_object() # Sync position, direction, and animation to other clients (unreliable broadcast) # Only send RPC if we're in the scene tree and ready to send RPCs (prevents errors when player hasn't spawned on all clients yet) # On server, also wait for all clients to be ready if multiplayer.has_multiplayer_peer() and is_inside_tree() and can_send_rpcs: # On server, continuously check if all clients are ready (in case new clients connect) # Also add a small delay after clients notify they're ready to ensure they've spawned all players if multiplayer.is_server() and not all_clients_ready: var game_world = get_tree().get_first_node_in_group("game_world") if game_world: var connected_peers = multiplayer.get_peers() var all_ready = true var max_ready_time = 0.0 # Track when the last client became ready for connected_peer_id in connected_peers: if connected_peer_id != multiplayer.get_unique_id(): if not game_world.clients_ready.has(connected_peer_id) or not game_world.clients_ready[connected_peer_id]: all_ready = false break else: # Client is ready, check when they became ready var ready_time_key = str(connected_peer_id) + "_ready_time" if game_world.clients_ready.has(ready_time_key): var ready_time = game_world.clients_ready[ready_time_key] as float if ready_time > max_ready_time: max_ready_time = ready_time # If no peers, we're ready if connected_peers.size() == 0: all_ready = true # If all clients are ready, wait a bit more to ensure they've spawned all players if all_ready: var current_time = Time.get_ticks_msec() / 1000.0 var time_since_last_ready = current_time - max_ready_time # Wait at least 1.0 second after the last client became ready # This gives time for all spawn RPCs to be processed and nodes to be registered in scene tree if max_ready_time == 0.0 or time_since_last_ready >= 1.0: if not all_clients_ready: # Only set once all_clients_ready = true all_clients_ready_time = current_time if max_ready_time > 0.0: print("Player ", name, " (server) - all clients now ready! (waited ", time_since_last_ready, "s after last client)") else: print("Player ", name, " (server) - all clients now ready! (no ready times tracked)") # On server, also wait a bit after setting all_clients_ready to ensure nodes are registered if not multiplayer.is_server(): _sync_position.rpc(position, velocity, position_z, is_airborne, current_direction, current_animation) elif all_clients_ready: # Wait an additional 0.2 seconds after setting all_clients_ready before sending RPCs var current_time = Time.get_ticks_msec() / 1000.0 var time_since_ready = current_time - all_clients_ready_time if time_since_ready >= 0.2: _sync_position.rpc(position, velocity, position_z, is_airborne, current_direction, current_animation) # Always move and slide to maintain horizontal velocity # When airborne, velocity is set by throw and decreases with friction move_and_slide() # Apply air friction when airborne if is_airborne: velocity = velocity * 0.98 # Slight air resistance # Handle walking sound effects (works for all players - server and clients) # This checks velocity which is synced via RPC, so it works for remote players too _handle_walking_sfx() func _handle_input(): var input_vector = Vector2.ZERO if input_device == -1: # Keyboard input input_vector.x = Input.get_action_strength("move_right") - Input.get_action_strength("move_left") input_vector.y = Input.get_action_strength("move_down") - Input.get_action_strength("move_up") else: # Gamepad input input_vector.x = Input.get_joy_axis(input_device, JOY_AXIS_LEFT_X) input_vector.y = Input.get_joy_axis(input_device, JOY_AXIS_LEFT_Y) # Normalize diagonal movement if input_vector.length() > 1.0: input_vector = input_vector.normalized() # If pushing, lock movement to push axis if is_pushing and push_axis.length() > 0.1: # Project input onto the push axis var dot = input_vector.dot(push_axis) input_vector = push_axis * dot # Check if moving would push object into a wall BEFORE allowing movement if held_object and input_vector.length() > 0.1: # Calculate where the object would be if we move var push_speed = move_speed * 0.5 # Pushing speed var proposed_movement = input_vector.normalized() * push_speed * get_process_delta_time() var proposed_player_pos = global_position + proposed_movement var proposed_player_movement = proposed_player_pos - initial_player_position var proposed_movement_along_axis = proposed_player_movement.dot(push_axis) * push_axis var proposed_object_pos = initial_grab_position + proposed_movement_along_axis # Check if proposed object position would collide with walls var space_state = get_world_2d().direct_space_state var would_hit_wall = false # Get collision shape from held object var held_collision_shape = null if held_object is CharacterBody2D: for child in held_object.get_children(): if child is CollisionShape2D: held_collision_shape = child break if held_collision_shape and held_collision_shape.shape: # Use shape query var query = PhysicsShapeQueryParameters2D.new() query.shape = held_collision_shape.shape # Account for collision shape offset var shape_offset = held_collision_shape.position if held_collision_shape else Vector2.ZERO query.transform = Transform2D(0, proposed_object_pos + shape_offset) query.collision_mask = 64 # Layer 7 = walls (bit 6 = 64) query.collide_with_areas = false query.collide_with_bodies = true query.exclude = [held_object.get_rid()] var results = space_state.intersect_shape(query) would_hit_wall = results.size() > 0 if would_hit_wall: print("DEBUG: Would hit wall in _handle_input! Blocking movement. Results: ", results.size()) else: # Fallback: point query var query = PhysicsPointQueryParameters2D.new() query.position = proposed_object_pos query.collision_mask = 64 query.collide_with_areas = false query.collide_with_bodies = true if held_object is CharacterBody2D: query.exclude = [held_object.get_rid()] var results = space_state.intersect_point(query) would_hit_wall = results.size() > 0 # If would hit wall and trying to push forward, block movement if would_hit_wall: var movement_direction = input_vector.normalized() var push_direction = push_axis.normalized() var dot_product = movement_direction.dot(push_direction) if dot_product > 0.1: # Pushing forward, not pulling # Block movement - would push object into wall input_vector = Vector2.ZERO object_blocked_by_wall = true # Also check the flag from previous frame if object_blocked_by_wall and held_object: # Check if trying to move in the direction that's blocked var movement_direction = input_vector.normalized() var push_direction = push_axis.normalized() # If moving in the same direction as push axis (pushing forward), block it # Allow pulling away (opposite direction) var dot_product = movement_direction.dot(push_direction) if dot_product > 0.1: # Trying to push forward into wall - block movement completely input_vector = Vector2.ZERO # Track last movement direction if moving if input_vector.length() > 0.1: last_movement_direction = input_vector.normalized() # Update facing direction (except when pushing - locked direction) if not is_pushing: current_direction = _get_direction_from_vector(input_vector) as Direction else: # Keep locked direction when pushing current_direction = push_direction_locked as Direction # Set animation based on state if is_lifting: _set_animation("RUN_HOLD") elif is_pushing: _set_animation("RUN_PUSH") elif current_animation != "SWORD": _set_animation("RUN") else: # Idle animations if is_lifting: if current_animation != "LIFT" and current_animation != "IDLE_HOLD": _set_animation("IDLE_HOLD") elif is_pushing: _set_animation("IDLE_PUSH") # Keep locked direction when pushing current_direction = push_direction_locked as Direction else: if current_animation != "THROW" and current_animation != "DAMAGE" and current_animation != "SWORD": _set_animation("IDLE") # Handle drag sound for interactable objects var is_dragging_now = false if held_object and is_pushing and not is_lifting: # Player is pushing (not lifting) - check if moving if input_vector.length() > 0.1 and not object_blocked_by_wall: # Player is moving while pushing - this is dragging is_dragging_now = true # Continuously play drag sound while dragging (method checks if already playing) if held_object.has_method("play_drag_sound"): held_object.play_drag_sound() # Stop drag sound when stopping or not dragging if not is_dragging_now and was_dragging_last_frame: # Stopped dragging - stop drag sound if held_object and held_object.has_method("stop_drag_sound"): held_object.stop_drag_sound() # Update drag state for next frame was_dragging_last_frame = is_dragging_now # Reduce speed by half when pushing/pulling var current_speed = move_speed * (0.5 if is_pushing else 1.0) velocity = input_vector * current_speed func _handle_movement(_delta): # Simple top-down movement is handled by velocity set in _handle_input pass func _handle_walking_sfx(): # Check if player is moving (not dead, not airborne, velocity is significant) var is_moving = velocity.length() > 0.1 and not is_dead and not is_airborne if is_moving: # Player is moving - play walking sound if sfx_walk and timer_walk: if not sfx_walk.playing and timer_walk.is_stopped(): timer_walk.start() sfx_walk.play() else: # Player is not moving - stop walking sound if sfx_walk and sfx_walk.playing: sfx_walk.stop() func _handle_interactions(): var grab_button_down = false var grab_just_pressed = false var grab_just_released = false if input_device == -1: # Keyboard input grab_button_down = Input.is_action_pressed("grab") grab_just_pressed = Input.is_action_just_pressed("grab") grab_just_released = Input.is_action_just_released("grab") # DEBUG: Log button states if there's a conflict if grab_just_pressed and grab_just_released: print("DEBUG: WARNING - Both grab_just_pressed and grab_just_released are true!") if grab_just_released and grab_button_down: print("DEBUG: WARNING - grab_just_released=true but grab_button_down=true!") else: # Gamepad input var button_currently_pressed = Input.is_joy_button_pressed(input_device, JOY_BUTTON_A) grab_button_down = button_currently_pressed grab_just_pressed = button_currently_pressed and grab_button_pressed_time == 0.0 # Check for release by tracking if button was down last frame but not now if not button_currently_pressed and grab_button_pressed_time > 0.0: grab_just_released = true else: grab_just_released = false # Track how long grab button is held if grab_button_down: grab_button_pressed_time += get_process_delta_time() else: # Only reset timer when button is released (not just not pressed) # This allows gamepad release detection to work correctly if grab_just_released: grab_button_pressed_time = 0.0 # Handle grab button press FIRST (before checking release) # Note: just_grabbed_this_frame is reset at the END of this function if grab_just_pressed and can_grab: print("DEBUG: grab_just_pressed, can_grab=", can_grab, " held_object=", held_object != null, " is_lifting=", is_lifting, " grab_button_down=", grab_button_down) if not held_object: # Try to grab something (but don't lift yet - wait for release to determine if it's a tap) print("DEBUG: Calling _try_grab()") _try_grab() just_grabbed_this_frame = true # Record when we grabbed to detect quick tap on release grab_start_time = Time.get_ticks_msec() / 1000.0 print("DEBUG: After _try_grab(), held_object=", held_object != null, " is_lifting=", is_lifting, " grab_button_down=", grab_button_down, " just_grabbed_this_frame=", just_grabbed_this_frame) elif is_lifting: # Already lifting - check if moving to throw, or just put down var is_moving = velocity.length() > 10.0 if is_moving: # Moving + tap E = throw _throw_object() else: # Not moving + tap E = put down _place_down_object() # Handle grab button release # CRITICAL: Don't process release if: # 1. We just grabbed this frame (prevents immediate release bug) - THIS IS THE MOST IMPORTANT CHECK # 2. Button is still down (shouldn't happen, but safety check) # 3. grab_just_pressed is also true (same frame tap) if grab_just_released and held_object: # Check if we just grabbed (either this frame or recently) # Use grab_start_time to determine if this was a quick tap var time_since_grab = (Time.get_ticks_msec() / 1000.0) - grab_start_time var was_recent_grab = time_since_grab <= grab_tap_threshold * 2.0 # Give some buffer if just_grabbed_this_frame or (grab_start_time > 0.0 and was_recent_grab): # Just grabbed - check if it was a quick tap (within threshold) var was_quick_tap = time_since_grab <= grab_tap_threshold print("DEBUG: Release after grab - was_quick_tap=", was_quick_tap, " time_since_grab=", time_since_grab, " threshold=", grab_tap_threshold, " is_pushing=", is_pushing) if was_quick_tap: # Quick tap - lift the object! print("DEBUG: Quick tap detected - lifting object") # Check if object can be lifted var can_lift = true if held_object.has_method("can_be_lifted"): can_lift = held_object.can_be_lifted() if can_lift: _lift_object() else: # Can't lift - just release (stop pushing if we were pushing) print("DEBUG: Can't lift this object - releasing") if is_pushing: _stop_pushing() else: _place_down_object() else: # Held too long - we were pushing/pulling, so just stop pushing (don't change position!) print("DEBUG: Held too long (", time_since_grab, "s) - stopping push without changing position") if is_pushing: _stop_pushing() else: # Not pushing, just release _stop_pushing() # Use stop_pushing for consistency (it handles position correctly) # Reset the flag and start time now that we've processed the release just_grabbed_this_frame = false grab_start_time = 0.0 else: var can_release = not grab_button_down and not grab_just_pressed print("DEBUG: Release check - grab_just_released=", grab_just_released, " held_object=", held_object != null, " just_grabbed_this_frame=", just_grabbed_this_frame, " grab_button_down=", grab_button_down, " grab_just_pressed=", grab_just_pressed, " can_release=", can_release) if can_release: print("DEBUG: Processing release - is_lifting=", is_lifting, " is_pushing=", is_pushing) # Button was just released - release the object if is_lifting: # If lifting, place down print("DEBUG: Releasing lifted object") _place_down_object() elif is_pushing: # If pushing, stop pushing _stop_pushing() else: print("DEBUG: Release BLOCKED - grab_button_down=", grab_button_down, " grab_just_pressed=", grab_just_pressed) # Update object position based on mode (only if button is still held) if held_object and grab_button_down: if is_lifting: _update_lifted_object() elif is_pushing: _update_pushed_object() else: # Not lifting or pushing yet - start pushing/pulling immediately when holding E # DO NOT lift while holding E - only lift on release if it's a quick tap! # When holding E, we always push/pull (if pushable), regardless of whether it can be lifted var can_push = true if held_object.has_method("can_be_pushed"): can_push = held_object.can_be_pushed() if can_push and not is_pushing: # Start pushing/pulling immediately when holding E _start_pushing() # Lift will only happen on release if it was a quick tap # Handle attack input var attack_just_pressed = false if input_device == -1: # Keyboard attack_just_pressed = Input.is_action_just_pressed("attack") else: # Gamepad (X button) attack_just_pressed = Input.is_joy_button_pressed(input_device, JOY_BUTTON_X) if attack_just_pressed and can_attack and not is_lifting and not is_pushing: _perform_attack() # Note: just_grabbed_this_frame is reset when we block a release, or stays true if we grabbed this frame # This ensures it persists to the next frame to block immediate release func _try_grab(): if not grab_area: return var bodies = grab_area.get_overlapping_bodies() var closest_body = null var closest_distance = grab_range for body in bodies: if body == self: continue # Check if it's grabbable var is_grabbable = false # Check for objects with can_be_grabbed method if body.has_method("can_be_grabbed"): if body.can_be_grabbed(): is_grabbable = true # Also allow grabbing other players elif body.is_in_group("player") or ("peer_id" in body and body is CharacterBody2D): is_grabbable = true if is_grabbable: var distance = position.distance_to(body.position) if distance < closest_distance: closest_distance = distance closest_body = body if closest_body: held_object = closest_body # Store the initial positions - don't change the grabbed object's position yet! initial_grab_position = closest_body.global_position initial_player_position = global_position grab_offset = closest_body.position - position # Calculate push axis from grab direction (but don't move the object yet) var grab_direction = grab_offset.normalized() if grab_direction.length() < 0.1: grab_direction = last_movement_direction push_axis = _snap_to_8_directions(grab_direction) # Disable collision with players and other objects when grabbing # But keep collision with walls (layer 7) enabled for pushing if _is_box(closest_body): # Objects are on layer 2 closest_body.set_collision_layer_value(2, false) closest_body.set_collision_mask_value(1, false) # Disable collision with players closest_body.set_collision_mask_value(2, false) # Disable collision with other objects # IMPORTANT: Keep collision with walls (layer 7) enabled so we can detect wall collisions! closest_body.set_collision_mask_value(7, true) # Enable collision with walls elif _is_player(closest_body): # Players are on layer 1 closest_body.set_collision_layer_value(1, false) closest_body.set_collision_mask_value(1, false) # IMPORTANT: Keep collision with walls (layer 7) enabled so we can detect wall collisions! closest_body.set_collision_mask_value(7, true) # Enable collision with walls # When grabbing, immediately try to lift if possible _set_animation("IDLE") is_pushing = false is_lifting = false # Notify the object it's being grabbed if closest_body.has_method("on_grabbed"): closest_body.on_grabbed(self) # DON'T lift immediately - wait for release to determine if it's a tap or hold # If it's a quick tap (release within grab_tap_threshold), we'll lift on release # If it's held longer, we'll keep it grabbed (or push if can't lift) # Sync initial grab to network if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree(): _sync_initial_grab.rpc(held_object.get_path(), grab_offset) # Sync the grab state _sync_grab.rpc(held_object.get_path(), is_lifting, push_axis) print("Grabbed: ", closest_body.name) func _lift_object(): print("DEBUG: _lift_object() called, held_object=", held_object != null) if not held_object: print("DEBUG: _lift_object() - no held_object, returning") return # Check if object can be lifted # Players are always liftable (they don't have can_be_lifted method) # Objects need to check can_be_lifted() var can_lift = true if held_object.has_method("can_be_lifted"): can_lift = held_object.can_be_lifted() print("DEBUG: _lift_object() - can_be_lifted() returned ", can_lift) # If object doesn't have the method (like players), assume it can be lifted # Only prevent lifting if the method exists AND returns false if not can_lift: # Can't lift this object, just push/pull it print("DEBUG: _lift_object() - cannot lift, starting push instead") _start_pushing() return print("DEBUG: _lift_object() - setting is_lifting=true, is_pushing=false") is_lifting = true is_pushing = false # Freeze physics (collision already disabled in _try_grab) if _is_box(held_object): # Box: set frozen flag if "is_frozen" in held_object: held_object.is_frozen = true elif _is_player(held_object): # Player: use set_being_held if held_object.has_method("set_being_held"): held_object.set_being_held(true) if held_object.has_method("on_lifted"): held_object.on_lifted(self) # Play lift animation (fast transition) _set_animation("LIFT") # Sync to network (non-blocking) if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree(): _sync_grab.rpc(held_object.get_path(), true, push_axis) print("Lifted: ", held_object.name) $SfxLift.play() func _start_pushing(): if not held_object: return # Check if object can be pushed if held_object.has_method("can_be_pushed") and not held_object.can_be_pushed(): # Can't push this object (like chests) - just grab it but don't move it is_pushing = false is_lifting = false return is_pushing = true is_lifting = false # Lock to the direction we're facing when we start pushing var initial_direction = grab_offset.normalized() if initial_direction.length() < 0.1: initial_direction = last_movement_direction.normalized() # Snap to one of 8 directions push_axis = _snap_to_8_directions(initial_direction) # Lock the facing direction push_direction_locked = _get_direction_from_vector(push_axis) # Re-enable collision with walls (layer 7) for pushing, but keep collision with players/objects disabled if _is_box(held_object): # Re-enable collision with walls so we can detect wall collisions when pushing held_object.set_collision_mask_value(7, true) # Enable collision with walls # Box: unfreeze so it can be pushed if "is_frozen" in held_object: held_object.is_frozen = false # Sync push state to network if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree(): _sync_grab.rpc(held_object.get_path(), false, push_axis) # false = pushing, not lifting print("Pushing: ", held_object.name, " along axis: ", push_axis, " facing dir: ", push_direction_locked) func _force_drop_held_object(): # Force drop any held object (used when level completes) if held_object: if is_lifting: _place_down_object() elif is_pushing: _stop_pushing() else: # Just release _stop_pushing() func _stop_pushing(): if not held_object: return is_pushing = false push_axis = Vector2.ZERO # Stop drag sound when releasing object if held_object and held_object.has_method("stop_drag_sound"): held_object.stop_drag_sound() was_dragging_last_frame = false # Reset drag state # Store reference and CURRENT position - don't change it! var released_obj = held_object var released_obj_position = released_obj.global_position # Store exact position # Sync release to network if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree(): _sync_release.rpc(released_obj.get_path()) # Release the object and re-enable collision completely if _is_box(released_obj): # Objects: back on layer 2 released_obj.set_collision_layer_value(2, true) released_obj.set_collision_mask_value(1, true) released_obj.set_collision_mask_value(2, true) elif _is_player(released_obj): # Players: back on layer 1 released_obj.set_collision_layer_value(1, true) released_obj.set_collision_mask_value(1, true) if released_obj is CharacterBody2D and released_obj.has_method("set_being_held"): released_obj.set_being_held(false) # Ensure position stays exactly where it is - no movement on release! # Do this AFTER calling on_released in case it tries to change position if released_obj.has_method("on_released"): released_obj.on_released(self) # Force position to stay exactly where it was - no snapping or movement! if released_obj is CharacterBody2D: released_obj.global_position = released_obj_position released_obj.velocity = Vector2.ZERO # Stop any velocity held_object = null grab_offset = Vector2.ZERO initial_grab_position = Vector2.ZERO initial_player_position = Vector2.ZERO print("Stopped pushing") func _throw_object(): if not held_object or not is_lifting: return # Check if object can be thrown if held_object.has_method("can_be_thrown") and not held_object.can_be_thrown(): # Can't throw this object, place it down instead _place_down_object() return var throw_direction = velocity.normalized() var is_moving = throw_direction.length() > 0.1 if not is_moving: # If not moving, place down instead of throw _place_down_object() return # Position object at player's position before throwing var throw_start_pos = global_position + throw_direction * 10 # Start slightly in front # Store reference before clearing var thrown_obj = held_object # Clear state first (important!) held_object = null grab_offset = Vector2.ZERO is_lifting = false is_pushing = false # Re-enable collision completely if _is_box(thrown_obj): # Box: set position and physics first thrown_obj.global_position = throw_start_pos # Set throw velocity for box (same force as player throw) if "throw_velocity" in thrown_obj: thrown_obj.throw_velocity = throw_direction * throw_force / thrown_obj.weight if "is_frozen" in thrown_obj: thrown_obj.is_frozen = false # Make box airborne with same arc as players if "is_airborne" in thrown_obj: thrown_obj.is_airborne = true thrown_obj.position_z = 2.5 thrown_obj.velocity_z = 100.0 # Scaled down for 1x scale # Call on_thrown if available if thrown_obj.has_method("on_thrown"): thrown_obj.on_thrown(self, throw_direction * throw_force) # ⚡ Delay collision re-enable to prevent self-collision await get_tree().create_timer(0.1).timeout if thrown_obj and is_instance_valid(thrown_obj): thrown_obj.set_collision_layer_value(2, true) thrown_obj.set_collision_mask_value(1, true) thrown_obj.set_collision_mask_value(2, true) elif _is_player(thrown_obj): # Player: set position and physics first thrown_obj.global_position = throw_start_pos # Set horizontal velocity for the arc thrown_obj.velocity = throw_direction * throw_force * 0.8 # Slightly reduced for arc # Make player airborne with Z velocity if "is_airborne" in thrown_obj: thrown_obj.is_airborne = true thrown_obj.position_z = 2.5 # Start slightly off ground thrown_obj.velocity_z = 100.0 # Scaled down for 1x scale if thrown_obj.has_method("set_being_held"): thrown_obj.set_being_held(false) # ⚡ Delay collision re-enable to prevent self-collision await get_tree().create_timer(0.1).timeout if thrown_obj and is_instance_valid(thrown_obj): thrown_obj.set_collision_layer_value(1, true) thrown_obj.set_collision_mask_value(1, true) if thrown_obj.has_method("on_thrown"): thrown_obj.on_thrown(self, throw_direction * throw_force) # Play throw animation _set_animation("THROW") $SfxThrow.play() # Sync throw over network if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree(): _sync_throw.rpc(thrown_obj.get_path(), throw_start_pos, throw_direction * throw_force, get_path()) print("Threw ", thrown_obj.name, " from ", throw_start_pos, " with force: ", throw_direction * throw_force) func _place_down_object(): if not held_object: return # Place object in front of player based on last movement direction var place_offset = last_movement_direction * 15 # Scaled down for 1x scale var place_pos = global_position + place_offset var placed_obj = held_object # Clear state held_object = null grab_offset = Vector2.ZERO is_lifting = false is_pushing = false # Re-enable collision completely and physics placed_obj.global_position = place_pos if _is_box(placed_obj): # Box: back on layer 2 placed_obj.set_collision_layer_value(2, true) placed_obj.set_collision_mask_value(1, true) placed_obj.set_collision_mask_value(2, true) # Stop movement and reset all state if "throw_velocity" in placed_obj: placed_obj.throw_velocity = Vector2.ZERO if "is_frozen" in placed_obj: placed_obj.is_frozen = false if "is_being_held" in placed_obj: placed_obj.is_being_held = false if "held_by_player" in placed_obj: placed_obj.held_by_player = null if "is_airborne" in placed_obj: placed_obj.is_airborne = false if "position_z" in placed_obj: placed_obj.position_z = 0.0 if "velocity_z" in placed_obj: placed_obj.velocity_z = 0.0 elif _is_player(placed_obj): # Player: back on layer 1 placed_obj.set_collision_layer_value(1, true) placed_obj.set_collision_mask_value(1, true) placed_obj.global_position = place_pos placed_obj.velocity = Vector2.ZERO if placed_obj.has_method("set_being_held"): placed_obj.set_being_held(false) if placed_obj.has_method("on_released"): placed_obj.on_released(self) # Sync place down over network if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree(): _sync_place_down.rpc(placed_obj.get_path(), place_pos) print("Placed down ", placed_obj.name, " at ", place_pos) func _perform_attack(): if not can_attack or is_attacking: return can_attack = false is_attacking = true # Play attack animation _set_animation("SWORD") # Calculate attack direction based on player's facing direction var attack_direction = Vector2.ZERO match current_direction: Direction.RIGHT: attack_direction = Vector2.RIGHT Direction.DOWN_RIGHT: attack_direction = Vector2(1, 1).normalized() Direction.DOWN: attack_direction = Vector2.DOWN Direction.DOWN_LEFT: attack_direction = Vector2(-1, 1).normalized() Direction.LEFT: attack_direction = Vector2.LEFT Direction.UP_LEFT: attack_direction = Vector2(-1, -1).normalized() Direction.UP: attack_direction = Vector2.UP Direction.UP_RIGHT: attack_direction = Vector2(1, -1).normalized() # Delay before spawning sword slash await get_tree().create_timer(0.15).timeout # Calculate damage from character_stats with randomization var base_damage = 20.0 # Default damage if character_stats: base_damage = character_stats.damage # D&D style randomization: ±20% variance var damage_variance = 0.2 var damage_multiplier = 1.0 + randf_range(-damage_variance, damage_variance) var final_damage = base_damage * damage_multiplier # Critical strike chance (based on LCK stat) var crit_chance = 0.0 if character_stats: crit_chance = (character_stats.baseStats.lck + character_stats.get_pass("lck")) * 0.01 # 1% per LCK point var is_crit = randf() < crit_chance if is_crit: final_damage *= 2.0 # Critical strikes deal 2x damage print(name, " CRITICAL STRIKE! (LCK: ", character_stats.baseStats.lck + character_stats.get_pass("lck"), ")") # Round to 1 decimal place final_damage = round(final_damage * 10.0) / 10.0 # Spawn sword projectile if sword_projectile_scene: var projectile = sword_projectile_scene.instantiate() get_parent().add_child(projectile) projectile.setup(attack_direction, self, final_damage) # Store crit status for visual feedback if is_crit: projectile.set_meta("is_crit", true) # Spawn projectile a bit in front of the player var spawn_offset = attack_direction * 10.0 # 10 pixels in front projectile.global_position = global_position + spawn_offset print(name, " attacked with sword projectile! Damage: ", final_damage, " (base: ", base_damage, ", crit: ", is_crit, ")") # Sync attack over network if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree(): _sync_attack.rpc(current_direction, attack_direction) # Reset attack cooldown (instant if cooldown is 0) if attack_cooldown > 0: await get_tree().create_timer(attack_cooldown).timeout can_attack = true is_attacking = false func _update_lifted_object(): if held_object and is_instance_valid(held_object): # Check if object is still being held (prevent updates after release) if held_object.has_method("is_being_held") and not held_object.is_being_held: held_object = null return # Object floats above player's head var target_pos = position + Vector2(0, -12) # Above head # Instant follow for local player, smooth for network sync if is_local_player and is_multiplayer_authority(): held_object.global_position = target_pos # Instant! else: held_object.global_position = held_object.global_position.lerp(target_pos, 0.3) # Sync held object position over network if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree(): _sync_held_object_pos.rpc(held_object.get_path(), held_object.global_position) func _update_pushed_object(): if held_object and is_instance_valid(held_object): # Check if object is still being held (prevent updates after release) if held_object.has_method("is_being_held") and not held_object.is_being_held: held_object = null return # Check if object can be pushed (chests shouldn't move) if held_object.has_method("can_be_pushed") and not held_object.can_be_pushed(): # Object can't be pushed - don't update position return # Calculate how much the player has moved since grabbing var player_movement = global_position - initial_player_position # Project player movement onto the push axis (only move along locked axis) var movement_along_axis = player_movement.dot(push_axis) * push_axis # Calculate target position: initial position + movement along axis var target_pos = initial_grab_position + movement_along_axis # Check for wall collisions BEFORE moving # Test if moving to target position would collide with walls var space_state = get_world_2d().direct_space_state var was_blocked = false # Get collision shape from held object var held_collision_shape = null if held_object is CharacterBody2D: for child in held_object.get_children(): if child is CollisionShape2D: held_collision_shape = child break if held_collision_shape and held_collision_shape.shape: # Use shape query to test collision var query = PhysicsShapeQueryParameters2D.new() query.shape = held_collision_shape.shape # Account for collision shape offset var shape_offset = held_collision_shape.position if held_collision_shape else Vector2.ZERO query.transform = Transform2D(0, target_pos + shape_offset) query.collision_mask = 64 # Layer 7 = walls (bit 6 = 64) query.collide_with_areas = false query.collide_with_bodies = true query.exclude = [held_object.get_rid()] # Exclude the object itself var results = space_state.intersect_shape(query) was_blocked = results.size() > 0 if was_blocked: print("DEBUG: Wall collision detected in _update_pushed_object! Results: ", results.size(), " target_pos: ", target_pos) else: # Fallback: use point query var query = PhysicsPointQueryParameters2D.new() query.position = target_pos query.collision_mask = 64 # Layer 7 = walls (bit 6 = 64) query.collide_with_areas = false query.collide_with_bodies = true if held_object is CharacterBody2D: query.exclude = [held_object.get_rid()] var results = space_state.intersect_point(query) was_blocked = results.size() > 0 # Update the flag for next frame's input handling object_blocked_by_wall = was_blocked # If we would hit a wall, don't move the object if was_blocked: # Would hit a wall - keep object at current position # Don't update position at all pass else: # No collision - move to target position if is_local_player and is_multiplayer_authority(): held_object.global_position = target_pos else: held_object.global_position = held_object.global_position.lerp(target_pos, 0.5) # Sync position over network if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree(): _sync_held_object_pos.rpc(held_object.get_path(), held_object.global_position) # Network sync @rpc("any_peer", "unreliable") func _sync_position(pos: Vector2, vel: Vector2, z_pos: float = 0.0, airborne: bool = false, dir: int = 0, anim: String = "IDLE"): # Only update if we're not the authority (remote player) if not is_multiplayer_authority(): position = pos velocity = vel position_z = z_pos is_airborne = airborne current_direction = dir as Direction # Sync animation if different if current_animation != anim: _set_animation(anim) # Update visual based on Z position (handled in _update_z_physics now) # Update shadow based on Z position if shadow and is_airborne: var shadow_scale = 1.0 - (position_z / 75.0) * 0.5 # Scaled down for smaller Z values shadow.scale = Vector2.ONE * max(0.5, shadow_scale) shadow.modulate.a = 0.5 - (position_z / 100.0) * 0.3 @rpc("any_peer", "reliable") func _sync_attack(direction: int, attack_dir: Vector2): # Sync attack to other clients if not is_multiplayer_authority(): current_direction = direction as Direction _set_animation("SWORD") # Delay before spawning sword slash await get_tree().create_timer(0.15).timeout # Spawn sword projectile on client if sword_projectile_scene: var projectile = sword_projectile_scene.instantiate() get_parent().add_child(projectile) projectile.setup(attack_dir, self) # Spawn projectile a bit in front of the player var spawn_offset = attack_dir * 10.0 # 10 pixels in front projectile.global_position = global_position + spawn_offset print(name, " performed synced attack!") @rpc("any_peer", "reliable") func _sync_throw(obj_path: NodePath, throw_pos: Vector2, force: Vector2, thrower_path: NodePath): # Sync throw to all clients (RPC sender already threw on their side) var obj = get_node_or_null(obj_path) var thrower = get_node_or_null(thrower_path) print("_sync_throw received: ", obj_path, " found obj: ", obj != null, " thrower: ", thrower != null, " is_authority: ", is_multiplayer_authority()) if obj: obj.global_position = throw_pos var is_box = _is_box(obj) var is_player = _is_player(obj) print("Object type check - is_box: ", is_box, " is_player: ", is_player) if is_box: print("Syncing box throw on client! pos: ", throw_pos, " force: ", force) # Reset held state and set thrower if "is_being_held" in obj: obj.is_being_held = false if "held_by_player" in obj: obj.held_by_player = null if "thrown_by_player" in obj: obj.thrown_by_player = thrower # Set who threw it if "throw_velocity" in obj: obj.throw_velocity = force / obj.weight if "is_frozen" in obj: obj.is_frozen = false # Make box airborne with same arc as players if "is_airborne" in obj: obj.is_airborne = true obj.position_z = 2.5 obj.velocity_z = 100.0 # Scaled down for 1x scale print("Box is now airborne on client!") # ⚡ Delay collision re-enable to prevent self-collision on clients await get_tree().create_timer(0.1).timeout if obj and is_instance_valid(obj): obj.set_collision_layer_value(2, true) obj.set_collision_mask_value(1, true) obj.set_collision_mask_value(2, true) elif is_player: print("Syncing player throw on client! pos: ", throw_pos, " force: ", force) # Player: set physics first obj.velocity = force * 0.8 if "is_airborne" in obj: obj.is_airborne = true obj.position_z = 2.5 obj.velocity_z = 100.0 # Scaled down for 1x scale if obj.has_method("set_being_held"): obj.set_being_held(false) print("Player is now airborne on client!") # ⚡ Delay collision re-enable to prevent self-collision on clients await get_tree().create_timer(0.1).timeout if obj and is_instance_valid(obj): obj.set_collision_layer_value(1, true) obj.set_collision_mask_value(1, true) @rpc("any_peer", "reliable") func _sync_initial_grab(obj_path: NodePath, _offset: Vector2): # Sync initial grab to other clients if not is_multiplayer_authority(): var obj = get_node_or_null(obj_path) if obj: # Disable collision for grabbed object if _is_box(obj): obj.set_collision_layer_value(2, false) obj.set_collision_mask_value(1, false) obj.set_collision_mask_value(2, false) elif _is_player(obj): obj.set_collision_layer_value(1, false) obj.set_collision_mask_value(1, false) print("Synced initial grab on client: ", obj_path) @rpc("any_peer", "reliable") func _sync_grab(obj_path: NodePath, is_lift: bool, axis: Vector2 = Vector2.ZERO): # Sync lift/push state to other clients if not is_multiplayer_authority(): var obj = get_node_or_null(obj_path) if obj: if is_lift: # Lifting - completely disable collision if _is_box(obj): obj.set_collision_layer_value(2, false) obj.set_collision_mask_value(1, false) obj.set_collision_mask_value(2, false) # Set box state if "is_frozen" in obj: obj.is_frozen = true if "is_being_held" in obj: obj.is_being_held = true if "throw_velocity" in obj: obj.throw_velocity = Vector2.ZERO elif _is_player(obj): obj.set_collision_layer_value(1, false) obj.set_collision_mask_value(1, false) if obj.has_method("set_being_held"): obj.set_being_held(true) else: # Pushing - keep collision disabled but unfreeze if _is_box(obj): obj.set_collision_layer_value(2, false) obj.set_collision_mask_value(1, false) obj.set_collision_mask_value(2, false) if "is_frozen" in obj: obj.is_frozen = false if "is_being_held" in obj: obj.is_being_held = true if "throw_velocity" in obj: obj.throw_velocity = Vector2.ZERO elif _is_player(obj): obj.set_collision_layer_value(1, false) obj.set_collision_mask_value(1, false) if obj.has_method("set_being_held"): obj.set_being_held(true) print("Synced grab on client: lift=", is_lift, " axis=", axis) @rpc("any_peer", "reliable") func _sync_release(obj_path: NodePath): # Sync release to other clients if not is_multiplayer_authority(): var obj = get_node_or_null(obj_path) if obj: # Re-enable collision completely if _is_box(obj): obj.set_collision_layer_value(2, true) obj.set_collision_mask_value(1, true) obj.set_collision_mask_value(2, true) if "is_frozen" in obj: obj.is_frozen = false # CRITICAL: Clear is_being_held so object can be grabbed again and switches can detect it if "is_being_held" in obj: obj.is_being_held = false if "held_by_player" in obj: obj.held_by_player = null elif _is_player(obj): obj.set_collision_layer_value(1, true) obj.set_collision_mask_value(1, true) if obj.has_method("set_being_held"): obj.set_being_held(false) @rpc("any_peer", "reliable") func _sync_place_down(obj_path: NodePath, place_pos: Vector2): # Sync placing down to other clients if not is_multiplayer_authority(): var obj = get_node_or_null(obj_path) if obj: obj.global_position = place_pos # Re-enable collision completely if _is_box(obj): obj.set_collision_layer_value(2, true) obj.set_collision_mask_value(1, true) obj.set_collision_mask_value(2, true) # Reset all state if "throw_velocity" in obj: obj.throw_velocity = Vector2.ZERO if "is_frozen" in obj: obj.is_frozen = false if "is_being_held" in obj: obj.is_being_held = false if "held_by_player" in obj: obj.held_by_player = null if "is_airborne" in obj: obj.is_airborne = false if "position_z" in obj: obj.position_z = 0.0 if "velocity_z" in obj: obj.velocity_z = 0.0 elif _is_player(obj): obj.set_collision_layer_value(1, true) obj.set_collision_mask_value(1, true) obj.velocity = Vector2.ZERO if obj.has_method("set_being_held"): obj.set_being_held(false) @rpc("any_peer", "reliable") func _sync_teleport_position(new_pos: Vector2): # Sync teleport position from server to clients # Server calls this to teleport any player (even if client has authority over their own player) # Only update if we're not on the server (server already set position directly) if not multiplayer.is_server(): global_position = new_pos # Reset velocity to prevent player from moving back to old position velocity = Vector2.ZERO # Set flag to prevent position sync from overriding teleportation this frame teleported_this_frame = true print(name, " teleported to ", new_pos, " (synced from server, is_authority: ", is_multiplayer_authority(), ")") @rpc("any_peer", "unreliable") func _sync_held_object_pos(obj_path: NodePath, pos: Vector2): # Sync held object position to other clients if not is_multiplayer_authority(): var obj = get_node_or_null(obj_path) if obj: # Don't update position if object is airborne (being thrown) if "is_airborne" in obj and obj.is_airborne: return # Don't update position if object is not being held if "is_being_held" in obj and not obj.is_being_held: return obj.global_position = pos func can_be_grabbed() -> bool: return true func _handle_struggle(delta): # Player is being held - check for struggle input var input_dir = Vector2.ZERO if input_device == -1: # Keyboard input_dir.x = Input.get_axis("move_left", "move_right") input_dir.y = Input.get_axis("move_up", "move_down") else: # Gamepad input_dir.x = Input.get_joy_axis(input_device, JOY_AXIS_LEFT_X) input_dir.y = Input.get_joy_axis(input_device, JOY_AXIS_LEFT_Y) # Check if player is trying to move if input_dir.length() > 0.3: # Player is struggling! struggle_time += delta struggle_direction = input_dir.normalized() # Visual feedback - shake body sprite if sprite_body: sprite_body.position.x = randf_range(-2, 2) # Break free after threshold if struggle_time >= struggle_threshold: print(name, " broke free!") _break_free_from_holder() else: # Not struggling - reset timer struggle_time = 0.0 struggle_direction = Vector2.ZERO if sprite_body: sprite_body.position.x = 0 func _break_free_from_holder(): if being_held_by and is_instance_valid(being_held_by): # Force the holder to place us down in struggle direction if being_held_by.has_method("_force_place_down"): being_held_by._force_place_down(struggle_direction) # Sync break free over network if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree(): _sync_break_free.rpc(being_held_by.get_path(), struggle_direction) struggle_time = 0.0 struggle_direction = Vector2.ZERO being_held_by = null @rpc("any_peer", "reliable") func _sync_break_free(holder_path: NodePath, direction: Vector2): var holder = get_node_or_null(holder_path) if holder and holder.has_method("_force_place_down"): holder._force_place_down(direction) func _force_place_down(direction: Vector2): # Forced to place down held object in specified direction if held_object and is_lifting: var place_offset = direction.normalized() * 20 if place_offset.length() < 0.1: place_offset = last_movement_direction * 20 var place_pos = position + place_offset var placed_obj = held_object # Clear state held_object = null grab_offset = Vector2.ZERO is_lifting = false is_pushing = false # Re-enable collision and physics placed_obj.global_position = place_pos if _is_box(placed_obj): placed_obj.set_collision_layer_value(2, true) placed_obj.set_collision_mask_value(1, true) placed_obj.set_collision_mask_value(2, true) if "throw_velocity" in placed_obj: placed_obj.throw_velocity = Vector2.ZERO if "is_frozen" in placed_obj: placed_obj.is_frozen = false if "is_being_held" in placed_obj: placed_obj.is_being_held = false if "held_by_player" in placed_obj: placed_obj.held_by_player = null if "is_airborne" in placed_obj: placed_obj.is_airborne = false if "position_z" in placed_obj: placed_obj.position_z = 0.0 if "velocity_z" in placed_obj: placed_obj.velocity_z = 0.0 elif _is_player(placed_obj): placed_obj.set_collision_layer_value(1, true) placed_obj.set_collision_mask_value(1, true) placed_obj.velocity = Vector2.ZERO if placed_obj.has_method("set_being_held"): placed_obj.set_being_held(false) if placed_obj.has_method("on_released"): placed_obj.on_released(self) print("Forced to place down ", placed_obj.name) func set_being_held(held: bool): # When being held by another player, disable movement # But keep physics_process running for network sync if held: # Just prevent input handling, don't disable physics velocity = Vector2.ZERO is_airborne = false position_z = 0.0 velocity_z = 0.0 else: # Released - reset struggle state struggle_time = 0.0 struggle_direction = Vector2.ZERO being_held_by = null # RPC function called by attacker to deal damage to this player @rpc("any_peer", "reliable") func rpc_take_damage(amount: float, attacker_position: Vector2): # Only apply damage on the victim's own client (where they're authority) if is_multiplayer_authority(): take_damage(amount, attacker_position) func take_damage(amount: float, attacker_position: Vector2): # Don't take damage if already dead if is_dead: return # Check for dodge chance (based on DEX) var _was_dodged = false if character_stats: var dodge_roll = randf() var dodge_chance = character_stats.dodge_chance if dodge_roll < dodge_chance: _was_dodged = true print(name, " DODGED the attack! (DEX: ", character_stats.baseStats.dex + character_stats.get_pass("dex"), ", dodge chance: ", dodge_chance * 100.0, "%)") # Show "DODGED" text _show_damage_number(0.0, attacker_position, false, false, true) # is_dodged = true # Sync dodge visual to other clients if multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree(): _sync_damage.rpc(0.0, attacker_position, false, false, true) # is_dodged = true return # No damage taken, exit early # If not dodged, apply damage with DEF reduction var actual_damage = amount if character_stats: # Calculate damage after DEF reduction (critical hits pierce 80% of DEF) actual_damage = character_stats.calculate_damage(amount, false, false) # false = not magical, false = not critical (enemy attacks don't crit yet) # Apply the reduced damage using take_damage (which handles health modification and signals) var _old_hp = character_stats.hp character_stats.modify_health(-actual_damage) if character_stats.hp <= 0: character_stats.no_health.emit() character_stats.character_changed.emit(character_stats) print(name, " took ", actual_damage, " damage (", amount, " base - ", character_stats.defense, " DEF = ", actual_damage, ")! Health: ", character_stats.hp, "/", character_stats.maxhp) else: # Fallback for legacy current_health -= amount actual_damage = amount print(name, " took ", amount, " damage! Health: ", current_health) # Play damage sound effect if sfx_take_damage: sfx_take_damage.play() # Play damage animation _set_animation("DAMAGE") # Calculate direction FROM attacker TO victim var direction_from_attacker = (global_position - attacker_position).normalized() # Knockback - push player away from attacker velocity = direction_from_attacker * 250.0 # Scaled down for 1x scale # Face the attacker (opposite of knockback direction) current_direction = _get_direction_from_vector(-direction_from_attacker) as Direction # Enable knockback state (prevents player control for a short time) is_knocked_back = true knockback_time = 0.0 # Flash red on body sprite if sprite_body: var tween = create_tween() tween.tween_property(sprite_body, "modulate", Color.RED, 0.1) tween.tween_property(sprite_body, "modulate", Color.WHITE, 0.1) # Show damage number (red, using dmg_numbers.png font) _show_damage_number(actual_damage, attacker_position) # Sync damage visual effects to other clients (including damage numbers) if multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree(): _sync_damage.rpc(actual_damage, attacker_position) # Check if dead - but wait for damage animation to play first var health = character_stats.hp if character_stats else current_health if health <= 0: if character_stats: character_stats.hp = 0 # Clamp to 0 else: current_health = 0 # Clamp to 0 is_dead = true # Set flag immediately to prevent more damage # Wait a bit for damage animation and knockback to show await get_tree().create_timer(0.3).timeout _die() func _die(): # Already processing death - prevent multiple concurrent death sequences if is_processing_death: print(name, " already processing death, ignoring duplicate call") return is_processing_death = true # Set IMMEDIATELY to block duplicates is_dead = true # Ensure flag is set velocity = Vector2.ZERO is_knocked_back = false # CRITICAL: Release any held object/player BEFORE dying to restore their collision layers if held_object: var released_obj = held_object held_object = null is_lifting = false is_pushing = false grab_offset = Vector2.ZERO push_axis = Vector2.ZERO # Re-enable collision for released object/player if _is_box(released_obj): released_obj.set_collision_layer_value(2, true) released_obj.set_collision_mask_value(1, true) released_obj.set_collision_mask_value(2, true) if "is_being_held" in released_obj: released_obj.is_being_held = false if "held_by_player" in released_obj: released_obj.held_by_player = null elif _is_player(released_obj): released_obj.set_collision_layer_value(1, true) released_obj.set_collision_mask_value(1, true) if released_obj.has_method("set_being_held"): released_obj.set_being_held(false) # Sync release to network if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree(): _sync_release.rpc(released_obj.get_path()) print(name, " released ", released_obj.name, " on death") else: is_lifting = false is_pushing = false print(name, " died!") # Play death sound effect if sfx_die: for i in 12: var angle = randf_range(0, TAU) var speed = randf_range(50, 100) var initial_velocityZ = randf_range(50, 90) var b = blood_scene.instantiate() as CharacterBody2D b.scale = Vector2(randf_range(0.3, 2), randf_range(0.3, 2)) b.global_position = global_position # Set initial velocities from the synchronized data var direction = Vector2.from_angle(angle) b.velocity = direction * speed b.velocityZ = initial_velocityZ get_parent().call_deferred("add_child", b) sfx_die.play() # Play DIE animation _set_animation("DIE") # Sync death over network (only authority sends) if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree(): _sync_death.rpc() # Wait for DIE animation to complete (200+200+200+800 = 1400ms = 1.4s) await get_tree().create_timer(1.4).timeout # Fade out over 0.5 seconds (fade all sprite layers) var fade_tween = create_tween() fade_tween.set_parallel(true) for sprite_layer in [sprite_body, sprite_boots, sprite_armour, sprite_facial_hair, sprite_hair, sprite_eyes, sprite_eyelashes, sprite_addons, sprite_headgear, sprite_weapon, shadow]: if sprite_layer: fade_tween.tween_property(sprite_layer, "modulate:a", 0.0, 0.5) # Wait for fade to finish await fade_tween.finished # Force holder to drop us NOW (after fade, before respawn) # Search for any player holding us (don't rely on being_held_by) print(name, " searching for anyone holding us...") var found_holder = false for other_player in get_tree().get_nodes_in_group("player"): if other_player != self and other_player.held_object == self: print(name, " FOUND holder: ", other_player.name, "! Clearing locally and syncing via RPC") # Clear LOCALLY first other_player.held_object = null other_player.is_lifting = false other_player.is_pushing = false other_player.grab_offset = Vector2.ZERO other_player.push_axis = Vector2.ZERO # Re-enable our collision set_collision_layer_value(1, true) set_collision_mask_value(1, true) # THEN sync to other clients if multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree(): _force_holder_to_drop.rpc(other_player.get_path()) found_holder = true break if not found_holder: print(name, " is NOT being held by anyone") being_held_by = null # Wait 0.5 seconds after fade before respawning await get_tree().create_timer(0.5).timeout # Respawn (this will reset is_processing_death) _respawn() func _respawn(): print(name, " respawning!") # being_held_by already cleared in _die() before this # Holder already dropped us 0.2 seconds ago # Re-enable collision in case it was disabled while being carried set_collision_layer_value(1, true) set_collision_mask_value(1, true) # Reset health and state if character_stats: character_stats.hp = character_stats.maxhp else: current_health = max_health is_dead = false is_processing_death = false # Reset processing flag velocity = Vector2.ZERO is_knocked_back = false is_airborne = false position_z = 0.0 velocity_z = 0.0 # Restore visibility (fade all sprite layers back in) for sprite_layer in [sprite_body, sprite_boots, sprite_armour, sprite_facial_hair, sprite_hair, sprite_eyes, sprite_eyelashes, sprite_addons, sprite_headgear, sprite_weapon, shadow]: if sprite_layer: sprite_layer.modulate.a = 1.0 # Get respawn position - use spawn room (start room) for respawning var new_respawn_pos = respawn_point var game_world = get_tree().get_first_node_in_group("game_world") if not game_world: push_error(name, " respawn: Could not find game_world!") return if not game_world.dungeon_data.has("start_room"): push_error(name, " respawn: No start_room in dungeon_data!") return # Update spawn points to use the start room (spawn room) var start_room = game_world.dungeon_data.start_room if start_room.is_empty(): push_error(name, " respawn: start_room is empty!") return game_world._update_spawn_points(start_room, true) # Clear existing and use start room # Get a free spawn point from player manager var player_manager = game_world.get_node_or_null("PlayerManager") if not player_manager: push_error(name, " respawn: Could not find PlayerManager!") return if player_manager.spawn_points.size() == 0: push_error(name, " respawn: No spawn points available!") # Fallback: Calculate center of start room var tile_size = 16 var room_center_x = (start_room.x + start_room.w / 2.0) * tile_size var room_center_y = (start_room.y + start_room.h / 2.0) * tile_size new_respawn_pos = Vector2(room_center_x, room_center_y) print(name, " respawn: Using fallback center position: ", new_respawn_pos) else: # Find a free spawn point var found_free = false for spawn_pos in player_manager.spawn_points: # Validate spawn position is within reasonable bounds if spawn_pos.x < 0 or spawn_pos.y < 0 or spawn_pos.x > 2000 or spawn_pos.y > 2000: continue # Skip invalid positions var is_free = true # Check if any player is too close for player in player_manager.get_all_players(): if player != self and spawn_pos.distance_to(player.position) < 32: is_free = false break if is_free: new_respawn_pos = spawn_pos found_free = true break # If no free spawn point, use a random one if not found_free: var random_spawn = player_manager.spawn_points[randi() % player_manager.spawn_points.size()] # Validate the random spawn position if random_spawn.x >= 0 and random_spawn.y >= 0 and random_spawn.x < 2000 and random_spawn.y < 2000: new_respawn_pos = random_spawn else: # Last resort: use center of start room var tile_size = 16 var room_center_x = (start_room.x + start_room.w / 2.0) * tile_size var room_center_y = (start_room.y + start_room.h / 2.0) * tile_size new_respawn_pos = Vector2(room_center_x, room_center_y) print(name, " respawning at spawn room position: ", new_respawn_pos) # Teleport to respawn point (AFTER release is processed) global_position = new_respawn_pos respawn_point = new_respawn_pos # Update respawn point for next time # Play idle animation _set_animation("IDLE") # Sync respawn over network (only authority sends) if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree(): _sync_respawn.rpc(new_respawn_pos) @rpc("any_peer", "reliable") func _force_holder_to_drop(holder_path: NodePath): # Force a specific player to drop what they're holding _force_holder_to_drop_local(holder_path) func _force_holder_to_drop_local(holder_path: NodePath): # Local function to clear holder's held object print("_force_holder_to_drop_local called for holder path: ", holder_path) var holder = get_node_or_null(holder_path) if holder and is_instance_valid(holder): print(" Found holder: ", holder.name, ", their held_object: ", holder.held_object) if holder.held_object == self: print(" ✓ DROPPING! Clearing ", holder.name, "'s held_object (dropping ", name, ")") holder.held_object = null holder.is_lifting = false holder.is_pushing = false holder.grab_offset = Vector2.ZERO holder.push_axis = Vector2.ZERO # Re-enable collision on dropped player set_collision_layer_value(1, true) set_collision_mask_value(1, true) else: print(" ✗ held_object doesn't match self") else: print(" ✗ Holder not found or invalid") @rpc("any_peer", "reliable") func _sync_death(): if not is_multiplayer_authority(): _die() @rpc("any_peer", "reliable") func _sync_respawn(spawn_pos: Vector2): if not is_multiplayer_authority(): # being_held_by already cleared via RPC in _die() # Holder already dropped us via _force_holder_to_drop RPC # Re-enable collision in case it was disabled while being carried set_collision_layer_value(1, true) set_collision_mask_value(1, true) # Just teleport and reset on clients (AFTER release is processed) global_position = spawn_pos current_health = max_health is_dead = false is_processing_death = false # Reset processing flag # Restore visibility for sprite_layer in [sprite_body, sprite_boots, sprite_armour, sprite_facial_hair, sprite_hair, sprite_eyes, sprite_eyelashes, sprite_addons, sprite_headgear, sprite_weapon, shadow]: if sprite_layer: sprite_layer.modulate.a = 1.0 _set_animation("IDLE") func add_coins(amount: int): if character_stats: character_stats.add_coin(amount) print(name, " picked up ", amount, " coin(s)! Total coins: ", character_stats.coin) # Sync coins to client if this is server-side coin collection # (e.g., when loot is collected on server, sync to client) if multiplayer.has_multiplayer_peer() and multiplayer.is_server() and can_send_rpcs and is_inside_tree(): # Server is adding coins to a player - sync to the client if it's a client player var the_peer_id = get_multiplayer_authority() # Only sync if this is a client player (not server's own player) if the_peer_id != 0 and the_peer_id != multiplayer.get_unique_id(): print(name, " syncing stats to client peer_id=", the_peer_id, " kills=", character_stats.kills, " coins=", character_stats.coin) _sync_stats_update.rpc_id(the_peer_id, character_stats.kills, character_stats.coin) else: coins += amount print(name, " picked up ", amount, " coin(s)! Total coins: ", coins) @rpc("any_peer", "reliable") func _sync_stats_update(kills_count: int, coins_count: int): # Client receives stats update from server (for kills and coins) # Update local stats to match server # Only process on client (not on server where the update originated) if multiplayer.is_server(): return # Server ignores this (it's the sender) if character_stats: character_stats.kills = kills_count character_stats.coin = coins_count print(name, " stats synced from server: kills=", kills_count, " coins=", coins_count) func heal(amount: float): if is_dead: return if character_stats: character_stats.heal(amount) print(name, " healed for ", amount, " HP! Health: ", character_stats.hp, "/", character_stats.maxhp) else: # Fallback for legacy current_health = min(current_health + amount, max_health) print(name, " healed for ", amount, " HP! Health: ", current_health, "/", max_health) func add_key(amount: int = 1): keys += amount print(name, " picked up ", amount, " key(s)! Total keys: ", keys) func has_key() -> bool: return keys > 0 func use_key(): if keys > 0: keys -= 1 print(name, " used a key! Remaining keys: ", keys) return true return false @rpc("authority", "reliable") func _show_damage_number(amount: float, from_position: Vector2, is_crit: bool = false, is_miss: bool = false, is_dodged: bool = false): # Show damage number (red, using dmg_numbers.png font) above player # Show even if amount is 0 for MISS/DODGED var damage_number_scene = preload("res://scenes/damage_number.tscn") if not damage_number_scene: return var damage_label = damage_number_scene.instantiate() if not damage_label: return # Set text and color based on type if is_dodged: damage_label.label = "DODGED" damage_label.color = Color.CYAN elif is_miss: damage_label.label = "MISS" damage_label.color = Color.GRAY else: damage_label.label = str(int(amount)) damage_label.color = Color.ORANGE if is_crit else Color.RED # Calculate direction from attacker (slight upward variation) var direction_from_attacker = (global_position - from_position).normalized() # Add slight upward bias direction_from_attacker = direction_from_attacker.lerp(Vector2(0, -1), 0.5).normalized() damage_label.direction = direction_from_attacker # Position above player's head var game_world = get_tree().get_first_node_in_group("game_world") if game_world: var entities_node = game_world.get_node_or_null("Entities") if entities_node: entities_node.add_child(damage_label) damage_label.global_position = global_position + Vector2(0, -16) # Above player head else: get_tree().current_scene.add_child(damage_label) damage_label.global_position = global_position + Vector2(0, -16) else: get_tree().current_scene.add_child(damage_label) damage_label.global_position = global_position + Vector2(0, -16) func _on_level_up_stats(stats_increased: Array): # Show floating text for level up - "LEVEL UP!" and stat increases # Use damage_number scene with damage_numbers font if not character_stats: return # Stat name to display name mapping var stat_display_names = { "str": "STR", "dex": "DEX", "int": "INT", "end": "END", "wis": "WIS", "lck": "LCK" } # Stat name to color mapping var stat_colors = { "str": Color.RED, "dex": Color.GREEN, "int": Color.BLUE, "end": Color.WHITE, "wis": Color(0.5, 0.0, 0.5), # Purple "lck": Color.YELLOW } var damage_number_scene = preload("res://scenes/damage_number.tscn") if not damage_number_scene: return # Get entities node for adding text var game_world = get_tree().get_first_node_in_group("game_world") var entities_node = null if game_world: entities_node = game_world.get_node_or_null("Entities") if not entities_node: entities_node = get_tree().current_scene var base_y_offset = -32.0 # Start above player head var y_spacing = 12.0 # Space between each text # Show "LEVEL UP!" first (in white) var level_up_text = damage_number_scene.instantiate() if level_up_text: level_up_text.label = "LEVEL UP!" level_up_text.color = Color.WHITE level_up_text.direction = Vector2(0, -1) # Straight up entities_node.add_child(level_up_text) level_up_text.global_position = global_position + Vector2(0, base_y_offset) base_y_offset -= y_spacing # Show each stat increase for i in range(stats_increased.size()): var stat_name = stats_increased[i] var stat_text = damage_number_scene.instantiate() if stat_text: var display_name = stat_display_names.get(stat_name, stat_name.to_upper()) stat_text.label = "+1 " + display_name stat_text.color = stat_colors.get(stat_name, Color.WHITE) stat_text.direction = Vector2(randf_range(-0.2, 0.2), -1.0).normalized() # Slight random spread entities_node.add_child(stat_text) stat_text.global_position = global_position + Vector2(0, base_y_offset) base_y_offset -= y_spacing @rpc("any_peer", "reliable") func _sync_damage(_amount: float, attacker_position: Vector2, is_crit: bool = false, is_miss: bool = false, is_dodged: bool = false): # This RPC only syncs visual effects, not damage application # (damage is already applied via rpc_take_damage) if not is_multiplayer_authority(): # If dodged, only show dodge text, no other effects if is_dodged: _show_damage_number(0.0, attacker_position, false, false, true) return # Play damage sound and effects if sfx_take_damage: sfx_take_damage.play() # Play damage animation _set_animation("DAMAGE") # Calculate direction FROM attacker TO victim var direction_from_attacker = (global_position - attacker_position).normalized() # Knockback visual velocity = direction_from_attacker * 250.0 # Face the attacker current_direction = _get_direction_from_vector(-direction_from_attacker) as Direction # Enable knockback state is_knocked_back = true knockback_time = 0.0 # Flash red on body sprite if sprite_body: var tween = create_tween() tween.tween_property(sprite_body, "modulate", Color.RED, 0.1) tween.tween_property(sprite_body, "modulate", Color.WHITE, 0.1) # Show damage number _show_damage_number(_amount, attacker_position, is_crit, is_miss, false) func on_grabbed(by_player): print(name, " grabbed by ", by_player.name) func on_released(by_player): print(name, " released by ", by_player.name) func on_thrown(by_player, force: Vector2): velocity = force print(name, " thrown by ", by_player.name)