# Enemy.gd # ───────────────────────────────────────────────────────────────────────────── # Gegner-KI – State Machine mit NavigationAgent3D # # Zustände: # IDLE → wartet bis Spieler in detection_range kommt # CHASING → läuft via NavMesh zum Spieler # ATTACKING → steht, dreht sich zum Spieler, greift in attack_speed-Intervallen an # DEAD → Kollision deaktiviert, Node wird nach kurzer Verzögerung entfernt # # Signale: # enemy_died(spawn_position, xp_reward) → World.gd → Respawn + XP # enemy_dropped_loot(loot, world_pos) → World.gd → Spieler → LootWindow # ───────────────────────────────────────────────────────────────────────────── extends CharacterBody3D # ═══════════════════════════════════════════════════════════════ # SIGNALE # ═══════════════════════════════════════════════════════════════ signal enemy_died(spawn_position: Vector3, xp_reward: int) signal enemy_dropped_loot(loot: Dictionary, world_pos: Vector3) # ═══════════════════════════════════════════════════════════════ # STATS # ═══════════════════════════════════════════════════════════════ @export var max_hp: int = 50 @export var min_damage: int = 3 @export var max_damage: int = 7 @export var attack_range: float = 2.0 @export var attack_speed: float = 2.0 # Sekunden zwischen Angriffen @export var move_speed: float = 5.5 @export var xp_reward: int = 20 @export var detection_range: float = 15.0 @export var loot_table: LootTable = null var current_hp: int var target = null # Spieler # ═══════════════════════════════════════════════════════════════ # ZUSTAND # ═══════════════════════════════════════════════════════════════ enum State { IDLE, PATROL, CHASING, ATTACKING, DEAD } var state: State = State.IDLE var attack_cooldown: float = 0.0 var is_dead: bool = false # Patrol @export var patrol_radius: float = 8.0 @export var patrol_speed: float = 1.5 # Laufgeschwindigkeit beim Patrouillieren var spawn_position: Vector3 = Vector3.ZERO var patrol_target: Vector3 = Vector3.ZERO var patrol_wait_timer: float = 0.0 const GRAVITY = 9.8 # ═══════════════════════════════════════════════════════════════ # ANIMATION # ═══════════════════════════════════════════════════════════════ const ANIM_IDLE = "res://assets/Warrior+Animation/idle.fbx" const ANIM_WALK = "res://assets/Warrior+Animation/walking.fbx" const ANIM_RUN = "res://assets/Warrior+Animation/running.fbx" const ANIM_AUTOATTACK = "res://assets/Warrior+Animation/Autoattack.fbx" const ANIM_DEATH = "res://assets/Warrior+Animation/Dying Backwards.fbx" const ANIM_TURN_LEFT = "res://assets/Warrior+Animation/Left Turn 90.fbx" const ANIM_TURN_RIGHT = "res://assets/Warrior+Animation/Right Turn 90.fbx" var anim_player: AnimationPlayer = null var current_anim: String = "" var is_turning: bool = false # ═══════════════════════════════════════════════════════════════ # NODE-REFERENZEN # ═══════════════════════════════════════════════════════════════ @onready var nav_agent: NavigationAgent3D = $NavigationAgent3D @onready var health_label: Label3D = $HealthDisplay/Label3D @onready var model: Node3D = $Model # ═══════════════════════════════════════════════════════════════ # READY # ═══════════════════════════════════════════════════════════════ func _ready(): current_hp = max_hp _update_health_display() health_label.visible = false # NavigationAgent konfigurieren nav_agent.path_desired_distance = 0.5 nav_agent.target_desired_distance = attack_range * 0.9 # Animationen einrichten _setup_animations() # Spawnpunkt erst nach dem ersten Frame setzen (global_position ist in _ready() noch nicht final) await get_tree().process_frame spawn_position = global_position _pick_patrol_target() patrol_wait_timer = 5.0 # 5 Sekunden idle nach Spawn state = State.PATROL # ═══════════════════════════════════════════════════════════════ # ANIMATIONEN # ═══════════════════════════════════════════════════════════════ func _setup_animations(): if model: anim_player = model.find_child("AnimationPlayer", true, false) as AnimationPlayer if anim_player == null: push_warning("Enemy: Kein AnimationPlayer gefunden!") return # FBX-Import-Animationen komplett aufräumen anim_player.stop() anim_player.autoplay = "" # Alle vorhandenen Libraries entfernen und frische erstellen for lib_name in anim_player.get_animation_library_list(): anim_player.remove_animation_library(lib_name) anim_player.add_animation_library("", AnimationLibrary.new()) _load_anim_from_fbx(ANIM_IDLE, "idle", true) _load_anim_from_fbx(ANIM_WALK, "walk", true) _load_anim_from_fbx(ANIM_RUN, "run", true) _load_anim_from_fbx(ANIM_AUTOATTACK, "autoattack", false) _load_anim_from_fbx(ANIM_DEATH, "death", false) _load_anim_from_fbx(ANIM_TURN_LEFT, "turn_left", false) _load_anim_from_fbx(ANIM_TURN_RIGHT, "turn_right", false) anim_player.animation_finished.connect(_on_animation_finished) _play_anim("idle") func _load_anim_from_fbx(fbx_path: String, anim_name: String, loop: bool = false): var scene = load(fbx_path) if scene == null: push_warning("Enemy: FBX nicht geladen: " + fbx_path) return var instance = scene.instantiate() var ext_ap = instance.find_child("AnimationPlayer", true, false) as AnimationPlayer if ext_ap == null: instance.queue_free() push_warning("Enemy: Kein AnimationPlayer in " + fbx_path) return var anim_list = ext_ap.get_animation_list() if anim_list.is_empty(): instance.queue_free() return var anim = ext_ap.get_animation(anim_list[0]).duplicate(true) _strip_root_motion(anim) anim.loop_mode = Animation.LOOP_LINEAR if loop else Animation.LOOP_NONE var lib = anim_player.get_animation_library("") if not lib.has_animation(anim_name): lib.add_animation(anim_name, anim) instance.queue_free() func _strip_root_motion(anim: Animation): for i in range(anim.get_track_count() - 1, -1, -1): if anim.track_get_type(i) != Animation.TYPE_POSITION_3D: continue var np: NodePath = anim.track_get_path(i) if np.get_subname_count() == 0: # Node-Position-Track (Armature-Root) → komplett entfernen anim.remove_track(i) else: # Knochen-Position: ALLE XZ-Werte nullen, Y behalten var key_count = anim.track_get_key_count(i) for k in range(key_count): var v: Vector3 = anim.track_get_key_value(i, k) anim.track_set_key_value(i, k, Vector3(0.0, v.y, 0.0)) func _play_anim(anim_name: String): if anim_player == null: return if anim_name != current_anim: current_anim = anim_name if anim_player.has_animation(anim_name): anim_player.play(anim_name) func _on_animation_finished(anim_name: StringName): if anim_name == "autoattack": _play_anim("idle") elif anim_name == "turn_left" or anim_name == "turn_right": is_turning = false # ═══════════════════════════════════════════════════════════════ # PHYSICS PROCESS # ═══════════════════════════════════════════════════════════════ func _physics_process(delta): if is_dead: return # Schwerkraft if not is_on_floor(): velocity.y -= GRAVITY * delta # Cooldown herunterzählen if attack_cooldown > 0: attack_cooldown -= delta # Kein Ziel → Spieler suchen if target == null or not is_instance_valid(target): target = null if state == State.CHASING or state == State.ATTACKING: state = State.PATROL _pick_patrol_target() # Spieler in Reichweite? → Aggro var players = get_tree().get_nodes_in_group("player") if players.size() > 0: var player = players[0] var dist = global_position.distance_to(player.global_position) if dist <= detection_range: target = player state = State.CHASING else: _do_patrol(delta) else: _do_patrol(delta) move_and_slide() return var distance = global_position.distance_to(target.global_position) match state: State.IDLE: _play_anim("idle") if distance <= detection_range: state = State.CHASING State.PATROL: if distance <= detection_range: state = State.CHASING else: _do_patrol(delta) State.CHASING: if distance <= attack_range: state = State.ATTACKING velocity.x = 0 velocity.z = 0 else: _chase_target() _play_anim("run") State.ATTACKING: if distance > attack_range * 1.5: state = State.CHASING else: velocity.x = 0 velocity.z = 0 _face_target() if attack_cooldown <= 0: _perform_attack() elif current_anim != "autoattack": _play_anim("idle") move_and_slide() func _chase_target(): if target == null: return var direction = (target.global_position - global_position) direction.y = 0 if direction.length() < 0.1: return direction = direction.normalized() velocity.x = direction.x * move_speed velocity.z = direction.z * move_speed _face_direction(direction) func _do_patrol(delta: float): # Turn-Animation läuft → warten if is_turning: velocity.x = 0 velocity.z = 0 return # Warten zwischen Patrol-Punkten if patrol_wait_timer > 0: patrol_wait_timer -= delta velocity.x = 0 velocity.z = 0 _play_anim("idle") if patrol_wait_timer <= 0: # Wartezeit vorbei → Turn-Animation zum nächsten Ziel abspielen _turn_toward_patrol_target() return # Zum Patrol-Ziel laufen var dist = global_position.distance_to(patrol_target) if dist <= 1.0: # Ziel erreicht → kurz warten, neues Ziel velocity.x = 0 velocity.z = 0 _play_anim("idle") patrol_wait_timer = randf_range(2.0, 5.0) _pick_patrol_target() return var direction = (patrol_target - global_position) direction.y = 0 direction = direction.normalized() velocity.x = direction.x * patrol_speed velocity.z = direction.z * patrol_speed _face_direction(direction) _play_anim("walk") func _turn_toward_patrol_target(): var dir = (patrol_target - global_position) dir.y = 0 if dir.length() < 0.1: return dir = dir.normalized() # Winkel zwischen aktueller Blickrichtung und Zielrichtung berechnen var forward = Vector3(sin(rotation.y), 0, cos(rotation.y)) var cross = forward.cross(dir).y # positiv = Ziel rechts, negativ = Ziel links is_turning = true if cross >= 0: _play_anim("turn_right") else: _play_anim("turn_left") # Schon mal in die Zielrichtung drehen _face_direction(dir) func _pick_patrol_target(): var angle = randf() * TAU var dist = randf_range(3.0, patrol_radius) patrol_target = spawn_position + Vector3(cos(angle) * dist, 0, sin(angle) * dist) func _face_target(): if target == null: return var dir = (target.global_position - global_position) dir.y = 0 if dir.length() > 0.01: _face_direction(dir.normalized()) func _face_direction(dir: Vector3): if dir.length() > 0.01: rotation.y = atan2(dir.x, dir.z) # ═══════════════════════════════════════════════════════════════ # KAMPF # ═══════════════════════════════════════════════════════════════ func _perform_attack(): if target == null or not is_instance_valid(target): return var damage = randi_range(min_damage, max_damage) target.take_damage(damage) attack_cooldown = attack_speed _play_anim("autoattack") print(name + " greift an: " + str(damage) + " Schaden") func take_damage(amount: int): if is_dead: return current_hp = clamp(current_hp - amount, 0, max_hp) _update_health_display() # Aggro: Bei Schaden sofort den Spieler verfolgen if state == State.IDLE or state == State.PATROL: var players = get_tree().get_nodes_in_group("player") if players.size() > 0: target = players[0] state = State.CHASING if current_hp <= 0: _die() # ═══════════════════════════════════════════════════════════════ # HP-ANZEIGE # ═══════════════════════════════════════════════════════════════ func show_health(): if health_label: health_label.visible = true func hide_health(): if health_label: health_label.visible = false func _update_health_display(): if health_label: health_label.text = str(current_hp) + " / " + str(max_hp) # ═══════════════════════════════════════════════════════════════ # TOD & LOOT # ═══════════════════════════════════════════════════════════════ func _die(): if is_dead: return is_dead = true state = State.DEAD velocity = Vector3.ZERO _play_anim("death") print(name + " gestorben!") # Loot generieren if loot_table != null: var loot = loot_table.generate_loot() enemy_dropped_loot.emit(loot, global_position) # XP und Respawn-Signal (nur einmal!) enemy_died.emit(global_position, xp_reward) # Kollision deaktivieren und Node entfernen set_deferred("collision_layer", 0) set_deferred("collision_mask", 0) await get_tree().create_timer(2.0).timeout queue_free()