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 = 65.0 # Base move speed (not affected by DEX) @export var grab_range: float = 20.0 @export var base_throw_force: float = 80.0 # Base throw force (reduced from 150), scales with STR @export var cone_light_angle: float = 180.0 # Cone spread angle in degrees (adjustable, default wider) const CONE_LIGHT_LERP_SPEED: float = 12.0 # How quickly cone rotation follows target (higher = snappier) # 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 var mouse_control_active: bool = false # True when mouse is controlling facing direction # 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 is_pulling = false # True when pulling (moving backwards while pushing) var is_disarming = false # True when disarming a trap var grab_button_pressed_time = 0.0 var just_grabbed_this_frame = false # Prevents immediate release bug - persists across frames var grab_released_while_lifting = false # Track if grab was released while lifting (for drop-on-second-press logic) 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 is_shielding: bool = false # True when holding grab with shield in offhand and nothing to grab/lift var is_reviving: bool = false # True when holding grab on a corpse and charging revive var revive_charge: float = 0.0 var was_reviving_last_frame: bool = false const REVIVE_DURATION: float = 2.0 # Seconds holding grab to revive 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 is_being_held: bool = false # Set by set_being_held(); reliable on all clients for fallout immunity var grabbed_by_enemy_hand: Node = null # Set when enemy hand grabs (snatch); locks movement until release var enemy_hand_grab_knockback_time: float = 0.0 # Timer for initial knockback when grabbed const ENEMY_HAND_GRAB_KNOCKBACK_DURATION: float = 0.15 # Short knockback duration before moving to hand 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 # Web net (boss): when netted_by_web != null, player is stuck and cannot attack with main weapon var netted_by_web: Node = null var netted_overlay_sprite: Sprite2D = null # Frame 679 for netted visual # Attack/Combat var can_attack: bool = true var attack_cooldown: float = 0.0 # No cooldown - instant attacks! var is_attacking: bool = false var is_charging_bow: bool = false # True when holding attack with bow+arrows var bow_charge_start_time: float = 0.0 var bow_charge_percentage: float = 1.0 # 0.5, 0.75, or 1.0 based on charge time var is_charging_spell: bool = false # True when holding grab with spellbook or hotkey 1/2/3 var spell_charge_hotkey_slot: String = "" # "1", "2", or "3" when charging from hotkey (else "") var spell_charge_start_time: float = 0.0 var spell_charge_duration: float = 1.0 # Time to fully charge spell (1 second) var use_spell_charge_particles: bool = false # If true, use red_star particles; if false, use AnimationIncantation var spell_charge_particles: Node2D = null # Particle system for charging var spell_charge_particle_timer: float = 0.0 # Timer for spawning particles var spell_incantation_fire_ready_shown: bool = false # Track when we've switched to fire_ready var spell_charge_tint: Color = Color(2.0, 0.2, 0.2, 2.0) # Tint color when fully charged var spell_charge_tint_pulse_time: float = 0.0 # Timer for pulsing tint animation var spell_charge_tint_pulse_speed: float = 8.0 # Speed of tint pulse animation while charging var spell_charge_tint_pulse_speed_charged: float = 20.0 # Speed of tint pulse animation when fully charged var bow_charge_tint: Color = Color(1.2, 1.2, 1.2, 1.0) # White tint when fully charged var bow_charge_tint_pulse_time: float = 0.0 # Timer for pulsing tint animation var bow_charge_tint_pulse_speed_charged: float = 20.0 # Speed of tint pulse animation when fully charged var original_sprite_tints: Dictionary = {} # Store original tint values for restoration var spell_incantation_played: bool = false # Track if incantation sound has been played var current_spell_element: String = "fire" # "fire" or "healing" for cursor/tint var burn_debuff_timer: float = 0.0 # Timer for burn debuff var burn_debuff_duration: float = 5.0 # Burn lasts 5 seconds var burn_debuff_damage_per_second: float = 1.0 # 1 HP per second var burn_debuff_visual: Node2D = null # Visual indicator for burn debuff var burn_damage_timer: float = 0.0 # Timer for burn damage ticks var movement_lock_timer: float = 0.0 # Lock movement when bow is released or after casting spell const SPELL_CAST_LOCK_DURATION: float = 0.28 # Lock in place briefly after casting a spell var direction_lock_timer: float = 0.0 # Lock facing direction when attacking var locked_facing_direction: Vector2 = Vector2.ZERO # Locked facing direction during attack var damage_direction_lock_timer: float = 0.0 # Lock facing when taking damage (enemies/players) var damage_direction_lock_duration: float = 0.3 # Duration to block direction change after damage var shield_block_cooldown_timer: float = 0.0 # After blocking, cannot block again until this reaches 0 var shield_block_cooldown_duration: float = 1.2 # Seconds before can block again var shield_block_direction: Vector2 = Vector2.DOWN # Facing direction when we started blocking (locked) var was_shielding_last_frame: bool = false # For detecting shield activate transition var _key1_was_pressed: bool = false var _key2_was_pressed: bool = false var _key3_was_pressed: bool = false var empty_bow_shot_attempts: int = 0 var _arrow_spawn_seq: int = 0 # Per-player sequence for stable arrow names (multiplayer sync) 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 flame_spell_scene = preload("res://scenes/attack_spell_flame.tscn") # Flame spell for Tome of Flames var frostspike_spell_scene = preload("res://scenes/attack_spell_frostspike.tscn") # Frostspike for Tome of Frostspike var water_bubble_spell_scene = preload("res://scenes/attack_spell_water_bubble.tscn") # Water bubble projectile var earth_spike_spell_scene = preload("res://scenes/attack_spell_earth_spike.tscn") # Earth spike (like frostspike) var healing_effect_scene = preload("res://scenes/healing_effect.tscn") # Visual effect for Tome of Healing var attack_bomb_scene = preload("res://scenes/attack_bomb.tscn") # Bomb projectile var attack_punch_scene = preload("res://scenes/attack_punch.tscn") # Unarmed punch var attack_axe_swing_scene = preload("res://scenes/attack_axe_swing.tscn") # Axe swing (orbits) var blood_scene = preload("res://scenes/blood_clot.tscn") # Preload for _create_bomb_object so placing a bomb doesn't spike const _INTERACTABLE_OBJECT_SCENE: PackedScene = preload("res://scenes/interactable_object.tscn") # Cache appearance texture paths so _apply_appearance_to_sprites() doesn't load() every time (avoids ~29ms spike on equipment change) const _APPEARANCE_TEXTURE_CACHE_MAX: int = 48 var _appearance_texture_cache: Dictionary = {} var _appearance_texture_cache_order: Array = [] # FIFO keys for eviction # Lazy cache for spell SFX (avoids has_node + $ every frame in _physics_process) var _sfx_spell_incantation: Node = null # 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 # Spawn fall-down: hidden at start, fall from high Z, land with DIE+concussion, then stand up const SPAWN_FALL_INITIAL_Z: float = 700.0 # High enough to start off-screen (y_offset = -350) const SPAWN_FALL_GRAVITY: float = 180.0 # Slower fall so descent is visible (~2.8s from 700) const SPAWN_LANDING_BOUNCE_GRAVITY: float = 420.0 # Heavier gravity during bounce so it's snappier const SPAWN_LANDING_BOUNCE_UP: float = 95.0 # Upward velocity on first impact for a noticeable bounce const SPAWN_LANDING_LAND_DURATION: float = 0.85 # Time in LAND (and concussion) before switching to STAND const SPAWN_LANDING_STAND_DURATION: float = 0.16 # STAND anim duration (40+40+40+40 ms) before allow control var spawn_landing: bool = false var spawn_landing_landed: bool = false var spawn_landing_bounced: bool = false var spawn_landing_visible_shown: bool = false # One-shot: set true when we show right before falling var has_seen_exit_this_level: bool = false # Track if player has seen exit notification for current level # Fallout (quicksand) state: sink into tile, then respawn at last safe var fallout_state: bool = false var fallout_scale_progress: float = 1.0 # 1.0 -> 0.0 during sink var fallout_respawn_delay_timer: float = 0.0 # After scale hits 0, wait this long before respawn var fallout_respawn_stun_timer: float = 0.0 # After respawn from fallout, stun for this long (no control) var on_fallout_tile_near_sink: bool = false # True when on fallout tile but not yet at center (fast walk plays) var animation_speed_multiplier: float = 1.0 # 1.0 = normal; >1 when on fallout tile so run anim plays faster const FALLOUT_CENTER_THRESHOLD: float = 2.0 # Player center must be almost exactly at tile center to sink (Zelda Link's Awakening style) const FALLOUT_DRAG_STRENGTH: float = 820.0 # Base pull toward fallout center (strong enough to prevent running over) const FALLOUT_CENTER_PULL_BOOST: float = 1.8 # Pull is stronger near center: at center (1+BOOST)x, at edge 1x const FALLOUT_DRAG_EDGE_FACTOR: float = 0.45 # At tile edge drag is 45% strength; ramps to 100% toward center const FALLOUT_MOVEMENT_FACTOR: float = 0.3 # Movement speed on fallout tile (30%) so player cannot run over it const FALLOUT_TILE_HALF_SIZE: float = 8.0 # Half of tile size (16) for distance-based strength const FALLOUT_PLAYER_BOX_HALF: float = 8.0 # Player treated as 16x16 box for quicksand (center ± 8) const FALLOUT_TILE_ANIMATION_SPEED: float = 3.0 # Run animation plays this many times faster when on fallout tile (warning to player) const FALLOUT_SINK_DURATION: float = 0.5 # Seconds to scale from 1 to 0 (faster sink) const FALLOUT_RESPAWN_DELAY: float = 0.3 # Seconds after scale reaches 0 before respawning at safe tile const FALLOUT_RESPAWN_STUN_DURATION: float = 0.3 # Seconds of stun after respawn from fallout const FALLOUT_RESPAWN_HP_PENALTY: float = 1.0 # HP lost when respawning from fallout const HELD_POSITION_Z: float = 12.0 # Z height when held/lifted (above ground; immune to fallout) # Components # @onready var sprite = $Sprite2D # REMOVED: Now using layered sprites @onready var shadow = $Shadow @onready var cone_light = $ConeLight @onready var point_light = $PointLight2D @onready var collision_shape = $CollisionShape2D @onready var grab_area = $GrabArea @onready var quicksand_area = $QuicksandArea @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 @onready var sfx_look_out = $SfxLookOut @onready var sfx_ahaa = $SfxAhaa @onready var sfx_secret_found = $SfxSecretFound # Alert indicator (exclamation mark) var alert_indicator: Sprite2D = null # 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_shield = $Sprite2DShield @onready var sprite_shield_holding = $Sprite2DShieldHolding @onready var sprite_weapon = $Sprite2DWeapon # Player stats (legacy - now using character_stats) var max_health: float: get: return character_stats.maxhp if character_stats else 100.0 var current_health: float: get: return character_stats.hp if character_stats else 100.0 set(value): if character_stats: character_stats.hp = value var is_dead: bool = false var is_processing_death: bool = false # Prevent multiple death sequences var was_revived: bool = false # Set by reviver; aborts _die() wait-for-all-dead 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": [60, 90, 120], "loop": false, "nextAnimation": "IDLE" }, "BOW_STRING": { "frames": [9], "frameDurations": [30], "loop": true, "nextAnimation": null, }, "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": true, "nextAnimation": null }, "FINISH_SPELL": { "frames": [21], "frameDurations": [200], "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 }, "RUN_PULL": { "frames": [33, 32, 33, 34], "frameDurations": [260, 260, 260, 260], "loop": true, "nextAnimation": null }, "FALL": { "frames": [19, 21], "frameDurations": [10, 10], "loop": true, "nextAnimation": null }, "LAND": { "frames": [23], "frameDurations": [10], "loop": true, "nextAnimation": null }, "STAND": { "frames": [23, 24, 22, 1], "frameDurations": [40, 40, 40, 40], "loop": false, "nextAnimation": "IDLE" } } var current_animation = "IDLE" var current_frame = 0 var current_direction = Direction.DOWN var facing_direction_vector: Vector2 = Vector2.DOWN # Full 360-degree facing direction for attacks var time_since_last_frame = 0.0 func _ready(): # Add to player group for easy identification add_to_group("player") # Check if this is a joiner (player joining an already-started game) # A joiner is identified by: there are other players with different peer_ids already in the game # This distinguishes joiners from the initial host spawn var is_joiner = false for p in get_tree().get_nodes_in_group("player"): if p != self and is_instance_valid(p): # If there's another player with a different peer_id, this is a joiner if "peer_id" in p and p.peer_id != peer_id: is_joiner = true break # Spawn: joiners get fall-from-sky but stay visible (no hide). Initial host spawn hides until fall. if is_joiner: # Joiners (local + remote): fall from sky, always visible. No hide/show – fixes invisible + attack bug. visible = true spawn_landing = true spawn_landing_landed = false spawn_landing_bounced = false spawn_landing_visible_shown = true # Already visible position_z = SPAWN_FALL_INITIAL_Z velocity_z = 0.0 is_airborne = true if cone_light: cone_light.visible = is_local_player if point_light: point_light.visible = true # Show point light for all joiners (cone is local-only) elif is_local_player: # Local players (initial spawn only): hide until right before fall-from-sky visible = false spawn_landing = true spawn_landing_landed = false spawn_landing_bounced = false spawn_landing_visible_shown = false position_z = SPAWN_FALL_INITIAL_Z velocity_z = 0.0 is_airborne = true if cone_light: cone_light.visible = false if point_light: point_light.visible = false call_deferred("_schedule_joiner_visibility_fallback") else: # Remote players that are NOT joiners: keep visible (they're already in game) visible = true spawn_landing = false if cone_light: cone_light.visible = false # Don't show other players' cone lights # point_light stays visible for other players # Set respawn point to starting position respawn_point = global_position # Initialize facing direction vector based on current direction facing_direction_vector = Vector2.DOWN # 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) # ONLY run this for the authority (owner of this player) # Remote players will receive appearance via _sync_equipment and character_changed signal if is_multiplayer_authority(): _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 alert indicator (exclamation mark) - similar to enemy humanoids _setup_alert_indicator() # 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 var my_peer_id = multiplayer.get_unique_id() 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 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 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(), ")") # When we become ready to send RPCs, re-sync appearance to ensure new clients get it # This handles the case where appearance was set up before new clients connected if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and character_stats and character_stats.race != "": # Emit character_changed to trigger appearance sync for any newly connected clients character_stats.character_changed.emit(character_stats) func _schedule_joiner_visibility_fallback(): if not is_instance_valid(self) or not is_inside_tree(): return get_tree().create_timer(2.0).timeout.connect(func(): if not is_instance_valid(self) or not is_inside_tree(): return # Joiner must see self; if still hidden, force show (handles missed teleport/fall paths) if is_local_player and not visible: visible = true spawn_landing_visible_shown = true if cone_light: cone_light.visible = true if point_light: point_light.visible = true print(name, " visibility fallback: forced visible (joiner self)") ) func _duplicate_sprite_materials(): # Duplicate shader materials for ALL sprites that use tint parameters # This prevents shared material state between players # Each player needs their own material instances to avoid cross-player tint effects if sprite_body and sprite_body.material: sprite_body.material = sprite_body.material.duplicate() 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_facial_hair and sprite_facial_hair.material: sprite_facial_hair.material = sprite_facial_hair.material.duplicate() if sprite_hair and sprite_hair.material: sprite_hair.material = sprite_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() if sprite_addons and sprite_addons.material: sprite_addons.material = sprite_addons.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() if sprite_shield and sprite_shield.material: sprite_shield.material = sprite_shield.material.duplicate() if sprite_shield_holding and sprite_shield_holding.material: sprite_shield_holding.material = sprite_shield_holding.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 # CRITICAL: Only the authority should re-initialize appearance! # Non-authority players will receive appearance via race/equipment sync if not is_multiplayer_authority(): remove_meta("needs_appearance_reset") # Clear flag even if we skip return # Non-authority will receive appearance via sync 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 (stats, equipment) before re-initializing # Race and appearance will be re-randomized with correct seed (deterministic) 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 EVERYTHING including race, appearance, stats # Since the seed is deterministic, this will match what was generated on other clients _setup_player_appearance() # Restore saved stats (but keep the newly randomized race/appearance from correct seed) # The race/appearance from _setup_player_appearance() is now correct (deterministic seed) 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 (but Elf starting equipment will be re-added by _setup_player_appearance) 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) character_stats.baseStats.per = 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, Medium PER (for disarming) character_stats.baseStats.str += 3 character_stats.baseStats.int -= 2 character_stats.baseStats.wis -= 2 character_stats.baseStats.lck -= 2 character_stats.baseStats.per += 1 "Elf": # Elf: Medium STR, Higher DEX, lower INT, medium WIS, higher LCK, Highest PER (trap detection) character_stats.baseStats.dex += 3 character_stats.baseStats.int -= 2 character_stats.baseStats.lck += 2 character_stats.baseStats.per += 4 # Highest perception for trap detection "Human": # Human: Lower STR, lower DEX, higher INT, higher WIS, lower LCK, Lower PER 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 character_stats.baseStats.per -= 1 # 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 # Use race from select screen if set (local player only); otherwise randomize (affects appearance and stats) var races = ["Dwarf", "Elf", "Human"] var selected_race: String var gs_race_read: String = "" if is_local_player: var gs = get_node_or_null("/root/GameState") if gs: gs_race_read = gs.selected_race if gs.selected_race != "" and gs.selected_race in races: selected_race = gs.selected_race if selected_race.is_empty(): selected_race = races[appearance_rng.randi() % races.size()] # Log what joiner/local player was made (authority runs this; joiner's client runs it for joiner's player) print("Player ", name, " _setup_player_appearance: peer_id=", peer_id, " is_local_player=", is_local_player, " is_authority=", is_multiplayer_authority(), " GameState.selected_race='", gs_race_read, "' -> USING race='", selected_race, "'") character_stats.setRace(selected_race) # Randomize stats AFTER race is set (race affects stat modifiers) _randomize_stats() # Give Elf race starting bow and arrows if selected_race == "Elf": var starting_bow = ItemDatabase.create_item("short_bow") var starting_arrows = ItemDatabase.create_item("arrow") if starting_bow and starting_arrows: starting_arrows.quantity = 3 character_stats.equipment["mainhand"] = starting_bow character_stats.equipment["offhand"] = starting_arrows print("Elf player ", name, " spawned with short bow and 3 arrows") # Give Dwarf race starting bomb + debug weapons in inventory (axe, dagger/knife, sword) if selected_race == "Dwarf": var starting_bomb = ItemDatabase.create_item("bomb") if starting_bomb: starting_bomb.quantity = 5 # Give Dwarf 5 bombs to start character_stats.equipment["offhand"] = starting_bomb var debug_axe = ItemDatabase.create_item("axe") if debug_axe: character_stats.add_item(debug_axe) var debug_dagger = ItemDatabase.create_item("knife") if debug_dagger: character_stats.add_item(debug_dagger) var debug_sword = ItemDatabase.create_item("short_sword") if debug_sword: character_stats.add_item(debug_sword) print("Dwarf player ", name, " spawned with 5 bombs and debug axe/dagger/sword in inventory") # Give Human race (Wizard) starting tomes in inventory; use (F) each to learn spell (spell book system) if selected_race == "Human": var starting_tome = ItemDatabase.create_item("tome_of_flames") if starting_tome: character_stats.add_item(starting_tome) var tome_healing = ItemDatabase.create_item("tome_of_healing") if tome_healing: character_stats.add_item(tome_healing) var tome_frostspike = ItemDatabase.create_item("tome_of_frostspike") if tome_frostspike: character_stats.add_item(tome_frostspike) var starting_hat = ItemDatabase.create_item("hat") if starting_hat: character_stats.equipment["headgear"] = starting_hat print("Human player ", name, " spawned with Tome of Flames, Tome of Healing, Tome of Frostspike, and Hat") # 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() # Emit character_changed to trigger equipment/race sync if character_stats: character_stats.character_changed.emit(character_stats) print("Player ", name, " appearance set up: race=", character_stats.race) func _setup_player_appearance_preserve_race(): # Same as _setup_player_appearance() but preserves existing race instead of randomizing it # Ensure character_stats exists before setting appearance if not character_stats: LogManager.log_error("Player " + str(name) + " _setup_player_appearance_preserve_race: character_stats is null!", LogManager.CATEGORY_GAMEPLAY) return # Use existing race (don't randomize) var selected_race = character_stats.race if selected_race == "": # Fallback: if no race set, randomize var races = ["Dwarf", "Elf", "Human"] selected_race = races[appearance_rng.randi() % races.size()] character_stats.setRace(selected_race) # Don't randomize stats - they should be synced separately # Don't give starting equipment - that should be synced separately # Randomize skin (human only for players) var weights = [7, 6, 5, 4, 3, 2, 1] 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 var hair_colors = [ Color.WHITE, Color.BLACK, Color(0.4, 0.2, 0.1), Color(0.8, 0.6, 0.4), Color(0.6, 0.3, 0.1), Color(0.9, 0.7, 0.5), Color(0.2, 0.2, 0.2), Color(0.5, 0.5, 0.5), Color(0.5, 0.8, 0.2), Color(0.9, 0.5, 0.1), Color(0.8, 0.3, 0.9), Color(1.0, 0.9, 0.2), Color(1.0, 0.5, 0.8), Color(0.9, 0.2, 0.2), Color(0.2, 0.9, 0.9), Color(0.6, 0.2, 0.9), Color(0.9, 0.7, 0.2), Color(0.3, 0.9, 0.3), Color(0.2, 0.2, 0.9), Color(0.9, 0.4, 0.6), Color(0.5, 0.2, 0.8), Color(0.9, 0.6, 0.1) ] 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": facial_hair_style = appearance_rng.randi_range(1, 3) "Elf": facial_hair_style = 0 "Human": facial_hair_style = 3 if appearance_rng.randf() < 0.5 else 0 character_stats.setFacialHair(facial_hair_style) # Randomize facial hair color if facial_hair_style > 0: if appearance_rng.randf() < 0.3: character_stats.setFacialHairColor(hair_colors[appearance_rng.randi() % hair_colors.size()]) else: character_stats.setFacialHairColor(character_stats.hair_color) # Randomize eyes var eye_style = appearance_rng.randi_range(1, 14) character_stats.setEyes(eye_style) # Randomize eye color var white_color = Color(0.9, 0.9, 0.9) var other_eye_colors = [ Color(0.1, 0.1, 0.1), Color(0.2, 0.3, 0.8), Color(0.3, 0.7, 0.9), Color(0.5, 0.8, 0.2), Color(0.9, 0.5, 0.1), Color(0.8, 0.3, 0.9), Color(1.0, 0.9, 0.2), Color(1.0, 0.5, 0.8), Color(0.9, 0.2, 0.2), Color(0.2, 0.9, 0.9), Color(0.6, 0.2, 0.9), Color(0.9, 0.7, 0.2), Color(0.3, 0.9, 0.3), Color(0.2, 0.2, 0.9), Color(0.9, 0.4, 0.6), Color(0.5, 0.2, 0.8), Color(0.9, 0.6, 0.1) ] if appearance_rng.randf() < 0.75: character_stats.setEyeColor(white_color) else: character_stats.setEyeColor(other_eye_colors[appearance_rng.randi() % other_eye_colors.size()]) # Randomize eyelashes var eyelash_style = appearance_rng.randi_range(0, 8) character_stats.setEyeLashes(eyelash_style) # Randomize eyelash color var eyelash_colors = [ Color(0.1, 0.1, 0.1), Color(0.2, 0.2, 0.2), Color(0.3, 0.2, 0.15), Color(0.4, 0.3, 0.2), Color(0.5, 0.8, 0.2), Color(0.9, 0.5, 0.1), Color(0.8, 0.3, 0.9), Color(1.0, 0.9, 0.2), Color(1.0, 0.5, 0.8), Color(0.9, 0.2, 0.2), Color(0.9, 0.9, 0.9), Color(0.6, 0.2, 0.9) ] 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": var elf_ear_style = skin_index + 1 character_stats.setEars(elf_ear_style) _: character_stats.setEars(0) # Apply appearance to sprite layers _apply_appearance_to_sprites() print("Player ", name, " appearance re-initialized (preserved race=", selected_race, ")") func _get_appearance_texture(path: String) -> Texture2D: if path.is_empty(): return null # Use global preloaded cache first (avoids 13ms+ spike on equip) var global_cache = get_node_or_null("/root/AppearanceTextureCache") if global_cache and global_cache.has_method("get_texture"): var tex = global_cache.get_texture(path) if tex: return tex # Fallback: local cache for paths not in preload (e.g. future content) if _appearance_texture_cache.has(path): return _appearance_texture_cache[path] as Texture2D var t = load(path) as Texture2D if t: if _appearance_texture_cache_order.size() >= _APPEARANCE_TEXTURE_CACHE_MAX: var old_key = _appearance_texture_cache_order.pop_front() _appearance_texture_cache.erase(old_key) _appearance_texture_cache[path] = t _appearance_texture_cache_order.append(path) return t func _apply_appearance_to_sprites(): # Apply character_stats appearance to sprite layers (uses texture cache to avoid load() spikes) if not character_stats: return # Body/Skin if sprite_body and character_stats.skin != "": var body_texture = _get_appearance_texture(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 = _get_appearance_texture(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 = _get_appearance_texture(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 = _get_appearance_texture(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 = _get_appearance_texture(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 = _get_appearance_texture(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 = _get_appearance_texture(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 = _get_appearance_texture(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 = _get_appearance_texture(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) _update_shield_visibility() # 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]) # ALWAYS sync race and base stats to all clients (for proper display) _rpc_to_ready_peers("_sync_race_and_stats", [character_stats.race, character_stats.baseStats.duplicate()]) # Sync full appearance data (skin, hair, eyes, colors, etc.) to remote players # This ensures remote players have the exact same appearance as authority var appearance_data = { "skin": character_stats.skin, "hairstyle": character_stats.hairstyle, "hair_color": character_stats.hair_color.to_html(true), "facial_hair": character_stats.facial_hair, "facial_hair_color": character_stats.facial_hair_color.to_html(true), "eyes": character_stats.eyes, "eye_color": character_stats.eye_color.to_html(true), "eye_lashes": character_stats.eye_lashes, "eyelash_color": character_stats.eyelash_color.to_html(true), "add_on": character_stats.add_on } _rpc_to_ready_peers("_sync_appearance", [appearance_data]) # Sync inventory changes to all clients var inventory_data = [] for item in character_stats.inventory: if item: inventory_data.append(item.save()) _rpc_to_ready_peers("_sync_inventory", [inventory_data]) print(name, " synced equipment, race, and inventory to all clients. 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: # Check if object is still valid if not is_instance_valid(obj) or not obj.is_inside_tree(): # Object is invalid or not in tree - return empty string to skip sync return "" # 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) # But only if it's a valid name (not auto-generated if possible) if obj.name.begins_with("@") and obj.has_meta("object_index"): # Try to use object_index instead of auto-generated name var obj_index = obj.get_meta("object_index") return "InteractableObject_%d" % obj_index 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 (not Area2D triggers - those don't block placement) 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) params.collide_with_areas = false # Only solid bodies block; ignore trigger areas (e.g. door key zones) params.collide_with_bodies = true # CRITICAL: Exclude using RIDs so the physics engine actually excludes them (Node refs may not work) var exclude_list: Array[RID] = [get_rid()] if placed_obj and is_instance_valid(placed_obj) and placed_obj is CollisionObject2D: exclude_list.append(placed_obj.get_rid()) 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 # 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 (faster when on fallout tile to warn player) var frame_duration_sec = ANIMATIONS[current_animation]["frameDurations"][current_frame] / 1000.0 / animation_speed_multiplier time_since_last_frame += delta if time_since_last_frame >= frame_duration_sec: 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_shield: sprite_shield.frame = frame_index if sprite_shield_holding: sprite_shield_holding.frame = frame_index # Update weapon sprite - use BOW_STRING animation if charging bow if sprite_weapon: if is_charging_bow: # Show BOW_STRING animation on weapon sprite only var bow_string_frame = current_direction * 35 + ANIMATIONS["BOW_STRING"]["frames"][0] sprite_weapon.frame = bow_string_frame else: 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): # Don't update facing when dead if is_dead: return # Don't update facing during spawn fall/land/stand (locked to DOWN until stand up) if spawn_landing: return # Only update if using keyboard input (not gamepad) if input_device != -1: return # Don't update if pushing (locked direction) if is_pushing: return # Don't update if shielding (locked block direction) if is_shielding: return # Don't update if direction is locked (during attack) if direction_lock_timer > 0.0: return # Don't update if direction is locked (taking damage from enemies/players) if damage_direction_lock_timer > 0.0: return # Mark that mouse control is active (prevents movement keys from overriding attack direction) mouse_control_active = true # Store full 360-degree direction for attacks (cone light uses this for smooth rotation) if mouse_direction.length() > 0.1: facing_direction_vector = mouse_direction.normalized() var new_direction = _get_direction_from_vector(mouse_direction) as Direction if new_direction != current_direction: current_direction = new_direction 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 facing (lerps toward target for smooth 360° movement) func _update_cone_light_rotation(delta: float = 1.0): if not cone_light: return var target_angle: float if facing_direction_vector.length() > 0.1: target_angle = facing_direction_vector.angle() + (PI / 2.0) else: target_angle = _direction_to_angle(current_direction) + (PI / 2.0) # Lerp toward target (delta=1.0 in _ready snaps; in _physics_process uses smooth follow) var t = 1.0 - exp(-CONE_LIGHT_LERP_SPEED * delta) cone_light.rotation = lerp_angle(cone_light.rotation, target_angle, t) # 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 (slower spawn-fall; snappier bounce) var g = gravity_z if spawn_landing and not spawn_landing_landed: g = SPAWN_LANDING_BOUNCE_GRAVITY if spawn_landing_bounced else SPAWN_FALL_GRAVITY # Show right before falling (local + joiner): was invisible until this moment if not spawn_landing_visible_shown: spawn_landing_visible_shown = true visible = true velocity_z -= g * delta # Update Z position position_z += velocity_z * delta # Check if landed if position_z <= 0.0: position_z = 0.0 # Spawn landing: first impact -> bounce + LAND; second impact -> settle, then STAND if spawn_landing and not spawn_landing_landed and is_multiplayer_authority() and not spawn_landing_bounced: spawn_landing_bounced = true velocity_z = SPAWN_LANDING_BOUNCE_UP velocity = velocity * 0.3 current_direction = Direction.RIGHT facing_direction_vector = Vector2.RIGHT _set_animation("LAND") if has_node("SfxFallDownLand"): $SfxFallDownLand.play() if multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree(): _rpc_to_ready_peers("_sync_spawn_bounced", [name]) # keep is_airborne true, continue falling after bounce else: velocity_z = 0.0 is_airborne = false velocity = velocity * 0.3 if not spawn_landing: print(name, " landed!") elif spawn_landing and not spawn_landing_landed and is_multiplayer_authority(): spawn_landing_landed = true _spawn_landing_on_land() # 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_shield, sprite_shield_holding, 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 # Spawn fall: DOWN during fall; RIGHT on landing (bounce + rest). FALL until first impact, then LAND. if spawn_landing: if not spawn_landing_bounced: current_direction = Direction.DOWN facing_direction_vector = Vector2.DOWN else: current_direction = Direction.RIGHT facing_direction_vector = Vector2.RIGHT if is_airborne and not spawn_landing_landed and not spawn_landing_bounced: _set_animation("FALL") elif is_airborne and spawn_landing_bounced: _set_animation("LAND") # Update animations _update_animation(delta) # Update Z-axis physics (height simulation) if is_airborne: _update_z_physics(delta) # Mana regeneration (slowly regain mana over time) if character_stats and is_multiplayer_authority(): # Regenerate 2 mana per second (slow regeneration) const MANA_REGEN_RATE = 2.0 # mana per second if character_stats.mp < character_stats.maxmp: character_stats.restore_mana(MANA_REGEN_RATE * delta) # Tick down temporary buffs (e.g. dodge potion) character_stats.tick_buffs(delta) # Spell charge visuals (incantation, tint pulse) - run for ALL players so others see the pulse if is_charging_spell: if _sfx_spell_incantation == null: _sfx_spell_incantation = get_node_or_null("SfxSpellIncantation") var charge_time = (Time.get_ticks_msec() / 1000.0) - spell_charge_start_time var charge_progress = clamp(charge_time / spell_charge_duration, 0.0, 1.0) spell_charge_particle_timer += delta _update_spell_charge_particles(charge_progress) _update_spell_charge_incantation(charge_progress) if charge_progress >= 1.0: spell_charge_tint_pulse_time += delta * spell_charge_tint_pulse_speed_charged _apply_spell_charge_tint() if not spell_incantation_played and _sfx_spell_incantation: _sfx_spell_incantation.play() spell_incantation_played = true else: spell_charge_tint_pulse_time = 0.0 _clear_spell_charge_tint() if _sfx_spell_incantation: _sfx_spell_incantation.stop() spell_incantation_played = false else: spell_charge_tint_pulse_time = 0.0 # Revive charge visuals - same as healing (healing_charging on AnimationIncantation) if is_reviving: was_reviving_last_frame = true if has_node("AnimationIncantation") and not is_charging_spell: $AnimationIncantation.play("healing_charging") elif was_reviving_last_frame: was_reviving_last_frame = false _stop_spell_charge_incantation() # Fallout (quicksand) sink: run for ALL players so remote see scale/rotation/FALL animation if fallout_state: current_direction = Direction.DOWN facing_direction_vector = Vector2.DOWN _set_animation("FALL") scale = Vector2.ONE * max(0.0, fallout_scale_progress) rotation = deg_to_rad(45.0) velocity = Vector2.ZERO if fallout_respawn_delay_timer > 0.0: fallout_respawn_delay_timer -= delta if fallout_respawn_delay_timer <= 0.0: fallout_respawn_delay_timer = 0.0 if is_multiplayer_authority(): _respawn_from_fallout() else: fallout_scale_progress -= delta / FALLOUT_SINK_DURATION if fallout_scale_progress <= 0.0: fallout_scale_progress = 0.0 fallout_respawn_delay_timer = FALLOUT_RESPAWN_DELAY elif is_local_player and is_multiplayer_authority(): # When dead: only corpse knockback friction + sync; no input or other logic if is_dead: if is_knocked_back: knockback_time += delta if knockback_time >= knockback_duration: is_knocked_back = false knockback_time = 0.0 else: velocity = velocity.lerp(Vector2.ZERO, delta * 8.0) else: # Reset fallout-tile animation state each frame (set when on fallout tile below) animation_speed_multiplier = 1.0 on_fallout_tile_near_sink = false # Held players cannot fallout (use is_being_held so it works on all clients; position can put held player over a tile) var is_held = is_being_held or (being_held_by != null and is_instance_valid(being_held_by)) if position_z == 0.0 and not is_held: # Quicksand: only when player CENTER is on a fallout tile (avoids vortex pull from adjacent ground) var gw = get_tree().get_first_node_in_group("game_world") var area_center = quicksand_area.global_position if quicksand_area else global_position if gw and gw.has_method("_is_position_on_fallout_tile"): if not gw._is_position_on_fallout_tile(area_center): gw.update_last_safe_position_for_player(self, global_position) else: # Center is on fallout: use this tile's center for drag/sink (symmetric) var tile_center = gw._get_tile_center_at(area_center) var dist_to_center = area_center.distance_to(tile_center) if dist_to_center < FALLOUT_CENTER_THRESHOLD: # If carrying something, throw it in the direction we were looking before falling if held_object and is_lifting: _force_throw_held_object(facing_direction_vector) # Snap player center exactly to fallout tile center so sink looks correct global_position = tile_center fallout_state = true fallout_scale_progress = 1.0 velocity = Vector2.ZERO current_direction = Direction.DOWN facing_direction_vector = Vector2.DOWN _set_animation("FALL") if has_node("SfxFallout"): $SfxFallout.play() if multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree(): _rpc_to_ready_peers("_sync_fallout_start", [tile_center]) else: on_fallout_tile_near_sink = true animation_speed_multiplier = FALLOUT_TILE_ANIMATION_SPEED _set_animation("RUN") # 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 # Handle enemy hand grab knockback timer if grabbed_by_enemy_hand: enemy_hand_grab_knockback_time += delta # Update fallout respawn stun timer (no control for 0.3s after respawn from fallout) if fallout_respawn_stun_timer > 0.0: fallout_respawn_stun_timer -= delta if fallout_respawn_stun_timer <= 0.0: fallout_respawn_stun_timer = 0.0 # Update movement lock timer (for bow release) if movement_lock_timer > 0.0: movement_lock_timer -= delta if movement_lock_timer <= 0.0: movement_lock_timer = 0.0 # Update direction lock timer (for attacks) if direction_lock_timer > 0.0: direction_lock_timer -= delta if direction_lock_timer <= 0.0: direction_lock_timer = 0.0 # Update damage direction lock timer (block facing change when taking damage) if damage_direction_lock_timer > 0.0: damage_direction_lock_timer -= delta if damage_direction_lock_timer <= 0.0: damage_direction_lock_timer = 0.0 if shield_block_cooldown_timer > 0.0: shield_block_cooldown_timer -= delta if shield_block_cooldown_timer <= 0.0: shield_block_cooldown_timer = 0.0 # Update bow charge tint (when fully charged) if is_charging_bow: var charge_time = (Time.get_ticks_msec() / 1000.0) - bow_charge_start_time # Smooth curve: charge from 0.2s to 1.0s var charge_progress = clamp((charge_time - 0.2) / 0.8, 0.0, 1.0) # 0.0 at 0.2s, 1.0 at 1.0s # Update tint pulse timer when fully charged if charge_progress >= 1.0: # Use fast pulse speed when fully charged bow_charge_tint_pulse_time += delta * bow_charge_tint_pulse_speed_charged _apply_bow_charge_tint() else: bow_charge_tint_pulse_time = 0.0 _clear_bow_charge_tint() else: # Reset pulse timer when not charging bow_charge_tint_pulse_time = 0.0 _clear_bow_charge_tint() # Update burn debuff (works on both authority and clients) if burn_debuff_timer > 0.0: burn_debuff_timer -= delta # Only deal damage on authority (where we have authority) if is_multiplayer_authority(): burn_damage_timer += delta # Deal burn damage every second (no knockback) if burn_damage_timer >= 1.0: burn_damage_timer = 0.0 # Deal burn damage directly (no knockback, no animation) if character_stats: var old_hp = character_stats.hp character_stats.modify_health(-burn_debuff_damage_per_second) # Check if dead (use epsilon to handle floating point precision) if character_stats.hp <= 0.001: character_stats.hp = 0.0 # Ensure exactly 0 character_stats.no_health.emit() character_stats.character_changed.emit(character_stats) var actual_damage = old_hp - character_stats.hp print(name, " takes ", actual_damage, " burn damage! Health: ", character_stats.hp, "/", character_stats.maxhp) # Show damage number for burn damage _show_damage_number(actual_damage, global_position, false, false, false) # Show burn damage number # Sync burn damage visual to other clients if multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree(): _rpc_to_ready_peers("_sync_damage", [actual_damage, global_position]) # Animate burn visual if it's a sprite (works on both authority and clients) if burn_debuff_visual and is_instance_valid(burn_debuff_visual): if burn_debuff_visual is Sprite2D: var sprite = burn_debuff_visual as Sprite2D var anim_timer = sprite.get_meta("burn_animation_timer", 0.0) anim_timer += delta if anim_timer >= 0.1: # ~10 FPS anim_timer = 0.0 var frame = sprite.get_meta("burn_animation_frame", 0) frame = (frame + 1) % 16 sprite.frame = frame sprite.set_meta("burn_animation_frame", frame) sprite.set_meta("burn_animation_timer", anim_timer) # Remove burn debuff when timer expires (works on both authority and clients) if burn_debuff_timer <= 0.0: burn_debuff_timer = 0.0 burn_damage_timer = 0.0 _remove_burn_debuff() # Skip input if controls are disabled (e.g., when inventory is open) or spawn landing (fall → DIE → stand up) or fallout (sinking) or post-fallout stun # But still allow knockback to continue (handled above) # CRITICAL: During entrance walk-out cut-scene, game_world sets velocity; do NOT zero it here var entrance_walk_out = controls_disabled and has_meta("entrance_walk_target") var skip_input = controls_disabled or spawn_landing or fallout_state or (fallout_respawn_stun_timer > 0.0) if controls_disabled or spawn_landing or fallout_state or (fallout_respawn_stun_timer > 0.0): if not is_knocked_back and not entrance_walk_out: # Immediately stop movement when controls are disabled (e.g., inventory opened) # Exception: entrance walk-out - velocity is driven by game_world for cut-scene velocity = Vector2.ZERO # Reset animation to IDLE if not in a special state (skip when spawn_landing: we use DIE until stand up) if not spawn_landing and not fallout_state and current_animation != "THROW" and current_animation != "DAMAGE" and current_animation != "SWORD" and current_animation != "BOW" and current_animation != "STAFF" and current_animation != "AXE" and current_animation != "PUNCH": if is_lifting: _set_animation("IDLE_HOLD") elif is_pushing: _set_animation("IDLE_PUSH") else: _set_animation("IDLE") # Check if being held by someone (another player) or grabbed by enemy hand 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: is_shielding = false was_shielding_last_frame = false _update_shield_visibility() # Handle struggle mechanic _handle_struggle(delta) elif grabbed_by_enemy_hand: is_shielding = false was_shielding_last_frame = false _update_shield_visibility() # First phase: Apply knockback toward hand if enemy_hand_grab_knockback_time < ENEMY_HAND_GRAB_KNOCKBACK_DURATION: # Still in knockback phase - let velocity carry player velocity = velocity.lerp(Vector2.ZERO, delta * 4.0) # Slow down gradually else: # Second phase: Move player toward hand position (slightly above it) var hand_pos = grabbed_by_enemy_hand.global_position var target_pos = hand_pos + Vector2(0, -12) # Slightly above the hand # Smoothly move player to hand position var distance_to_target = global_position.distance_to(target_pos) if distance_to_target > 2.0: # If not close enough, move toward it var direction_to_hand = (target_pos - global_position).normalized() velocity = direction_to_hand * 200.0 # Move speed toward hand else: # Close enough - snap to position and stop global_position = target_pos velocity = Vector2.ZERO elif is_knocked_back: is_shielding = false was_shielding_last_frame = false _update_shield_visibility() # 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() # Apply quicksand only when player CENTER is on a fallout tile (no vortex pull from adjacent tiles) if position_z == 0.0 and not (is_being_held or (being_held_by != null and is_instance_valid(being_held_by))): var gw = get_tree().get_first_node_in_group("game_world") var area_center = quicksand_area.global_position if quicksand_area else global_position if gw and gw.has_method("_is_position_on_fallout_tile") and gw._is_position_on_fallout_tile(area_center): # Heavy movement penalty so running over the tile is not possible velocity *= FALLOUT_MOVEMENT_FACTOR var tile_center = gw._get_tile_center_at(area_center) var area_center_dist = area_center.distance_to(tile_center) if area_center_dist >= FALLOUT_CENTER_THRESHOLD: # Drag toward this tile's center (same strength from all directions) var dir = (tile_center - area_center).normalized() # Softer at edge: drag ramps from FALLOUT_DRAG_EDGE_FACTOR at tile edge to 1.0 toward center var edge_t = clamp(area_center_dist / FALLOUT_TILE_HALF_SIZE, 0.0, 1.0) var edge_drag_factor = lerp(1.0, FALLOUT_DRAG_EDGE_FACTOR, edge_t) # Strength: stronger when player center is closer to fallout tile center (distance-based only, no direction bias) var strength_mult = 1.0 + FALLOUT_CENTER_PULL_BOOST * (1.0 - clamp(area_center_dist / FALLOUT_TILE_HALF_SIZE, 0.0, 1.0)) velocity += dir * FALLOUT_DRAG_STRENGTH * strength_mult * edge_drag_factor * delta _handle_movement(delta) _handle_interactions() else: is_shielding = false was_shielding_last_frame = false _update_shield_visibility() # 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)") # Re-sync appearance to all clients now that they're all ready # This ensures new clients get the correct appearance even if they connected after initial sync if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and character_stats and character_stats.race != "": character_stats.character_changed.emit(character_stats) # 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 # Detect if pulling (moving backwards while pushing) is_pulling = false if is_pushing and held_object and input_vector.length() > 0.1: 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: # Moving opposite to push direction = pulling is_pulling = true # Prevent movement during disarming (unless cancelled or finished) if is_disarming: input_vector = Vector2.ZERO # Track last movement direction if moving if input_vector.length() > 0.1: last_movement_direction = input_vector.normalized() # Update full 360-degree facing direction for attacks (gamepad/keyboard input) # Only update if mouse control is not active (i.e., mouse is outside window or using gamepad) # Don't update if direction is locked (during attack) or shielding if is_shielding: facing_direction_vector = shield_block_direction elif not is_pushing and (not mouse_control_active or input_device != -1) and direction_lock_timer <= 0.0: facing_direction_vector = input_vector.normalized() elif direction_lock_timer > 0.0 and locked_facing_direction.length() > 0.0: # Use locked direction during attack facing_direction_vector = locked_facing_direction # Update facing direction for animations (except when pushing - locked direction) # Only update from movement input if mouse control is not active or using gamepad # Don't update if direction is locked (during attack) or shielding if is_shielding: var new_direction = _get_direction_from_vector(shield_block_direction) as Direction if new_direction != current_direction: current_direction = new_direction elif not is_pushing and (not mouse_control_active or input_device != -1) and direction_lock_timer <= 0.0: var new_direction = _get_direction_from_vector(input_vector) as Direction if new_direction != current_direction: current_direction = new_direction elif direction_lock_timer > 0.0 and locked_facing_direction.length() > 0.0: var new_direction = _get_direction_from_vector(locked_facing_direction) as Direction if new_direction != current_direction: current_direction = new_direction elif is_pushing or (held_object and not is_lifting): if push_direction_locked != current_direction: current_direction = push_direction_locked as Direction # Set animation based on state if grabbed_by_enemy_hand: # Keep FALL animation while grabbed by enemy hand if current_animation != "FALL": _set_animation("FALL") elif is_charging_spell: # Use LIFT animation when charging spell and moving if current_animation != "LIFT" and current_animation != "FINISH_SPELL": _set_animation("LIFT") elif is_disarming: # Use RUN_PULL animation when disarming _set_animation("RUN_PULL") elif is_lifting: _set_animation("RUN_HOLD") elif is_pushing: if is_pulling: _set_animation("RUN_PULL") else: _set_animation("RUN_PUSH") elif current_animation != "SWORD" and current_animation != "BOW" and current_animation != "STAFF" and current_animation != "AXE" and current_animation != "PUNCH" and current_animation != "CONJURE" and current_animation != "LIFT" and current_animation != "FINISH_SPELL": _set_animation("RUN") else: # Idle animations if grabbed_by_enemy_hand: # Keep FALL animation while grabbed by enemy hand if current_animation != "FALL": _set_animation("FALL") elif is_charging_spell: # Use CONJURE animation when charging spell and standing still if current_animation != "CONJURE" and current_animation != "FINISH_SPELL": _set_animation("CONJURE") elif is_disarming: # Use RUN_PULL animation when disarming (even when idle) _set_animation("RUN_PULL") elif is_lifting: if current_animation != "LIFT" and current_animation != "IDLE_HOLD": _set_animation("IDLE_HOLD") elif is_pushing or (held_object and not is_lifting): if is_pushing: _set_animation("IDLE_PUSH") if push_direction_locked != current_direction: current_direction = push_direction_locked as Direction elif is_shielding: var new_direction = _get_direction_from_vector(shield_block_direction) as Direction if new_direction != current_direction: current_direction = new_direction else: if current_animation != "THROW" and current_animation != "DAMAGE" and current_animation != "SWORD" and current_animation != "BOW" and current_animation != "STAFF" and current_animation != "AXE" and current_animation != "PUNCH" and current_animation != "CONJURE" and current_animation != "LIFT" and current_animation != "FINISH_SPELL": _set_animation("IDLE") # Cone light lerps toward facing direction every frame (360°) if is_local_player and cone_light and cone_light.visible: _update_cone_light_rotation(get_physics_process_delta_time()) # 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 # Reduce speed by 50% when charging bow # Reduce speed by 80% when charging spell (20% speed) # Reduce speed to 60% when shielding # Calculate speed with encumbrance penalty var speed_multiplier = 1.0 if is_pushing: speed_multiplier = 0.5 elif is_charging_bow: speed_multiplier = 0.5 elif is_charging_spell: speed_multiplier = 0.5 # 50% speed (50% reduction) elif is_shielding: speed_multiplier = 0.6 # 60% speed when blocking with shield var base_speed = move_speed * speed_multiplier 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 # Lock movement if movement_lock_timer is active, reviving a corpse, or netted by web if movement_lock_timer > 0.0 or is_reviving or netted_by_web: velocity = Vector2.ZERO else: 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 _web_net_apply(web_node: Node) -> void: netted_by_web = web_node func _web_net_release(_web_node: Node) -> void: netted_by_web = null _web_net_show_netted_frame(false) func _web_net_show_netted_frame(show_net: bool) -> void: if show_net: if netted_overlay_sprite == null: netted_overlay_sprite = Sprite2D.new() var tex = load("res://assets/gfx/fx/shade_spell_effects.png") as Texture2D if tex: netted_overlay_sprite.texture = tex netted_overlay_sprite.hframes = 105 netted_overlay_sprite.vframes = 79 netted_overlay_sprite.frame = 679 netted_overlay_sprite.centered = true netted_overlay_sprite.z_index = 5 add_child(netted_overlay_sprite) if netted_overlay_sprite: netted_overlay_sprite.visible = true else: if netted_overlay_sprite: netted_overlay_sprite.visible = false 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 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 # One overlap query per frame; reuse for shield check and spell block below var nearby_grabbable_body = _get_nearby_grabbable() var would_shield = (not is_dead and grab_button_down and _has_shield_in_offhand() \ and not held_object and not is_lifting and not is_pushing \ and nearby_grabbable_body == null and not is_disarming) if would_shield and shield_block_cooldown_timer > 0.0: is_shielding = false if has_node("SfxDenyActivateShield"): $SfxDenyActivateShield.play() elif would_shield: if not was_shielding_last_frame: shield_block_direction = facing_direction_vector.normalized() if facing_direction_vector.length() > 0.1 else last_movement_direction.normalized() if has_node("SfxActivateShield"): $SfxActivateShield.play() # Sync shield up over network so host/other clients see it if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree(): _sync_shield.rpc(true, shield_block_direction) is_shielding = true else: if was_shielding_last_frame: # Sync shield down over network if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree(): _sync_shield.rpc(false, Vector2.ZERO) is_shielding = false was_shielding_last_frame = is_shielding _update_shield_visibility() # Cancel bow charging if grab is pressed if grab_just_pressed and is_charging_bow: is_charging_bow = false # Clear bow charge tint _clear_bow_charge_tint() # Sync bow charge end to other clients if multiplayer.has_multiplayer_peer(): _sync_bow_charge_end.rpc() print(name, " cancelled bow charge") # Check for trap disarm FIRST (Dwarf only) - only when grab involved to avoid get_nodes_in_group every frame if character_stats and character_stats.race == "Dwarf" and (grab_just_pressed or grab_just_released or grab_button_down): var nearby_trap = _get_nearby_disarmable_trap() if nearby_trap: # Check if we're currently disarming this trap var currently_disarming = (nearby_trap.disarming_player == self) if grab_just_pressed and not currently_disarming: # Start disarming - cancel any spell charging if is_charging_spell: is_charging_spell = false spell_charge_hotkey_slot = "" current_spell_element = "fire" spell_incantation_played = false _stop_spell_charge_particles() _stop_spell_charge_incantation() _clear_spell_charge_tint() if has_node("SfxSpellCharge"): $SfxSpellCharge.stop() if has_node("SfxSpellIncantation"): $SfxSpellIncantation.stop() if multiplayer.has_multiplayer_peer(): _sync_spell_charge_end.rpc() # Start disarming is_disarming = true nearby_trap.disarming_player = self nearby_trap.disarm_progress = 0.0 elif grab_just_released and currently_disarming: is_disarming = false nearby_trap._cancel_disarm() elif not currently_disarming: # Not disarming anymore - reset flag is_disarming = false # Don't process regular grab actions or spell casting if near trap if grab_button_down: # Skip grab handling and spell casting below just_grabbed_this_frame = false return else: # No nearby trap - reset disarming flag is_disarming = false # Spell hotkey key state (for 1/2/3 casting from learnt spells) var k1 = Input.is_key_pressed(KEY_1) var k2 = Input.is_key_pressed(KEY_2) var k3 = Input.is_key_pressed(KEY_3) var key1_just_pressed = k1 and not _key1_was_pressed var key2_just_pressed = k2 and not _key2_was_pressed var key3_just_pressed = k3 and not _key3_was_pressed var key1_just_released = _key1_was_pressed and not k1 var key2_just_released = _key2_was_pressed and not k2 var key3_just_released = _key3_was_pressed and not k3 _key1_was_pressed = k1 _key2_was_pressed = k2 _key3_was_pressed = k3 # Check for spell casting (Tome of Flames, Frostspike, or Healing) — from offhand OR hotkey 1/2/3 var offhand_item = character_stats.equipment["offhand"] if (character_stats and character_stats.equipment.has("offhand")) else null var spell_from_offhand = offhand_item and offhand_item.weapon_type == Item.WeaponType.SPELLBOOK var spell_from_hotkey = character_stats and character_stats.learnt_spells.size() > 0 and spell_charge_hotkey_slot != "" if character_stats and (spell_from_offhand or spell_from_hotkey or character_stats.learnt_spells.size() > 0): # Start charge from hotkey 1/2/3 if that key just pressed and slot has a learnt spell if not is_charging_spell and not spell_from_offhand: var slot_pressed = "" var spell_id = "" if key1_just_pressed and character_stats.spell_hotkeys.get("1", "") in character_stats.learnt_spells: slot_pressed = "1" spell_id = character_stats.spell_hotkeys.get("1", "") elif key2_just_pressed and character_stats.spell_hotkeys.get("2", "") in character_stats.learnt_spells: slot_pressed = "2" spell_id = character_stats.spell_hotkeys.get("2", "") elif key3_just_pressed and character_stats.spell_hotkeys.get("3", "") in character_stats.learnt_spells: slot_pressed = "3" spell_id = character_stats.spell_hotkeys.get("3", "") if slot_pressed != "" and spell_id != "": var game_world = get_tree().get_first_node_in_group("game_world") var target_pos = Vector2.ZERO var heal_target: Node = null var is_heal = (spell_id == "healing") if (spell_id == "flames" or spell_id == "frostspike" or spell_id == "earth_spike") and game_world and game_world.has_method("get_grid_locked_cursor_position"): target_pos = game_world.get_grid_locked_cursor_position() elif is_heal: heal_target = _get_heal_target() var has_valid_target = ((spell_id == "flames" or spell_id == "frostspike" or spell_id == "earth_spike") and target_pos != Vector2.ZERO) or (is_heal and heal_target != null) or (spell_id == "water_bubble") var can_start = is_heal or has_valid_target var mana_ok = (spell_id == "flames" or spell_id == "frostspike" or spell_id == "water_bubble" or spell_id == "earth_spike") and character_stats.mp >= 15.0 or (spell_id == "healing" and character_stats.mp >= 20.0) if can_start and mana_ok and not nearby_grabbable_body and not is_lifting and not held_object: spell_charge_hotkey_slot = slot_pressed is_charging_spell = true current_spell_element = "healing" if is_heal else ("frost" if spell_id == "frostspike" else ("water" if spell_id == "water_bubble" else ("earth" if spell_id == "earth_spike" else "fire"))) spell_charge_start_time = Time.get_ticks_msec() / 1000.0 spell_incantation_played = false _start_spell_charge_particles() _start_spell_charge_incantation() if has_node("SfxSpellCharge"): $SfxSpellCharge.play() if multiplayer.has_multiplayer_peer(): _sync_spell_charge_start.rpc() elif not mana_ok and is_local_player: _show_not_enough_mana_text() if character_stats and (spell_from_offhand or spell_from_hotkey): var is_fire = false var is_frost = false var is_heal = false var is_water_bubble = false var is_earth_spike = false if spell_charge_hotkey_slot != "": var sid = character_stats.spell_hotkeys.get(spell_charge_hotkey_slot, "") is_fire = (sid == "flames") is_frost = (sid == "frostspike") is_heal = (sid == "healing") is_water_bubble = (sid == "water_bubble") is_earth_spike = (sid == "earth_spike") else: is_fire = offhand_item.item_name == "Tome of Flames" is_frost = offhand_item.item_name == "Tome of Frostspike" is_heal = offhand_item.item_name == "Tome of Healing" is_water_bubble = offhand_item.item_name == "Tome of Water Bubble" is_earth_spike = offhand_item.item_name == "Tome of Earth Spike" if is_fire or is_frost or is_heal or is_water_bubble or is_earth_spike: var game_world = get_tree().get_first_node_in_group("game_world") var target_pos = Vector2.ZERO var heal_target: Node = null if (is_fire or is_frost or is_earth_spike) and game_world and game_world.has_method("get_grid_locked_cursor_position"): target_pos = game_world.get_grid_locked_cursor_position() elif is_heal: heal_target = _get_heal_target() var has_valid_target = ((is_fire or is_frost or is_earth_spike) and target_pos != Vector2.ZERO) or (is_heal and heal_target != null) or is_water_bubble var can_start_charge = is_heal or has_valid_target # Reuse grabbable from single query above (avoids second get_overlapping_bodies) var nearby_grabbable = nearby_grabbable_body var hotkey_released = (spell_charge_hotkey_slot == "1" and key1_just_released) or (spell_charge_hotkey_slot == "2" and key2_just_released) or (spell_charge_hotkey_slot == "3" and key3_just_released) if grab_just_pressed and not is_charging_spell and can_start_charge and not nearby_grabbable and not is_lifting and not held_object: # Check if player has enough mana before starting to charge var has_enough_mana = false if character_stats: if is_fire or is_frost or is_water_bubble or is_earth_spike: has_enough_mana = character_stats.mp >= 15.0 else: has_enough_mana = character_stats.mp >= 20.0 # Heal spell cost if not has_enough_mana: if is_local_player: _show_not_enough_mana_text() just_grabbed_this_frame = false return is_charging_spell = true current_spell_element = "healing" if is_heal else ("frost" if is_frost else ("water" if is_water_bubble else ("earth" if is_earth_spike else "fire"))) spell_charge_start_time = Time.get_ticks_msec() / 1000.0 spell_incantation_played = false _start_spell_charge_particles() _start_spell_charge_incantation() if has_node("SfxSpellCharge"): $SfxSpellCharge.play() if multiplayer.has_multiplayer_peer(): _sync_spell_charge_start.rpc() just_grabbed_this_frame = false return elif (grab_just_released or hotkey_released) and is_charging_spell: var charge_time = (Time.get_ticks_msec() / 1000.0) - spell_charge_start_time if charge_time < 0.2: is_charging_spell = false spell_charge_hotkey_slot = "" current_spell_element = "fire" spell_incantation_played = false _stop_spell_charge_particles() _stop_spell_charge_incantation() _clear_spell_charge_tint() _set_animation("IDLE") if has_node("SfxSpellCharge"): $SfxSpellCharge.stop() if has_node("SfxSpellIncantation"): $SfxSpellIncantation.stop() if multiplayer.has_multiplayer_peer(): _sync_spell_charge_end.rpc() just_grabbed_this_frame = false return var is_fully_charged = charge_time >= spell_charge_duration if (is_fire or is_frost or is_earth_spike) and game_world and game_world.has_method("get_grid_locked_cursor_position"): target_pos = game_world.get_grid_locked_cursor_position() if is_heal: heal_target = _get_heal_target() has_valid_target = ((is_fire or is_frost or is_earth_spike) and target_pos != Vector2.ZERO) or (is_heal and heal_target != null) or is_water_bubble if has_valid_target and is_fully_charged: # Check if player has enough mana before casting var has_enough_mana = false if character_stats: if is_fire or is_frost or is_water_bubble or is_earth_spike: has_enough_mana = character_stats.mp >= 15.0 else: has_enough_mana = character_stats.mp >= 20.0 # Heal spell cost if has_enough_mana: if is_fire: _cast_flame_spell(target_pos) elif is_frost: _cast_frostspike_spell(target_pos) elif is_water_bubble: var dir = Vector2.ZERO if game_world and game_world.has_method("get_grid_locked_cursor_position"): var cursor_pos = game_world.get_grid_locked_cursor_position() dir = (cursor_pos - global_position).normalized() if dir == Vector2.ZERO: dir = Vector2.RIGHT.rotated(rotation) _cast_water_bubble_spell(dir) elif is_earth_spike: _cast_earth_spike_spell(target_pos) else: _cast_heal_spell(heal_target) else: is_charging_spell = false spell_charge_hotkey_slot = "" current_spell_element = "fire" spell_incantation_played = false _stop_spell_charge_particles() _stop_spell_charge_incantation() _clear_spell_charge_tint() _set_animation("IDLE") if has_node("SfxSpellCharge"): $SfxSpellCharge.stop() if has_node("SfxSpellIncantation"): $SfxSpellIncantation.stop() if multiplayer.has_multiplayer_peer(): _sync_spell_charge_end.rpc() return _set_animation("FINISH_SPELL") movement_lock_timer = SPELL_CAST_LOCK_DURATION is_charging_spell = false spell_charge_hotkey_slot = "" current_spell_element = "fire" spell_incantation_played = false _stop_spell_charge_particles() _stop_spell_charge_incantation() _clear_spell_charge_tint() if has_node("SfxSpellCharge"): $SfxSpellCharge.stop() else: is_charging_spell = false spell_charge_hotkey_slot = "" current_spell_element = "fire" spell_incantation_played = false _stop_spell_charge_particles() _stop_spell_charge_incantation() _clear_spell_charge_tint() _set_animation("IDLE") if has_node("SfxSpellCharge"): $SfxSpellCharge.stop() if has_node("SfxSpellIncantation"): $SfxSpellIncantation.stop() if multiplayer.has_multiplayer_peer(): _sync_spell_charge_end.rpc() just_grabbed_this_frame = false return elif is_charging_spell and (is_lifting or held_object or ((not is_heal) and not has_valid_target)): # Don't cancel heal charge for no target (allow hover over enemy/wall); cancel fire/frost is_charging_spell = false spell_charge_hotkey_slot = "" current_spell_element = "fire" spell_incantation_played = false _stop_spell_charge_particles() _stop_spell_charge_incantation() _clear_spell_charge_tint() _set_animation("IDLE") if has_node("SfxSpellCharge"): $SfxSpellCharge.stop() if has_node("SfxSpellIncantation"): $SfxSpellIncantation.stop() if multiplayer.has_multiplayer_peer(): _sync_spell_charge_end.rpc() print(name, " spell charge cancelled (no target / lift / held)") # Check for bomb usage (if bomb equipped in offhand) # Also check if we're already holding a bomb - if so, skip normal grab handling var is_holding_bomb = false if held_object and "object_type" in held_object: # Check if it's a bomb by checking object_type if held_object.object_type == "Bomb": is_holding_bomb = true if character_stats and character_stats.equipment.has("offhand"): var offhand_equipped = character_stats.equipment["offhand"] if offhand_equipped and offhand_equipped.weapon_type == Item.WeaponType.BOMB and offhand_equipped.quantity > 0: # Check if there's a grabbable object nearby - prioritize grabbing over bomb var nearby_grabbable = null if grab_area: var bodies = grab_area.get_overlapping_bodies() for body in bodies: if body == self: continue var is_grabbable = false if body.has_method("can_be_grabbed"): if body.can_be_grabbed(): is_grabbable = true elif body.is_in_group("player") or ("peer_id" in body and body is CharacterBody2D): if body.get("position_z", 0.0) <= 0.0: is_grabbable = true if is_grabbable: var distance = position.distance_to(body.position) if distance < grab_range: nearby_grabbable = body break # Only use bomb if no grabbable object nearby and not lifting/grabbing if grab_just_pressed and not nearby_grabbable and not is_lifting and not held_object: # Use bomb based on race if character_stats.race == "Dwarf": # Dwarf: Create interactable bomb object that can be lifted/thrown _create_bomb_object() # Skip the normal grab handling below - bomb is already lifted just_grabbed_this_frame = true # Set to true to prevent immediate release grab_start_time = Time.get_ticks_msec() / 1000.0 # Record grab time return else: # Human/Elf: Throw bomb or drop next to player # Consume one bomb offhand_equipped.quantity -= 1 var remaining = offhand_equipped.quantity if offhand_equipped.quantity <= 0: character_stats.equipment["offhand"] = null if character_stats: character_stats.character_changed.emit(character_stats) # Determine throw direction based on movement var throw_direction = velocity.normalized() var is_moving = throw_direction.length() > 0.1 if not is_moving: # Not moving: use facing direction or last movement direction if facing_direction_vector.length() > 0.1: throw_direction = facing_direction_vector.normalized() elif last_movement_direction.length() > 0.1: throw_direction = last_movement_direction.normalized() else: throw_direction = Vector2.DOWN # Throw bomb in the direction (or drop next to player if not moving) _throw_bomb_from_offhand(throw_direction, is_moving) print(name, " used bomb! Remaining: ", remaining) just_grabbed_this_frame = false return # If holding a bomb, skip normal grab press handling to prevent dropping it # But still allow grab release handling for the drop-on-second-press logic if is_holding_bomb: # Update bomb position if holding it if held_object and grab_button_down and is_lifting: _update_lifted_object() # Skip grab press handling, but continue to release handling below # This allows the drop-on-second-press logic to work # 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: # Skip grab press handling if holding a bomb (to prevent instant drop) # But still allow the drop-on-second-press logic if is_holding_bomb: if is_lifting and grab_released_while_lifting: # Already lifting AND grab was released then pressed again - drop the bomb _place_down_object() grab_released_while_lifting = false else: # Normal grab handling for non-bomb objects 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 and grab_released_while_lifting: # Already lifting AND grab was released then pressed again - drop the object _place_down_object() grab_released_while_lifting = false # 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: # Stop reviving if was reviving if is_reviving: is_reviving = false revive_charge = 0.0 # Sync revive end to all clients if multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree(): _sync_revive_end.rpc() else: is_reviving = false revive_charge = 0.0 if grab_just_released and held_object: # For bombs that are already lifted, skip the "just grabbed" logic # and go straight to the normal release handling (drop-on-second-press) var is_bomb_already_lifted = is_holding_bomb and is_lifting # 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 not is_bomb_already_lifted and (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 if is_lifting: # If lifting, mark that grab was released (but don't drop - wait for next press) print("DEBUG: Grab released while lifting - will drop on next press") grab_released_while_lifting = true 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: var holding_dead_player = _is_player(held_object) and "is_dead" in held_object and held_object.is_dead var reviver_hp = character_stats.hp if character_stats else 1.0 if holding_dead_player and reviver_hp > 1.0: # Start reviving if not already if not is_reviving: is_reviving = true # Sync revive start to all clients if multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree(): _sync_revive_start.rpc() revive_charge += get_process_delta_time() if revive_charge >= REVIVE_DURATION: _do_revive(held_object) _place_down_object() is_reviving = false revive_charge = 0.0 # Sync revive end to all clients if multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree(): _sync_revive_end.rpc() else: _update_lifted_object() else: if holding_dead_player: # Stop reviving if was reviving if is_reviving: is_reviving = false revive_charge = 0.0 # Sync revive end to all clients if multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree(): _sync_revive_end.rpc() else: is_reviving = false revive_charge = 0.0 _update_lifted_object() # Clear the "released while lifting" flag if button is held again if grab_released_while_lifting: grab_released_while_lifting = false 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 with bow charging mechanic var attack_pressed = false var attack_just_pressed = false var attack_just_released = false if input_device == -1: # Keyboard or Mouse attack_pressed = Input.is_action_pressed("attack") attack_just_pressed = Input.is_action_just_pressed("attack") attack_just_released = Input.is_action_just_released("attack") else: # Gamepad (X button) attack_pressed = Input.is_joy_button_pressed(input_device, JOY_BUTTON_X) attack_just_pressed = attack_pressed and not is_attacking and not is_charging_bow # For gamepad, detect release by checking if was pressing last frame attack_just_released = not attack_pressed and is_charging_bow # Check if player has bow + arrows equipped var has_bow_and_arrows = false var equipped_weapon = null var equipped_arrows = null if character_stats and character_stats.equipment.has("mainhand") and character_stats.equipment.has("offhand"): equipped_weapon = character_stats.equipment["mainhand"] equipped_arrows = character_stats.equipment["offhand"] if equipped_weapon and equipped_arrows: if equipped_weapon.weapon_type == Item.WeaponType.BOW and equipped_arrows.weapon_type == Item.WeaponType.AMMUNITION and equipped_arrows.quantity > 0: has_bow_and_arrows = true # Handle bow charging if has_bow_and_arrows and not is_lifting and not is_pushing: if attack_just_pressed and can_attack and not is_charging_bow and not spawn_landing and not netted_by_web: if !$SfxBuckleBow.playing: $SfxBuckleBow.play() # Start charging bow is_charging_bow = true bow_charge_start_time = Time.get_ticks_msec() / 1000.0 # Sync bow charge start to other clients if multiplayer.has_multiplayer_peer(): _sync_bow_charge_start.rpc() print(name, " started charging bow") elif attack_just_released and is_charging_bow: $SfxBuckleBow.stop() # Calculate charge time var charge_time = (Time.get_ticks_msec() / 1000.0) - bow_charge_start_time # Minimum charge time: 0.2 seconds, otherwise cancel if charge_time < 0.2: is_charging_bow = false # CRITICAL: Sync bow charge end to other clients when cancelling if multiplayer.has_multiplayer_peer(): _sync_bow_charge_end.rpc() print(name, " cancelled arrow (released too quickly, need at least 0.2s)") return # Smooth curve: charge from 0.2s to 1.0s # Speed scales from 50% to 100% (160 to 320 speed) var charge_progress = clamp((charge_time - 0.2) / 0.8, 0.0, 1.0) # 0.0 at 0.2s, 1.0 at 1.0s bow_charge_percentage = 0.5 + (charge_progress * 0.5) # 0.5 to 1.0 # Release bow and shoot is_charging_bow = false # Clear bow charge tint _clear_bow_charge_tint() # Lock movement for 0.15 seconds when bow is released movement_lock_timer = 0.15 # Sync bow charge end to other clients if multiplayer.has_multiplayer_peer(): _sync_bow_charge_end.rpc() _perform_attack() print(name, " released bow and shot arrow at ", bow_charge_percentage * 100, "% charge (", charge_time, "s)") # Handle normal attack (non-bow or no arrows) or cancel bow if conditions changed if not (has_bow_and_arrows and not is_lifting and not is_pushing): # Conditions for charging are no longer met (no bow/arrows, started lifting/pushing) # Only cancel if we were actually charging if is_charging_bow: $SfxBuckleBow.stop() is_charging_bow = false # Clear bow charge tint _clear_bow_charge_tint() # Sync bow charge end to other clients if multiplayer.has_multiplayer_peer(): _sync_bow_charge_end.rpc() print(name, " bow charge cancelled (conditions changed)") # Normal attack (non-bow or no arrows) # Also allow throwing when lifting (even if bow is equipped). Block during spawn fall. if attack_just_pressed and can_attack and not spawn_landing and not netted_by_web: if is_lifting: # Attack while lifting -> throw immediately in facing direction _force_throw_held_object(facing_direction_vector) elif not has_bow_and_arrows and not is_pushing: _perform_attack() # Note: just_grabbed_this_frame is reset when we block a release, or stays true if we grabbed this frame # This ensures it persists to the next frame to block immediate release func _get_nearby_disarmable_trap() -> Node: # Check for nearby trap that can be disarmed (Dwarf only) # Use exact DisarmArea radius from trap scene (17.117243) const DISARM_RANGE: float = 17.117243 var traps = get_tree().get_nodes_in_group("trap") for trap in traps: if not trap or not is_instance_valid(trap): continue # Check if trap is detected, not disarmed, and within disarm range if trap.is_detected and not trap.is_disarmed: # Check if player is actually inside the DisarmArea (more accurate than distance check) if trap.has_node("DisarmArea"): var disarm_area = trap.get_node("DisarmArea") if disarm_area: # Get overlapping bodies in DisarmArea var bodies = disarm_area.get_overlapping_bodies() for body in bodies: if body == self: # Player is inside DisarmArea - can disarm return trap else: # Fallback: use distance check if DisarmArea not found var distance = global_position.distance_to(trap.global_position) if distance < DISARM_RANGE: return trap return null func _has_shield_in_offhand() -> bool: if not character_stats or not character_stats.equipment.has("offhand"): return false var off = character_stats.equipment["offhand"] return off != null and "shield" in off.item_name.to_lower() func _get_nearby_grabbable() -> Node: # Single overlap query; call once per frame and reuse result (avoids 2x get_overlapping_bodies in _handle_interactions) if not grab_area: return null var bodies = grab_area.get_overlapping_bodies() for body in bodies: if body == self: continue var is_grabbable = false if body.has_method("can_be_grabbed"): if body.can_be_grabbed(): is_grabbable = true elif body.is_in_group("player") or ("peer_id" in body and body is CharacterBody2D): if body.get("position_z", 0.0) <= 0.0: is_grabbable = true if is_grabbable and position.distance_to(body.position) < grab_range: return body return null func _has_nearby_grabbable() -> bool: return _get_nearby_grabbable() != null func _update_shield_visibility() -> void: if not sprite_shield or not sprite_shield_holding: return var has_shield = _has_shield_in_offhand() if not has_shield: sprite_shield.visible = false sprite_shield_holding.visible = false return if is_shielding: sprite_shield.visible = false sprite_shield_holding.visible = true else: sprite_shield.visible = true sprite_shield_holding.visible = false 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 (not when they're mid-air / thrown) elif body.is_in_group("player") or ("peer_id" in body and body is CharacterBody2D): if body.get("position_z", 0.0) <= 0.0: 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: # Placed bomb (attack_bomb with fuse): collect to inventory, don't lift if "is_fused" in closest_body and "can_be_collected" in closest_body and "player_owner" in closest_body: if closest_body.has_method("can_be_grabbed") and closest_body.can_be_grabbed() and closest_body.has_method("on_grabbed"): closest_body.on_grabbed(self) return 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) # Use player's current facing when grab started (do not turn to face the object) var grab_direction = facing_direction_vector.normalized() if facing_direction_vector.length() > 0.1 else last_movement_direction.normalized() if grab_direction.length() < 0.1: grab_direction = Vector2.DOWN push_axis = _snap_to_8_directions(grab_direction) push_direction_locked = _get_direction_from_vector(push_axis) as 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 closest_body.has_method("can_be_grabbed") and closest_body.can_be_grabbed(): # Bomb or other grabbable CharacterBody2D - disable collision closest_body.set_collision_layer_value(2, false) closest_body.set_collision_mask_value(1, false) closest_body.set_collision_mask_value(2, false) closest_body.set_collision_mask_value(7, true) # Keep wall collision elif _is_player(closest_body): # Players: no collision layer at all while held closest_body.collision_layer = 0 closest_body.collision_mask = 0 # 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) # CRITICAL: Validate object is still valid right before sending RPC if obj_name != "" and is_instance_valid(held_object) and held_object.is_inside_tree(): _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 (also sets position_z = HELD_POSITION_Z) if held_object.has_method("set_being_held"): held_object.set_being_held(true) # Any held object with position_z gets lifted above ground if "position_z" in held_object: held_object.position_z = HELD_POSITION_Z 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) # CRITICAL: Validate object is still valid right before sending RPC if obj_name != "" and is_instance_valid(held_object) and held_object.is_inside_tree(): _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 # Keep the direction we had when we started the grab (do not face the object) var initial_direction = facing_direction_vector.normalized() if facing_direction_vector.length() > 0.1 else last_movement_direction.normalized() if initial_direction.length() < 0.1: initial_direction = Vector2.DOWN push_axis = _snap_to_8_directions(initial_direction) push_direction_locked = _get_direction_from_vector(push_axis) facing_direction_vector = push_axis.normalized() # 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) # CRITICAL: Validate object is still valid right before sending RPC if obj_name != "" and is_instance_valid(held_object) and held_object.is_inside_tree(): _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 reset_grab_state(): # Force reset all grab/lift/push states (used when transitioning levels) if held_object and is_instance_valid(held_object): # Re-enable collision on held object if _is_box(held_object): held_object.set_collision_layer_value(2, true) held_object.set_collision_mask_value(1, true) held_object.set_collision_mask_value(2, true) held_object.set_collision_mask_value(7, true) if "is_frozen" in held_object: held_object.is_frozen = false if "is_being_held" in held_object: held_object.is_being_held = false if "held_by_player" in held_object: held_object.held_by_player = null elif _is_player(held_object): held_object.collision_layer = 1 held_object.collision_mask = 1 | 2 | 64 # players, objects, walls if held_object.has_method("set_being_held"): held_object.set_being_held(false) if "position_z" in held_object: held_object.position_z = 0.0 # Stop drag sound if playing if held_object.has_method("stop_drag_sound"): held_object.stop_drag_sound() # Clear all state held_object = null grab_offset = Vector2.ZERO grab_distance = 0.0 is_lifting = false is_pushing = false push_axis = Vector2.ZERO initial_grab_position = Vector2.ZERO initial_player_position = Vector2.ZERO just_grabbed_this_frame = false grab_start_time = 0.0 grab_released_while_lifting = false was_dragging_last_frame = false is_shielding = false was_shielding_last_frame = false _update_shield_visibility() # Reset to idle animation if current_animation == "IDLE_HOLD" or current_animation == "RUN_HOLD" or current_animation == "LIFT" or current_animation == "IDLE_PUSH" or current_animation == "RUN_PUSH": _set_animation("IDLE") print("Reset grab state for ", name) 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) released_obj.set_collision_mask_value(7, true) # Re-enable wall collision! elif _is_player(released_obj): # Players: restore collision layer and mask (layer 1, mask 1|2|64 so we collide with players, objects, walls) released_obj.collision_layer = 1 released_obj.collision_mask = 1 | 2 | 64 if released_obj is CharacterBody2D and released_obj.has_method("set_being_held"): released_obj.set_being_held(false) if "position_z" in released_obj: released_obj.position_z = 0.0 # 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 _get_throw_force() -> float: # Calculate throw force based on player's STR stat # Base: 80, +3 per STR point var str_stat = 10.0 # Default STR if character_stats: str_stat = character_stats.baseStats.str + character_stats.get_pass("str") return base_throw_force + (str_stat * 3.0) 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 # Calculate throw force based on STR var throw_force = _get_throw_force() # 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 # Track if on_thrown was already called (bombs convert to projectile and free themselves) var on_thrown_called = false # Check if it's a bomb (bombs convert to projectile and free themselves) var is_bomb = false if thrown_obj and is_instance_valid(thrown_obj) and "object_type" in thrown_obj: is_bomb = (thrown_obj.object_type == "Bomb") # Re-enable collision completely if _is_box(thrown_obj) or is_bomb: # Box or Bomb: set position and physics first thrown_obj.global_position = throw_start_pos # Set throw velocity (same force as player throw) if "throw_velocity" in thrown_obj: var obj_weight = thrown_obj.weight if "weight" in thrown_obj else 1.0 thrown_obj.throw_velocity = throw_direction * throw_force / obj_weight if "is_frozen" in thrown_obj: thrown_obj.is_frozen = false # Make box/bomb 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 (check validity first) # Note: For bombs, this will convert to projectile and free the object if thrown_obj and is_instance_valid(thrown_obj) and thrown_obj.has_method("on_thrown"): thrown_obj.on_thrown(self, throw_direction * throw_force) on_thrown_called = true # Check if object was freed (bomb conversion) if not is_instance_valid(thrown_obj): thrown_obj = null # ⚡ Delay collision re-enable to prevent self-collision (only if object still exists) if thrown_obj and is_instance_valid(thrown_obj): 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) # Re-add to layer DIRECTLY when thrown (no delay); restore full mask 1|2|64 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(2, true) thrown_obj.set_collision_mask_value(7, true) elif thrown_obj and thrown_obj.has_method("can_be_grabbed") and thrown_obj.can_be_grabbed(): # Bomb or other grabbable object - handle like box thrown_obj.global_position = throw_start_pos # Set throw velocity if "throw_velocity" in thrown_obj: thrown_obj.throw_velocity = throw_direction * throw_force / (thrown_obj.weight if "weight" in thrown_obj else 1.0) if "is_frozen" in thrown_obj: thrown_obj.is_frozen = false # Make airborne if "is_airborne" in thrown_obj: thrown_obj.is_airborne = true thrown_obj.position_z = 2.5 thrown_obj.velocity_z = 100.0 # Call on_thrown if available (bombs will convert to projectile here) if thrown_obj and is_instance_valid(thrown_obj) and thrown_obj.has_method("on_thrown"): thrown_obj.on_thrown(self, throw_direction * throw_force) on_thrown_called = true # Check if object was freed (bomb conversion) if not is_instance_valid(thrown_obj): thrown_obj = null # Delay collision re-enable (only if object still exists) if thrown_obj and is_instance_valid(thrown_obj): 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) # Only call on_thrown if it wasn't already called (prevents double-call for bombs) if not on_thrown_called and thrown_obj and is_instance_valid(thrown_obj) and 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]) 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 # Calculate throw force based on STR var throw_force = _get_throw_force() # 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 # Track if on_thrown was already called (bombs convert to projectile and free themselves) var on_thrown_called = false # Check if it's a bomb (bombs convert to projectile and free themselves) var is_bomb = false if thrown_obj and is_instance_valid(thrown_obj) and "object_type" in thrown_obj: is_bomb = (thrown_obj.object_type == "Bomb") # Re-enable collision completely if _is_box(thrown_obj) or is_bomb: # Box or Bomb: set position and physics first thrown_obj.global_position = throw_start_pos # Set throw velocity (same force as player throw) if "throw_velocity" in thrown_obj: var obj_weight = thrown_obj.weight if "weight" in thrown_obj else 1.0 thrown_obj.throw_velocity = throw_direction * throw_force / obj_weight if "is_frozen" in thrown_obj: thrown_obj.is_frozen = false # Make box/bomb 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 (check validity first) # Note: For bombs, this will convert to projectile and free the object if thrown_obj and is_instance_valid(thrown_obj) and thrown_obj.has_method("on_thrown"): thrown_obj.on_thrown(self, throw_direction * throw_force) on_thrown_called = true # Check if object was freed (bomb conversion) if not is_instance_valid(thrown_obj): thrown_obj = null # ⚡ Delay collision re-enable to prevent self-collision (only if object still exists) if thrown_obj and is_instance_valid(thrown_obj): 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) # Re-enable wall collision! 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) # Re-add to layer DIRECTLY when thrown (no delay) if thrown_obj and is_instance_valid(thrown_obj): thrown_obj.collision_layer = 1 thrown_obj.collision_mask = 1 | 2 | 64 # players, objects, walls elif thrown_obj and thrown_obj.has_method("can_be_grabbed") and thrown_obj.can_be_grabbed(): # Other grabbable object - handle like box thrown_obj.global_position = throw_start_pos # Set throw velocity if "throw_velocity" in thrown_obj: var obj_weight = thrown_obj.weight if "weight" in thrown_obj else 1.0 thrown_obj.throw_velocity = throw_direction * throw_force / obj_weight if "is_frozen" in thrown_obj: thrown_obj.is_frozen = false # Make airborne if "is_airborne" in thrown_obj: thrown_obj.is_airborne = true thrown_obj.position_z = 2.5 thrown_obj.velocity_z = 100.0 # Call on_thrown if available if thrown_obj and is_instance_valid(thrown_obj) and thrown_obj.has_method("on_thrown"): thrown_obj.on_thrown(self, throw_direction * throw_force) on_thrown_called = true # Check if object was freed (bomb conversion) if not is_instance_valid(thrown_obj): thrown_obj = null # Delay collision re-enable (only if object still exists) if thrown_obj and is_instance_valid(thrown_obj): 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) # Only call on_thrown if it wasn't already called (prevents double-call for bombs) if not on_thrown_called and thrown_obj and is_instance_valid(thrown_obj) and 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 (only if object still exists - bombs convert to projectile) if thrown_obj and is_instance_valid(thrown_obj): 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) if obj_name != "": _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) else: # Bomb was converted to projectile (object was freed) print("Threw bomb (converted to projectile) from ", throw_start_pos, " with force: ", throw_direction * throw_force) func _play_sfx_deny(): if has_node("SfxDeny"): $SfxDeny.play() func _place_down_object(): if not held_object: return var placed_obj = held_object var place_pos = _find_closest_place_pos(facing_direction_vector, placed_obj) # Dwarf dropping bomb: place attack_bomb with fuse lit (explodes if not picked up in time) if "object_type" in placed_obj and placed_obj.object_type == "Bomb": if not _can_place_down_at(place_pos, placed_obj): _play_sfx_deny() return var bomb_name = placed_obj.name held_object = null grab_offset = Vector2.ZERO is_lifting = false is_pushing = false placed_obj.queue_free() if not attack_bomb_scene: return var bomb = attack_bomb_scene.instantiate() bomb.name = "PlacedBomb_" + bomb_name get_parent().add_child(bomb) bomb.global_position = place_pos bomb.setup(place_pos, self, Vector2.ZERO, false) # not thrown, fuse starts in _deferred_ready if multiplayer.has_multiplayer_peer(): bomb.set_multiplayer_authority(get_multiplayer_authority()) if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree(): _rpc_to_ready_peers("_sync_bomb_dropped", [bomb_name, place_pos]) print(name, " dropped bomb at ", place_pos, " (fuse lit)") return 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) _play_sfx_deny() 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) placed_obj.set_collision_mask_value(7, true) # Re-enable wall collision! # 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: restore collision layer and mask (1|2|64 so we collide with players, objects, walls) placed_obj.collision_layer = 1 placed_obj.collision_mask = 1 | 2 | 64 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) 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]) func _perform_attack(): if not can_attack or is_attacking or spawn_landing or netted_by_web: 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 var is_axe = false var is_unarmed = (equipped_weapon == null) 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 elif equipped_weapon.weapon_type == Item.WeaponType.AXE: is_axe = true # Play attack animation based on weapon (PUNCH when no mainhand) if is_bow: _set_animation("BOW") elif is_staff: _set_animation("STAFF") elif is_axe: _set_animation("AXE") elif is_unarmed: _set_animation("PUNCH") else: _set_animation("SWORD") # Lock facing direction for 0.15 seconds when attack starts locked_facing_direction = facing_direction_vector.normalized() direction_lock_timer = 0.15 # Use full 360-degree facing direction for attack var attack_direction = facing_direction_vector.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 # Round to 1 decimal place final_damage = round(final_damage * 10.0) / 10.0 # Track what we spawned so we only sync when we actually shot a projectile var spawned_projectile_type: String = "" var sync_arrow_name: String = "" # Stable name for arrow sync (multiplayer) # 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 # Reset empty-bow counter when we have arrows if arrows and arrows.quantity > 0: empty_bow_shot_attempts = 0 # Only spawn arrow if we have arrows if arrows and arrows.quantity > 0: if attack_arrow_scene: spawned_projectile_type = "arrow" $SfxBowShoot.play() var arrow_projectile = attack_arrow_scene.instantiate() sync_arrow_name = "arrow_%d_%d" % [multiplayer.get_unique_id(), _arrow_spawn_seq] _arrow_spawn_seq += 1 arrow_projectile.name = sync_arrow_name get_parent().add_child(arrow_projectile) # Spawn arrow 4 pixels in the direction player is looking var arrow_spawn_pos = global_position + (attack_direction * 4.0) # Pass charge percentage to arrow (affects speed) arrow_projectile.shoot(attack_direction, arrow_spawn_pos, self, bow_charge_percentage) # Play bow shoot sound if has_node("SfxBowShoot"): $SfxBowShoot.play() # Consume one arrow arrows.quantity -= 1 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: if character_stats: character_stats.character_changed.emit(character_stats) else: if has_node("SfxBowWithoutArrow"): $SfxBowWithoutArrow.play() # Track empty bow attempts; after 3, unequip bow and equip another weapon empty_bow_shot_attempts += 1 if empty_bow_shot_attempts >= 3: empty_bow_shot_attempts = 0 _unequip_bow_and_equip_other_weapon() elif is_staff: # Spawn staff projectile for staff weapons if staff_projectile_scene and equipped_weapon: spawned_projectile_type = "staff" 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) var spawn_offset = attack_direction * 6.0 projectile.global_position = global_position + spawn_offset elif is_axe: # Axe swing - stays on player, plays directional animation if attack_axe_swing_scene and equipped_weapon: spawned_projectile_type = "axe" var axe_swing = attack_axe_swing_scene.instantiate() get_parent().add_child(axe_swing) axe_swing.setup(attack_direction, self, -1.0, equipped_weapon) axe_swing.global_position = global_position elif is_unarmed: # Unarmed punch - low STR-based damage (enemy armour mitigates via take_damage) if attack_punch_scene: spawned_projectile_type = "punch" var punch_damage = 2.0 if character_stats: var str_total = character_stats.baseStats.str + character_stats.get_pass("str") punch_damage = 2.0 + str_total * 0.1 punch_damage = max(1.0, round(punch_damage * 10.0) / 10.0) var punch = attack_punch_scene.instantiate() get_parent().add_child(punch) punch.setup(attack_direction, self, punch_damage) punch.global_position = global_position + attack_direction * 12.0 else: # Spawn sword projectile for non-bow/staff/axe weapons if sword_projectile_scene: spawned_projectile_type = "sword" 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) var spawn_offset = attack_direction * 6.0 projectile.global_position = global_position + spawn_offset # Sync attack over network only when we actually spawned a projectile if spawned_projectile_type != "" and multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree(): var arrow_name_arg = sync_arrow_name if spawned_projectile_type == "arrow" else "" _rpc_to_ready_peers("_sync_attack", [current_direction, attack_direction, bow_charge_percentage, spawned_projectile_type, arrow_name_arg]) # 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 _unequip_bow_and_equip_other_weapon(): # After 3 empty bow shots: unequip bow, equip another mainhand weapon if any, show messages if not is_multiplayer_authority() or not character_stats: return var mainhand = character_stats.equipment.get("mainhand", null) if not mainhand or mainhand.weapon_type != Item.WeaponType.BOW: return # Unequip bow (moves it to inventory) character_stats.unequip_item(mainhand, true) # Show "Bow unequipped" message _show_equipment_message("Bow unequipped.") # Find first mainhand weapon in inventory that is not a bow var other_weapon = null for i in range(character_stats.inventory.size()): var it = character_stats.inventory[i] if not it: continue if it.equipment_type != Item.EquipmentType.MAINHAND: continue if it.weapon_type == Item.WeaponType.BOW: continue other_weapon = it break if other_weapon: character_stats.equip_item(other_weapon, -1) _show_equipment_message("%s equipped." % other_weapon.item_name) # Sync equipment/inventory to other clients (same as _on_character_changed) if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree(): 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 _rpc_to_ready_peers("_sync_equipment", [equipment_data]) var inventory_data = [] for item in character_stats.inventory: if item: inventory_data.append(item.save()) _rpc_to_ready_peers("_sync_inventory", [inventory_data]) _apply_appearance_to_sprites() var other_name = other_weapon.item_name if other_weapon else "none" print(name, " unequipped bow (no arrows x3); other weapon: ", other_name) func _show_equipment_message(text: String): # Local-only so the player who unequipped always sees it (host or client) var chat_ui = get_tree().get_first_node_in_group("chat_ui") if chat_ui and chat_ui.has_method("add_local_message"): chat_ui.add_local_message("System", text) func _create_bomb_object(): # Dwarf: Create interactable bomb object that can be lifted/thrown if not is_multiplayer_authority(): return # Consume one bomb if not character_stats or not character_stats.equipment.has("offhand"): return var offhand_item = character_stats.equipment["offhand"] if not offhand_item or offhand_item.weapon_type != Item.WeaponType.BOMB or offhand_item.quantity <= 0: return offhand_item.quantity -= 1 var remaining = offhand_item.quantity if offhand_item.quantity <= 0: character_stats.equipment["offhand"] = null if character_stats: character_stats.character_changed.emit(character_stats) var entities_node = get_parent() if not entities_node: entities_node = get_tree().get_first_node_in_group("game_world").get_node_or_null("Entities") if not entities_node: return var bomb_obj = _INTERACTABLE_OBJECT_SCENE.instantiate() bomb_obj.name = "BombObject_" + str(Time.get_ticks_msec()) bomb_obj.global_position = global_position + (facing_direction_vector * 8.0) # Spawn slightly in front # Set multiplayer authority if multiplayer.has_multiplayer_peer(): bomb_obj.set_multiplayer_authority(get_multiplayer_authority()) entities_node.add_child(bomb_obj) # Setup as bomb object bomb_obj.setup_bomb() # Immediately grab it held_object = bomb_obj grab_offset = bomb_obj.position - position grab_distance = global_position.distance_to(bomb_obj.global_position) initial_grab_position = bomb_obj.global_position initial_player_position = global_position # Disable collision bomb_obj.set_collision_layer_value(2, false) bomb_obj.set_collision_mask_value(1, false) bomb_obj.set_collision_mask_value(2, false) bomb_obj.set_collision_mask_value(7, true) # Keep wall collision # Notify object it's being grabbed if bomb_obj.has_method("on_grabbed"): bomb_obj.on_grabbed(self) # Immediately lift the bomb (Dwarf lifts it directly) is_lifting = true is_pushing = false push_axis = _snap_to_8_directions(facing_direction_vector) if facing_direction_vector.length() > 0.1 else Vector2.DOWN if "is_being_held" in bomb_obj: bomb_obj.is_being_held = true if "held_by_player" in bomb_obj: bomb_obj.held_by_player = self # Freeze the bomb if "is_frozen" in bomb_obj: bomb_obj.is_frozen = true # Call on_lifted if available if bomb_obj.has_method("on_lifted"): bomb_obj.on_lifted(self) # Play lift animation _set_animation("LIFT") # Sync bomb spawn to other clients so they see it when lifted, then sync grab state if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree(): var obj_name = bomb_obj.name if obj_name != "" and is_instance_valid(bomb_obj) and bomb_obj.is_inside_tree(): _rpc_to_ready_peers("_sync_create_bomb_object", [obj_name, bomb_obj.global_position]) _rpc_to_ready_peers("_sync_grab", [obj_name, true, push_axis]) # true = lifting print(name, " created bomb object! Remaining bombs: ", remaining) func _throw_bomb(_target_position: Vector2): # Legacy function - kept for Human/Elf if needed, but Dwarf uses _create_bomb_object now # This is now unused for Dwarf but kept for compatibility pass func _throw_bomb_from_offhand(throw_direction: Vector2, is_moving: bool): # Human/Elf: Throw bomb if moving, or drop next to player if not moving if not attack_bomb_scene: return # Only authority can spawn bombs if not is_multiplayer_authority(): return var throw_force = Vector2.ZERO var bomb_start_pos = global_position if is_moving: # Moving: throw bomb in movement direction (like enemies) var throw_force_magnitude = _get_throw_force() throw_force = throw_direction * throw_force_magnitude bomb_start_pos = global_position + throw_direction * 12.0 # Start slightly in front else: # Not moving: drop next to player (like Dwarf placing down) # Find a valid position next to player var game_world = get_tree().get_first_node_in_group("game_world") var drop_pos = global_position + throw_direction * 16.0 # One tile away if game_world and game_world.has_method("_get_valid_spell_target_position"): var found_pos = game_world._get_valid_spell_target_position(drop_pos) if found_pos != Vector2.ZERO: bomb_start_pos = found_pos else: # Fallback: just use position next to player bomb_start_pos = drop_pos else: bomb_start_pos = drop_pos # No throw force - bomb is dropped/placed throw_force = Vector2.ZERO # Unique id for sync var bomb_id = "Bomb_%d_%d" % [Time.get_ticks_msec(), get_multiplayer_authority()] var bomb = attack_bomb_scene.instantiate() bomb.name = bomb_id get_parent().add_child(bomb) bomb.global_position = bomb_start_pos # Set multiplayer authority if multiplayer.has_multiplayer_peer(): bomb.set_multiplayer_authority(get_multiplayer_authority()) # Setup bomb: thrown if moving (with force), placed if not moving (no force) bomb.setup(bomb_start_pos, self, throw_force, is_moving) # is_moving = is_thrown # Sync bomb spawn to other clients if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree(): _rpc_to_ready_peers("_sync_throw_bomb_from_offhand", [bomb_id, bomb_start_pos, throw_force, is_moving]) print(name, " " + ("threw" if is_moving else "dropped") + " bomb!") func _place_bomb(target_position: Vector2): # Human/Elf places bomb (no throw, just spawns at target) - DEPRECATED: Use _throw_bomb_from_offhand instead if not attack_bomb_scene: return # Only authority can spawn bombs if not is_multiplayer_authority(): return # Find valid target position var game_world = get_tree().get_first_node_in_group("game_world") var valid_target_pos = target_position if game_world and game_world.has_method("_get_valid_spell_target_position"): var found_pos = game_world._get_valid_spell_target_position(target_position) if found_pos != Vector2.ZERO: valid_target_pos = found_pos else: print(name, " cannot place bomb - no valid target position") return # Unique id for sync (collect/remove on other clients) var bomb_id = "DirectBomb_%d_%d" % [Time.get_ticks_msec(), get_multiplayer_authority()] var bomb = attack_bomb_scene.instantiate() bomb.name = bomb_id get_parent().add_child(bomb) bomb.global_position = valid_target_pos # Set multiplayer authority if multiplayer.has_multiplayer_peer(): bomb.set_multiplayer_authority(get_multiplayer_authority()) # Setup bomb without throw (placed directly) bomb.setup(valid_target_pos, self, Vector2.ZERO, false) # false = not thrown # Sync bomb spawn to other clients if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree(): _rpc_to_ready_peers("_sync_place_bomb", [bomb_id, valid_target_pos]) print(name, " placed bomb!") func _cast_flame_spell(target_position: Vector2): # Cast flame spell at target position (grid-locked cursor) # If target is blocked, find closest valid position if not flame_spell_scene: return # Only authority can spawn spells if not is_multiplayer_authority(): return # Check mana cost (15 mana for flame spell) const FLAME_SPELL_MANA_COST = 15.0 if not character_stats: return if not character_stats.use_mana(FLAME_SPELL_MANA_COST): print(name, " cannot cast flame spell - not enough mana (need ", FLAME_SPELL_MANA_COST, ", have ", character_stats.mp, ")") return # Find valid spell target position (closest valid if target is blocked) var game_world = get_tree().get_first_node_in_group("game_world") var valid_target_pos = target_position if game_world and game_world.has_method("_get_valid_spell_target_position"): var found_pos = game_world._get_valid_spell_target_position(target_position) if found_pos != Vector2.ZERO: valid_target_pos = found_pos else: # No valid position found, cancel spell and refund mana if character_stats: character_stats.restore_mana(FLAME_SPELL_MANA_COST) print(name, " cannot cast spell - no valid target position") return # Calculate damage from character_stats var spell_damage = 15.0 # Base damage if character_stats: spell_damage = character_stats.damage * 0.75 # 75% of normal damage # Spawn flame spell at valid target position var flame_spell = flame_spell_scene.instantiate() get_parent().add_child(flame_spell) flame_spell.setup(valid_target_pos, self, spell_damage) # Play fire sound from the spell scene if flame_spell.has_node("SfxFire"): flame_spell.get_node("SfxFire").play() # Sync spell spawn to other clients if multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree(): _rpc_to_ready_peers("_sync_flame_spell", [valid_target_pos, spell_damage]) print(name, " cast flame spell at ", valid_target_pos, " (requested: ", target_position, ")") @rpc("any_peer", "reliable") func _sync_flame_spell(target_position: Vector2, spell_damage: float): # Client receives flame spell spawn sync if is_multiplayer_authority(): return # Authority already spawned it if not flame_spell_scene: return # Spawn flame spell on client var flame_spell = flame_spell_scene.instantiate() get_parent().add_child(flame_spell) flame_spell.setup(target_position, self, spell_damage) print(name, " (synced) spawned flame spell at ", target_position) func _cast_frostspike_spell(target_position: Vector2): if not frostspike_spell_scene: return if not is_multiplayer_authority(): return # Check mana cost (15 mana for frostspike spell) const FROSTSPIKE_SPELL_MANA_COST = 15.0 if not character_stats: return if not character_stats.use_mana(FROSTSPIKE_SPELL_MANA_COST): print(name, " cannot cast frostspike - not enough mana (need ", FROSTSPIKE_SPELL_MANA_COST, ", have ", character_stats.mp, ")") return var game_world = get_tree().get_first_node_in_group("game_world") var valid_target_pos = target_position if game_world and game_world.has_method("_get_valid_spell_target_position"): var found_pos = game_world._get_valid_spell_target_position(target_position) if found_pos != Vector2.ZERO: valid_target_pos = found_pos else: # No valid position found, cancel spell and refund mana if character_stats: character_stats.restore_mana(FROSTSPIKE_SPELL_MANA_COST) print(name, " cannot cast frostspike - no valid target position") return var spell_damage = 15.0 if character_stats: spell_damage = character_stats.damage * 0.75 var frost = frostspike_spell_scene.instantiate() frost.setup(valid_target_pos, self, spell_damage, true) get_parent().add_child(frost) if multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree(): _rpc_to_ready_peers("_sync_frostspike_spell", [valid_target_pos, spell_damage]) print(name, " cast frostspike at ", valid_target_pos) @rpc("any_peer", "reliable") func _sync_frostspike_spell(target_position: Vector2, spell_damage: float): if is_multiplayer_authority(): return if not frostspike_spell_scene: return var frost = frostspike_spell_scene.instantiate() frost.setup(target_position, self, spell_damage, true) get_parent().add_child(frost) print(name, " (synced) spawned frostspike at ", target_position) func _cast_water_bubble_spell(direction: Vector2): if not water_bubble_spell_scene or not is_multiplayer_authority(): return const MANA_COST = 15.0 if not character_stats or not character_stats.use_mana(MANA_COST): if is_local_player: _show_not_enough_mana_text() return var spell_damage = 15.0 if character_stats: spell_damage = character_stats.damage * 0.75 var bubble = water_bubble_spell_scene.instantiate() bubble.setup(global_position, direction, self, spell_damage) get_parent().add_child(bubble) if multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree(): _rpc_to_ready_peers("_sync_water_bubble_spell", [direction, spell_damage]) print(name, " cast water bubble") @rpc("any_peer", "reliable") func _sync_water_bubble_spell(direction: Vector2, spell_damage: float): if is_multiplayer_authority(): return if not water_bubble_spell_scene: return var bubble = water_bubble_spell_scene.instantiate() bubble.setup(global_position, direction, self, spell_damage) get_parent().add_child(bubble) print(name, " (synced) spawned water bubble") func _cast_earth_spike_spell(target_position: Vector2): if not earth_spike_spell_scene or not is_multiplayer_authority(): return const MANA_COST = 15.0 if not character_stats or not character_stats.use_mana(MANA_COST): if is_local_player: _show_not_enough_mana_text() return var game_world = get_tree().get_first_node_in_group("game_world") var valid_target_pos = target_position if game_world and game_world.has_method("_get_valid_spell_target_position"): var found_pos = game_world._get_valid_spell_target_position(target_position) if found_pos != Vector2.ZERO: valid_target_pos = found_pos else: if character_stats: character_stats.restore_mana(MANA_COST) return var spell_damage = 15.0 if character_stats: spell_damage = character_stats.damage * 0.75 var earth = earth_spike_spell_scene.instantiate() earth.setup(valid_target_pos, self, spell_damage, true) get_parent().add_child(earth) if multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree(): _rpc_to_ready_peers("_sync_earth_spike_spell", [valid_target_pos, spell_damage]) print(name, " cast earth spike at ", valid_target_pos) @rpc("any_peer", "reliable") func _sync_earth_spike_spell(target_position: Vector2, spell_damage: float): if is_multiplayer_authority(): return if not earth_spike_spell_scene: return var earth = earth_spike_spell_scene.instantiate() earth.setup(target_position, self, spell_damage, true) get_parent().add_child(earth) print(name, " (synced) spawned earth spike at ", target_position) func _cast_heal_spell(target: Node): if not target or not is_instance_valid(target): return if not character_stats: return # Check mana cost (20 mana for heal spell - more expensive since it's healing) const HEAL_SPELL_MANA_COST = 20.0 if not character_stats.use_mana(HEAL_SPELL_MANA_COST): print(name, " cannot cast heal spell - not enough mana (need ", HEAL_SPELL_MANA_COST, ", have ", character_stats.mp, ")") return var gw = get_tree().get_first_node_in_group("game_world") var dungeon_seed: int = 0 if gw and "dungeon_seed" in gw: dungeon_seed = gw.dungeon_seed var seed_val = dungeon_seed + hash(target.name) + int(global_position.x) * 31 + int(global_position.y) * 17 + int(Time.get_ticks_msec() / 50.0) var rng = RandomNumberGenerator.new() rng.seed = seed_val var int_val = character_stats.baseStats.int + character_stats.get_pass("int") var lck_val = character_stats.baseStats.lck + character_stats.get_pass("lck") var crit_chance_pct = (character_stats.baseStats.lck + character_stats.get_pass("lck")) * 1.2 var base_heal = 10.0 + int_val * 0.5 var variance = 0.2 var amount = base_heal * (1.0 + (rng.randf() * 2.0 - 1.0) * variance) amount = max(1.0, floor(amount)) var is_crit = rng.randf() * 100.0 < crit_chance_pct if is_crit: amount = floor(amount * 2.0) var display_amount = int(amount) # Undead enemies take damage from healing spell if target.is_in_group("enemy") and "is_undead" in target and target.is_undead: var damage_amount = float(display_amount) var eid = target.get_multiplayer_authority() var my_id = multiplayer.get_unique_id() if eid == my_id: target.take_damage(damage_amount, global_position, is_crit) elif multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree(): target.rpc_take_damage.rpc_id(eid, damage_amount, global_position, is_crit, false, false) else: target.rpc_take_damage.rpc(damage_amount, global_position, is_crit, false, false) _spawn_heal_effect_and_text(target, display_amount, is_crit, false, true) if gw and gw.has_method("_apply_heal_spell_sync"): _rpc_to_ready_peers("_sync_heal_spell_via_gw", [target.name, damage_amount, display_amount, is_crit, false, false, false, true]) print(name, " cast heal on undead ", target.name, " for ", display_amount, " damage (crit: ", is_crit, ")") return var is_revive = "is_dead" in target and target.is_dead var actual_heal = amount var allow_overheal = false var is_overheal = false if is_revive: actual_heal = amount else: var overheal_chance_pct = 1.0 + lck_val * 0.3 var can_overheal = target.character_stats and target.character_stats.hp <= target.character_stats.maxhp is_overheal = can_overheal and rng.randf() * 100.0 < overheal_chance_pct if is_overheal: allow_overheal = true else: var cap = 0.0 if target.character_stats: cap = target.character_stats.maxhp - target.character_stats.hp actual_heal = min(amount, max(0.0, cap)) var me = multiplayer.get_unique_id() var tid = target.get_multiplayer_authority() # Handle healing for players and enemies if is_revive: # Revive only works for players if target.is_in_group("player"): if me == tid: target._revive_from_heal(display_amount) elif multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree(): target._revive_from_heal.rpc_id(tid, display_amount) else: # Heal players or enemies if actual_heal > 0: if target.is_in_group("player"): # Player healing if me == tid: target.heal(actual_heal, allow_overheal) elif target.is_in_group("enemy"): # Enemy healing - use character_stats.heal() directly if target.character_stats: if me == tid: target.character_stats.heal(actual_heal, allow_overheal) # Sync current_health for backwards compatibility target.current_health = target.character_stats.hp elif multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree(): # Sync enemy healing via RPC target.rpc_heal_enemy.rpc_id(tid, actual_heal, allow_overheal) # Spawn healing effect and text on target (works for both players and enemies) _spawn_heal_effect_and_text(target, display_amount, is_crit, is_overheal, false) # Sync healing to all clients if multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree() and gw and gw.has_method("_apply_heal_spell_sync"): _rpc_to_ready_peers("_sync_heal_spell_via_gw", [target.name, actual_heal, display_amount, is_crit, is_overheal, allow_overheal, is_revive, false]) var target_type = "enemy" if target.is_in_group("enemy") else "player" print(name, " cast heal on ", target_type, " ", target.name, " for ", display_amount, " HP", " (actual: ", actual_heal, ", crit: ", is_crit, ", overheal: ", is_overheal, ", revive: ", is_revive, ")") func _spawn_heal_effect_and_text(target: Node, display_amount: int, is_crit: bool, is_overheal: bool, is_damage_to_enemy: bool = false): if not target or not is_instance_valid(target): return var game_world = get_tree().get_first_node_in_group("game_world") var entities = game_world.get_node_or_null("Entities") if game_world else null # Parent effect as sibling of target so it always follows the target (player or enemy) var parent = target.get_parent() if target.get_parent() else (entities if entities else null) if not parent: return if healing_effect_scene: var eff = healing_effect_scene.instantiate() parent.add_child(eff) var pos = target.global_position eff.global_position = pos if eff.has_method("setup"): eff.setup(target) eff.global_position = pos if is_damage_to_enemy: # Undead: enemy's take_damage already shows damage number; we only spawn effect return var prefix = "" if is_crit and is_overheal: prefix = "CRIT OVERHEAL! " elif is_crit: prefix = "CRIT! " elif is_overheal: prefix = "OVERHEAL! " var heal_text = prefix + "+" + str(display_amount) + " HP" var floating_text_scene = preload("res://scenes/floating_text.tscn") if floating_text_scene: var ft = floating_text_scene.instantiate() parent.add_child(ft) ft.global_position = Vector2(target.global_position.x, target.global_position.y - 20) ft.setup(heal_text, Color.GREEN, 0.5, 0.5, null, 1, 1, 0) @rpc("any_peer", "reliable") func _sync_heal_spell_via_gw(target_name: String, amount_to_apply: float, display_amount: int, is_crit: bool, is_overheal: bool, allow_overheal: bool, is_revive: bool = false, is_damage_to_enemy: bool = false): if is_multiplayer_authority(): return var gw = get_tree().get_first_node_in_group("game_world") if gw and gw.has_method("_apply_heal_spell_sync"): gw._apply_heal_spell_sync(target_name, amount_to_apply, display_amount, is_crit, is_overheal, allow_overheal, is_revive, is_damage_to_enemy) func _is_healing_spell() -> bool: if not character_stats or not character_stats.equipment.has("offhand"): return false var off = character_stats.equipment["offhand"] return off != null and off.item_name == "Tome of Healing" func _is_frost_spell() -> bool: if not character_stats or not character_stats.equipment.has("offhand"): return false var off = character_stats.equipment["offhand"] return off != null and off.item_name == "Tome of Frostspike" func _get_heal_target() -> Node: var game_world = get_tree().get_first_node_in_group("game_world") if not game_world or not game_world.has_node("Camera2D"): return null var cam = game_world.get_node("Camera2D") var mouse_world = cam.get_global_mouse_position() const HEAL_RANGE: float = 56.0 var best: Node = null var best_d: float = HEAL_RANGE # Check players first for p in get_tree().get_nodes_in_group("player"): if not is_instance_valid(p): continue var d = p.global_position.distance_to(mouse_world) if d < best_d: best_d = d best = p # Check ALL enemies (not just undead) - can heal regular enemies, damage undead for e in get_tree().get_nodes_in_group("enemy"): if not is_instance_valid(e) or ("is_dead" in e and e.is_dead): continue var d = e.global_position.distance_to(mouse_world) if d < best_d: best_d = d best = e return best func _can_cast_spell_at(target_position: Vector2) -> bool: # Check if spell can be cast at target position # Must be on floor tile and not blocked by walls # Get game world for dungeon data var game_world = get_tree().get_first_node_in_group("game_world") if not game_world: return false # Access dungeon_data property if not "dungeon_data" in game_world: return false var dungeon_data = game_world.dungeon_data if dungeon_data.is_empty() or not dungeon_data.has("grid"): return false # Check if target is on a floor tile var tile_size = 16 var tile_x = int(target_position.x / tile_size) var tile_y = int(target_position.y / tile_size) var grid = dungeon_data.grid var map_size = dungeon_data.map_size # Check bounds if tile_x < 0 or tile_y < 0 or tile_x >= map_size.x or tile_y >= map_size.y: return false # Check if it's floor (1), door (2), or corridor (3) - same as walkable var grid_value = grid[tile_x][tile_y] if grid_value != 1 and grid_value != 2 and grid_value != 3: return false # Check if there's a wall between player and target using raycast var space_state = get_world_2d().direct_space_state var query = PhysicsRayQueryParameters2D.new() query.from = global_position query.to = target_position query.collision_mask = 64 # Layer 7 = walls (bit 6 = 64) query.exclude = [get_rid()] var result = space_state.intersect_ray(query) if result: # Hit something - check if it's a wall if result.has("collider"): # Wall blocks spell casting return false return true func _start_spell_charge_particles(): # Create particle system for spell charging (only if enabled) if not use_spell_charge_particles: return if spell_charge_particles: _stop_spell_charge_particles() spell_charge_particles = Node2D.new() spell_charge_particles.name = "SpellChargeParticles" add_child(spell_charge_particles) spell_charge_particle_timer = 0.0 func _update_spell_charge_particles(charge_progress: float): # Update particle system based on charge progress (skip if disabled) if not use_spell_charge_particles or not spell_charge_particles or not is_instance_valid(spell_charge_particles): return var star_texture = load("res://assets/gfx/fx/magic/red_star.png") if not star_texture: return # Spawn particles periodically (more frequent as charge increases) var spawn_interval = 0.15 - (charge_progress * 0.1) # 0.15s to 0.05s interval if spell_charge_particle_timer >= spawn_interval: spell_charge_particle_timer = 0.0 # Spawn a new particle var particle = Sprite2D.new() particle.texture = star_texture # Random position at player's feet var feet_y = 8.0 # Player's feet position var random_x = randf_range(-4.0, 4.0) particle.position = Vector2(random_x, feet_y) # Scale from 0.2 to 1 based on charge progress var base_scale = 0.2 + (charge_progress * 0.8) particle.scale = Vector2.ONE * base_scale # Store initial properties for animation particle.set_meta("initial_scale", base_scale) particle.set_meta("initial_y", feet_y) particle.set_meta("lifetime", 0.0) particle.set_meta("max_lifetime", 0.5 + (charge_progress * 0.5)) # 0.5 to 1.0 seconds spell_charge_particles.add_child(particle) # Animate existing particles var particles_to_remove = [] for child in spell_charge_particles.get_children(): if child is Sprite2D: var lifetime = child.get_meta("lifetime", 0.0) var max_lifetime = child.get_meta("max_lifetime", 1.0) var initial_scale = child.get_meta("initial_scale", 1.0) var initial_y = child.get_meta("initial_y", 8.0) lifetime += get_process_delta_time() child.set_meta("lifetime", lifetime) # Move upward var progress = lifetime / max_lifetime child.position.y = initial_y - (progress * 30.0) # Move up 30 pixels # Scale down as it lives var scale_factor = 1.0 - progress child.scale = Vector2.ONE * (initial_scale * scale_factor) # Fade out child.modulate.a = 1.0 - progress # Remove if expired if lifetime >= max_lifetime: particles_to_remove.append(child) # Remove expired particles for particle in particles_to_remove: particle.queue_free() func _stop_spell_charge_particles(): # Remove particle system if spell_charge_particles and is_instance_valid(spell_charge_particles): spell_charge_particles.queue_free() spell_charge_particles = null func _start_spell_charge_incantation(): spell_incantation_fire_ready_shown = false if has_node("AnimationIncantation"): if _is_healing_spell() or current_spell_element == "healing": $AnimationIncantation.play("healing_charging") elif _is_frost_spell() or current_spell_element == "frost" or current_spell_element == "water": $AnimationIncantation.play("frost_charging") else: $AnimationIncantation.play("fire_charging") func _update_spell_charge_incantation(charge_progress: float): if not has_node("AnimationIncantation"): return if charge_progress >= 1.0 and not spell_incantation_fire_ready_shown: if _is_healing_spell() or current_spell_element == "healing": $AnimationIncantation.play("healing_ready") elif _is_frost_spell() or current_spell_element == "frost" or current_spell_element == "water": $AnimationIncantation.play("frost_ready") else: $AnimationIncantation.play("fire_ready") spell_incantation_fire_ready_shown = true func _stop_spell_charge_incantation(): # Reset incantation when spell charge ends spell_incantation_fire_ready_shown = false if has_node("AnimationIncantation"): $AnimationIncantation.play("idle") func _apply_spell_charge_tint(): if not is_charging_spell: return var tint = spell_charge_tint if _is_healing_spell() or current_spell_element == "healing": tint = Color(0.25, 2.0, 0.3, 2.0) # Green pulse for healing elif _is_frost_spell() or current_spell_element == "frost": tint = Color(0.25, 0.5, 2.0, 2.0) # Blue pulse for frost elif current_spell_element == "water": tint = Color(0.25, 0.6, 2.0, 2.0) # Blue pulse for water bubble elif current_spell_element == "earth": tint = Color(0.9, 0.55, 0.2, 2.0) # Brown/orange pulse for earth spike var sprites = [ {"sprite": sprite_body, "name": "body"}, {"sprite": sprite_boots, "name": "boots"}, {"sprite": sprite_armour, "name": "armour"}, {"sprite": sprite_facial_hair, "name": "facial_hair"}, {"sprite": sprite_hair, "name": "hair"}, {"sprite": sprite_eyes, "name": "eyes"}, {"sprite": sprite_eyelashes, "name": "eyelashes"}, {"sprite": sprite_addons, "name": "addons"}, {"sprite": sprite_headgear, "name": "headgear"} ] # Calculate pulse value (0.0 to 1.0) using sine wave var pulse_value = (sin(spell_charge_tint_pulse_time) + 1.0) / 2.0 # 0.0 to 1.0 for sprite_data in sprites: var sprite = sprite_data.sprite var sprite_name = sprite_data.name # Double-check sprite belongs to this player instance if not sprite or not is_instance_valid(sprite): continue # Verify sprite is a child of this player node if sprite.get_parent() != self and not is_ancestor_of(sprite): continue if sprite.material and sprite.material is ShaderMaterial: var shader_material = sprite.material as ShaderMaterial # Store original tint if not already stored (use unique key per player) var tint_key = str(get_instance_id()) + "_" + sprite_name if not tint_key in original_sprite_tints: # Try to get the current tint parameter value var original_tint_param = shader_material.get_shader_parameter("tint") if original_tint_param is Vector4: # Convert Vector4 to Color original_sprite_tints[tint_key] = Color(original_tint_param.x, original_tint_param.y, original_tint_param.z, original_tint_param.w) elif original_tint_param is Color: # Already a Color original_sprite_tints[tint_key] = original_tint_param else: # Default to white if no tint parameter or invalid original_sprite_tints[tint_key] = Color.WHITE # Get original tint var original_tint = original_sprite_tints.get(tint_key, Color.WHITE) # Calculate fully charged tint (original * tint) var full_charged_tint = Color( original_tint.r * tint.r, original_tint.g * tint.g, original_tint.b * tint.b, original_tint.a * tint.a ) # Interpolate between original and fully charged tint based on pulse var current_tint = original_tint.lerp(full_charged_tint, pulse_value) # Apply the pulsing tint shader_material.set_shader_parameter("tint", Vector4(current_tint.r, current_tint.g, current_tint.b, current_tint.a)) func _clear_spell_charge_tint(): # Restore original tint values for all sprite layers # IMPORTANT: Only restore THIS player's sprites (not other players) var sprites = [ {"sprite": sprite_body, "name": "body"}, {"sprite": sprite_boots, "name": "boots"}, {"sprite": sprite_armour, "name": "armour"}, {"sprite": sprite_facial_hair, "name": "facial_hair"}, {"sprite": sprite_hair, "name": "hair"}, {"sprite": sprite_eyes, "name": "eyes"}, {"sprite": sprite_eyelashes, "name": "eyelashes"}, {"sprite": sprite_addons, "name": "addons"}, {"sprite": sprite_headgear, "name": "headgear"} ] var instance_id_str = str(get_instance_id()) var keys_to_remove = [] for sprite_data in sprites: var sprite = sprite_data.sprite var sprite_name = sprite_data.name # Double-check sprite belongs to this player instance if not sprite or not is_instance_valid(sprite): continue # Verify sprite is a child of this player node if sprite.get_parent() != self and not is_ancestor_of(sprite): continue if sprite.material and sprite.material is ShaderMaterial: var shader_material = sprite.material as ShaderMaterial # Use unique key per player var tint_key = instance_id_str + "_" + sprite_name # Restore original tint if we stored it if tint_key in original_sprite_tints: var original_tint = original_sprite_tints[tint_key] shader_material.set_shader_parameter("tint", Vector4(original_tint.r, original_tint.g, original_tint.b, original_tint.a)) keys_to_remove.append(tint_key) # Clear stored tints for this player only for key in keys_to_remove: original_sprite_tints.erase(key) func _apply_bow_charge_tint(): # Apply pulsing white tint to all sprite layers when fully charged using shader parameters # Pulse between original tint and bow charge tint (white) # IMPORTANT: Only apply to THIS player's sprites (not other players) if not is_charging_bow: return var sprites = [ {"sprite": sprite_body, "name": "body"}, {"sprite": sprite_boots, "name": "boots"}, {"sprite": sprite_armour, "name": "armour"}, {"sprite": sprite_facial_hair, "name": "facial_hair"}, {"sprite": sprite_hair, "name": "hair"}, {"sprite": sprite_eyes, "name": "eyes"}, {"sprite": sprite_eyelashes, "name": "eyelashes"}, {"sprite": sprite_addons, "name": "addons"}, {"sprite": sprite_headgear, "name": "headgear"} ] # Calculate pulse value (0.0 to 1.0) using sine wave var pulse_value = (sin(bow_charge_tint_pulse_time) + 1.0) / 2.0 # 0.0 to 1.0 for sprite_data in sprites: var sprite = sprite_data.sprite var sprite_name = sprite_data.name # Double-check sprite belongs to this player instance if not sprite or not is_instance_valid(sprite): continue # Verify sprite is a child of this player node if sprite.get_parent() != self and not is_ancestor_of(sprite): continue if sprite.material and sprite.material is ShaderMaterial: var shader_material = sprite.material as ShaderMaterial # Store original tint if not already stored (use unique key per player) var tint_key = str(get_instance_id()) + "_bow_" + sprite_name if not tint_key in original_sprite_tints: # Try to get the current tint parameter value var original_tint_param = shader_material.get_shader_parameter("tint") if original_tint_param is Vector4: # Convert Vector4 to Color original_sprite_tints[tint_key] = Color(original_tint_param.x, original_tint_param.y, original_tint_param.z, original_tint_param.w) elif original_tint_param is Color: # Already a Color original_sprite_tints[tint_key] = original_tint_param else: # Default to white if no tint parameter or invalid original_sprite_tints[tint_key] = Color.WHITE # Get original tint var original_tint = original_sprite_tints.get(tint_key, Color.WHITE) # Calculate fully charged tint (original * bow_charge_tint - white tint) var full_charged_tint = Color( original_tint.r * bow_charge_tint.r, original_tint.g * bow_charge_tint.g, original_tint.b * bow_charge_tint.b, original_tint.a * bow_charge_tint.a ) # Interpolate between original and fully charged tint based on pulse var current_tint = original_tint.lerp(full_charged_tint, pulse_value) # Apply the pulsing tint shader_material.set_shader_parameter("tint", Vector4(current_tint.r, current_tint.g, current_tint.b, current_tint.a)) func _clear_bow_charge_tint(): # Restore original tint values for all sprite layers # IMPORTANT: Only restore THIS player's sprites (not other players) var sprites = [ {"sprite": sprite_body, "name": "body"}, {"sprite": sprite_boots, "name": "boots"}, {"sprite": sprite_armour, "name": "armour"}, {"sprite": sprite_facial_hair, "name": "facial_hair"}, {"sprite": sprite_hair, "name": "hair"}, {"sprite": sprite_eyes, "name": "eyes"}, {"sprite": sprite_eyelashes, "name": "eyelashes"}, {"sprite": sprite_addons, "name": "addons"}, {"sprite": sprite_headgear, "name": "headgear"} ] var instance_id_str = str(get_instance_id()) var keys_to_remove = [] for sprite_data in sprites: var sprite = sprite_data.sprite var sprite_name = sprite_data.name # Double-check sprite belongs to this player instance if not sprite or not is_instance_valid(sprite): continue # Verify sprite is a child of this player node if sprite.get_parent() != self and not is_ancestor_of(sprite): continue if sprite.material and sprite.material is ShaderMaterial: var shader_material = sprite.material as ShaderMaterial # Use unique key per player (with "_bow_" prefix to separate from spell tints) var tint_key = instance_id_str + "_bow_" + sprite_name # Restore original tint if we stored it if tint_key in original_sprite_tints: var original_tint = original_sprite_tints[tint_key] shader_material.set_shader_parameter("tint", Vector4(original_tint.r, original_tint.g, original_tint.b, original_tint.a)) keys_to_remove.append(tint_key) # Clear stored tints for this player only for key in keys_to_remove: original_sprite_tints.erase(key) @rpc("any_peer", "reliable") func _sync_spell_charge_start(): # Sync spell charge start to other clients if not is_multiplayer_authority(): is_charging_spell = true spell_charge_start_time = Time.get_ticks_msec() / 1000.0 _start_spell_charge_particles() _start_spell_charge_incantation() print(name, " (synced) started charging spell") @rpc("any_peer", "reliable") func _sync_spell_charge_end(): if not is_multiplayer_authority(): is_charging_spell = false spell_charge_hotkey_slot = "" current_spell_element = "fire" spell_incantation_played = false _stop_spell_charge_particles() _stop_spell_charge_incantation() _clear_spell_charge_tint() _set_animation("IDLE") # Stop spell charging sounds if has_node("SfxSpellCharge"): $SfxSpellCharge.stop() if has_node("SfxSpellIncantation"): $SfxSpellIncantation.stop() print(name, " (synced) ended charging spell") @rpc("any_peer", "reliable") func _sync_revive_start(): # Sync revive start to other clients - show AnimationIncantation effect if not is_multiplayer_authority(): is_reviving = true revive_charge = 0.0 # Play healing_charging animation on AnimationIncantation if has_node("AnimationIncantation") and not is_charging_spell: $AnimationIncantation.play("healing_charging") print(name, " (synced) started reviving") @rpc("any_peer", "reliable") func _sync_revive_end(): # Sync revive end to other clients - stop AnimationIncantation effect if not is_multiplayer_authority(): is_reviving = false revive_charge = 0.0 # Stop healing_charging animation if has_node("AnimationIncantation") and not is_charging_spell: _stop_spell_charge_incantation() print(name, " (synced) stopped reviving") func _apply_burn_debuff(): # Apply burn debuff to player var was_already_burning = burn_debuff_timer > 0.0 if was_already_burning: # Already burning - refresh duration burn_debuff_timer = burn_debuff_duration burn_damage_timer = 0.0 # Reset damage timer print(name, " burn debuff refreshed") else: # Start burn debuff burn_debuff_timer = burn_debuff_duration burn_damage_timer = 0.0 print(name, " applied burn debuff (", burn_debuff_duration, " seconds)") # Create visual indicator _create_burn_debuff_visual() # Sync burn debuff to other clients (always sync, even on refresh) if multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree(): _sync_burn_debuff.rpc(true) # true = apply/refresh burn debuff func _create_burn_debuff_visual(): # Remove existing visual if any if burn_debuff_visual and is_instance_valid(burn_debuff_visual): burn_debuff_visual.queue_free() # Load burn debuff scene var burn_debuff_scene = load("res://scenes/debuff_burn.tscn") if burn_debuff_scene: burn_debuff_visual = burn_debuff_scene.instantiate() add_child(burn_debuff_visual) # Position on player (centered) burn_debuff_visual.position = Vector2(0, 0) burn_debuff_visual.z_index = 5 # Above player sprites burn_debuff_visual.visible = true print(name, " created burn debuff visual (scene), visible: ", burn_debuff_visual.visible, ", z_index: ", burn_debuff_visual.z_index) else: # Fallback: create simple sprite if scene doesn't exist var burn_texture = load("res://assets/gfx/fx/burn.png") if burn_texture: var sprite = Sprite2D.new() sprite.name = "BurnDebuffSprite" sprite.texture = burn_texture sprite.hframes = 4 sprite.vframes = 4 sprite.frame = 0 sprite.position = Vector2(0, 0) sprite.z_index = 5 # Above player sprites sprite.set_meta("burn_animation_frame", 0) sprite.set_meta("burn_animation_timer", 0.0) add_child(sprite) burn_debuff_visual = sprite func _remove_burn_debuff(): # Remove burn debuff visual if burn_debuff_visual and is_instance_valid(burn_debuff_visual): burn_debuff_visual.queue_free() burn_debuff_visual = null print(name, " burn debuff removed") # Sync burn debuff removal to other clients if multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree(): _sync_burn_debuff.rpc(false) # false = remove burn debuff @rpc("any_peer", "reliable") func _sync_burn_debuff(apply: bool): # Sync burn debuff visual to other clients # Note: Only the authority deals damage, clients just show the visual if not is_multiplayer_authority(): # Client receives burn debuff sync if apply: if burn_debuff_timer <= 0.0: # Only create visual if not already burning _create_burn_debuff_visual() print(name, " (client) created burn debuff visual from sync") # Set timer for visual duration (clients don't deal damage, just show visual) burn_debuff_timer = burn_debuff_duration burn_damage_timer = 0.0 else: # Remove visual if burn_debuff_visual and is_instance_valid(burn_debuff_visual): burn_debuff_visual.queue_free() burn_debuff_visual = null burn_debuff_timer = 0.0 burn_damage_timer = 0.0 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 (XY) and at HELD_POSITION_Z (above ground, immune to fallout) 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) # Keep held object at Z height so it's "above" ground (no fallout under it) if "position_z" in held_object: held_object.position_z = HELD_POSITION_Z # 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) # CRITICAL: Validate object is still valid right before sending RPC if obj_name != "" and is_instance_valid(held_object) and held_object.is_inside_tree(): _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 # StonePillar must NOT be pushed onto fallout - treat fallout as solid if not was_blocked and held_object.get("object_type") == "Pillar": var gw = get_tree().get_first_node_in_group("game_world") if gw and gw.has_method("_is_position_on_fallout_tile") and gw._is_position_on_fallout_tile(target_pos): was_blocked = true # 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) # CRITICAL: Validate object is still valid right before sending RPC if obj_name != "" and is_instance_valid(held_object) and held_object.is_inside_tree(): _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) # Push this player's full state (equipment, inventory, race, appearance) to a single peer. # Used when a new joiner connects so they receive the host's (and other existing players') state. func _push_full_state_to_peer(target_peer_id: int) -> void: if not is_multiplayer_authority() or not character_stats or not is_inside_tree(): return 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 var inventory_data = [] for item in character_stats.inventory: if item: inventory_data.append(item.save()) var appearance_data = { "skin": character_stats.skin, "hairstyle": character_stats.hairstyle, "hair_color": character_stats.hair_color.to_html(true), "facial_hair": character_stats.facial_hair, "facial_hair_color": character_stats.facial_hair_color.to_html(true), "eyes": character_stats.eyes, "eye_color": character_stats.eye_color.to_html(true), "eye_lashes": character_stats.eye_lashes, "eyelash_color": character_stats.eyelash_color.to_html(true), "add_on": character_stats.add_on } _sync_equipment.rpc_id(target_peer_id, equipment_data) _sync_inventory.rpc_id(target_peer_id, inventory_data) _sync_race_and_stats.rpc_id(target_peer_id, character_stats.race, character_stats.baseStats.duplicate()) _sync_appearance.rpc_id(target_peer_id, appearance_data) print(name, " pushed full state (equipment, inventory, race, appearance) to peer ", target_peer_id, " inventory size: ", inventory_data.size()) 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, charge_percentage: float = 1.0, projectile_type: String = "sword", arrow_name: String = ""): # Sync attack to other clients. Use projectile_type from authority (what was actually spawned), # not equipment - fixes no-arrows and post-unequip bow desync. if not is_inside_tree() or not is_instance_valid(self): return if not is_multiplayer_authority(): current_direction = direction as Direction # Set animation from projectile_type (authority knows what they shot) match projectile_type: "staff": _set_animation("STAFF") "arrow": _set_animation("BOW") "axe": _set_animation("AXE") "punch": _set_animation("PUNCH") _: _set_animation("SWORD") # Delay before spawning projectile await get_tree().create_timer(0.15).timeout if not is_inside_tree() or not is_instance_valid(self): return # Spawn only what authority actually spawned (ignore equipment) if projectile_type == "staff" and staff_projectile_scene: var equipped_weapon = character_stats.equipment.get("mainhand", null) if character_stats else null var projectile = staff_projectile_scene.instantiate() get_parent().add_child(projectile) projectile.setup(attack_dir, self, 20.0, equipped_weapon if equipped_weapon else null) var spawn_offset = attack_dir * 10.0 projectile.global_position = global_position + spawn_offset print(name, " performed synced staff attack!") elif projectile_type == "arrow" and attack_arrow_scene: var arrow_projectile = attack_arrow_scene.instantiate() if arrow_name != "": arrow_projectile.name = arrow_name get_parent().add_child(arrow_projectile) arrow_projectile.shoot(attack_dir, global_position, self, charge_percentage) print(name, " performed synced bow attack with arrow (charge: ", charge_percentage * 100, "%)") elif projectile_type == "axe" and attack_axe_swing_scene: var axe_swing = attack_axe_swing_scene.instantiate() get_parent().add_child(axe_swing) var axe_item = character_stats.equipment.get("mainhand", null) if character_stats else null axe_swing.setup(attack_dir, self, -1.0, axe_item) axe_swing.global_position = global_position print(name, " performed synced axe swing!") elif projectile_type == "punch" and attack_punch_scene: var punch = attack_punch_scene.instantiate() get_parent().add_child(punch) punch.setup(attack_dir, self, 3.0) punch.global_position = global_position + attack_dir * 12.0 print(name, " performed synced punch!") elif (projectile_type == "sword" or projectile_type == "") and sword_projectile_scene: var projectile = sword_projectile_scene.instantiate() get_parent().add_child(projectile) projectile.setup(attack_dir, self) var spawn_offset = attack_dir * 10.0 projectile.global_position = global_position + spawn_offset print(name, " performed synced attack!") @rpc("any_peer", "reliable") func _sync_bow_charge_start(): # Sync bow charge start to other clients if not is_multiplayer_authority(): is_charging_bow = true print(name, " (synced) started charging bow") @rpc("any_peer", "reliable") func _sync_bow_charge_end(): # Sync bow charge end to other clients if not is_multiplayer_authority(): is_charging_bow = false bow_charge_tint_pulse_time = 0.0 _clear_bow_charge_tint() print(name, " (synced) ended charging bow") @rpc("any_peer", "reliable") func _sync_shield(shielding: bool, block_dir: Vector2): # Sync shield up/down to other clients so host sees joiner's shield if is_multiplayer_authority(): return is_shielding = shielding was_shielding_last_frame = shielding if shielding and block_dir.length() > 0.01: shield_block_direction = block_dir.normalized() _update_shield_visibility() @rpc("any_peer", "reliable") func _sync_create_bomb_object(bomb_name: String, spawn_pos: Vector2): # Sync Dwarf's lifted bomb spawn to other clients so they see it when held if is_multiplayer_authority(): return var game_world = get_tree().get_first_node_in_group("game_world") if not game_world: return var entities_node = game_world.get_node_or_null("Entities") if not entities_node: return if entities_node.get_node_or_null(bomb_name): return # Already exists (e.g. duplicate RPC) var interactable_scene = load("res://scenes/interactable_object.tscn") as PackedScene if not interactable_scene: return var bomb_obj = interactable_scene.instantiate() bomb_obj.name = bomb_name bomb_obj.global_position = spawn_pos if multiplayer.has_multiplayer_peer(): bomb_obj.set_multiplayer_authority(get_multiplayer_authority()) entities_node.add_child(bomb_obj) bomb_obj.setup_bomb() print(name, " (synced) created bomb object ", bomb_name, " at ", spawn_pos) @rpc("any_peer", "reliable") func _sync_bomb_dropped(bomb_name: String, place_pos: Vector2): # Sync Dwarf drop: free lifted bomb on clients, spawn attack_bomb with fuse lit if not is_multiplayer_authority(): var game_world = get_tree().get_first_node_in_group("game_world") var entities_node = game_world.get_node_or_null("Entities") if game_world else null if entities_node and bomb_name.begins_with("BombObject_"): var lifted = entities_node.get_node_or_null(bomb_name) if lifted and is_instance_valid(lifted): lifted.queue_free() if not attack_bomb_scene: return var bomb = attack_bomb_scene.instantiate() bomb.name = "PlacedBomb_" + bomb_name get_parent().add_child(bomb) bomb.global_position = place_pos bomb.setup(place_pos, self, Vector2.ZERO, false) # not thrown, fuse lit if multiplayer.has_multiplayer_peer(): bomb.set_multiplayer_authority(get_multiplayer_authority()) print(name, " (synced) dropped bomb at ", place_pos) @rpc("any_peer", "reliable") func _sync_throw_bomb_from_offhand(bomb_id: String, bomb_pos: Vector2, throw_force: Vector2, is_thrown: bool): # Sync bomb throw/drop from offhand to other clients (Human/Elf) if not is_multiplayer_authority(): if not attack_bomb_scene: return var bomb = attack_bomb_scene.instantiate() bomb.name = bomb_id get_parent().add_child(bomb) bomb.global_position = bomb_pos bomb.setup(bomb_pos, self, throw_force, is_thrown) print(name, " (synced) " + ("threw" if is_thrown else "dropped") + " bomb at ", bomb_pos) @rpc("any_peer", "reliable") func _sync_place_bomb(bomb_id: String, target_pos: Vector2): # Sync bomb placement to other clients (Human/Elf) - DEPRECATED: Use _sync_throw_bomb_from_offhand instead if not is_multiplayer_authority(): if not attack_bomb_scene: return var bomb = attack_bomb_scene.instantiate() bomb.name = bomb_id get_parent().add_child(bomb) bomb.global_position = target_pos bomb.setup(target_pos, self, Vector2.ZERO, false) # false = not thrown print(name, " (synced) placed bomb at ", target_pos) @rpc("any_peer", "reliable") func _sync_throw_bomb(bomb_name: String, bomb_pos: Vector2, throw_force: Vector2): # Sync bomb throw to other clients; free lifted bomb (BombObject_*) if it exists if not is_multiplayer_authority(): var game_world = get_tree().get_first_node_in_group("game_world") var entities_node = game_world.get_node_or_null("Entities") if game_world else null if entities_node and bomb_name.begins_with("BombObject_"): var lifted = entities_node.get_node_or_null(bomb_name) if lifted and is_instance_valid(lifted): lifted.queue_free() if not attack_bomb_scene: return var bomb = attack_bomb_scene.instantiate() bomb.name = "ThrownBomb_" + bomb_name get_parent().add_child(bomb) bomb.global_position = bomb_pos bomb.setup(bomb_pos, self, throw_force, true) # true = is_thrown if bomb.has_node("Sprite2D"): bomb.get_node("Sprite2D").visible = true print(name, " (synced) threw bomb from ", bomb_pos) @rpc("any_peer", "reliable") func _sync_bomb_collected(bomb_name: String): # Another peer collected this bomb – remove our copy so it doesn't keep exploding # Collector already removed and added to inventory locally; we just free our instance var bombs = get_tree().get_nodes_in_group("attack_bomb") for b in bombs: if b.name == bomb_name and is_instance_valid(b): b.queue_free() return @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!") # Re-add to layer DIRECTLY when thrown (no delay); full mask 1|2|64 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(2, 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.collision_layer = 0 obj.collision_mask = 0 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.collision_layer = 0 obj.collision_mask = 0 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.collision_layer = 0 obj.collision_mask = 0 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) obj.set_collision_mask_value(7, true) # Re-enable wall collision! 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) obj.set_collision_mask_value(2, true) obj.set_collision_mask_value(7, true) # Re-enable wall collision! 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) obj.set_collision_mask_value(7, true) # Re-enable wall collision! # 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.set_collision_mask_value(2, true) obj.set_collision_mask_value(7, true) # Re-enable wall collision! 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 # Always place teleported player on ground (fixes joiner seeing host "in air" when host was mid-bounce on join) position_z = 0.0 velocity_z = 0.0 is_airborne = false spawn_landing = false # Clear spawn-fall state so we're not stuck "in air" spawn_landing_visible_shown = true # Skip fall; we're placed, so we're "shown" # Set flag to prevent position sync from overriding teleportation this frame teleported_this_frame = true # Always show teleported player (joiner must see self when placed in room) visible = true if is_local_player: if cone_light: cone_light.visible = true if point_light: point_light.visible = 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 is_being_held = false @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") _play_sfx_deny() 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) placed_obj.set_collision_mask_value(7, true) # Re-enable wall collision! 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.set_collision_mask_value(2, true) placed_obj.set_collision_mask_value(7, true) # Re-enable wall collision! 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 and collision; use HELD_POSITION_Z so we're "above" ground (immune to fallout) is_being_held = held if held: velocity = Vector2.ZERO is_airborne = false position_z = HELD_POSITION_Z velocity_z = 0.0 collision_layer = 0 collision_mask = 0 else: struggle_time = 0.0 struggle_direction = Vector2.ZERO being_held_by = null position_z = 0.0 collision_layer = 1 collision_mask = 1 | 2 | 64 # layer 1 players, 2 objects, 7 walls @rpc("any_peer", "reliable") func rpc_grabbed_by_enemy_hand(enemy_name: String) -> void: var en: Node = null var gw = get_tree().get_first_node_in_group("game_world") if gw: var entities = gw.get_node_or_null("Entities") if entities: en = entities.get_node_or_null(enemy_name) if not en: en = _find_node_by_name(gw, enemy_name) grabbed_by_enemy_hand = en if en and is_instance_valid(en) else null if grabbed_by_enemy_hand: # Apply initial knockback toward the hand var hand_pos = grabbed_by_enemy_hand.global_position var direction_to_hand = (hand_pos - global_position).normalized() # Apply knockback velocity toward the hand velocity = direction_to_hand * 200.0 # Moderate knockback speed is_knocked_back = true knockback_time = 0.0 # Play FALL animation when grabbed by enemy hand _set_animation("FALL") else: velocity = Vector2.ZERO @rpc("any_peer", "reliable") func rpc_released_from_enemy_hand() -> void: grabbed_by_enemy_hand = null enemy_hand_grab_knockback_time = 0.0 # Reset knockback timer func _find_node_by_name(node: Node, n: String) -> Node: if not node: return null if node.name == n: return node for c in node.get_children(): var found = _find_node_by_name(c, n) if found: return found return 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, is_burn_damage: bool = false, apply_burn_debuff: bool = false): # Only apply damage on the victim's own client (where they're authority) if is_multiplayer_authority(): take_damage(amount, attacker_position, is_burn_damage, apply_burn_debuff) func take_damage(amount: float, attacker_position: Vector2, is_burn_damage: bool = false, apply_burn_debuff: bool = false, no_knockback: bool = false): # Don't take damage if already dead if is_dead: return # Invulnerable during fallout sink (can't take damage from anything while falling) if fallout_state: return # Taking damage while webbed immediately frees you from the web if netted_by_web: var web = netted_by_web netted_by_web = null _web_net_show_netted_frame(false) if web and is_instance_valid(web) and web.has_method("cut_by_attack"): web.cut_by_attack(null) # Cancel bow charging when taking damage if is_charging_bow: is_charging_bow = false # Clear bow charge tint _clear_bow_charge_tint() # Sync bow charge end to other clients if multiplayer.has_multiplayer_peer(): _sync_bow_charge_end.rpc() # Check if spell charging should be cancelled (50% chance, using gameworld seed) if is_charging_spell: var should_cancel = false var world_node = get_tree().get_first_node_in_group("game_world") if world_node and "dungeon_seed" in world_node: # Use deterministic RNG based on gameworld seed and player position/time var rng_seed = world_node.dungeon_seed rng_seed += int(global_position.x) * 1000 + int(global_position.y) rng_seed += int(Time.get_ticks_msec() / 100.0) # Add time component for uniqueness var rng = RandomNumberGenerator.new() rng.seed = rng_seed should_cancel = rng.randf() < 0.5 # 50% chance else: # Fallback to regular random if no gameworld seed should_cancel = randf() < 0.5 if should_cancel: is_charging_spell = false spell_charge_hotkey_slot = "" spell_incantation_played = false _stop_spell_charge_particles() _stop_spell_charge_incantation() _clear_spell_charge_tint() # Return to IDLE animation _set_animation("IDLE") # Stop spell charging sounds if has_node("SfxSpellCharge"): $SfxSpellCharge.stop() if has_node("SfxSpellIncantation"): $SfxSpellIncantation.stop() # Sync spell charge end to other clients if multiplayer.has_multiplayer_peer(): _sync_spell_charge_end.rpc() print(name, " spell charging cancelled due to damage!") # 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 # Check for shield block (would have hit; enemy attack from blocked direction; no burn) if not is_burn_damage and is_shielding and shield_block_cooldown_timer <= 0.0: var dir_to_attacker = (attacker_position - global_position).normalized() if dir_to_attacker.length() < 0.01: dir_to_attacker = Vector2.RIGHT var block_dir = shield_block_direction.normalized() if shield_block_direction.length() > 0.01 else Vector2.DOWN var dot = block_dir.dot(dir_to_attacker) if dot > 0.5: # Lenient: attacker in front (~60° cone) # Blocked: no damage, small knockback, BLOCKED notification, cooldown shield_block_cooldown_timer = shield_block_cooldown_duration var direction_from_attacker = (global_position - attacker_position).normalized() velocity = direction_from_attacker * 90.0 # Small knockback is_knocked_back = true knockback_time = 0.0 if has_node("SfxBlockWithShield"): $SfxBlockWithShield.play() _show_damage_number(0.0, attacker_position, false, false, false, true) # is_blocked = true 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, false, true]) print(name, " BLOCKED attack from direction ", dir_to_attacker) return # 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) # Check if dead (use epsilon to handle floating point precision) if character_stats.hp <= 0.001: character_stats.hp = 0.0 # Ensure exactly 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") # Lock facing direction briefly so player can't change it while taking damage damage_direction_lock_timer = damage_direction_lock_duration # Only apply knockback if not burn damage and not suppressed (e.g. fallout respawn) if not is_burn_damage and not no_knockback: # 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) var face_direction = - direction_from_attacker current_direction = _get_direction_from_vector(face_direction) as Direction facing_direction_vector = face_direction.normalized() # Enable knockback state (prevents player control for a short time) is_knocked_back = true knockback_time = 0.0 # Apply burn debuff if requested if apply_burn_debuff: print(name, " applying burn debuff from take_damage") _apply_burn_debuff() # 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 - below 1 HP must always trigger death (trap, etc.) var health = character_stats.hp if character_stats else current_health if health < 1.0: if character_stats: character_stats.hp = 0.0 # Clamp to exactly 0 else: current_health = 0.0 # Clamp to exactly 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 if is_instance_valid(self) and is_dead: _die() func _die(): # Already processing death - prevent multiple concurrent death sequences if is_processing_death: return is_processing_death = true # Set IMMEDIATELY to block duplicates is_dead = true # Ensure flag is set velocity = Vector2.ZERO is_knocked_back = false damage_direction_lock_timer = 0.0 is_shielding = false was_shielding_last_frame = false _update_shield_visibility() # 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) released_obj.set_collision_mask_value(7, true) # Re-enable wall collision! 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) released_obj.set_collision_mask_value(2, true) released_obj.set_collision_mask_value(7, true) # Re-enable wall collision! 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]) pass # released on death else: is_lifting = false is_pushing = false # Show concussion status effect above head var status_anim = get_node_or_null("Sprite2DStatus/AnimationPlayerStatus") if status_anim and status_anim.has_animation("concussion"): status_anim.play("concussion") # Play death sound effect and spawn blood (preloaded blood_scene; add_child is cheaper than 12x call_deferred) var death_parent = get_parent() if sfx_die and death_parent: 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 var direction = Vector2.from_angle(angle) b.velocity = direction * speed b.velocityZ = initial_velocityZ death_parent.add_child(b) sfx_die.play() elif sfx_die: sfx_die.play() # Play DIE animation _set_animation("DIE") # Sync death over network (only authority sends). Replicas run _apply_death_visual only. 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 # Force holder to drop us NOW (before respawn wait) # Search for any player holding us (don't rely on being_held_by) 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: # 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 (layer 1, mask 1|2|64) collision_layer = 1 collision_mask = 1 | 2 | 64 # 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 is_being_held = false # Replicas: no wait loop; we get _sync_respawn from authority. if not is_multiplayer_authority(): return # Authority: server-authoritative respawn. Only server decides "all dead", then signals. # Avoids desync where only host or only joiner respawns (e.g. _sync_death not received). var gw = get_tree().get_first_node_in_group("game_world") if gw and gw.has_method("_register_player_died"): gw._register_player_died(self) var respawn_requested = [false] # ref so lambda can mutate if gw and gw.has_signal("respawn_all_ready"): var on_ready = func(): respawn_requested[0] = true gw.respawn_all_ready.connect(on_ready, CONNECT_ONE_SHOT) while not respawn_requested[0] and not was_revived: await get_tree().create_timer(0.2).timeout if was_revived: return _respawn() func _are_all_players_dead() -> bool: # Use PlayerManager.get_all_players() to avoid duplicates (e.g. same peer seen twice). var gw = get_tree().get_first_node_in_group("game_world") if not gw: return true var pm = gw.get_node_or_null("PlayerManager") if not pm or not pm.has_method("get_all_players"): # Fallback to group for p in get_tree().get_nodes_in_group("player"): if "is_dead" in p and not p.is_dead: return false return true for p in pm.get_all_players(): if is_instance_valid(p) and "is_dead" in p and not p.is_dead: return false return true func _spawn_landing_on_land(): if has_node("SfxFallDownLand"): $SfxFallDownLand.play() _set_animation("LAND") var status_anim = get_node_or_null("Sprite2DStatus/AnimationPlayerStatus") if status_anim and status_anim.has_animation("concussion"): status_anim.play("concussion") var gw = get_tree().get_first_node_in_group("game_world") if gw and gw.has_method("add_screenshake"): gw.add_screenshake(7.0, 0.28) if multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree(): _rpc_to_ready_peers("_sync_spawn_landed", [name]) get_tree().create_timer(SPAWN_LANDING_LAND_DURATION).timeout.connect(_spawn_landing_to_stand) @rpc("any_peer", "reliable") func _sync_spawn_bounced(_player_name: String): spawn_landing_bounced = true current_direction = Direction.RIGHT facing_direction_vector = Vector2.RIGHT _set_animation("LAND") if has_node("SfxFallDownLand"): $SfxFallDownLand.play() @rpc("any_peer", "reliable") func _sync_spawn_landed(_player_name: String): # Received on remote copies when authority lands from spawn fall spawn_landing_landed = true position_z = 0.0 velocity_z = 0.0 is_airborne = false if has_node("SfxFallDownLand"): $SfxFallDownLand.play() _set_animation("LAND") var status_anim = get_node_or_null("Sprite2DStatus/AnimationPlayerStatus") if status_anim and status_anim.has_animation("concussion"): status_anim.play("concussion") get_tree().create_timer(SPAWN_LANDING_LAND_DURATION).timeout.connect(_spawn_landing_to_stand) func _spawn_landing_to_stand(): if not is_instance_valid(self): return _set_animation("STAND") get_tree().create_timer(SPAWN_LANDING_STAND_DURATION).timeout.connect(_spawn_landing_stand_up) func _spawn_landing_stand_up(): if not is_instance_valid(self): return # Clear concussion status (was showing during LAND) var status_anim = get_node_or_null("Sprite2DStatus/AnimationPlayerStatus") if status_anim and status_anim.has_animation("idle"): status_anim.play("idle") # STAND's nextAnimation -> IDLE, so we're already IDLE or about to be spawn_landing = false # Only show cone light for local player (don't show other players' cone lights) if is_local_player and cone_light: cone_light.visible = true if point_light: point_light.visible = true # Joiners: ensure all other players are visible once we've finished falling down if is_local_player: for p in get_tree().get_nodes_in_group("player"): if p != self and is_instance_valid(p): p.visible = true # Start background music when player finishes standing (only on authority to avoid duplicates) if is_multiplayer_authority(): var game_world = get_tree().get_first_node_in_group("game_world") if game_world and game_world.has_method("_start_bg_music"): game_world._start_bg_music() func _respawn_from_fallout(): # Teleport to last safe tile, reset fallout state, then apply 1 HP damage via take_damage (no knockback) var gw = get_tree().get_first_node_in_group("game_world") if gw and gw.has_method("get_last_safe_position_for_player"): global_position = gw.get_last_safe_position_for_player(self) fallout_state = false fallout_scale_progress = 1.0 fallout_respawn_delay_timer = 0.0 scale = Vector2.ONE rotation = 0.0 velocity = Vector2.ZERO fallout_respawn_stun_timer = FALLOUT_RESPAWN_STUN_DURATION _set_animation("IDLE") # Apply damage via take_damage (shows damage number, sound, etc.) but with no knockback take_damage(FALLOUT_RESPAWN_HP_PENALTY, global_position, false, false, true) if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and can_send_rpcs and is_inside_tree(): _rpc_to_ready_peers("_sync_respawn_from_fallout", [global_position]) func _respawn(): print(name, " respawning!") was_revived = false # Get game_world reference (used multiple times in this function) var game_world = get_tree().get_first_node_in_group("game_world") # Hide GAME OVER screen and fade in game graphics when player respawns (only on authority) if is_multiplayer_authority(): if game_world and game_world.has_method("_hide_game_over"): game_world._hide_game_over() # 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) set_collision_mask_value(2, true) set_collision_mask_value(7, true) # Re-enable wall collision! # 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 damage_direction_lock_timer = 0.0 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_shield, sprite_shield_holding, sprite_weapon, shadow]: if sprite_layer: sprite_layer.modulate.a = 1.0 var status_anim = get_node_or_null("Sprite2DStatus/AnimationPlayerStatus") if status_anim and status_anim.has_animation("idle"): status_anim.play("idle") # Get respawn position - use spawn room (start room) for respawning var new_respawn_pos = respawn_point 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 # Clear concussion and play idle animation (server) if status_anim and status_anim.has_animation("idle"): status_anim.play("idle") _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 (layer 1, mask 1|2|64) set_collision_layer_value(1, true) set_collision_mask_value(1, true) set_collision_mask_value(2, true) set_collision_mask_value(7, true) # Re-enable wall collision! else: print(" ✗ held_object doesn't match self") else: print(" ✗ Holder not found or invalid") func _do_revive(corpse: Node): if not _is_player(corpse) or not "is_dead" in corpse or not corpse.is_dead: return var reviver_hp = character_stats.hp if character_stats else 1.0 if reviver_hp <= 1.0: return var half_hp = max(1, int(reviver_hp * 0.5)) if character_stats: character_stats.hp = max(1, character_stats.hp - half_hp) character_stats.character_changed.emit(character_stats) # Show -X HP on reviver (we "took" that much to revive) _show_revive_cost_number(half_hp) if multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree(): _rpc_to_ready_peers("_sync_revive_cost", [half_hp]) corpse._revive_from_player.rpc_id(corpse.get_multiplayer_authority(), half_hp) else: corpse._revive_from_player(half_hp) @rpc("any_peer", "reliable") func _revive_from_player(hp_amount: int): if not is_dead: return was_revived = true is_dead = false is_processing_death = false if character_stats: character_stats.hp = float(hp_amount) else: current_health = float(hp_amount) for sprite_layer in [sprite_body, sprite_boots, sprite_armour, sprite_facial_hair, sprite_hair, sprite_eyes, sprite_eyelashes, sprite_addons, sprite_headgear, sprite_shield, sprite_shield_holding, sprite_weapon, shadow]: if sprite_layer: sprite_layer.modulate.a = 1.0 var status_anim = get_node_or_null("Sprite2DStatus/AnimationPlayerStatus") if status_anim and status_anim.has_animation("idle"): status_anim.play("idle") _set_animation("IDLE") # Same healing effect as Tome of Healing (green frames, pulse, +X HP) _spawn_heal_effect_and_text(self, hp_amount, false, false) # CRITICAL: Unregister from dead_players dictionary so game knows we're alive var game_world = get_tree().get_first_node_in_group("game_world") if game_world and game_world.has_method("_unregister_player_died"): game_world._unregister_player_died(self) # Clear concussion on all clients (authority already did above; broadcast for others) if multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree(): _rpc_to_ready_peers("_sync_revived_clear_concussion", [name]) @rpc("any_peer", "reliable") func _sync_revived_clear_concussion(_player_name: String): # Received on each peer's copy of the revived player; sync revived state so game realizes we're alive. is_dead = false is_processing_death = false for layer in [sprite_body, sprite_boots, sprite_armour, sprite_facial_hair, sprite_hair, sprite_eyes, sprite_eyelashes, sprite_addons, sprite_headgear, sprite_shield, sprite_shield_holding, sprite_weapon, shadow]: if layer: layer.modulate.a = 1.0 var status_anim = get_node_or_null("Sprite2DStatus/AnimationPlayerStatus") if status_anim and status_anim.has_animation("idle"): status_anim.play("idle") _set_animation("IDLE") @rpc("any_peer", "reliable") func _revive_from_heal(hp_amount: int): if not is_dead: return was_revived = true is_dead = false is_processing_death = false if character_stats: character_stats.hp = float(hp_amount) else: current_health = float(hp_amount) for sprite_layer in [sprite_body, sprite_boots, sprite_armour, sprite_facial_hair, sprite_hair, sprite_eyes, sprite_eyelashes, sprite_addons, sprite_headgear, sprite_shield, sprite_shield_holding, sprite_weapon, shadow]: if sprite_layer: sprite_layer.modulate.a = 1.0 var status_anim = get_node_or_null("Sprite2DStatus/AnimationPlayerStatus") if status_anim and status_anim.has_animation("idle"): status_anim.play("idle") _set_animation("IDLE") _spawn_heal_effect_and_text(self, hp_amount, false, false) # CRITICAL: Unregister from dead_players dictionary so game knows we're alive var game_world = get_tree().get_first_node_in_group("game_world") if game_world and game_world.has_method("_unregister_player_died"): game_world._unregister_player_died(self) if multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree(): _rpc_to_ready_peers("_sync_revived_clear_concussion", [name]) func _apply_death_visual(): # Replicas only: set death state + concussion + DIE anim. No coroutine — ensures other peer always sees concussion. is_processing_death = true is_dead = true velocity = Vector2.ZERO is_knocked_back = false is_shielding = false was_shielding_last_frame = false _update_shield_visibility() var status_anim = get_node_or_null("Sprite2DStatus/AnimationPlayerStatus") if status_anim and status_anim.has_animation("concussion"): status_anim.play("concussion") _set_animation("DIE") @rpc("any_peer", "reliable") func _sync_death(): # Replicas: apply death visuals only (concussion + DIE). No coroutine — ensures other peer always sees concussion. if not is_multiplayer_authority(): _apply_death_visual() return @rpc("any_peer", "reliable") func _sync_fallout_start(tile_center_pos: Vector2): # Other clients: start fallout sink visuals; ignore if this player is being held (immune to fallout) if not is_multiplayer_authority(): if is_being_held: return global_position = tile_center_pos fallout_state = true fallout_scale_progress = 1.0 fallout_respawn_delay_timer = 0.0 velocity = Vector2.ZERO current_direction = Direction.DOWN facing_direction_vector = Vector2.DOWN _set_animation("FALL") @rpc("any_peer", "reliable") func _sync_respawn_from_fallout(safe_pos: Vector2): if not is_multiplayer_authority(): global_position = safe_pos fallout_state = false fallout_scale_progress = 1.0 fallout_respawn_delay_timer = 0.0 scale = Vector2.ONE rotation = 0.0 velocity = Vector2.ZERO fallout_respawn_stun_timer = FALLOUT_RESPAWN_STUN_DURATION _set_animation("IDLE") @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 (layer 1, mask 1|2|64) set_collision_layer_value(1, true) set_collision_mask_value(1, true) set_collision_mask_value(2, true) set_collision_mask_value(7, true) # Re-enable wall collision! # 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 damage_direction_lock_timer = 0.0 position_z = 0.0 velocity_z = 0.0 # Just teleport and reset on clients (AFTER release is processed) global_position = spawn_pos respawn_point = spawn_pos # 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_shield, sprite_shield_holding, sprite_weapon, shadow]: if sprite_layer: sprite_layer.modulate.a = 1.0 # Clear concussion on clients (AnimationPlayerStatus -> idle) var status_anim_node = get_node_or_null("Sprite2DStatus/AnimationPlayerStatus") if status_anim_node and status_anim_node.has_animation("idle"): status_anim_node.play("idle") # Set animation to IDLE _set_animation("IDLE") # Hide GAME OVER screen on clients too var game_world = get_tree().get_first_node_in_group("game_world") if game_world and game_world.has_method("_hide_game_over"): game_world._hide_game_over() 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, " xp=", character_stats.xp) _sync_stats_update.rpc_id(the_peer_id, character_stats.kills, character_stats.coin, character_stats.xp) 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, xp_amount: float = -1.0): # Client receives stats update from server (for kills, coins, and XP) # 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 if xp_amount >= 0.0: # Only update XP if provided (backwards compatible) # Calculate the difference and add it (to trigger level up if needed) var xp_diff = xp_amount - character_stats.xp if xp_diff > 0.0: character_stats.add_xp(xp_diff) else: character_stats.xp = xp_amount var xp_display = str(xp_amount) if xp_amount >= 0.0 else "unchanged" print(name, " stats synced from server: kills=", kills_count, " coins=", coins_count, " xp=", xp_display) @rpc("any_peer", "reliable") func _sync_race_and_stats(race: String, base_stats: Dictionary): # Client receives race and base stats from authority player # Accept initial sync (when race is empty), but reject changes if we're authority print("Player ", name, " RECEIVED _sync_race_and_stats: race='", race, "' (peer_id=", peer_id, " is_authority=", is_multiplayer_authority(), " current_race=", character_stats.race if character_stats else "null", ")") # CRITICAL: If we're the authority for this player, we should NOT process race syncs # The authority player manages its own appearance and only syncs to others if is_multiplayer_authority(): # Only allow initial sync if race is empty (first time setup) if character_stats and character_stats.race != "": # We're authority and already have a race set - ignore updates print(name, " _sync_race_and_stats REJECTED (we're authority and already have race)") return if character_stats: character_stats.race = race character_stats.baseStats = base_stats print("Player ", name, " APPLIED _sync_race_and_stats: this node now has race='", race, "' (server/other peer's view of this player)") # For remote players, we don't re-initialize appearance here # Instead, we wait for _sync_appearance RPC which contains the full appearance data # This ensures remote players have the exact same appearance as authority # Update race-specific appearance parts (ears) var skin_index = 0 if character_stats.skin != "": var regex = RegEx.new() regex.compile("Human(\\d+)\\.png") var result = regex.search(character_stats.skin) if result: skin_index = int(result.get_string(1)) - 1 match race: "Elf": var elf_ear_style = skin_index + 1 character_stats.setEars(elf_ear_style) # Give Elf starting bow and arrows to remote players ONLY when slots are null (initial sync) # Never overwrite existing equipment (e.g. shield, picked-up items) - preserves loadout across level transitions if not is_multiplayer_authority(): var mainhand_empty = character_stats.equipment["mainhand"] == null var offhand_empty = character_stats.equipment["offhand"] == null if mainhand_empty and offhand_empty: var starting_bow = ItemDatabase.create_item("short_bow") var starting_arrows = ItemDatabase.create_item("arrow") if starting_bow and starting_arrows: starting_arrows.quantity = 3 character_stats.equipment["mainhand"] = starting_bow character_stats.equipment["offhand"] = starting_arrows _apply_appearance_to_sprites() print("Elf player ", name, " (remote) received short bow and 3 arrows via race sync") "Dwarf": character_stats.setEars(0) # Give Dwarf starting bombs + debug weapons to remote players ONLY when offhand is null (initial sync) # Never overwrite existing equipment (e.g. shield, tome) - preserves loadout across level transitions if not is_multiplayer_authority(): if character_stats.equipment["offhand"] == null: var starting_bomb = ItemDatabase.create_item("bomb") if starting_bomb: starting_bomb.quantity = 5 # Give Dwarf 5 bombs to start character_stats.equipment["offhand"] = starting_bomb var debug_axe = ItemDatabase.create_item("axe") if debug_axe: character_stats.add_item(debug_axe) var debug_dagger = ItemDatabase.create_item("knife") if debug_dagger: character_stats.add_item(debug_dagger) var debug_sword = ItemDatabase.create_item("short_sword") if debug_sword: character_stats.add_item(debug_sword) _apply_appearance_to_sprites() print("Dwarf player ", name, " (remote) received 5 bombs and debug axe/dagger/sword via race sync") "Human": character_stats.setEars(0) # Give Human (Wizard) starting tomes and hat to remote players ONLY when headgear empty (initial sync) if not is_multiplayer_authority(): var headgear_empty = character_stats.equipment["headgear"] == null if headgear_empty: var starting_tome = ItemDatabase.create_item("tome_of_flames") if starting_tome: character_stats.add_item(starting_tome) var tome_healing = ItemDatabase.create_item("tome_of_healing") if tome_healing: character_stats.add_item(tome_healing) var tome_frostspike = ItemDatabase.create_item("tome_of_frostspike") if tome_frostspike: character_stats.add_item(tome_frostspike) var starting_hat = ItemDatabase.create_item("hat") if starting_hat: character_stats.equipment["headgear"] = starting_hat _apply_appearance_to_sprites() print("Human player ", name, " (remote) received Tome of Flames, Tome of Healing, Tome of Frostspike, and Hat via race sync") _: character_stats.setEars(0) print(name, " race and stats synced: race=", race, " STR=", base_stats.str, " PER=", base_stats.per) @rpc("any_peer", "reliable") func _sync_appearance(appearance_data: Dictionary): # Client receives full appearance data from authority player # Apply it directly without any randomization if is_multiplayer_authority(): # We're authority - ignore appearance syncs for ourselves return if not character_stats: return # Apply the synced appearance data directly if appearance_data.has("skin"): character_stats.skin = appearance_data["skin"] if appearance_data.has("hairstyle"): character_stats.hairstyle = appearance_data["hairstyle"] if appearance_data.has("hair_color"): character_stats.hair_color = Color(appearance_data["hair_color"]) if appearance_data.has("facial_hair"): character_stats.facial_hair = appearance_data["facial_hair"] if appearance_data.has("facial_hair_color"): character_stats.facial_hair_color = Color(appearance_data["facial_hair_color"]) if appearance_data.has("eyes"): character_stats.eyes = appearance_data["eyes"] if appearance_data.has("eye_color"): character_stats.eye_color = Color(appearance_data["eye_color"]) if appearance_data.has("eye_lashes"): character_stats.eye_lashes = appearance_data["eye_lashes"] if appearance_data.has("eyelash_color"): character_stats.eyelash_color = Color(appearance_data["eyelash_color"]) if appearance_data.has("add_on"): character_stats.add_on = appearance_data["add_on"] # Apply appearance to sprites _apply_appearance_to_sprites() print(name, " appearance synced from authority") @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 if not character_stats: return # CRITICAL: Don't accept equipment syncs for our own player AFTER initial setup # Accept initial sync (when all equipment is null), but reject changes if we're authority var has_any_equipment = false for slot in character_stats.equipment.values(): if slot != null: has_any_equipment = true break if is_multiplayer_authority() and has_any_equipment: # We're authority and already have equipment - ignore updates 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 # If we received equipment but don't have race yet, request race sync # This handles the case where equipment sync arrives before race sync if character_stats.race == "" and not is_multiplayer_authority(): # Request race sync from authority (they should send it, but if not, this ensures it) # Actually, race should come via _sync_race_and_stats, so just wait for it pass # 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 # Accept initial sync (when inventory is empty), but reject changes if we're authority if is_multiplayer_authority() and character_stats and character_stats.inventory.size() > 0: # We're authority and already have items - ignore updates return if not character_stats: return # Clear and rebuild inventory from server data (only for OTHER players or initial sync) 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: ", character_stats.inventory.size(), " items") @rpc("any_peer", "reliable") func _apply_inventory_and_equipment_from_server(inventory_data: Array, equipment_data: Dictionary): # Joiner receives inventory+equipment push from server after loot pickup (or other server-driven change). # Always apply – no authority rejection. Used only when server adds items to a remote player. if multiplayer.is_server(): return if not character_stats: return character_stats.inventory.clear() for item_data in inventory_data: if item_data != null: character_stats.inventory.append(Item.new(item_data)) for slot_name in character_stats.equipment.keys(): var item_data = equipment_data.get(slot_name, null) character_stats.equipment[slot_name] = Item.new(item_data) if item_data != null else null _apply_appearance_to_sprites() character_stats.character_changed.emit(character_stats) print(name, " inventory+equipment applied from server: ", character_stats.inventory.size(), " items") func heal(amount: float, allow_overheal: bool = false): if is_dead: return if character_stats: character_stats.heal(amount, allow_overheal) print(name, " healed for ", amount, " HP! Health: ", character_stats.hp, "/", character_stats.maxhp) else: # Fallback for legacy var new_hp = current_health + amount current_health = max(0.0, new_hp) if allow_overheal else clamp(new_hp, 0.0, 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, is_blocked: bool = false): # Show damage number (red, using dmg_numbers.png font) above player # Show even if amount is 0 for MISS/DODGED/BLOCKED 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 elif is_blocked: damage_label.label = "BLOCKED" damage_label.color = Color(0.4, 0.65, 1.0) # Light blue else: damage_label.label = str(int(amount)) damage_label.color = Color(1.0, 0.6, 0.2) if is_crit else Color(1.0, 0.35, 0.35) # Bright orange / bright red damage_label.z_index = 5 # 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 _show_revive_cost_number(amount: int): 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 damage_label.label = "-" + str(amount) + " HP" damage_label.color = Color(1.0, 0.35, 0.35) damage_label.z_index = 5 damage_label.direction = Vector2(0, -1) var game_world = get_tree().get_first_node_in_group("game_world") var parent = game_world.get_node_or_null("Entities") if game_world else get_parent() if parent: parent.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 _show_not_enough_mana_text(): """Show 'NOT ENOUGH MANA' in damage_number font above player (local player only).""" var damage_number_scene = preload("res://scenes/damage_number.tscn") if not damage_number_scene: return var lbl = damage_number_scene.instantiate() if not lbl: return lbl.label = "NOT ENOUGH MANA" lbl.color = Color(1.0, 0.5, 0.2) # Orange/red color lbl.z_index = 5 lbl.direction = Vector2(0, -1) var game_world = get_tree().get_first_node_in_group("game_world") var parent = game_world.get_node_or_null("Entities") if game_world else get_tree().current_scene if parent: parent.add_child(lbl) lbl.global_position = global_position + Vector2(0, -20) func show_floating_status(text: String, col: Color = Color.WHITE) -> void: """Show a damage-number-style floating text above player (e.g. 'Encumbered!').""" var damage_number_scene = preload("res://scenes/damage_number.tscn") if not damage_number_scene: return var lbl = damage_number_scene.instantiate() if not lbl: return lbl.label = text lbl.color = col lbl.z_index = 5 lbl.direction = Vector2(0, -1) var game_world = get_tree().get_first_node_in_group("game_world") var parent = game_world.get_node_or_null("Entities") if game_world else get_tree().current_scene if parent: parent.add_child(lbl) lbl.global_position = global_position + Vector2(0, -20) @rpc("any_peer", "reliable") func _sync_revive_cost(amount: int): if is_multiplayer_authority(): return _show_revive_cost_number(amount) 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 # Play level-up fanfare locally only when this player (you) gained the level if is_multiplayer_authority() and has_node("SfxLevelUp"): $SfxLevelUp.play() # 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 +1!" prominently (gold, larger, longer on screen) var level_up_text = damage_number_scene.instantiate() if level_up_text: level_up_text.label = "LEVEL UP +1!" level_up_text.color = Color(1.0, 0.88, 0.2) # Gold level_up_text.direction = Vector2(0, -1) level_up_text.rise_distance = 48.0 level_up_text.fade_delay = 1.4 level_up_text.fade_duration = 0.6 level_up_text.add_theme_font_size_override("font_size", 20) 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 rpc_apply_corpse_knockback(dir_x: float, dir_y: float, force: float): if not is_multiplayer_authority(): return if not is_dead: return var d = Vector2(dir_x, dir_y) if d.length_squared() < 0.01: return d = d.normalized() velocity = d * force is_knocked_back = true knockback_time = 0.0 @rpc("any_peer", "reliable") func _on_attack_blocked_by_enemy(blocker_position: Vector2): # Called when this player's attack was blocked by an enemy (e.g. humanoid with shield). Show BLOCKED and small knockback. var dir_away = (global_position - blocker_position).normalized() if dir_away.length() < 0.01: dir_away = Vector2.RIGHT velocity = dir_away * 75.0 is_knocked_back = true knockback_time = 0.0 _show_damage_number(0.0, blocker_position, false, false, false, true) @rpc("any_peer", "reliable") func _sync_damage(_amount: float, attacker_position: Vector2, is_crit: bool = false, is_miss: bool = false, is_dodged: bool = false, is_blocked: 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, false) return # If blocked, show BLOCKED, small knockback, block sound; no damage effects if is_blocked: var block_knock_dir = (global_position - attacker_position).normalized() velocity = block_knock_dir * 90.0 is_knocked_back = true knockback_time = 0.0 if has_node("SfxBlockWithShield"): $SfxBlockWithShield.play() _show_damage_number(0.0, attacker_position, false, 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 var face_direction = - direction_from_attacker current_direction = _get_direction_from_vector(face_direction) as Direction facing_direction_vector = face_direction.normalized() # 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, false) func _setup_alert_indicator(): # Create alert indicator (exclamation mark) similar to enemy humanoids # Check if it already exists (in case it was added to scene) alert_indicator = get_node_or_null("AlertIndicator") if not alert_indicator: # Create it programmatically alert_indicator = Sprite2D.new() alert_indicator.name = "AlertIndicator" var exclamation_texture = load("res://assets/gfx/enemies/_utropstecken.png") if exclamation_texture: alert_indicator.texture = exclamation_texture alert_indicator.hframes = 3 alert_indicator.visible = false alert_indicator.z_index = 100 alert_indicator.position = Vector2(0, -20) add_child(alert_indicator) else: push_error("Player: Could not load exclamation mark texture!") alert_indicator = null else: # Ensure it's set up correctly alert_indicator.visible = false alert_indicator.z_index = 100 if alert_indicator.position == Vector2.ZERO: alert_indicator.position = Vector2(0, -20) func _show_alert_indicator(): # Show exclamation mark above player head if alert_indicator: alert_indicator.visible = true alert_indicator.frame = 0 # Hide after 1.5 seconds get_tree().create_timer(1.5).timeout.connect(func(): if is_instance_valid(self) and alert_indicator: alert_indicator.visible = false ) func _on_trap_detected(): # Called when player detects a trap if not is_multiplayer_authority(): return # Only authority triggers # Show exclamation mark _show_alert_indicator() # Play sound locally if sfx_look_out: sfx_look_out.play() # Sync to all clients (so all players can hear it) if multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree(): _rpc_to_ready_peers("_sync_trap_detected_alert", []) func _on_exit_found(): # Called when player finds exit stairs if not is_multiplayer_authority(): return # Only authority triggers # Only show notification once per level if has_seen_exit_this_level: return # Mark as seen for this level has_seen_exit_this_level = true # Show exclamation mark _show_alert_indicator() # Play sound locally if sfx_ahaa: sfx_ahaa.play() # Sync to all clients (so all players can hear it) if multiplayer.has_multiplayer_peer() and can_send_rpcs and is_inside_tree(): _rpc_to_ready_peers("_sync_exit_found_alert", []) @rpc("any_peer", "reliable") func _sync_trap_detected_alert(): # Sync trap detection alert to all clients if is_multiplayer_authority(): return # Authority already handled it locally # Show exclamation mark _show_alert_indicator() # Play sound if sfx_look_out: sfx_look_out.play() @rpc("any_peer", "reliable") func _on_cracked_floor_detected(): # Called when this player detects a cracked floor (perception roll success). Only the detecting player plays SfxLookOut and sees alert. if not is_multiplayer_authority(): return _show_alert_indicator() if sfx_look_out: sfx_look_out.play() @rpc("any_peer", "reliable") func _on_secret_chest_detected(): # Called when this player detects a hidden chest (perception roll success). Only the detecting player plays SfxAhaa + SfxSecretFound and sees alert. if not is_multiplayer_authority(): return _show_alert_indicator() if sfx_ahaa: sfx_ahaa.play() if sfx_secret_found: sfx_secret_found.play() @rpc("any_peer", "reliable") func _sync_exit_found_alert(): # Sync exit found alert to all clients if is_multiplayer_authority(): return # Authority already handled it locally # Show exclamation mark _show_alert_indicator() # Play sound if sfx_ahaa: sfx_ahaa.play() 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)