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