extends "res://scripts/enemy_base.gd" # Bat Enemy - Flies around, stationary when idle enum BatState { IDLE, FLYING } var state: BatState = BatState.IDLE var state_timer: float = 0.0 var idle_duration: float = 2.0 # How long to stay idle var fly_duration: float = 3.0 # How long to fly around var detection_range: float = 80.0 # Range to detect players (much smaller) var fly_height: float = 8.0 # Z position when flying # Audio @onready var bat_flap_sfx: AudioStreamPlayer2D = $BatFlapSfx @onready var bat_chirp_sfx: AudioStreamPlayer2D = $BatChirpSfx var has_played_chase_sound: bool = false # Track if we've played sound when starting to chase func _ready(): super._ready() max_health = 18.0 # Reduced from 30.0 for better balance current_health = max_health move_speed = 40.0 # Reasonable speed for bats damage = 5.0 exp_reward = 6.0 # Bats give low-moderate EXP state_timer = idle_duration # CRITICAL: Ensure collision mask is set correctly (walls are on layer 7 = bit 6 = 64) collision_mask = 1 | 2 | 64 # Collide with players (layer 1), objects (layer 2), and walls (layer 7 = bit 6 = 64) # Override to set weak stats for bats func _initialize_character_stats(): super._initialize_character_stats() # Bats are weak enemies - very low END for low DEF character_stats.baseStats.end = 5 # END=5 gives DEF=1.0 (5 * 0.2) character_stats.baseStats.str = 5 # Low STR = low damage character_stats.baseStats.dex = 10 # Higher DEX (bats are agile) character_stats.baseStats.int = 5 character_stats.baseStats.wis = 5 character_stats.baseStats.lck = 5 # Re-initialize hp and mp with new stats character_stats.hp = character_stats.maxhp character_stats.mp = character_stats.maxmp # Sync max_health from character_stats max_health = character_stats.maxhp current_health = max_health func _physics_process(delta): # Always update animation (even when dead, and on clients) _update_animation(delta) # Always update Z position and shadow (even on clients) _update_z_position(delta) # Call parent physics process (handles dead state, authority checks, etc.) super._physics_process(delta) func _ai_behavior(delta): # Update state timer state_timer -= delta # Find nearest player within detection range target_player = _find_nearest_player_in_range(detection_range) # State machine match state: BatState.IDLE: _idle_behavior(delta) BatState.FLYING: _flying_behavior(delta) # Animation and Z position are updated in _physics_process (always, even on clients) func _idle_behavior(_delta): velocity = Vector2.ZERO position_z = 0.0 # Show idle frame (frame 2) anim_frame = 2 # Check if player is nearby if target_player: var dist = global_position.distance_to(target_player.global_position) if dist < detection_range: # Start flying (chasing player) var was_idle = (state == BatState.IDLE) state = BatState.FLYING state_timer = fly_duration # Play sound very seldom when starting to chase (only once per bat, with low chance) if was_idle and not has_played_chase_sound and randf() < 0.15: # 15% chance _play_chase_sound() has_played_chase_sound = true return # Switch to flying after idle duration if state_timer <= 0: state = BatState.FLYING state_timer = fly_duration func _flying_behavior(_delta): position_z = fly_height # Fly towards player if found if target_player and is_instance_valid(target_player): var direction = (target_player.global_position - global_position).normalized() velocity = direction * move_speed current_direction = _get_direction_from_vector(direction) else: # Wander randomly if randf() < 0.02: # Small chance to change direction var random_dir = Vector2(randf() - 0.5, randf() - 0.5).normalized() velocity = random_dir * move_speed current_direction = _get_direction_from_vector(random_dir) # Switch back to idle after flying duration if state_timer <= 0: state = BatState.IDLE state_timer = idle_duration func _update_animation(delta): if state == BatState.IDLE: # Show idle frame anim_frame = 2 else: # Animate flying (frames 0, 1, 2) anim_time += delta if anim_time >= anim_speed: anim_time = 0.0 anim_frame = (anim_frame + 1) % 3 # Map 8 directions to 4 sprite directions var sprite_dir = _get_sprite_direction() # Set sprite frame if sprite: sprite.frame = anim_frame + (sprite_dir * 3) # 3 frames per direction func _get_sprite_direction() -> int: # Map 8 directions to 4 sprite rows match current_direction: Direction.DOWN, Direction.DOWN_LEFT, Direction.DOWN_RIGHT: return 0 # Down row Direction.LEFT, Direction.UP_LEFT: return 1 # Left row Direction.RIGHT, Direction.UP_RIGHT: return 2 # Right row Direction.UP: return 3 # Up row return 0 func _update_z_position(_delta): # Update sprite Y offset based on height if sprite: sprite.position.y = -position_z * 0.5 # Update shadow based on height (use same logic as base class _update_client_visuals for consistency) 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 _update_client_visuals(): # Update visuals on clients based on synced state super._update_client_visuals() # Update animation based on synced state _update_animation(0.0) # Update animation immediately when state changes # Update sprite frame based on synced anim_frame and direction if sprite: var sprite_dir = _get_sprite_direction() sprite.frame = anim_frame + (sprite_dir * 3) # 3 frames per direction func _play_death_animation(): # Fall to ground var fall_tween = create_tween() fall_tween.tween_property(self, "position_z", 0.0, 0.3) await fall_tween.finished # Fade out var fade_tween = create_tween() fade_tween.tween_property(sprite, "modulate:a", 0.0, 0.3) await fade_tween.finished queue_free() func _play_chase_sound(): # Play a random bat sound when starting to chase (very seldom) if not bat_flap_sfx and not bat_chirp_sfx: return # Randomly choose between flap or chirp if randf() < 0.5: if bat_flap_sfx and bat_flap_sfx.stream: bat_flap_sfx.play() else: if bat_chirp_sfx and bat_chirp_sfx.stream: bat_chirp_sfx.play()