extends CharacterBody2D

# Base Enemy Class - All enemies inherit from this

@export var max_health: float = 50.0
@export var move_speed: float = 80.0
@export var damage: float = 10.0 # Legacy - use character_stats.damage instead
@export var attack_cooldown: float = 1.0
@export var exp_reward: float = 10.0 # EXP granted when this enemy is defeated

var current_health: float = 50.0
var character_stats: CharacterStats # RPG stats system (same as players)
var is_dead: bool = false
@export var is_undead: bool = false # Zombies etc.; healing spell damages them
var target_player: Node = null
var attack_timer: float = 0.0
var killer_player: Node = null # Track who killed this enemy (for kill credit)

# Knockback
var is_knocked_back: bool = false
var knockback_time: float = 0.0
var knockback_duration: float = 0.3
var knockback_force: float = 125.0 # Scaled down for 1x scale

# Burn debuff
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

# Z-axis for flying enemies
var position_z: float = 0.0
var velocity_z: float = 0.0

# Animation
enum Direction {DOWN = 0, LEFT = 1, RIGHT = 2, UP = 3, DOWN_LEFT = 4, DOWN_RIGHT = 5, UP_LEFT = 6, UP_RIGHT = 7}
var current_direction: Direction = Direction.DOWN
var anim_frame: int = 0
var anim_time: float = 0.0
var anim_speed: float = 0.15 # Seconds per frame

@onready var sprite = get_node_or_null("Sprite2D")
@onready var shadow = get_node_or_null("Shadow")
@onready var collision_shape = get_node_or_null("CollisionShape2D")

func _ready():
	# Initialize CharacterStats for RPG system
	_initialize_character_stats()
	
	current_health = max_health
	add_to_group("enemy")
	
	# Setup shadow (if it exists - some enemies like humanoids don't have a Shadow node)
	if shadow:
		shadow.modulate = Color(0, 0, 0, 0.5)
		shadow.z_index = -1
	
	# Top-down physics
	motion_mode = MOTION_MODE_FLOATING
	
	# CRITICAL: Set collision mask to include interactable objects (layer 2) and walls (layer 7)
	# This allows enemies to collide with interactable objects so they can path around them
	# Walls are on layer 7 (bit 6 = 64), not layer 4!
	collision_mask = 1 | 2 | 64 # Collide with players (layer 1), objects (layer 2), and walls (layer 7 = bit 6 = 64)

# Initialize CharacterStats for this enemy
# Override in subclasses to set specific baseStats
func _initialize_character_stats():
	character_stats = CharacterStats.new()
	character_stats.character_type = "enemy"
	character_stats.character_name = name
	# Default stats - override in subclasses
	character_stats.baseStats.str = 10
	character_stats.baseStats.dex = 10
	character_stats.baseStats.int = 10
	character_stats.baseStats.end = 10
	character_stats.baseStats.wis = 10
	character_stats.baseStats.cha = 10
	character_stats.baseStats.lck = 10
	# Initialize hp and mp
	character_stats.hp = character_stats.maxhp
	character_stats.mp = character_stats.maxmp
	# Sync max_health and current_health from character_stats (for backwards compatibility)
	max_health = character_stats.maxhp
	current_health = character_stats.hp

func _physics_process(delta):
	if is_dead:
		# Even when dead, allow knockback to continue briefly
		if is_knocked_back:
			knockback_time += delta
			if knockback_time >= knockback_duration:
				is_knocked_back = false
				knockback_time = 0.0
				velocity = Vector2.ZERO
			else:
				# Apply friction to slow down knockback
				velocity = velocity.lerp(Vector2.ZERO, delta * 8.0)
				move_and_slide()
		return
	
	# Only server (authority) runs AI and physics
	if multiplayer.has_multiplayer_peer() and not is_multiplayer_authority():
		# Clients only interpolate position (handled by _sync_position)
		# But still update burn visual animation on clients
		if burn_debuff_visual and is_instance_valid(burn_debuff_visual):
			if burn_debuff_visual is Sprite2D:
				var burn_sprite = burn_debuff_visual as Sprite2D
				var anim_timer = burn_sprite.get_meta("burn_animation_timer", 0.0)
				anim_timer += delta
				if anim_timer >= 0.1: # ~10 FPS
					anim_timer = 0.0
					var frame = burn_sprite.get_meta("burn_animation_frame", 0)
					frame = (frame + 1) % 16
					burn_sprite.frame = frame
					burn_sprite.set_meta("burn_animation_frame", frame)
				burn_sprite.set_meta("burn_animation_timer", anim_timer)
		return
	
	# Update attack timer
	if attack_timer > 0:
		attack_timer -= delta
	
	# Handle knockback
	if is_knocked_back:
		knockback_time += delta
		if knockback_time >= knockback_duration:
			is_knocked_back = false
			knockback_time = 0.0
			velocity = Vector2.ZERO
		else:
			# Apply friction to slow down knockback
			velocity = velocity.lerp(Vector2.ZERO, delta * 8.0)
	
	# Enemy AI (override in subclasses) - only if not knocked back
	if not is_knocked_back:
		_ai_behavior(delta)
	
	# Move
	move_and_slide()
	
	# Check collisions with players
	_check_player_collision()
	
	# Check collisions with interactable objects
	_check_interactable_object_collision()
	
	# Update burn debuff
	if burn_debuff_timer > 0.0:
		burn_debuff_timer -= delta
		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)
				current_health = character_stats.hp
				if character_stats.hp <= 0:
					character_stats.no_health.emit()
				var actual_damage = old_hp - character_stats.hp
				LogManager.log(str(name) + " takes " + str(actual_damage) + " burn damage! Health: " + str(current_health) + "/" + str(character_stats.maxhp), LogManager.CATEGORY_ENEMY)
				# Show damage number for burn damage
				# Use a position slightly offset from global_position to ensure proper direction calculation
				# (if from_position equals global_position, direction becomes Vector2.ZERO which causes issues)
				var burn_damage_source_pos = global_position + Vector2(randf_range(-10, 10), -20) # Slight random offset above enemy
				_show_damage_number(actual_damage, burn_damage_source_pos, false, false, false) # Show burn damage number
				# Sync burn damage visual to clients
				if multiplayer.has_multiplayer_peer() and is_inside_tree():
					var enemy_name = name
					var enemy_index = get_meta("enemy_index") if has_meta("enemy_index") else -1
					var game_world = get_tree().get_first_node_in_group("game_world")
					if game_world and game_world.has_method("_sync_enemy_damage_visual"):
						game_world._rpc_to_ready_peers("_sync_enemy_damage_visual", [enemy_name, enemy_index, actual_damage, global_position, false])
		
		# Animate burn visual if it's a sprite (only on authority/server)
		if is_multiplayer_authority() and burn_debuff_visual and is_instance_valid(burn_debuff_visual):
			if burn_debuff_visual is Sprite2D:
				var burn_sprite = burn_debuff_visual as Sprite2D
				var anim_timer = burn_sprite.get_meta("burn_animation_timer", 0.0)
				anim_timer += delta
				if anim_timer >= 0.1: # ~10 FPS
					anim_timer = 0.0
					var frame = burn_sprite.get_meta("burn_animation_frame", 0)
					frame = (frame + 1) % 16
					burn_sprite.frame = frame
					burn_sprite.set_meta("burn_animation_frame", frame)
				burn_sprite.set_meta("burn_animation_timer", anim_timer)
		
		# Remove burn debuff when timer expires
		if burn_debuff_timer <= 0.0:
			burn_debuff_timer = 0.0
			burn_damage_timer = 0.0
			_remove_burn_debuff()
	
	# Sync position and animation to clients (only server sends)
	if multiplayer.has_multiplayer_peer() and is_multiplayer_authority():
		# Get state value if enemy has a state variable (for bats/slimes)
		var state_val = -1
		if "state" in self:
			state_val = get("state") as int
		# Only send RPC if we're in the scene tree
		if is_inside_tree():
			# Get enemy name/index for identification
			var enemy_name = name
			var enemy_index = get_meta("enemy_index") if has_meta("enemy_index") else -1
			
			# Use game_world to send RPC instead of rpc() on node instance
			# This avoids node path resolution issues when clients haven't spawned yet
			var game_world = get_tree().get_first_node_in_group("game_world")
			if game_world and game_world.has_method("_sync_enemy_position"):
				# Send via game_world using enemy name/index and position for identification
				game_world._rpc_to_ready_peers("_sync_enemy_position", [enemy_name, enemy_index, position, velocity, position_z, current_direction, anim_frame, "", 0, state_val])
			# Removed fallback rpc() call - it causes node path resolution errors
			# If game_world is not available, skip sync (will sync next frame)

func _ai_behavior(_delta):
	# Override in subclasses
	pass

func _check_player_collision():
	# Check if touching a player to deal damage
	for i in get_slide_collision_count():
		var collision = get_slide_collision(i)
		var collider = collision.get_collider()
		
		if collider and collider.is_in_group("player"):
			_attack_player(collider)

func _check_interactable_object_collision():
	# Check collisions with interactable objects and handle pathfinding around them
	var blocked_objects = []
	
	for i in get_slide_collision_count():
		var collision = get_slide_collision(i)
		var collider = collision.get_collider()
		
		if collider and collider.is_in_group("interactable_object"):
			var obj = collider
			
			# CRITICAL: Enemies cannot move objects that cannot be lifted
			# If object is not liftable, enemy should try to path around it
			if obj.has_method("can_be_lifted") and not obj.can_be_lifted():
				# Object cannot be lifted - store for pathfinding
				blocked_objects.append({"object": obj, "collision": collision})
			# If object is liftable but not currently being held, we can still try to push it
			# but enemies don't actively push liftable objects (only players do)
			elif obj.has_method("is_being_held") and obj.is_being_held():
				# Object is being held by someone - treat as obstacle
				blocked_objects.append({"object": obj, "collision": collision})
	
	# Handle pathfinding around blocked objects
	if blocked_objects.size() > 0 and not is_knocked_back:
		var collision_normal = blocked_objects[0].collision.get_normal()
		var _obj_pos = blocked_objects[0].object.global_position
		
		# Try to path around the object by moving perpendicular to collision normal
		# This creates a side-stepping behavior to go around obstacles
		var perpendicular = Vector2(-collision_normal.y, collision_normal.x) # Rotate 90 degrees
		
		# Choose perpendicular direction that moves toward target (if we have one)
		if target_player and is_instance_valid(target_player):
			var to_target = (target_player.global_position - global_position).normalized()
			# If perpendicular dot product with target direction is negative, flip it
			if perpendicular.dot(to_target) < 0:
				perpendicular = - perpendicular
		
		# Apply perpendicular movement (side-step around object)
		var side_step_velocity = perpendicular * move_speed * 0.7 # 70% of move speed for side-step
		velocity = velocity.lerp(side_step_velocity, 0.3) # Smoothly blend with existing velocity
		
		# Also add some push-away from object to create clearance
		var push_away = collision_normal * move_speed * 0.3
		velocity = velocity + push_away
		
		# Limit total velocity to move_speed
		if velocity.length() > move_speed:
			velocity = velocity.normalized() * move_speed
		
		# For humanoid enemies, sometimes try to destroy the object
		if has_method("_try_attack_object") and randf() < 0.1: # 10% chance per frame when blocked
			call("_try_attack_object", blocked_objects[0].object)

func _attack_player(player):
	# Attack cooldown
	if attack_timer > 0:
		return
	
	# Deal damage - send RPC to player's authority peer
	if player.has_method("rpc_take_damage"):
		var player_peer_id = player.get_multiplayer_authority()
		if player_peer_id != 0:
			# If target peer is the same as server (us), call directly
			# rpc_id() might not execute locally when called to same peer
			if multiplayer.is_server() and player_peer_id == multiplayer.get_unique_id():
				# Call directly on the same peer
				player.rpc_take_damage(damage, global_position)
			else:
				# Send RPC to remote peer
				player.rpc_take_damage.rpc_id(player_peer_id, damage, global_position)
		else:
			# Fallback: broadcast if we can't get peer_id
			player.rpc_take_damage.rpc(damage, global_position)
		attack_timer = attack_cooldown
		LogManager.log(str(name) + " attacked " + str(player.name) + " (peer: " + str(player_peer_id) + ", server: " + str(multiplayer.get_unique_id()) + ")", LogManager.CATEGORY_ENEMY)
		# Bite effect when bat or rat hits player
		var script_path = get_script().resource_path if get_script() else ""
		if "enemy_bat" in script_path or "enemy_rat" in script_path:
			var game_world = get_tree().get_first_node_in_group("game_world")
			if game_world and game_world.has_method("_sync_bite_effect"):
				game_world._sync_bite_effect(player.name)
				if game_world.has_method("_rpc_to_ready_peers"):
					game_world._rpc_to_ready_peers("_sync_bite_effect", [player.name])

func _find_nearest_player() -> Node:
	var players = get_tree().get_nodes_in_group("player")
	var nearest = null
	var nearest_dist = INF
	
	for player in players:
		if player and is_instance_valid(player) and not player.is_dead:
			var dist = global_position.distance_to(player.global_position)
			if dist < nearest_dist:
				nearest_dist = dist
				nearest = player
	
	return nearest

func _find_nearest_player_in_range(max_range: float) -> Node:
	# Find nearest player within specified range
	var players = get_tree().get_nodes_in_group("player")
	var nearest = null
	var nearest_dist = INF
	
	for player in players:
		if player and is_instance_valid(player) and not player.is_dead:
			var dist = global_position.distance_to(player.global_position)
			if dist <= max_range and dist < nearest_dist:
				nearest_dist = dist
				nearest = player
	
	return nearest

func _find_nearest_player_to_position(pos: Vector2, max_range: float = 100.0) -> Node:
	# Find the nearest player to a specific position (used to find attacker)
	var players = get_tree().get_nodes_in_group("player")
	if players.is_empty():
		return null
	
	var nearest: Node = null
	var nearest_dist = max_range
	
	for player in players:
		if not is_instance_valid(player):
			continue
		var dist = pos.distance_to(player.global_position)
		if dist <= max_range and dist < nearest_dist:
			nearest_dist = dist
			nearest = player
	
	return nearest

func take_damage(amount: float, from_position: Vector2, is_critical: bool = false, is_burn_damage: bool = false, apply_burn_debuff: bool = false):
	# Only process damage on server/authority
	if not is_multiplayer_authority():
		return
	
	if is_dead:
		return
	
	# Find the nearest player to the attack position (likely the attacker)
	# This allows us to credit kills correctly
	var nearest_player = _find_nearest_player_to_position(from_position)
	if nearest_player:
		killer_player = nearest_player # Update killer to the most recent attacker
	
	# Check for dodge chance (based on DEX) - same as players
	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
			var dex_total = character_stats.baseStats.dex + character_stats.get_pass("dex")
			LogManager.log(str(name) + " DODGED the attack! (DEX: " + str(dex_total) + ", dodge chance: " + str(dodge_chance * 100.0) + "%)", LogManager.CATEGORY_ENEMY)
			# Show "DODGED" text
			_show_damage_number(0.0, from_position, false, false, true) # is_dodged = true
			# Sync dodge visual to clients (pass damage_amount=0.0 and is_dodged=true to indicate dodge)
			if multiplayer.has_multiplayer_peer() and is_inside_tree():
				var enemy_name = name
				var enemy_index = get_meta("enemy_index") if has_meta("enemy_index") else -1
				var game_world = get_tree().get_first_node_in_group("game_world")
				if game_world and game_world.has_method("_sync_enemy_damage_visual"):
					game_world._rpc_to_ready_peers("_sync_enemy_damage_visual", [enemy_name, enemy_index, 0.0, from_position, false, true])
			return # No damage taken, exit early
	
	# If not dodged, apply damage with DEF reduction
	var actual_damage = amount
	if character_stats:
		# Calculate damage after DEF reduction (critical hits pierce 80% of DEF)
		actual_damage = character_stats.calculate_damage(amount, false, is_critical) # false = not magical, is_critical = crit pierce
		character_stats.modify_health(-actual_damage)
		current_health = character_stats.hp
		if character_stats.hp <= 0:
			character_stats.no_health.emit()
		var effective_def = character_stats.defense * (0.2 if is_critical else 1.0)
		LogManager.log(str(name) + " took " + str(actual_damage) + " damage (" + str(amount) + " base - " + str(effective_def) + " DEF = " + str(actual_damage) + ")! Health: " + str(current_health) + "/" + str(character_stats.maxhp), LogManager.CATEGORY_ENEMY)
	else:
		# Fallback for legacy (shouldn't happen if _initialize_character_stats is called)
		current_health -= amount
		actual_damage = amount
		LogManager.log(str(name) + " took " + str(amount) + " damage! Health: " + str(current_health) + " (critical: " + str(is_critical) + ")", LogManager.CATEGORY_ENEMY)
	
	# Only apply knockback if not burn damage
	if not is_burn_damage:
		# Calculate knockback direction (away from attacker)
		var knockback_direction = (global_position - from_position).normalized()
		velocity = knockback_direction * knockback_force
		is_knocked_back = true
		knockback_time = 0.0
	
	# Apply burn debuff if requested
	if apply_burn_debuff:
		_apply_burn_debuff()
	
	_on_take_damage(from_position)
	
	# Flash red (even if dying, show the hit)
	_flash_damage()
	
	# Show damage number (red/orange, using dmg_numbers.png font) above enemy
	# Always show damage number, even if 0
	_show_damage_number(actual_damage, from_position, is_critical)
	
	# Sync damage visual to clients
	# Use game_world to route damage visual sync instead of direct RPC to avoid node path issues
	if multiplayer.has_multiplayer_peer() and is_inside_tree():
		var enemy_name = name
		var enemy_index = get_meta("enemy_index") if has_meta("enemy_index") else -1
		var game_world = get_tree().get_first_node_in_group("game_world")
		if game_world and game_world.has_method("_sync_enemy_damage_visual"):
			game_world._rpc_to_ready_peers("_sync_enemy_damage_visual", [enemy_name, enemy_index, actual_damage, from_position, is_critical])
		else:
			# Fallback: try direct RPC (may fail if node path doesn't match)
			_sync_damage_visual.rpc(actual_damage, from_position, is_critical)
	
	if current_health <= 0:
		# Prevent multiple death triggers
		if is_dead:
			return # Already dying
		
		# Don't set is_dead here - let _die() set it to avoid early return bug
		# Mark as dead in _die() function instead of here
		
		# Delay death slightly so knockback is visible
		call_deferred("_die")
		
		# Notify doors that an enemy has died (if spawned from spawner)
		# This needs to happen after _die() sets is_dead, so defer it
		if has_meta("spawned_from_spawner") and get_meta("spawned_from_spawner"):
			call_deferred("_notify_doors_enemy_died")

@rpc("any_peer", "reliable")
func rpc_take_damage(amount: float, from_position: Vector2, is_critical: bool = false, is_burn_damage: bool = false, apply_burn_debuff: bool = false):
	# RPC version - only process on server/authority
	if is_multiplayer_authority():
		take_damage(amount, from_position, is_critical, is_burn_damage, apply_burn_debuff)

@rpc("any_peer", "reliable")
func rpc_heal_enemy(amount: float, allow_overheal: bool = false):
	# RPC version - only process on server/authority
	if is_multiplayer_authority():
		if character_stats:
			character_stats.heal(amount, allow_overheal)
			# Sync current_health for backwards compatibility
			current_health = character_stats.hp
			LogManager.log(str(name) + " healed for " + str(amount) + " HP! Health: " + str(current_health) + "/" + str(character_stats.maxhp), LogManager.CATEGORY_ENEMY)

func _show_damage_number(amount: float, from_position: Vector2, is_critical: bool = false, is_miss: bool = false, is_dodged: bool = false):
	# Show damage number (red/orange for crits, cyan for dodge, gray for miss, using dmg_numbers.png font) above enemy
	# Show even if amount is 0 for MISS/DODGED
	var damage_number_scene = preload("res://scenes/damage_number.tscn")
	if not damage_number_scene:
		return
	
	var damage_label = damage_number_scene.instantiate()
	if not damage_label:
		return
	
	# Set text and color based on type
	if is_dodged:
		damage_label.label = "DODGED"
		damage_label.color = Color.CYAN
	elif is_miss:
		damage_label.label = "MISS"
		damage_label.color = Color.GRAY
	else:
		damage_label.label = str(int(amount))
		damage_label.color = Color(1.0, 0.6, 0.2) if is_critical 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 enemy'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 enemy 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_exp_number(amount: float, exp_pos: Vector2):
	# Show EXP number (green/yellow, using dmg_numbers.png font) at position
	var damage_number_scene = preload("res://scenes/damage_number.tscn")
	if not damage_number_scene:
		return
	
	var exp_label = damage_number_scene.instantiate()
	if not exp_label:
		return
	
	# Set text and color for EXP (green/yellow)
	exp_label.label = "+" + str(int(amount)) + " EXP"
	exp_label.color = Color(0.4, 1.0, 0.4) # Bright green
	exp_label.z_index = 5
	
	# Direction is straight up
	exp_label.direction = Vector2(0, -1)
	
	# Position at the specified location
	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(exp_label)
			exp_label.global_position = exp_pos + Vector2(0, -20) # Slightly above
		else:
			get_tree().current_scene.add_child(exp_label)
			exp_label.global_position = exp_pos + Vector2(0, -20)
	else:
		get_tree().current_scene.add_child(exp_label)
		exp_label.global_position = exp_pos + Vector2(0, -20)

func _flash_damage():
	# Flash red visual effect
	if sprite:
		var tween = create_tween()
		tween.tween_property(sprite, "modulate", Color.RED, 0.1)
		tween.tween_property(sprite, "modulate", Color.WHITE, 0.1)

func _update_client_visuals():
	# Override in subclasses to update sprite frame and visuals based on synced state
	# Base implementation just updates Z position
	if sprite:
		sprite.position.y = - position_z * 0.5
	if shadow:
		var shadow_scale = 1.0 - (position_z / 50.0) * 0.5
		shadow.scale = Vector2.ONE * max(0.3, shadow_scale)
		shadow.modulate.a = 0.5 - (position_z / 50.0) * 0.2

func _on_take_damage(_attacker_position: Vector2 = Vector2.ZERO):
	# Override in subclasses for custom damage reactions
	# attacker_position is the position of the attacker (for facing logic)
	pass

func _notify_doors_enemy_died():
	# Notify all doors that require enemies to check puzzle state
	# This ensures doors open immediately when the last enemy dies
	if not has_meta("spawned_from_spawner") or not get_meta("spawned_from_spawner"):
		return # Only notify if this enemy was spawned from a spawner
	
	# Find all doors in the scene that require enemies
	for door in get_tree().get_nodes_in_group("blocking_door"):
		if not is_instance_valid(door):
			continue
		
		if not door.has_method("_check_puzzle_state"):
			continue
		
		# Check if this door requires enemies (requires_enemies is a property defined in door.gd)
		# Access property directly - it's always defined in door.gd class
		if door.requires_enemies:
			# Trigger puzzle state check immediately (doors will verify if all enemies are dead)
			door.call_deferred("_check_puzzle_state")
			LogManager.log("Enemy: Notified door " + str(door.name) + " to check puzzle state after enemy death", LogManager.CATEGORY_ENEMY)

func _set_animation(_anim_name: String):
	# Virtual function - override in subclasses that use animation state system
	# (e.g., enemy_humanoid.gd uses player-like animation system)
	pass

func _apply_burn_debuff():
	# Apply burn debuff to enemy
	if burn_debuff_timer > 0.0:
		# Already burning - refresh duration
		burn_debuff_timer = burn_debuff_duration
		burn_damage_timer = 0.0 # Reset damage timer
		LogManager.log(str(name) + " burn debuff refreshed", LogManager.CATEGORY_ENEMY)
		return
	
	# Start burn debuff
	burn_debuff_timer = burn_debuff_duration
	burn_damage_timer = 0.0
	LogManager.log(str(name) + " applied burn debuff (" + str(burn_debuff_duration) + " seconds)", LogManager.CATEGORY_ENEMY)
	
	# Create visual indicator
	_create_burn_debuff_visual()
	
	# Sync burn debuff to clients
	if multiplayer.has_multiplayer_peer() and is_inside_tree():
		var enemy_name = name
		var enemy_index = get_meta("enemy_index") if has_meta("enemy_index") else -1
		var game_world = get_tree().get_first_node_in_group("game_world")
		if game_world and game_world.has_method("_sync_enemy_burn_debuff"):
			game_world._rpc_to_ready_peers("_sync_enemy_burn_debuff", [enemy_name, enemy_index, true])

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 enemy (centered)
		burn_debuff_visual.position = Vector2(0, 0)
		burn_debuff_visual.z_index = 5 # Above enemy sprite
		LogManager.log(str(name) + " created burn debuff visual", LogManager.CATEGORY_ENEMY)
	else:
		# Fallback: create simple sprite if scene doesn't exist
		var burn_texture = load("res://assets/gfx/fx/burn.png")
		if burn_texture:
			var burn_sprite = Sprite2D.new()
			burn_sprite.name = "BurnDebuffSprite"
			burn_sprite.texture = burn_texture
			burn_sprite.hframes = 4
			burn_sprite.vframes = 4
			burn_sprite.frame = 0
			burn_sprite.position = Vector2(0, 0)
			burn_sprite.z_index = 5 # Above enemy sprite
			burn_sprite.set_meta("burn_animation_frame", 0)
			burn_sprite.set_meta("burn_animation_timer", 0.0)
			add_child(burn_sprite)
			burn_debuff_visual = burn_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
	LogManager.log(str(name) + " burn debuff removed", LogManager.CATEGORY_ENEMY)
	
	# Sync burn debuff removal to clients
	if multiplayer.has_multiplayer_peer() and is_inside_tree():
		var enemy_name = name
		var enemy_index = get_meta("enemy_index") if has_meta("enemy_index") else -1
		var game_world = get_tree().get_first_node_in_group("game_world")
		if game_world and game_world.has_method("_sync_enemy_burn_debuff"):
			game_world._rpc_to_ready_peers("_sync_enemy_burn_debuff", [enemy_name, enemy_index, false])

func _sync_burn_debuff(apply: bool):
	# Client-side sync of burn debuff visual
	if apply:
		if burn_debuff_timer <= 0.0:
			# Only create visual if not already burning
			_create_burn_debuff_visual()
		burn_debuff_timer = burn_debuff_duration
		burn_damage_timer = 0.0
	else:
		# Remove visual
		_remove_burn_debuff()
		burn_debuff_timer = 0.0
		burn_damage_timer = 0.0

func _die():
	if is_dead:
		return
	
	is_dead = true
	LogManager.log(str(name) + " died!", LogManager.CATEGORY_ENEMY)
	
	# Track defeated enemy for syncing to new clients
	if multiplayer.has_multiplayer_peer() and multiplayer.is_server():
		var game_world = get_tree().get_first_node_in_group("game_world")
		if game_world:
			var enemy_index = get_meta("enemy_index") if has_meta("enemy_index") else -1
			if enemy_index >= 0:
				game_world.defeated_enemies[enemy_index] = true
				LogManager.log("Enemy: Tracked defeated enemy with index " + str(enemy_index), LogManager.CATEGORY_ENEMY)
	
	# Credit kill to the player who dealt the fatal damage
	if killer_player and is_instance_valid(killer_player) and killer_player.character_stats:
		killer_player.character_stats.kills += 1
		LogManager.log(str(name) + " kill credited to " + str(killer_player.name) + " (total kills: " + str(killer_player.character_stats.kills) + ")", LogManager.CATEGORY_ENEMY)
		
		# Sync kill update to client if this player belongs to a client
		# Only sync if we're on the server and the killer is a client's player
		if multiplayer.has_multiplayer_peer() and is_multiplayer_authority():
			var killer_peer_id = killer_player.get_multiplayer_authority()
			# Only sync if killer is a client (not server's own player)
			if killer_peer_id != 0 and killer_peer_id != multiplayer.get_unique_id() and killer_player.has_method("_sync_stats_update"):
				# Server is updating a client's player stats - sync to the client
				var coins = killer_player.character_stats.coin if "coin" in killer_player.character_stats else 0
				var xp = killer_player.character_stats.xp if "xp" in killer_player.character_stats else 0.0
				LogManager.log(str(name) + " syncing kill stats to client peer_id=" + str(killer_peer_id) + " kills=" + str(killer_player.character_stats.kills) + " coins=" + str(coins) + " xp=" + str(xp), LogManager.CATEGORY_ENEMY)
				killer_player._sync_stats_update.rpc_id(killer_peer_id, killer_player.character_stats.kills, coins, xp)
	
	# Grant EXP to all players (split evenly among all players)
	if exp_reward > 0:
		var all_players = get_tree().get_nodes_in_group("player")
		var valid_players = []
		for player in all_players:
			if is_instance_valid(player) and player.character_stats:
				valid_players.append(player)
		
		if valid_players.size() > 0:
			# Split EXP evenly among all players
			var exp_per_player = exp_reward / valid_players.size()
			for player in valid_players:
				player.character_stats.add_xp(exp_per_player)
				LogManager.log(str(name) + " granted " + str(exp_per_player) + " EXP to " + str(player.name) + " (shared from " + str(exp_reward) + " total)", LogManager.CATEGORY_ENEMY)
				
				# Sync EXP to client if this player belongs to a client
				if multiplayer.has_multiplayer_peer() and is_multiplayer_authority():
					var player_peer_id = player.get_multiplayer_authority()
					if player_peer_id != 0 and player_peer_id != multiplayer.get_unique_id() and player.has_method("_sync_stats_update"):
						var coins = player.character_stats.coin if "coin" in player.character_stats else 0
						var xp = player.character_stats.xp if "xp" in player.character_stats else 0.0
						player._sync_stats_update.rpc_id(player_peer_id, player.character_stats.kills, coins, xp)
				
				# Show floating EXP text at enemy position and sync to all clients
				if is_multiplayer_authority():
					# Show locally first
					_show_exp_number(exp_per_player, global_position)
					# Sync to all clients via game_world
					var game_world = get_tree().get_first_node_in_group("game_world")
					if game_world and game_world.has_method("_sync_exp_text_at_position") and multiplayer.has_multiplayer_peer():
						game_world._sync_exp_text_at_position.rpc(exp_per_player, global_position)
	
	# Spawn loot immediately (before death animation)
	_spawn_loot()
	
	# Sync death to all clients (only server sends RPC)
	# Use game_world to route death sync instead of direct RPC to avoid node path issues
	if multiplayer.has_multiplayer_peer() and is_multiplayer_authority() and is_inside_tree():
		var enemy_name = name
		var enemy_index = get_meta("enemy_index") if has_meta("enemy_index") else -1
		var game_world = get_tree().get_first_node_in_group("game_world")
		if game_world and game_world.has_method("_sync_enemy_death"):
			game_world._rpc_to_ready_peers("_sync_enemy_death", [enemy_name, enemy_index])
		else:
			# Fallback: try direct RPC (may fail if node path doesn't match)
			_sync_death.rpc()
	
	# Play death animation (override in subclasses)
	_play_death_animation()

func _play_death_animation():
	# Override in subclasses
	await get_tree().create_timer(0.5).timeout
	queue_free()

func _spawn_loot():
	# Only spawn loot on server/authority
	if not is_multiplayer_authority():
		LogManager.log(str(name) + " _spawn_loot() called but not authority, skipping", LogManager.CATEGORY_ENEMY)
		return
	
	LogManager.log(str(name) + " _spawn_loot() called on authority", LogManager.CATEGORY_ENEMY)
	
	# Spawn random loot at enemy position
	var loot_scene = preload("res://scenes/loot.tscn")
	if not loot_scene:
		LogManager.log_error(str(name) + " ERROR: loot_scene is null!", LogManager.CATEGORY_ENEMY)
		return
	
	# Get killer's LCK stat to influence loot drops
	var killer_lck = 10.0 # Default LCK if no killer
	if killer_player and is_instance_valid(killer_player) and killer_player.character_stats:
		killer_lck = killer_player.character_stats.baseStats.lck + killer_player.character_stats.get_pass("lck")
		LogManager.log(str(name) + " killed by " + str(killer_player.name) + " with LCK: " + str(killer_lck), LogManager.CATEGORY_ENEMY)
	
	# Random chance to drop loot (85% chance - increased from 70%)
	# LCK can increase this: +0.01% per LCK point (capped at 95%)
	var base_loot_chance = 0.85
	var lck_bonus = min(killer_lck * 0.001, 0.1) # +0.1% per LCK, max +10% (95% cap)
	var loot_chance = randf()
	var loot_threshold = 1.0 - (base_loot_chance + lck_bonus)
	LogManager.log(str(name) + " loot chance roll: " + str(loot_chance) + " (need > " + str(loot_threshold) + ", base=" + str(base_loot_chance) + ", LCK bonus=" + str(lck_bonus) + ")", LogManager.CATEGORY_ENEMY)
	if loot_chance > loot_threshold:
		# Determine how many loot items to drop (1-4 items, influenced by LCK)
		# Base: 1-3 items, LCK can push towards 2-4 items
		# LCK effect: Each 5 points of LCK above 10 increases chance for extra drops
		var lck_modifier = (killer_lck - 10.0) / 5.0 # +1 per 5 LCK above 10
		var num_drops_roll = randf()
		var base_num_drops_roll = num_drops_roll - (lck_modifier * 0.1) # LCK reduces roll needed (up to -0.6 at LCK 40)
		var num_drops = 1
		if base_num_drops_roll < 0.5:
			num_drops = 1 # 50% base chance for 1 item (reduced from 60%)
		elif base_num_drops_roll < 0.8:
			num_drops = 2 # 30% base chance for 2 items
		elif base_num_drops_roll < 0.95:
			num_drops = 3 # 15% base chance for 3 items
		else:
			num_drops = 4 # 5% base chance for 4 items (LCK makes this more likely)
		
		# Ensure at least 1 drop
		num_drops = max(1, num_drops)
		LogManager.log(str(name) + " spawning " + str(num_drops) + " loot item(s) (LCK modifier: " + str(lck_modifier) + ")", LogManager.CATEGORY_ENEMY)
		
		# Find safe spawn position (on floor tile, not in walls)
		var game_world = get_tree().get_first_node_in_group("game_world")
		var base_spawn_pos = global_position
		if game_world and game_world.has_method("_find_nearby_safe_spawn_position"):
			base_spawn_pos = game_world._find_nearby_safe_spawn_position(global_position, 64.0)
		
		var entities_node = get_parent()
		if not entities_node:
			LogManager.log_error(str(name) + " ERROR: entities_node is null! Cannot spawn loot!", LogManager.CATEGORY_ENEMY)
			return
		
		# Spawn multiple loot items
		for i in range(num_drops):
			# Decide what to drop for this item, influenced by LCK
			# LCK makes better items more likely: reduces coin chance, increases item chance
			var lck_bonus_item = min(killer_lck * 0.01, 0.2) # Up to +20% item chance at LCK 20+
			var lck_penalty_coin = min(killer_lck * 0.005, 0.15) # Up to -15% coin chance at LCK 30+
			
			# Base probabilities: 70% coin, 15% food, 15% item (reduced from 30%)
			# Items are further split: 80% consumables, 20% equipment (making equipment very rare)
			var coin_chance = 0.7 - lck_penalty_coin
			var food_chance = 0.15
			var item_chance = 0.15 + lck_bonus_item # Reduced from 0.3
			
			# Normalize probabilities
			var total = coin_chance + food_chance + item_chance
			coin_chance /= total
			food_chance /= total
			item_chance /= total
			
			var drop_roll = randf()
			var loot_type = 0
			var drop_item = false
			var item_rarity_boost = false # LCK can boost item rarity
			
			if drop_roll < coin_chance:
				# Coin
				loot_type = 0 # COIN
			elif drop_roll < coin_chance + food_chance:
				# Food item
				var food_types = [1, 2, 3] # APPLE, BANANA, CHERRY
				loot_type = food_types[randi() % food_types.size()]
			else:
				# Item instance - LCK can boost rarity
				drop_item = true
				# Higher LCK = better chance for rarer items
				item_rarity_boost = killer_lck > 15.0
			
			# Generate deterministic random velocity values using dungeon seed
			# This ensures loot bounces the same on all clients
			var loot_rng = RandomNumberGenerator.new()
			# game_world is already declared above (line 587)
			var base_seed = 0
			if game_world and "dungeon_seed" in game_world:
				base_seed = game_world.dungeon_seed
			
			# Get loot_id first (needed for seed calculation to ensure determinism)
			var loot_id = 0
			if game_world:
				# Try to get loot_id_counter (it's always declared in game_world.gd)
				# Access it directly - if it doesn't exist, we'll use fallback
				var loot_counter = game_world.get("loot_id_counter")
				if loot_counter != null:
					loot_id = loot_counter
				else:
					# Fallback: use enemy_index + loot_index for deterministic ID
					var enemy_index = get_meta("enemy_index") if has_meta("enemy_index") else 0
					loot_id = enemy_index * 1000 + i
			else:
				# Fallback: use enemy_index + loot_index for deterministic ID
				var enemy_index = get_meta("enemy_index") if has_meta("enemy_index") else 0
				loot_id = enemy_index * 1000 + i
			
			# Create unique seed for this loot item: dungeon_seed + loot_id
			# This ensures each loot item gets a unique but deterministic seed
			var loot_seed = base_seed + loot_id + 10000 # Offset to avoid collisions
			loot_rng.seed = loot_seed
			
			var random_angle = loot_rng.randf() * PI * 2
			var random_force = loot_rng.randf_range(25.0, 50.0) # Reduced to half speed
			var random_velocity_z = loot_rng.randf_range(40.0, 60.0) # Reduced to half speed
			
			# Generate initial velocity (same on all clients via RPC)
			var initial_velocity = Vector2(cos(random_angle), sin(random_angle)) * random_force
			
			# Slightly offset position for multiple items (spread them out)
			var spawn_offset = Vector2(cos(random_angle), sin(random_angle)) * loot_rng.randf_range(10.0, 30.0)
			var safe_spawn_pos = base_spawn_pos + spawn_offset
			if game_world and game_world.has_method("_find_nearby_safe_spawn_position"):
				safe_spawn_pos = game_world._find_nearby_safe_spawn_position(safe_spawn_pos, 32.0)
			
			if drop_item:
				# Spawn Item instance as loot - prioritize consumables over equipment
				# 80% consumables (arrows, bombs, restoration), 20% equipment
				var item_type_roll = randf()
				var item = null
				
				if item_type_roll < 0.8:
					# Consumable drop (arrows, bombs, restoration items)
					item = ItemDatabase.get_random_consumable_drop()
				else:
					# Equipment drop (much rarer - only 20% of item drops, which is 20% of 15% = 3% total)
					# LCK boost still applies - higher LCK makes equipment drops more likely to be better quality
					item = ItemDatabase.get_random_equipment_drop()
				
				if item:
					ItemLootHelper.spawn_item_loot(item, safe_spawn_pos, entities_node, game_world)
					LogManager.log(str(name) + " ✓ dropped item #" + str(i + 1) + ": " + str(item.item_name) + " at " + str(safe_spawn_pos) + " (LCK boost: " + str(item_rarity_boost) + ")", LogManager.CATEGORY_ENEMY)
			else:
				# Spawn regular loot (coin or food)
				var loot = loot_scene.instantiate()
				entities_node.add_child(loot)
				loot.global_position = safe_spawn_pos
				loot.loot_type = loot_type
				# Set initial velocity before _ready() processes
				loot.velocity = initial_velocity
				loot.velocity_z = random_velocity_z
				loot.velocity_set_by_spawner = true
				loot.is_airborne = true
				LogManager.log(str(name) + " ✓ dropped loot #" + str(i + 1) + ": " + str(loot_type) + " at " + str(safe_spawn_pos), LogManager.CATEGORY_ENEMY)
				
				# Sync loot spawn to all clients (use safe position)
				if multiplayer.has_multiplayer_peer():
					# Reuse game_world variable from above
					if game_world:
						# Use the loot_id we already calculated (or get real one if we used fallback)
						# loot_id_counter is declared as a variable in game_world.gd, so it always exists
						if loot_id == 0:
							# We used fallback, get real ID now
							loot_id = game_world.loot_id_counter
							game_world.loot_id_counter += 1
							# Recalculate seed with real loot_id
							var real_loot_seed = base_seed + loot_id + 10000
							loot_rng.seed = real_loot_seed
							# Regenerate velocity with correct seed
							var real_random_angle = loot_rng.randf() * PI * 2
							var real_random_force = loot_rng.randf_range(25.0, 50.0) # Reduced to half speed
							var real_random_velocity_z = loot_rng.randf_range(40.0, 60.0) # Reduced to half speed
							initial_velocity = Vector2(cos(real_random_angle), sin(real_random_angle)) * real_random_force
							random_velocity_z = real_random_velocity_z
							# Update loot with correct velocity
							loot.velocity = initial_velocity
							loot.velocity_z = random_velocity_z
						else:
							# We already have the correct loot_id, just increment counter
							game_world.loot_id_counter += 1
						# Store loot ID on server loot instance
						loot.set_meta("loot_id", loot_id)
						# Sync to clients with ID
						game_world._rpc_to_ready_peers("_sync_loot_spawn", [safe_spawn_pos, loot_type, initial_velocity, random_velocity_z, loot_id])
						LogManager.log(str(name) + " ✓ synced loot #" + str(i + 1) + " spawn to clients", LogManager.CATEGORY_ENEMY)
					else:
						LogManager.log_error(str(name) + " ERROR: game_world not found for loot sync!", LogManager.CATEGORY_ENEMY)
	else:
		LogManager.log(str(name) + " loot chance failed (" + str(loot_chance) + " <= 0.15), no loot dropped", LogManager.CATEGORY_ENEMY)

# This function can be called directly (not just via RPC) when game_world routes the update
func _sync_position(pos: Vector2, vel: Vector2, z_pos: float = 0.0, dir: int = 0, frame: int = 0, anim: String = "", frame_num: int = 0, state_value: int = -1):
	# Clients receive position and animation updates from server
	# Only process if we're not the authority (i.e., we're a client)
	if is_multiplayer_authority():
		return # Server ignores its own updates
	
	# Debug: Log when client receives position update (first few times)
	if not has_meta("position_sync_count"):
		set_meta("position_sync_count", 0)
		LogManager.log("Enemy " + str(name) + " (client) RECEIVED first position sync! pos=" + str(pos) + " authority: " + str(get_multiplayer_authority()) + " is_authority: " + str(is_multiplayer_authority()) + " in_tree: " + str(is_inside_tree()), LogManager.CATEGORY_ENEMY)
	
	var sync_count = get_meta("position_sync_count") + 1
	set_meta("position_sync_count", sync_count)
	if sync_count <= 3: # Log first 3 syncs
		LogManager.log("Enemy " + str(name) + " (client) received position sync #" + str(sync_count) + ": pos=" + str(pos), LogManager.CATEGORY_ENEMY)
	
	# Update position and state
	position = pos
	velocity = vel
	position_z = z_pos
	current_direction = dir as Direction
	
	# Update state if provided (for enemies with state machines like bats/slimes)
	# CRITICAL: Don't update state if enemy is dead - this prevents overriding DYING state
	if state_value != -1 and "state" in self and not is_dead:
		set("state", state_value)
	
	# Update animation if provided (for humanoid enemies with player-like animation system)
	if anim != "":
		_set_animation(anim)
		if "current_frame" in self:
			set("current_frame", frame_num)
	else:
		# Default: use frame number for simple enemies
		anim_frame = frame
	
	# Update visual representation (override in subclasses for custom animation)
	_update_client_visuals()

# This function can be called directly (not just via RPC) when game_world routes the update
func _sync_damage_visual(damage_amount: float = 0.0, attacker_position: Vector2 = Vector2.ZERO, is_critical: bool = false, is_dodged: bool = false):
	# Clients receive damage visual sync
	# Only process if we're not the authority (i.e., we're a client)
	if is_multiplayer_authority():
		return # Server ignores its own updates
	
	# CRITICAL: Don't play damage animation if enemy is already dead
	# This prevents damage sync from overriding death animation (e.g., if packets arrive out of order)
	if is_dead:
		LogManager.log(str(name) + " (client) ignoring damage visual sync - already dead", LogManager.CATEGORY_ENEMY)
		return
	
	# Trigger damage animation and state change on client
	# This ensures clients play the damage animation (e.g., slime DAMAGE animation)
	_on_take_damage(attacker_position)
	
	_flash_damage()
	
	# Show damage number on client (even if damage_amount is 0 for dodges/misses)
	if attacker_position != Vector2.ZERO:
		_show_damage_number(damage_amount, attacker_position, is_critical, false, is_dodged)

# This function can be called directly (not just via RPC) when game_world routes the update
func _sync_death():
	# Clients receive death sync and play death animation locally
	# Only process if we're not the authority (i.e., we're a client)
	if is_multiplayer_authority():
		return # Server ignores its own updates
	
	if not is_dead:
		is_dead = true
		LogManager.log(str(name) + " received death sync, dying on client", LogManager.CATEGORY_ENEMY)
		
		# Remove collision layer so they don't collide with players, but still collide with walls
		# This matches what happens on the server when rats/slimes die
		set_collision_layer_value(2, false) # Remove from enemy collision layer (layer 2)
		
		# CRITICAL: For state-based enemies (like slimes), set state to DYING before setting animation
		# This ensures _update_client_visuals doesn't override the DIE animation with DAMAGE
		# Check if enemy has a state variable - if so, try to set it to DYING
		# For slimes: SlimeState.DYING = 4
		# This prevents _update_client_visuals from seeing DAMAGED state and setting DAMAGE animation
		if "state" in self:
			var current_state = get("state")
			# SlimeState enum: IDLE=0, MOVING=1, JUMPING=2, DAMAGED=3, DYING=4
			# Set state to DYING (4) if it's currently DAMAGED (3) or less
			if current_state <= 3: # DAMAGED or less
				set("state", 4) # Set to DYING
				LogManager.log(str(name) + " (client) set state to DYING (4) in _sync_death to override DAMAGED state", LogManager.CATEGORY_ENEMY)
		
		# For humanoid enemies, ensure death animation is set immediately and animation state is reset
		# This is critical for joiner clients who receive death sync
		if has_method("_set_animation"):
			LogManager.log(str(name) + " (client) setting DIE animation in _sync_death", LogManager.CATEGORY_ENEMY)
			_set_animation("DIE")
			# Also ensure animation frame is reset and animation system is ready
			if "current_frame" in self:
				set("current_frame", 0)
				LogManager.log(str(name) + " (client) reset current_frame to 0", LogManager.CATEGORY_ENEMY)
			if "time_since_last_frame" in self:
				set("time_since_last_frame", 0.0)
				LogManager.log(str(name) + " (client) reset time_since_last_frame to 0.0", LogManager.CATEGORY_ENEMY)
			# Verify animation was set
			if "current_animation" in self:
				var anim_name = get("current_animation")
				LogManager.log(str(name) + " (client) current_animation after _set_animation: " + str(anim_name), LogManager.CATEGORY_ENEMY)
		
		# CRITICAL: Force immediate animation update for humanoid enemies
		# This ensures DIE animation is visible immediately on clients
		if has_method("_update_animation") and "current_animation" in self:
			call("_update_animation", 0.0)
			LogManager.log(str(name) + " (client) forced immediate _update_animation(0.0) after setting DIE in _sync_death", LogManager.CATEGORY_ENEMY)
		
		# CRITICAL: Call _update_client_visuals immediately to ensure DIE animation is applied
		# This prevents _update_client_visuals from running later and overriding with DAMAGE
		if has_method("_update_client_visuals"):
			_update_client_visuals()
		
		# Immediately mark as dead and stop AI/physics
		# This prevents "inactive" enemies that are already dead
		_play_death_animation()
	else:
		# Already dead, but make sure collision is removed and it's removed from scene
		LogManager.log(str(name) + " received death sync but already dead, ensuring removal", LogManager.CATEGORY_ENEMY)
		
		# Remove collision layer if not already removed
		if get_collision_layer_value(2):
			set_collision_layer_value(2, false)
		
		if not is_queued_for_deletion():
			queue_free()

func _get_direction_from_vector(vec: Vector2) -> Direction:
	if vec.length() < 0.1:
		return current_direction
	
	var angle = vec.angle()
	
	# Convert angle to 8 directions
	if angle >= -PI / 8 and angle < PI / 8:
		return Direction.RIGHT
	elif angle >= PI / 8 and angle < 3 * PI / 8:
		return Direction.DOWN_RIGHT
	elif angle >= 3 * PI / 8 and angle < 5 * PI / 8:
		return Direction.DOWN
	elif angle >= 5 * PI / 8 and angle < 7 * PI / 8:
		return Direction.DOWN_LEFT
	elif angle >= 7 * PI / 8 or angle < -7 * PI / 8:
		return Direction.LEFT
	elif angle >= -7 * PI / 8 and angle < -5 * PI / 8:
		return Direction.UP_LEFT
	elif angle >= -5 * PI / 8 and angle < -3 * PI / 8:
		return Direction.UP
	return Direction.UP_RIGHT
