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 = 80.0 @export var grab_range: float = 20.0 @export var throw_force: float = 150.0 @export var cone_light_angle: float = 180.0 # Cone spread angle in degrees (adjustable, default wider) # 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 var virtual_joystick_input: Vector2 = Vector2.ZERO # Virtual joystick input from mobile controls var was_mouse_right_pressed: bool = false # Track previous mouse right button state var was_mouse_left_pressed: bool = false # Track previous mouse left button state # Interaction var held_object = null var grab_offset = Vector2.ZERO var grab_distance: float = 0.0 # Distance from player to object when grabbed (for placement) 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 staff_projectile_scene = preload("res://scenes/attack_staff.tscn") # Staff magic ball projectile var attack_arrow_scene = preload("res://scenes/attack_arrow.tscn") 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 @onready var cone_light = $ConeLight # 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": [100, 100, 300], "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() # CRITICAL: Duplicate shader materials for hair/facial hair to prevent shared state # If materials are shared between players, changing one affects all _duplicate_sprite_materials() # Set up player appearance (randomized based on stats) _setup_player_appearance() # Authority is set by player_manager after adding to scene # Hide interaction indicator by default if interaction_indicator: interaction_indicator.visible = false # Set up cone light blend mode, texture, initial rotation, and spread if cone_light: _update_cone_light_rotation() _update_cone_light_spread() # This calls _create_cone_light_texture() # 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 _duplicate_sprite_materials(): # Duplicate shader materials for sprites that use tint parameters # This prevents shared material state between players if sprite_hair and sprite_hair.material: sprite_hair.material = sprite_hair.material.duplicate() if sprite_facial_hair and sprite_facial_hair.material: sprite_facial_hair.material = sprite_facial_hair.material.duplicate() if sprite_eyes and sprite_eyes.material: sprite_eyes.material = sprite_eyes.material.duplicate() if sprite_eyelashes and sprite_eyelashes.material: sprite_eyelashes.material = sprite_eyelashes.material.duplicate() # Also duplicate materials for equipment sprites that use color replacements if sprite_boots and sprite_boots.material: sprite_boots.material = sprite_boots.material.duplicate() if sprite_armour and sprite_armour.material: sprite_armour.material = sprite_armour.material.duplicate() if sprite_headgear and sprite_headgear.material: sprite_headgear.material = sprite_headgear.material.duplicate() if sprite_weapon and sprite_weapon.material: sprite_weapon.material = sprite_weapon.material.duplicate() func _initialize_character_stats(): # Create character_stats if it doesn't exist if not character_stats: character_stats = CharacterStats.new() character_stats.character_type = "player" character_stats.character_name = "Player_" + str(peer_id) + "_" + str(local_player_index) # 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) # Ensure equipment starts empty (players spawn bare) character_stats.equipment["mainhand"] = null character_stats.equipment["offhand"] = null character_stats.equipment["headgear"] = null character_stats.equipment["armour"] = null character_stats.equipment["boots"] = null character_stats.equipment["accessory"] = null # Create deterministic RNG based on peer_id and local_index for sync across clients # Add session-based randomness so appearance changes each game session appearance_rng = RandomNumberGenerator.new() var session_seed = 0 # Use dungeon seed if available (for multiplayer sync), otherwise use time for variety var game_world = get_tree().get_first_node_in_group("game_world") if game_world and "dungeon_seed" in game_world and game_world.dungeon_seed != 0: session_seed = game_world.dungeon_seed else: session_seed = Time.get_ticks_msec() # Different each game session (for single-player) # Mark that we need to re-initialize appearance when dungeon_seed becomes available if multiplayer.has_multiplayer_peer(): set_meta("needs_appearance_reset", true) var seed_value = hash(str(peer_id) + "_" + str(local_player_index) + "_" + str(session_seed)) appearance_rng.seed = seed_value # Stats will be randomized AFTER race is set in _setup_player_appearance() func _reinitialize_appearance_with_seed(_seed_value: int): # Re-initialize appearance with the correct dungeon_seed # This is called when a joiner receives dungeon_seed after players were already spawned var game_world = get_tree().get_first_node_in_group("game_world") if not game_world or game_world.dungeon_seed == 0: return # Still no seed, skip # Only re-initialize if this player was spawned before dungeon_seed was available # Check if appearance needs to be reset (set in _initialize_character_stats) if not has_meta("needs_appearance_reset"): return # Appearance was already initialized with correct seed, skip # Ensure character_stats exists before trying to modify appearance if not character_stats: LogManager.log_error("Player " + str(name) + " _reinitialize_appearance_with_seed: character_stats is null!", LogManager.CATEGORY_GAMEPLAY) return # Save current state (race, stats, equipment) before re-initializing # We need to preserve these because they might have been set correctly already var saved_race = character_stats.race var saved_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, "hp": character_stats.hp, "maxhp": character_stats.maxhp, "mp": character_stats.mp, "maxmp": character_stats.maxmp, "kills": character_stats.kills, "coin": character_stats.coin, "exp": character_stats.xp, # CharacterStats uses 'xp' not 'exp' "level": character_stats.level } # Deep copy equipment var saved_equipment = {} for slot_name in character_stats.equipment.keys(): var item = character_stats.equipment[slot_name] saved_equipment[slot_name] = item.save() if item else null # Save inventory var saved_inventory = [] for item in character_stats.inventory: saved_inventory.append(item.save() if item else null) # Re-seed the RNG with the correct dungeon_seed var session_seed = game_world.dungeon_seed var new_seed_value = hash(str(peer_id) + "_" + str(local_player_index) + "_" + str(session_seed)) appearance_rng.seed = new_seed_value # Re-run appearance setup with the correct seed # This will re-randomize visual appearance (skin, hair, facial hair, eyes, etc.) _setup_player_appearance() # Restore saved race, stats, and equipment (preserve them from before re-initialization) character_stats.setRace(saved_race) # Restore original race character_stats.baseStats.str = saved_stats.str character_stats.baseStats.dex = saved_stats.dex character_stats.baseStats.int = saved_stats.int character_stats.baseStats.end = saved_stats.end character_stats.baseStats.wis = saved_stats.wis character_stats.baseStats.cha = saved_stats.cha character_stats.baseStats.lck = saved_stats.lck character_stats.hp = saved_stats.hp character_stats.maxhp = saved_stats.maxhp character_stats.mp = saved_stats.mp character_stats.maxmp = saved_stats.maxmp character_stats.kills = saved_stats.kills character_stats.coin = saved_stats.coin character_stats.xp = saved_stats.exp # CharacterStats uses 'xp' not 'exp' character_stats.level = saved_stats.level # Restore equipment for slot_name in saved_equipment.keys(): var item_data = saved_equipment[slot_name] character_stats.equipment[slot_name] = Item.new(item_data) if item_data else null # Restore inventory character_stats.inventory.clear() for item_data in saved_inventory: if item_data: character_stats.inventory.append(Item.new(item_data)) # Re-apply appearance to sprites to show the new visual appearance _apply_appearance_to_sprites() # Clear the flag so we don't re-initialize again remove_meta("needs_appearance_reset") LogManager.log("Player " + str(name) + " appearance re-initialized with dungeon_seed " + str(session_seed), LogManager.CATEGORY_GAMEPLAY) 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) # Apply race-based stat modifiers match character_stats.race: "Dwarf": # Dwarf: Higher STR, Medium DEX, lower INT, lower WIS, lower LCK character_stats.baseStats.str += 3 character_stats.baseStats.int -= 2 character_stats.baseStats.wis -= 2 character_stats.baseStats.lck -= 2 "Elf": # Elf: Medium STR, Higher DEX, lower INT, medium WIS, higher LCK character_stats.baseStats.dex += 3 character_stats.baseStats.int -= 2 character_stats.baseStats.lck += 2 "Human": # Human: Lower STR, lower DEX, higher INT, higher WIS, lower LCK character_stats.baseStats.str -= 2 character_stats.baseStats.dex -= 2 character_stats.baseStats.int += 3 character_stats.baseStats.wis += 3 character_stats.baseStats.lck -= 2 # Stats randomized (verbose logging removed) func _setup_player_appearance(): # Randomize appearance - players spawn "bare" (naked, no equipment) # But with randomized hair, facial hair, eyes, etc. # Ensure character_stats exists before setting appearance if not character_stats: LogManager.log_error("Player " + str(name) + " _setup_player_appearance: character_stats is null!", LogManager.CATEGORY_GAMEPLAY) return # Randomize race first (affects appearance constraints and stats) var races = ["Dwarf", "Elf", "Human"] var selected_race = races[appearance_rng.randi() % races.size()] character_stats.setRace(selected_race) # Randomize stats AFTER race is set (race affects stat modifiers) _randomize_stats() # Randomize skin (human only for players) # Weighted random: Human1 has highest chance, Human7 has lowest chance # Weights: Human1=7, Human2=6, Human3=5, Human4=4, Human5=3, Human6=2, Human7=1 (total=28) var weights = [7, 6, 5, 4, 3, 2, 1] # Higher weight = higher chance var total_weight = 28 var random_value = appearance_rng.randi() % total_weight var skin_index = 0 var cumulative = 0 for i in range(weights.size()): cumulative += weights[i] if random_value < cumulative: skin_index = i break 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 - vibrant and weird colors! 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 Color(0.5, 0.8, 0.2), # Snot green Color(0.9, 0.5, 0.1), # Orange Color(0.8, 0.3, 0.9), # Purple Color(1.0, 0.9, 0.2), # Yellow Color(1.0, 0.5, 0.8), # Pink Color(0.9, 0.2, 0.2), # Red Color(0.2, 0.9, 0.9), # Bright cyan Color(0.6, 0.2, 0.9), # Magenta Color(0.9, 0.7, 0.2), # Gold Color(0.3, 0.9, 0.3), # Bright green Color(0.2, 0.2, 0.9), # Bright blue Color(0.9, 0.4, 0.6), # Hot pink Color(0.5, 0.2, 0.8), # Deep purple Color(0.9, 0.6, 0.1) # Amber ] character_stats.setHairColor(hair_colors[appearance_rng.randi() % hair_colors.size()]) # Randomize facial hair based on race constraints var facial_hair_style = 0 match selected_race: "Dwarf": # Dwarf: must have mustache or beard (1-3, not 0) facial_hair_style = appearance_rng.randi_range(1, 3) "Elf": # Elf: cannot have facial hair (always 0) facial_hair_style = 0 "Human": # Human: only mustache or no facial hair (0 or 3) facial_hair_style = 3 if appearance_rng.randf() < 0.5 else 0 character_stats.setFacialHair(facial_hair_style) # Randomize facial hair color (usually matches hair, but can be different) if facial_hair_style > 0: if appearance_rng.randf() < 0.3: # 30% chance for different color character_stats.setFacialHairColor(hair_colors[appearance_rng.randi() % hair_colors.size()]) else: 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 eye color - vibrant and weird colors! # 75% chance for white, 25% chance for other colors var white_color = Color(0.9, 0.9, 0.9) # White var other_eye_colors = [ Color(0.1, 0.1, 0.1), # Black Color(0.2, 0.3, 0.8), # Blue Color(0.3, 0.7, 0.9), # Cyan Color(0.5, 0.8, 0.2), # Snot green Color(0.9, 0.5, 0.1), # Orange Color(0.8, 0.3, 0.9), # Purple Color(1.0, 0.9, 0.2), # Yellow Color(1.0, 0.5, 0.8), # Pink Color(0.9, 0.2, 0.2), # Red Color(0.2, 0.9, 0.9), # Bright cyan Color(0.6, 0.2, 0.9), # Magenta Color(0.9, 0.7, 0.2), # Gold Color(0.3, 0.9, 0.3), # Bright green Color(0.2, 0.2, 0.9), # Bright blue Color(0.9, 0.4, 0.6), # Hot pink Color(0.5, 0.2, 0.8), # Deep purple Color(0.9, 0.6, 0.1) # Amber ] if appearance_rng.randf() < 0.75: # 75% chance for white character_stats.setEyeColor(white_color) else: # 25% chance for other colors character_stats.setEyeColor(other_eye_colors[appearance_rng.randi() % other_eye_colors.size()]) # Randomize eyelashes (0 = none, 1-8 = various styles) var eyelash_style = appearance_rng.randi_range(0, 8) character_stats.setEyeLashes(eyelash_style) # Randomize eyelash color - vibrant and weird colors too! var eyelash_colors = [ Color(0.1, 0.1, 0.1), # Black Color(0.2, 0.2, 0.2), # Dark gray Color(0.3, 0.2, 0.15), # Dark brown Color(0.4, 0.3, 0.2), # Brown Color(0.5, 0.8, 0.2), # Snot green Color(0.9, 0.5, 0.1), # Orange Color(0.8, 0.3, 0.9), # Purple Color(1.0, 0.9, 0.2), # Yellow Color(1.0, 0.5, 0.8), # Pink Color(0.9, 0.2, 0.2), # Red Color(0.9, 0.9, 0.9), # White Color(0.6, 0.2, 0.9) # Magenta ] if eyelash_style > 0: character_stats.setEyelashColor(eyelash_colors[appearance_rng.randi() % eyelash_colors.size()]) # Randomize ears/addons based on race match selected_race: "Elf": # Elf: always gets elf ears (ElfEars1 to 7 based on skin number) # skin_index is 0-6 (Human1-7), ear styles are 1-7 (ElfEars1-7) var elf_ear_style = skin_index + 1 # Convert 0-6 to 1-7 character_stats.setEars(elf_ear_style) _: # Other races: no ears character_stats.setEars(0) # 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"] # Only render boots if it's actually boots equipment (not a weapon or other type) if equipped_boots and equipped_boots.equipment_type == Item.EquipmentType.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 # Apply color replacements if available _apply_color_replacements(sprite_boots, equipped_boots) else: sprite_boots.texture = null _clear_color_replacements(sprite_boots) else: sprite_boots.texture = null _clear_color_replacements(sprite_boots) # Armour if sprite_armour: var equipped_armour = character_stats.equipment["armour"] # Only render armour if it's actually armour equipment (not a weapon) if equipped_armour and equipped_armour.equipment_type == Item.EquipmentType.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 # Apply color replacements if available _apply_color_replacements(sprite_armour, equipped_armour) else: sprite_armour.texture = null _clear_color_replacements(sprite_armour) else: sprite_armour.texture = null _clear_color_replacements(sprite_armour) # 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 # Use shader tint parameter instead of modulate # Only update color if it's valid (not uninitialized black with alpha 0) # This prevents hair colors from changing when joiners connect and sync triggers character_changed var facial_hair_color = character_stats.facial_hair_color if facial_hair_color != Color(0, 0, 0, 0): if sprite_facial_hair.material and sprite_facial_hair.material is ShaderMaterial: sprite_facial_hair.material.set_shader_parameter("tint", Vector4(facial_hair_color.r, facial_hair_color.g, facial_hair_color.b, facial_hair_color.a)) else: # Fallback to modulate if no shader material sprite_facial_hair.modulate = 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 # Use shader tint parameter instead of modulate # Only update color if it's valid (not uninitialized black with alpha 0) # This prevents hair colors from changing when joiners connect and sync triggers character_changed var hair_color = character_stats.hair_color if hair_color != Color(0, 0, 0, 0): if sprite_hair.material and sprite_hair.material is ShaderMaterial: sprite_hair.material.set_shader_parameter("tint", Vector4(hair_color.r, hair_color.g, hair_color.b, hair_color.a)) else: # Fallback to modulate if no shader material sprite_hair.modulate = 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 # Use shader tint parameter for eye color if sprite_eyes.material and sprite_eyes.material is ShaderMaterial: sprite_eyes.material.set_shader_parameter("tint", Vector4(character_stats.eye_color.r, character_stats.eye_color.g, character_stats.eye_color.b, character_stats.eye_color.a)) else: # Fallback to modulate if no shader material sprite_eyes.modulate = character_stats.eye_color 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 # Use shader tint parameter for eyelash color if sprite_eyelashes.material and sprite_eyelashes.material is ShaderMaterial: sprite_eyelashes.material.set_shader_parameter("tint", Vector4(character_stats.eyelash_color.r, character_stats.eyelash_color.g, character_stats.eyelash_color.b, character_stats.eyelash_color.a)) else: # Fallback to modulate if no shader material sprite_eyelashes.modulate = character_stats.eyelash_color 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 # Apply color replacements if available _apply_color_replacements(sprite_headgear, equipped_headgear) else: sprite_headgear.texture = null _clear_color_replacements(sprite_headgear) else: sprite_headgear.texture = null _clear_color_replacements(sprite_headgear) # Weapon (Mainhand) # NOTE: Weapons NEVER change the Sprite2DWeapon sprite... # but they can apply color changes!!! if sprite_weapon: var equipped_weapon = null if character_stats and character_stats.equipment.has("mainhand"): equipped_weapon = character_stats.equipment["mainhand"] if equipped_weapon and equipped_weapon.weapon_type == Item.WeaponType.STAFF and equipped_weapon.equipmentPath != "": _apply_weapon_color_replacements(sprite_weapon, equipped_weapon) else: _clear_weapon_color_replacements(sprite_weapon) # Appearance applied (verbose logging removed) func _apply_color_replacements(sprite: Sprite2D, item: Item) -> void: # Apply color replacements using shader parameters if not sprite or not item: return if not sprite.material or not sprite.material is ShaderMaterial: return if not item.colorReplacements or item.colorReplacements.size() == 0: return var shader_material = sprite.material as ShaderMaterial for index in range(item.colorReplacements.size()): var color_replacement: Dictionary = item.colorReplacements[index] if color_replacement.has("original") and color_replacement.has("replace"): var original_color = color_replacement["original"] as Color var replace_color = color_replacement["replace"] as Color shader_material.set_shader_parameter("original_" + str(index), original_color) shader_material.set_shader_parameter("replace_" + str(index), replace_color) func _clear_color_replacements(sprite: Sprite2D) -> void: # Clear color replacement shader parameters if not sprite or not sprite.material or not sprite.material is ShaderMaterial: return var shader_material = sprite.material as ShaderMaterial # Clear up to 10 replacement slots (should be enough) for index in range(10): shader_material.set_shader_parameter("original_" + str(index), Color(0, 0, 0, 0)) shader_material.set_shader_parameter("replace_" + str(index), Color(0, 0, 0, 0)) func _apply_weapon_color_replacements(sprite: Sprite2D, item: Item) -> void: # Apply color replacements for staff colors only (RGB 209,142,54 and RGB 192,112,31) if not sprite or not item: return if not sprite.material or not sprite.material is ShaderMaterial: return if not item.colorReplacements or item.colorReplacements.size() == 0: return var shader_material = sprite.material as ShaderMaterial # Staff colors that should be replaced on the weapon sprite var staff_colors = [ Color(209/255.0, 142/255.0, 54/255.0), Color(192/255.0, 112/255.0, 31/255.0) ] var replacement_index = 0 for color_replacement in item.colorReplacements: if color_replacement.has("original") and color_replacement.has("replace"): var original_color = color_replacement["original"] as Color # Only apply replacements for staff colors for staff_color in staff_colors: # Check if this replacement matches a staff color (with some tolerance) if _colors_similar(original_color, staff_color, 0.1): var replace_color = color_replacement["replace"] as Color shader_material.set_shader_parameter("original_" + str(replacement_index), original_color) shader_material.set_shader_parameter("replace_" + str(replacement_index), replace_color) replacement_index += 1 break # Found match, move to next replacement func _clear_weapon_color_replacements(sprite: Sprite2D) -> void: # Clear weapon color replacement shader parameters (same as regular clear) _clear_color_replacements(sprite) func _colors_similar(color1: Color, color2: Color, tolerance: float = 0.1) -> bool: # Check if two colors are similar within tolerance var r_diff = abs(color1.r - color2.r) var g_diff = abs(color1.g - color2.g) var b_diff = abs(color1.b - color2.b) return r_diff <= tolerance and g_diff <= tolerance and b_diff <= tolerance func _on_character_changed(_char: CharacterStats): # Update appearance when character stats change (e.g., equipment) # Only update appearance-related sprites (equipment, not hair/facial hair colors) # Hair and facial hair colors should NEVER change after initial setup _apply_appearance_to_sprites() # Sync equipment changes to other clients (when authority player changes equipment) if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree(): # Sync equipment to all clients var equipment_data = {} for slot_name in character_stats.equipment.keys(): var item = character_stats.equipment[slot_name] if item: equipment_data[slot_name] = item.save() # Serialize item data else: equipment_data[slot_name] = null _rpc_to_ready_peers("_sync_equipment", [equipment_data]) # Sync equipment and inventory to client (when server adds/removes items for a client player) # This ensures joiners see items they pick up and equipment changes # This must be checked separately from the authority-based sync because on the server, # a joiner's player has authority set to their peer_id, not the server's unique_id if multiplayer.has_multiplayer_peer() and multiplayer.is_server() and can_send_rpcs and is_inside_tree(): 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(): # Sync equipment var equipment_data = {} for slot_name in character_stats.equipment.keys(): var item = character_stats.equipment[slot_name] if item: equipment_data[slot_name] = item.save() else: equipment_data[slot_name] = null _sync_equipment.rpc_id(the_peer_id, equipment_data) # Sync inventory var inventory_data = [] for item in character_stats.inventory: if item: inventory_data.append(item.save()) _sync_inventory.rpc_id(the_peer_id, inventory_data) print(name, " syncing equipment and inventory to client peer_id=", the_peer_id, " inventory_size=", inventory_data.size()) 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) # Helper function to get consistent object name for network sync func _get_object_name_for_sync(obj) -> String: # For interactable objects, use the consistent name (InteractableObject_X) if obj.name.begins_with("InteractableObject_"): return obj.name if obj.has_meta("object_index"): var obj_index = obj.get_meta("object_index") return "InteractableObject_%d" % obj_index # For players, use their unique name if _is_player(obj): return obj.name # Last resort: use the node name (might be auto-generated like @CharacterBody2D@82) return obj.name func _get_log_prefix() -> String: if multiplayer.has_multiplayer_peer(): return "[H] " if multiplayer.is_server() else "[J] " return "" func _can_place_down_at(place_pos: Vector2, placed_obj: Node) -> bool: var space_state = get_world_2d().direct_space_state # Get the actual collision shape and its transform (including position offset) var placed_shape = _get_collision_shape_for(placed_obj) var placed_shape_transform = _get_collision_shape_transform(placed_obj, place_pos) if not placed_shape: # Fallback to 16x16 placed_shape = RectangleShape2D.new() placed_shape.size = Vector2(16, 16) placed_shape_transform = Transform2D(0.0, place_pos) # Check if the placed object's collision shape would collide with anything # This includes: walls, other objects, and players var params = PhysicsShapeQueryParameters2D.new() params.shape = placed_shape params.transform = placed_shape_transform params.collision_mask = 1 | 2 | 64 # Players (layer 1), objects (layer 2), walls (layer 7 = bit 6 = 64) # CRITICAL: Exclude self, the object being placed, and make sure to exclude it properly # The object might still be in the scene tree with collision disabled, so we need to exclude it var exclude_list = [self] if placed_obj and is_instance_valid(placed_obj): exclude_list.append(placed_obj) params.exclude = exclude_list # Test the actual collision shape at the placement position var hits = space_state.intersect_shape(params, 32) # Check up to 32 collisions # Debug: Log what we found if hits.size() > 0: print("DEBUG: Placement blocked - found ", hits.size(), " collisions at ", place_pos) for i in min(hits.size(), 3): # Log first 3 collisions var hit = hits[i] if hit.has("collider"): print(" - Collision with: ", hit.collider, " (", hit.collider.name if hit.collider else "null", ")") if hit.has("rid"): print(" - RID: ", hit.rid) # If any collisions found, placement is invalid return hits.size() == 0 func _find_closest_place_pos(direction: Vector2, placed_obj: Node) -> Vector2: var dir = direction.normalized() if dir.length() < 0.1: dir = last_movement_direction.normalized() if dir.length() < 0.1: dir = Vector2.RIGHT # Use the stored grab distance if available, otherwise calculate a default var target_distance = grab_distance if target_distance <= 0.0: # Fallback: calculate minimum distance if grab_distance wasn't stored var player_extent = _get_collision_extent(self) var obj_extent = _get_collision_extent(placed_obj) target_distance = player_extent + obj_extent + 2.0 # Try placing at the exact grab distance first var place_pos = global_position + dir * target_distance if _can_place_down_at(place_pos, placed_obj): return place_pos # If exact distance doesn't work, search nearby positions # Search slightly closer and further to find valid placement var search_range = 10.0 var step = 0.5 var best_pos = place_pos # Try closer positions first (prefer closer placement) for offset in range(int(-search_range * 2), int(search_range * 2) + 1, int(step * 2)): var test_dist = target_distance + (float(offset) / 2.0) if test_dist < 2.0: # Don't get too close continue var test_pos = global_position + dir * test_dist if _can_place_down_at(test_pos, placed_obj): return test_pos # Return first valid position return best_pos func _get_collision_shape_for(node: Node) -> Shape2D: if not node: return null var shape_node = node.get_node_or_null("CollisionShape2D") if not shape_node: shape_node = node.find_child("CollisionShape2D", true, false) if shape_node and "shape" in shape_node: return shape_node.shape return null func _get_collision_shape_transform(node: Node, world_pos: Vector2) -> Transform2D: # Get the collision shape's local transform (position offset and rotation) # and combine it with the world position if not node: return Transform2D(0.0, world_pos) var shape_node = node.get_node_or_null("CollisionShape2D") if not shape_node: shape_node = node.find_child("CollisionShape2D", true, false) if shape_node: # Get the shape node's local position and rotation var shape_local_pos = shape_node.position var shape_rotation = shape_node.rotation # Create transform: rotation first, then translation # The shape's local position is relative to the node, so add it to world_pos var shape_transform = Transform2D(shape_rotation, world_pos + shape_local_pos) return shape_transform # No shape node found, just use world position return Transform2D(0.0, world_pos) func _get_collision_extent(node: Node) -> float: var shape = _get_collision_shape_for(node) if shape is RectangleShape2D: return max(shape.size.x, shape.size.y) * 0.5 if shape is CapsuleShape2D: return shape.radius + shape.height * 0.5 if shape is CircleShape2D: return shape.radius if shape is ConvexPolygonShape2D: var rect = shape.get_rect() return max(rect.size.x, rect.size.y) * 0.5 # Fallback return 8.0 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 # Update facing direction from mouse position (called by GameWorld) func _update_facing_from_mouse(mouse_direction: Vector2): # Only update if using keyboard input (not gamepad) if input_device != -1: return # Don't update if pushing (locked direction) if is_pushing: return var new_direction = _get_direction_from_vector(mouse_direction) as Direction # Update direction and cone light rotation if changed if new_direction != current_direction: current_direction = new_direction _update_cone_light_rotation() func _set_animation(anim_name: String): if current_animation != anim_name: current_animation = anim_name current_frame = 0 time_since_last_frame = 0.0 # Convert Direction enum to angle in radians for light rotation func _direction_to_angle(direction: int) -> float: match direction: Direction.DOWN: return PI / 2.0 # 90 degrees Direction.DOWN_RIGHT: return PI / 4.0 # 45 degrees Direction.RIGHT: return 0.0 # 0 degrees Direction.UP_RIGHT: return -PI / 4.0 # -45 degrees Direction.UP: return -PI / 2.0 # -90 degrees Direction.UP_LEFT: return -3.0 * PI / 4.0 # -135 degrees Direction.LEFT: return PI # 180 degrees Direction.DOWN_LEFT: return 3.0 * PI / 4.0 # 135 degrees _: return PI / 2.0 # Default to DOWN # Update cone light rotation based on player's facing direction func _update_cone_light_rotation(): if cone_light: cone_light.rotation = _direction_to_angle(current_direction)+(PI/2) # Create a cone-shaped light texture programmatically # Creates a directional cone texture that extends forward and fades to the sides func _create_cone_light_texture(): if not cone_light: return # Create a square texture (recommended size for lights) var texture_size = 256 var image = Image.create(texture_size, texture_size, false, Image.FORMAT_RGBA8) var center = Vector2(texture_size / 2.0, texture_size / 2.0) var max_distance = texture_size / 2.0 # Cone parameters (these control the shape) var cone_angle_rad = deg_to_rad(cone_light_angle) # Convert to radians var half_cone = cone_angle_rad / 2.0 var forward_dir = Vector2(0, -1) # Pointing up in texture space (will be rotated by light rotation) for x in range(texture_size): for y in range(texture_size): var pos = Vector2(x, y) var offset = pos - center var distance = offset.length() if distance > 0.0: # Normalize offset to get direction var dir = offset / distance # Calculate angle from forward direction # forward_dir is (0, -1) which has angle -PI/2 # We want to find the angle difference var pixel_angle = dir.angle() # Angle of pixel direction var forward_angle = forward_dir.angle() # Angle of forward direction (-PI/2) # Calculate angle difference (wrapped to -PI to PI) var angle_diff = pixel_angle - forward_angle # Normalize to -PI to PI range angle_diff = fmod(angle_diff + PI, 2.0 * PI) - PI var abs_angle_diff = abs(angle_diff) # Check if within cone angle (hard edge - no smooth falloff) if abs_angle_diff <= half_cone: # Within cone - calculate brightness var normalized_distance = distance / max_distance # Fade based on distance (from center) - keep distance falloff # Hard edge for angle (pixely) - no smoothstep on angle var distance_factor = 1.0 - smoothstep(0.0, 1.0, normalized_distance) var alpha = distance_factor # Hard edge on angle, smooth fade on distance var color = Color(1.0, 1.0, 1.0, alpha) image.set_pixel(x, y, color) else: # Outside cone - transparent (hard edge) image.set_pixel(x, y, Color.TRANSPARENT) else: # Center point - full brightness image.set_pixel(x, y, Color.WHITE) # Create ImageTexture from the image var texture = ImageTexture.create_from_image(image) cone_light.texture = texture # Update cone light spread/angle # Recreates the texture with the new angle to properly show the cone shape func _update_cone_light_spread(): if cone_light: # Recreate the texture with the new angle _create_cone_light_texture() # Set the cone light angle (in degrees) and update the light func set_cone_light_angle(angle_degrees: float): cone_light_angle = angle_degrees _update_cone_light_spread() # 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 # Handle knockback timer (always handle knockback, even when controls are disabled) if is_knocked_back: knockback_time += delta if knockback_time >= knockback_duration: is_knocked_back = false knockback_time = 0.0 # Skip input if controls are disabled (e.g., when inventory is open) # But still allow knockback to continue (handled above) var skip_input = controls_disabled if controls_disabled: if not is_knocked_back: # Immediately stop movement when controls are disabled (e.g., inventory opened) velocity = Vector2.ZERO # Reset animation to IDLE if not in a special state if current_animation != "THROW" and current_animation != "DAMAGE" and current_animation != "SWORD" and current_animation != "BOW" and current_animation != "STAFF": if is_lifting: _set_animation("IDLE_HOLD") elif is_pushing: _set_animation("IDLE_PUSH") else: _set_animation("IDLE") # 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 and not skip_input: # Normal input handling (only if controls are not disabled) 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)") # Sync position to all ready peers (clients and server) # Only send if node is still valid and in tree if multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree() and is_instance_valid(self): _rpc_to_ready_peers("_sync_position", [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 # Check for virtual joystick input first (mobile/touchscreen) if virtual_joystick_input.length() > 0.01: input_vector = virtual_joystick_input elif input_device == -1: # Keyboard input input_vector.x = max( Input.get_action_strength("move_right"), Input.get_action_strength("ui_right") ) - max( Input.get_action_strength("move_left"), Input.get_action_strength("ui_left") ) input_vector.y = max( Input.get_action_strength("move_down"), Input.get_action_strength("ui_down") ) - max( Input.get_action_strength("move_up"), Input.get_action_strength("ui_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) # Note: Mouse control will override this if mouse is being used var new_direction = current_direction if not is_pushing: new_direction = _get_direction_from_vector(input_vector) as Direction else: # Keep locked direction when pushing new_direction = push_direction_locked as Direction # Update direction and cone light rotation if changed if new_direction != current_direction: current_direction = new_direction _update_cone_light_rotation() # Set animation based on state if is_lifting: _set_animation("RUN_HOLD") elif is_pushing: _set_animation("RUN_PUSH") elif current_animation != "SWORD" and current_animation != "BOW" and current_animation != "STAFF": _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 if push_direction_locked != current_direction: current_direction = push_direction_locked as Direction _update_cone_light_rotation() else: if current_animation != "THROW" and current_animation != "DAMAGE" and current_animation != "SWORD" and current_animation != "BOW" and current_animation != "STAFF": _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 # Calculate speed with encumbrance penalty var base_speed = move_speed * (0.5 if is_pushing else 1.0) var current_speed = base_speed # Apply encumbrance penalty (1/4 speed if over-encumbered) if character_stats and character_stats.is_over_encumbered(): current_speed = base_speed * 0.25 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 or Mouse input var mouse_right_pressed = Input.is_mouse_button_pressed(MOUSE_BUTTON_RIGHT) grab_button_down = Input.is_action_pressed("grab") or mouse_right_pressed grab_just_pressed = Input.is_action_just_pressed("grab") or (mouse_right_pressed and not was_mouse_right_pressed) grab_just_released = Input.is_action_just_released("grab") or (not mouse_right_pressed and was_mouse_right_pressed) was_mouse_right_pressed = mouse_right_pressed # 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 or Mouse var mouse_left_pressed = Input.is_mouse_button_pressed(MOUSE_BUTTON_LEFT) attack_just_pressed = Input.is_action_just_pressed("attack") or (mouse_left_pressed and not was_mouse_left_pressed) was_mouse_left_pressed = mouse_left_pressed else: # Gamepad (X button) attack_just_pressed = Input.is_joy_button_pressed(input_device, JOY_BUTTON_X) if attack_just_pressed and can_attack: if is_lifting: # Attack while lifting -> throw immediately (no movement required) _force_throw_held_object(last_movement_direction) elif 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 # Store the distance from player to object when grabbed (for placement) grab_distance = global_position.distance_to(closest_body.global_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(): # Use consistent object name or index instead of path var obj_name = _get_object_name_for_sync(held_object) _rpc_to_ready_peers("_sync_initial_grab", [obj_name, grab_offset]) # Sync the grab state _rpc_to_ready_peers("_sync_grab", [obj_name, 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(): var obj_name = _get_object_name_for_sync(held_object) _rpc_to_ready_peers("_sync_grab", [obj_name, 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(): var obj_name = _get_object_name_for_sync(held_object) _rpc_to_ready_peers("_sync_grab", [obj_name, 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(): var obj_name = _get_object_name_for_sync(released_obj) _rpc_to_ready_peers("_sync_release", [obj_name]) # 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 grab_distance = 0.0 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) thrown_obj.set_collision_mask_value(7, 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) thrown_obj.set_collision_mask_value(7, 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(): var obj_name = _get_object_name_for_sync(thrown_obj) _rpc_to_ready_peers("_sync_throw", [obj_name, throw_start_pos, throw_direction * throw_force, name]) print("Threw ", thrown_obj.name, " from ", throw_start_pos, " with force: ", throw_direction * throw_force) func _force_throw_held_object(direction: Vector2): 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 = direction.normalized() if throw_direction.length() < 0.1: throw_direction = last_movement_direction.normalized() if throw_direction.length() < 0.1: throw_direction = Vector2.RIGHT # 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(): var obj_name = _get_object_name_for_sync(thrown_obj) _rpc_to_ready_peers("_sync_throw", [obj_name, throw_start_pos, throw_direction * throw_force, name]) 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_pos = _find_closest_place_pos(last_movement_direction, held_object) var placed_obj = held_object print("DEBUG: Attempting to place ", placed_obj.name if placed_obj else "null", " at ", place_pos, " (player at ", global_position, ", distance: ", global_position.distance_to(place_pos), ")") if not _can_place_down_at(place_pos, placed_obj): print("DEBUG: Place down blocked - space not free at ", place_pos) return # 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(): var obj_name = _get_object_name_for_sync(placed_obj) _rpc_to_ready_peers("_sync_place_down", [obj_name, 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 # Check what weapon is equipped var equipped_weapon = null if character_stats and character_stats.equipment.has("mainhand"): equipped_weapon = character_stats.equipment["mainhand"] var is_bow = false var is_staff = false if equipped_weapon: if equipped_weapon.weapon_type == Item.WeaponType.BOW: is_bow = true elif equipped_weapon.weapon_type == Item.WeaponType.STAFF: is_staff = true # Play attack animation based on weapon if is_bow: _set_animation("BOW") elif is_staff: _set_animation("STAFF") else: _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 projectile 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 # Handle bow attacks - require arrows in off-hand if is_bow: # Check for arrows in off-hand var arrows = null if character_stats and character_stats.equipment.has("offhand"): var offhand_item = character_stats.equipment["offhand"] if offhand_item and offhand_item.weapon_type == Item.WeaponType.AMMUNITION: arrows = offhand_item # Only spawn arrow if we have arrows if arrows and arrows.quantity > 0: if attack_arrow_scene: var arrow_projectile = attack_arrow_scene.instantiate() get_parent().add_child(arrow_projectile) arrow_projectile.shoot(attack_direction, global_position, self) # Play bow shoot sound if has_node("SfxBowShoot"): $SfxBowShoot.play() # Consume one arrow arrows.quantity -= 1 var remaining = arrows.quantity if arrows.quantity <= 0: # Remove arrows if quantity reaches 0 character_stats.equipment["offhand"] = null if character_stats: character_stats.character_changed.emit(character_stats) else: # Update equipment to reflect quantity change if character_stats: character_stats.character_changed.emit(character_stats) print(name, " shot arrow! Arrows remaining: ", remaining) else: # Play bow animation but no projectile # Play sound for trying to shoot without arrows if has_node("SfxBowWithoutArrow"): $SfxBowWithoutArrow.play() print(name, " tried to shoot but has no arrows!") elif is_staff: # Spawn staff projectile for staff weapons if staff_projectile_scene and equipped_weapon: var projectile = staff_projectile_scene.instantiate() get_parent().add_child(projectile) projectile.setup(attack_direction, self, final_damage, equipped_weapon) # 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 * 6.0 projectile.global_position = global_position + spawn_offset print(name, " attacked with staff projectile! Damage: ", final_damage, " (base: ", base_damage, ", crit: ", is_crit, ")") else: # Spawn sword projectile for non-bow/staff weapons 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 * 6.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(): _rpc_to_ready_peers("_sync_attack", [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(): var obj_name = _get_object_name_for_sync(held_object) _rpc_to_ready_peers("_sync_held_object_pos", [obj_name, 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 = 1 | 2 | 64 # Players, objects, walls query.collide_with_areas = false query.collide_with_bodies = true query.exclude = [held_object.get_rid(), get_rid()] # Exclude the object and the holder 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 = 1 | 2 | 64 # Players, objects, walls query.collide_with_areas = false query.collide_with_bodies = true if held_object is CharacterBody2D: query.exclude = [held_object.get_rid(), 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(): var obj_name = _get_object_name_for_sync(held_object) _rpc_to_ready_peers("_sync_held_object_pos", [obj_name, held_object.global_position]) # Send RPCs only to peers who are ready to receive them func _rpc_to_ready_peers(method: String, args: Array = []): if not multiplayer.has_multiplayer_peer(): return var game_world = get_tree().get_first_node_in_group("game_world") # Server can use the ready-peer helper for safe fanout if multiplayer.is_server() and game_world and game_world.has_method("_rpc_node_to_ready_peers"): game_world._rpc_node_to_ready_peers(self, method, args) return # Clients: only send to peers marked ready by server if game_world and "clients_ready" in game_world: # Get peers list once to avoid multiple calls var peers = multiplayer.get_peers() for target_peer_id in peers: # Final check: verify peer is still in get_peers() right before sending var current_peers = multiplayer.get_peers() if target_peer_id not in current_peers: continue # Always allow sending to server (peer 1), but check connection first if target_peer_id == 1: # For WebRTC, verify connection before sending to server if multiplayer.multiplayer_peer is WebRTCMultiplayerPeer: if not _is_peer_connected_for_rpc(target_peer_id): continue callv("rpc_id", [target_peer_id, method] + args) continue # Check if peer is ready and connected if game_world.clients_ready.has(target_peer_id) and game_world.clients_ready[target_peer_id]: # For WebRTC, verify connection before sending if multiplayer.multiplayer_peer is WebRTCMultiplayerPeer: if not _is_peer_connected_for_rpc(target_peer_id): continue callv("rpc_id", [target_peer_id, method] + args) else: # Fallback: send to all peers (but still check connections for WebRTC) if multiplayer.multiplayer_peer is WebRTCMultiplayerPeer: var peers = multiplayer.get_peers() for target_peer_id in peers: if _is_peer_connected_for_rpc(target_peer_id): callv("rpc_id", [target_peer_id, method] + args) else: callv("rpc", [method] + args) func _is_peer_connected_for_rpc(target_peer_id: int) -> bool: """Check if a peer is still connected and has open data channels before sending RPC""" if not multiplayer.has_multiplayer_peer(): return false # Check if peer is in get_peers() var peers = multiplayer.get_peers() if target_peer_id not in peers: return false # For WebRTC, check if data channels are actually open if multiplayer.multiplayer_peer is WebRTCMultiplayerPeer: var webrtc_peer = multiplayer.multiplayer_peer as WebRTCMultiplayerPeer if not webrtc_peer.has_peer(target_peer_id): return false var peer_info = webrtc_peer.get_peer(target_peer_id) if not peer_info: return false # Check if data channels are connected (this is the critical check) var is_net_connected = peer_info.get("connected", false) if not is_net_connected: return false # Also check connection state to be extra safe var connection_obj = peer_info.get("connection") if connection_obj != null and typeof(connection_obj) == TYPE_INT: var connection_val = int(connection_obj) # Connection state: 0=NEW, 1=CONNECTING, 2=CONNECTED, 3=DISCONNECTED, 4=FAILED if connection_val == 3 or connection_val == 4: # DISCONNECTED or FAILED return false # Also verify channels array to ensure channels are actually open # The "connected" flag might not update immediately when channels close var channels = peer_info.get("channels", []) if channels is Array and channels.size() > 0: for channel in channels: if channel != null: # Check if channel has ready_state method if channel.has_method("get_ready_state"): var ready_state = channel.get_ready_state() # WebRTCDataChannel.State: 0=CONNECTING, 1=OPEN, 2=CLOSING, 3=CLOSED if ready_state != 1: # Not OPEN return false elif "ready_state" in channel: var ready_state = channel.get("ready_state") if ready_state != 1: # Not OPEN return false # Also check matchbox_client connection state for additional verification var network_manager = get_node_or_null("/root/NetworkManager") if network_manager and network_manager.matchbox_client: var matchbox = network_manager.matchbox_client if "peer_connections" in matchbox: var pc = matchbox.peer_connections.get(target_peer_id) if pc and pc.has_method("get_connection_state"): var matchbox_conn_state = pc.get_connection_state() # Connection state: 0=NEW, 1=CONNECTING, 2=CONNECTED, 3=DISCONNECTED, 4=FAILED if matchbox_conn_state == 3 or matchbox_conn_state == 4: # DISCONNECTED or FAILED return false return true # 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"): # Check if node still exists and is valid before processing if not is_inside_tree() or not is_instance_valid(self): return # 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 # Check if node still exists and is valid before processing if not is_inside_tree() or not is_instance_valid(self): return if not is_multiplayer_authority(): current_direction = direction as Direction # Determine weapon type for animation and projectile var equipped_weapon = null var is_staff = false var is_bow = false if character_stats and character_stats.equipment.has("mainhand"): equipped_weapon = character_stats.equipment["mainhand"] if equipped_weapon: if equipped_weapon.weapon_type == Item.WeaponType.STAFF: is_staff = true elif equipped_weapon.weapon_type == Item.WeaponType.BOW: is_bow = true # Set appropriate animation if is_staff: _set_animation("STAFF") elif is_bow: _set_animation("BOW") else: _set_animation("SWORD") # Delay before spawning projectile await get_tree().create_timer(0.15).timeout # Check again after delay - node might have been destroyed if not is_inside_tree() or not is_instance_valid(self): return # Spawn appropriate projectile on client if is_staff and staff_projectile_scene and equipped_weapon: var projectile = staff_projectile_scene.instantiate() get_parent().add_child(projectile) projectile.setup(attack_dir, self, 20.0, equipped_weapon) # 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 staff attack!") elif is_bow: # For bow attacks, check if we have arrows (same logic as host) var arrows = null if character_stats and character_stats.equipment.has("offhand"): var offhand_item = character_stats.equipment["offhand"] if offhand_item and offhand_item.weapon_type == Item.WeaponType.AMMUNITION: arrows = offhand_item # Only spawn arrow if we have arrows (matches host behavior) if arrows and arrows.quantity > 0: if attack_arrow_scene: var arrow_projectile = attack_arrow_scene.instantiate() get_parent().add_child(arrow_projectile) arrow_projectile.shoot(attack_dir, global_position, self) print(name, " performed synced bow attack with arrow!") else: # No arrows - just play animation, no projectile (matches host behavior) print(name, " performed synced bow attack without arrows (no projectile)") elif 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_name: String, throw_pos: Vector2, force: Vector2, thrower_name: String): # Sync throw to all clients (RPC sender already threw on their side) # Check if node is still valid and in tree if not is_inside_tree(): return # Find object by name (consistent name like InteractableObject_X) var obj = null var game_world = get_tree().get_first_node_in_group("game_world") if not game_world or not game_world.is_inside_tree(): return # GameWorld not ready yet var entities_node = game_world.get_node_or_null("Entities") if entities_node: obj = entities_node.get_node_or_null(obj_name) # Fallback: if name lookup fails and name looks like InteractableObject_X, try object_index lookup if not obj and obj_name.begins_with("InteractableObject_") and entities_node: var index_str = obj_name.substr(20) # Skip "InteractableObject_" if index_str.is_valid_int(): var obj_index = index_str.to_int() for child in entities_node.get_children(): if child.has_meta("object_index") and child.get_meta("object_index") == obj_index: obj = child break var thrower = null if entities_node: thrower = entities_node.get_node_or_null(thrower_name) print("_sync_throw received: ", obj_name, " 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) obj.set_collision_mask_value(7, 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) obj.set_collision_mask_value(7, true) @rpc("any_peer", "reliable") func _sync_initial_grab(obj_name: String, _offset: Vector2): # Sync initial grab to other clients # Check if node is still valid and in tree if not is_inside_tree(): return if not is_multiplayer_authority(): # Find object by name (consistent name like InteractableObject_X) var obj = null var game_world = get_tree().get_first_node_in_group("game_world") if not game_world or not game_world.is_inside_tree(): return # GameWorld not ready yet var entities_node = game_world.get_node_or_null("Entities") if entities_node: obj = entities_node.get_node_or_null(obj_name) # Fallback: if name lookup fails and name looks like InteractableObject_X, try object_index lookup if not obj and obj_name.begins_with("InteractableObject_"): var index_str = obj_name.substr(20) # Skip "InteractableObject_" if index_str.is_valid_int(): var obj_index = index_str.to_int() for child in entities_node.get_children(): if child.has_meta("object_index") and child.get_meta("object_index") == obj_index: obj = child break 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_name) @rpc("any_peer", "reliable") func _sync_grab(obj_name: String, is_lift: bool, axis: Vector2 = Vector2.ZERO): # Sync lift/push state to other clients # Check if node is still valid and in tree if not is_inside_tree(): return if not is_multiplayer_authority(): # Find object by name (consistent name like InteractableObject_X) var obj = null var game_world = get_tree().get_first_node_in_group("game_world") if not game_world or not game_world.is_inside_tree(): return # GameWorld not ready yet var entities_node = game_world.get_node_or_null("Entities") if entities_node: obj = entities_node.get_node_or_null(obj_name) # Fallback: if name lookup fails and name looks like InteractableObject_X, try object_index lookup if not obj and obj_name.begins_with("InteractableObject_"): var index_str = obj_name.substr(20) # Skip "InteractableObject_" if index_str.is_valid_int(): var obj_index = index_str.to_int() for child in entities_node.get_children(): if child.has_meta("object_index") and child.get_meta("object_index") == obj_index: obj = child break 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_name: String): # Sync release to other clients # Check if node is still valid and in tree if not is_inside_tree(): return if not is_multiplayer_authority(): # Find object by name (consistent name like InteractableObject_X) var obj = null var game_world = get_tree().get_first_node_in_group("game_world") if not game_world or not game_world.is_inside_tree(): return # GameWorld not ready yet var entities_node = game_world.get_node_or_null("Entities") if entities_node: obj = entities_node.get_node_or_null(obj_name) # Fallback: if name lookup fails and name looks like InteractableObject_X, try object_index lookup if not obj and obj_name.begins_with("InteractableObject_"): var index_str = obj_name.substr(20) # Skip "InteractableObject_" if index_str.is_valid_int(): var obj_index = index_str.to_int() for child in entities_node.get_children(): if child.has_meta("object_index") and child.get_meta("object_index") == obj_index: obj = child break 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_name: String, place_pos: Vector2): # Sync placing down to other clients # Check if node is still valid and in tree if not is_inside_tree(): return if not is_multiplayer_authority(): # Find object by name (consistent name like InteractableObject_X) var obj = null var game_world = get_tree().get_first_node_in_group("game_world") if not game_world or not game_world.is_inside_tree(): return # GameWorld not ready yet var entities_node = game_world.get_node_or_null("Entities") if entities_node: obj = entities_node.get_node_or_null(obj_name) # Fallback: if name lookup fails and name looks like InteractableObject_X, try object_index lookup if not obj and obj_name.begins_with("InteractableObject_"): var index_str = obj_name.substr(20) # Skip "InteractableObject_" if index_str.is_valid_int(): var obj_index = index_str.to_int() for child in entities_node.get_children(): if child.has_meta("object_index") and child.get_meta("object_index") == obj_index: obj = child break 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_name: String, pos: Vector2): # Sync held object position to other clients # Check if node is still valid and in tree if not is_inside_tree(): return if not is_multiplayer_authority(): # Find object by name (consistent name like InteractableObject_X) var obj = null var game_world = get_tree().get_first_node_in_group("game_world") if not game_world or not game_world.is_inside_tree(): return # GameWorld not ready yet var entities_node = game_world.get_node_or_null("Entities") if entities_node: obj = entities_node.get_node_or_null(obj_name) # Fallback: if name lookup fails and name looks like InteractableObject_X, try object_index lookup if not obj and obj_name.begins_with("InteractableObject_"): var index_str = obj_name.substr(20) # Skip "InteractableObject_" if index_str.is_valid_int(): var obj_index = index_str.to_int() for child in entities_node.get_children(): if child.has_meta("object_index") and child.get_meta("object_index") == obj_index: obj = child break 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 = clamp( Input.get_axis("move_left", "move_right") + Input.get_axis("ui_left", "ui_right"), -1.0, 1.0 ) input_dir.y = clamp( Input.get_axis("move_up", "move_down") + Input.get_axis("ui_up", "ui_down"), -1.0, 1.0 ) 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(): _rpc_to_ready_peers("_sync_break_free", [being_held_by.name, struggle_direction]) struggle_time = 0.0 struggle_direction = Vector2.ZERO being_held_by = null @rpc("any_peer", "reliable") func _sync_break_free(holder_name: String, direction: Vector2): var holder = null 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: holder = entities_node.get_node_or_null(holder_name) 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_pos = _find_closest_place_pos(direction, held_object) var placed_obj = held_object if not _can_place_down_at(place_pos, placed_obj): print("DEBUG: Forced place down blocked - space not free") return # 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(): _rpc_to_ready_peers("_sync_damage", [0.0, attacker_position, false, false, true]) # is_dodged = true return # No damage taken, exit early # If taking damage while holding something, drop/throw immediately if held_object: if is_lifting: var throw_dir = (global_position - attacker_position).normalized() _force_throw_held_object(throw_dir) else: _stop_pushing() # 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 (rate limited to prevent spam when tab becomes active) var game_world = get_tree().get_first_node_in_group("game_world") if sfx_take_damage: if game_world and game_world.has_method("can_play_sound"): if game_world.can_play_sound("player_damage_" + str(get_instance_id())): sfx_take_damage.play() else: 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(): _rpc_to_ready_peers("_sync_damage", [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(): var obj_name = _get_object_name_for_sync(released_obj) _rpc_to_ready_peers("_sync_release", [obj_name]) 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(): _rpc_to_ready_peers("_sync_death", []) # 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(): _rpc_to_ready_peers("_force_holder_to_drop", [other_player.name]) 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(): _rpc_to_ready_peers("_sync_respawn", [new_respawn_pos]) @rpc("any_peer", "reliable") func _force_holder_to_drop(holder_name: String): # Force a specific player to drop what they're holding _force_holder_to_drop_local(holder_name) func _force_holder_to_drop_local(holder_name: String): # Local function to clear holder's held object print("_force_holder_to_drop_local called for holder: ", holder_name) var holder = null 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: holder = entities_node.get_node_or_null(holder_name) 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) @rpc("any_peer", "reliable") func _sync_equipment(equipment_data: Dictionary): # Client receives equipment update from server or other clients # Update equipment to match server/other players # Server also needs to accept equipment sync from clients (joiners) to update server's copy of joiner's player if not character_stats: return # On server, only accept if this is a client player (not server's own player) if multiplayer.is_server(): var the_peer_id = get_multiplayer_authority() # If this is the server's own player, ignore (server's own changes are handled differently) if the_peer_id == 0 or the_peer_id == multiplayer.get_unique_id(): return # Update equipment from data for slot_name in equipment_data.keys(): var item_data = equipment_data[slot_name] if item_data != null: character_stats.equipment[slot_name] = Item.new(item_data) else: character_stats.equipment[slot_name] = null # Update appearance _apply_appearance_to_sprites() print(name, " equipment synced: ", equipment_data.size(), " slots") @rpc("any_peer", "reliable") func _sync_inventory(inventory_data: Array): # Client receives inventory update from server # Update inventory to match server's inventory # Unlike _sync_equipment, we WANT to receive our own inventory from the server # So we check if we're the server (sender) and ignore, not if we're the authority if multiplayer.is_server(): return # Server ignores this (it's the sender) if not character_stats: return # Clear and rebuild inventory from server data character_stats.inventory.clear() for item_data in inventory_data: if item_data != null: character_stats.inventory.append(Item.new(item_data)) # Emit character_changed to update UI character_stats.character_changed.emit(character_stats) print(name, " inventory synced from server: ", character_stats.inventory.size(), " items") 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) # Sync key count to owning client (server authoritative) if multiplayer.has_multiplayer_peer() and multiplayer.is_server(): var owner_peer_id = get_multiplayer_authority() if owner_peer_id != 0 and owner_peer_id != multiplayer.get_unique_id(): _sync_keys.rpc_id(owner_peer_id, keys) func has_key() -> bool: return keys > 0 func use_key(): if keys > 0: keys -= 1 print(_get_log_prefix(), name, " used a key! Remaining keys: ", keys) return true return false @rpc("any_peer", "reliable") func _sync_keys(new_key_count: int): # Sync key count to client if not is_inside_tree(): return keys = new_key_count @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 (rate limited to prevent spam when tab becomes active) var game_world = get_tree().get_first_node_in_group("game_world") if sfx_take_damage: if game_world and game_world.has_method("can_play_sound"): if game_world.can_play_sound("player_damage_sync_" + str(get_instance_id())): sfx_take_damage.play() else: 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)