Files
DungeonsOfKharadum/src/scripts/player.gd
2026-02-08 16:48:21 +01:00

8018 lines
313 KiB
GDScript3
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)