extends CharacterBody2D var speed: float = 150.0 var gravity: float = 1200.0 var max_health: int = 100 var health: int = 100 var attack_range: float = 85.0 var attack_damage: int = 10 var cooldown: float = 0.0 var attack_windup: float = 0.18 var attack_recover: float = 0.85 var is_blocking: bool = false var block_chance: float = 0.25 var block_damage_multiplier: float = 0.35 var block_time_left: float = 0.0 var min_x: float = 40.0 var max_x: float = 1240.0 var anim: AnimatedSprite2D = null var player_ref: CharacterBody2D = null func set_player(p: CharacterBody2D) -> void: player_ref = p const SPRITE_FOLDER := "res://assets/enemy" func _ready() -> void: await get_tree().process_frame health = max_health velocity = Vector2.ZERO anim = _get_or_create_anim() if anim.sprite_frames == null or anim.sprite_frames.get_animation_names().is_empty(): anim.sprite_frames = _build_sprite_frames_from_folder(SPRITE_FOLDER) anim.visible = true anim.modulate.a = 1.0 if anim.scale == Vector2.ZERO: anim.scale = Vector2.ONE _safe_play("idle") func _physics_process(delta: float) -> void: if player_ref == null: return if cooldown > 0.0: cooldown = max(cooldown - delta, 0.0) if block_time_left > 0.0: block_time_left = max(block_time_left - delta, 0.0) else: is_blocking = false # gravity if not is_on_floor(): velocity.y += gravity * delta else: if velocity.y > 0.0: velocity.y = 0.0 var dx: float = player_ref.global_position.x - global_position.x var dist: float = abs(dx) # face player if anim != null: anim.flip_h = (dx < 0.0) # block sometimes when close if not is_blocking and dist < 120.0 and randf() < (block_chance * delta * 2.0): is_blocking = true block_time_left = 0.6 # movement if not is_blocking and dist > 70.0: velocity.x = speed if dx > 0.0 else -speed _safe_play("walk") else: velocity.x = 0.0 if is_blocking: _safe_play("block") else: _safe_play("idle") move_and_slide() global_position.x = clamp(global_position.x, min_x, max_x) # attack if (not is_blocking) and cooldown <= 0.0 and dist <= attack_range: _start_attack() func _start_attack() -> void: cooldown = attack_windup + attack_recover _safe_play("attack") var t := get_tree().create_timer(attack_windup) t.timeout.connect(func(): _try_apply_damage()) func _try_apply_damage() -> void: if player_ref == null: return var dist: float = abs(player_ref.global_position.x - global_position.x) if dist > attack_range: return if player_ref.has_method("take_damage"): player_ref.call("take_damage", attack_damage, global_position.x) func take_damage(amount: int, _attacker_x: float = 0.0) -> void: if health <= 0: return var final_amount := amount if is_blocking: final_amount = int(round(float(amount) * block_damage_multiplier)) health = max(health - final_amount, 0) _safe_play("hit") if not is_blocking and randf() < 0.25: is_blocking = true block_time_left = 0.7 if health <= 0: var parent_node := get_parent() if parent_node != null and parent_node.has_method("end_fight"): parent_node.call("end_fight", true) # -------- sprite helpers -------- func _get_or_create_anim() -> AnimatedSprite2D: var found := _find_first_animated_sprite(self) if found != null: return found var new_anim := AnimatedSprite2D.new() new_anim.name = "AnimatedSprite2D" add_child(new_anim) new_anim.position = Vector2(0, -40) new_anim.scale = Vector2(0.25, 0.25) return new_anim func _find_first_animated_sprite(root: Node) -> AnimatedSprite2D: if root is AnimatedSprite2D: return root for c in root.get_children(): var res := _find_first_animated_sprite(c) if res != null: return res return null func _safe_play(anim_name: String) -> void: if anim == null or anim.sprite_frames == null: return if not anim.sprite_frames.has_animation(anim_name): if anim_name != "idle" and anim.sprite_frames.has_animation("idle"): if anim.animation != "idle": anim.play("idle") return if anim.animation != anim_name: anim.play(anim_name) func _build_sprite_frames_from_folder(folder: String) -> SpriteFrames: var frames := SpriteFrames.new() _add_anim_from_prefix(frames, folder, "idle", 6.0, true) _add_anim_from_prefix(frames, folder, "walk", 10.0, true) _add_anim_from_prefix(frames, folder, "attack", 12.0, false) _add_anim_from_prefix(frames, folder, "hit", 10.0, false) _add_anim_from_prefix(frames, folder, "block", 8.0, true) if frames.get_animation_names().is_empty(): push_warning("Enemy: No sprites found in " + folder + " (expected idle_1.png, walk_1.png etc.)") return frames func _add_anim_from_prefix(frames: SpriteFrames, folder: String, anim_name: String, fps: float, loop: bool) -> void: var textures: Array[Texture2D] = _load_textures_sorted(folder, anim_name + "_") if textures.is_empty(): return if not frames.has_animation(anim_name): frames.add_animation(anim_name) frames.set_animation_speed(anim_name, fps) frames.set_animation_loop(anim_name, loop) for t in textures: frames.add_frame(anim_name, t) func _load_textures_sorted(folder: String, prefix: String) -> Array[Texture2D]: var out: Array[Texture2D] = [] var dir := DirAccess.open(folder) if dir == null: push_warning("Enemy: Folder not found: " + folder) return out dir.list_dir_begin() var file := dir.get_next() while file != "": if not dir.current_is_dir(): var lower := file.to_lower() if lower.ends_with(".png") and file.begins_with(prefix): var tex := load(folder + "/" + file) as Texture2D if tex != null: out.append(tex) file = dir.get_next() dir.list_dir_end() out.sort_custom(func(a: Texture2D, b: Texture2D) -> bool: return _extract_suffix_number(a.resource_path, prefix) < _extract_suffix_number(b.resource_path, prefix) ) return out func _extract_suffix_number(path: String, prefix: String) -> int: var file := path.get_file() if not file.begins_with(prefix): return 0 var s := file.replace(prefix, "").replace(".png", "") return int(s)