# 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 mob_level: int = 1 @export var detection_range: float = 15.0 @export var leash_range: float = 30.0 # Max Entfernung vom Spawn bevor Aggro verloren geht @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/Goblin+Animation/idle.fbx" const ANIM_WALK = "res://assets/Goblin+Animation/walking.fbx" const ANIM_RUN = "res://assets/Goblin+Animation/standard run.fbx" const ANIM_AUTOATTACK = "res://assets/Goblin+Animation/attack.fbx" const ANIM_DEATH = "res://assets/Goblin+Animation/die.fbx" const ANIM_TURN_LEFT = "res://assets/Goblin+Animation/left turn 90.fbx" const ANIM_TURN_RIGHT = "res://assets/Goblin+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 # ═══════════════════════════════════════════════════════════════ # LEVEL-SKALIERUNG # ═══════════════════════════════════════════════════════════════ func _apply_level_scaling(): if mob_level <= 1: return var lvl = mob_level - 1 max_hp = int(max_hp * (1.0 + lvl * 0.3)) # +30% HP pro Level min_damage = int(min_damage * (1.0 + lvl * 0.2)) # +20% Schaden pro Level max_damage = int(max_damage * (1.0 + lvl * 0.2)) xp_reward = int(xp_reward * (1.0 + lvl * 0.4)) # +40% XP pro Level # ═══════════════════════════════════════════════════════════════ # READY # ═══════════════════════════════════════════════════════════════ func _ready(): _apply_level_scaling() 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 nav_agent.radius = 0.4 # Gegner-Breite für Pfadberechnung # 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) # Leash-Check: Zu weit vom Spawn → Aggro verlieren, zurücklaufen var dist_from_spawn = global_position.distance_to(spawn_position) if dist_from_spawn > leash_range and (state == State.CHASING or state == State.ATTACKING): target = null state = State.PATROL _pick_patrol_target() _do_patrol(delta) move_and_slide() return 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 _has_navmesh() -> bool: var map_rid = get_world_3d().navigation_map return NavigationServer3D.map_get_regions(map_rid).size() > 0 func _move_toward(goal: Vector3, speed: float): # Nur neuen Pfad berechnen wenn sich das Ziel deutlich bewegt hat if nav_agent.target_position.distance_to(goal) > 1.5: nav_agent.target_position = goal if nav_agent.is_navigation_finished(): return var path = nav_agent.get_current_navigation_path() var next_pos = nav_agent.get_next_path_position() var direction = (next_pos - global_position) direction.y = 0 if direction.length() < 0.1: if not _has_navmesh() or path.size() <= 1: # Kein NavMesh oder kein gültiger Pfad → direkte Bewegung direction = (goal - global_position) direction.y = 0 if direction.length() < 0.1: return direction = direction.normalized() velocity.x = direction.x * speed velocity.z = direction.z * speed _face_direction(direction) func _chase_target(): if target == null: return if global_position.distance_to(target.global_position) < 0.5: return _move_toward(target.global_position, move_speed) 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 (via NavMesh mit Fallback) if global_position.distance_to(patrol_target) <= 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 _move_toward(patrol_target, patrol_speed) _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) var raw_target = spawn_position + Vector3(cos(angle) * dist, 0, sin(angle) * dist) # Auf nächsten begehbaren Punkt snappen (verhindert Ziele in Wänden) var map_rid = get_world_3d().navigation_map var snapped = NavigationServer3D.map_get_closest_point(map_rid, raw_target) # Fallback: wenn Snap fehlschlägt (kein NavMesh), Rohziel verwenden if snapped == Vector3.ZERO and raw_target != Vector3.ZERO: patrol_target = raw_target else: patrol_target = snapped 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(mob_level) 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(10.0).timeout queue_free()