1074 lines
46 KiB
GDScript
1074 lines
46 KiB
GDScript
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
|
|
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
|
|
_show_damage_number(actual_damage, global_position, 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)
|
|
|
|
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)
|
|
|
|
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.ORANGE if is_critical else Color.RED
|
|
|
|
# Calculate direction from attacker (slight upward variation)
|
|
var direction_from_attacker = (global_position - from_position).normalized()
|
|
# Add slight upward bias
|
|
direction_from_attacker = direction_from_attacker.lerp(Vector2(0, -1), 0.5).normalized()
|
|
damage_label.direction = direction_from_attacker
|
|
|
|
# Position above 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 _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 and grant EXP 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)
|
|
|
|
# Grant EXP to the killer
|
|
if exp_reward > 0:
|
|
killer_player.character_stats.add_xp(exp_reward)
|
|
LogManager.log(str(name) + " granted " + str(exp_reward) + " EXP to " + str(killer_player.name), 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
|
|
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), LogManager.CATEGORY_ENEMY)
|
|
killer_player._sync_stats_update.rpc_id(killer_peer_id, killer_player.character_stats.kills, coins)
|
|
|
|
# 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: 50% coin, 20% food, 30% item
|
|
var coin_chance = 0.5 - lck_penalty_coin
|
|
var food_chance = 0.2
|
|
var item_chance = 0.3 + lck_bonus_item
|
|
|
|
# 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 - LCK influences rarity
|
|
var item = null
|
|
if item_rarity_boost:
|
|
# High LCK: use chest rarity weights (better loot) instead of enemy drop weights
|
|
# Roll for rarity with LCK bonus: each 5 LCK above 15 increases rare/epic chance
|
|
var rarity_roll = randf()
|
|
var lck_rarity_bonus = min((killer_lck - 15.0) * 0.02, 0.15) # Up to +15% rare/epic chance
|
|
|
|
# Clamp values to prevent going below 0 or above 1
|
|
var common_threshold = max(0.0, 0.3 - lck_rarity_bonus)
|
|
var uncommon_threshold = max(common_threshold, 0.65 - (lck_rarity_bonus * 0.5))
|
|
var rare_threshold = min(1.0, 0.90 + (lck_rarity_bonus * 2.0))
|
|
|
|
if rarity_roll < common_threshold:
|
|
# Common (reduced by LCK)
|
|
item = ItemDatabase.get_random_item_by_rarity(ItemDatabase.ItemRarity.COMMON)
|
|
elif rarity_roll < uncommon_threshold:
|
|
# Uncommon (slightly reduced)
|
|
item = ItemDatabase.get_random_item_by_rarity(ItemDatabase.ItemRarity.UNCOMMON)
|
|
elif rarity_roll < rare_threshold:
|
|
# Rare (increased by LCK)
|
|
item = ItemDatabase.get_random_item_by_rarity(ItemDatabase.ItemRarity.RARE)
|
|
else:
|
|
# Epic/Consumable (greatly increased by LCK)
|
|
var epic_roll = randf()
|
|
if epic_roll < 0.5:
|
|
item = ItemDatabase.get_random_item_by_rarity(ItemDatabase.ItemRarity.EPIC)
|
|
else:
|
|
item = ItemDatabase.get_random_item_by_rarity(ItemDatabase.ItemRarity.CONSUMABLE)
|
|
else:
|
|
# Normal LCK: use standard enemy drop weights
|
|
item = ItemDatabase.get_random_enemy_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
|