8018 lines
313 KiB
GDScript3
8018 lines
313 KiB
GDScript3
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)
|