DungeonCrawler/enemy.gd
Andre e4efb239f2 Enemy-System komplett überarbeitet, Kamera-Steuerung verbessert
- Enemy: Neues castle_guard_01 Modell mit Animationen (idle, walk, run, autoattack, death, turn)
- Enemy: Patrol-KI mit Turn-Animationen beim Richtungswechsel, 5s idle nach Spawn
- Enemy: Aggro durch Detection Range (15m) und Schadens-Aggro, Patrol→Chase Übergang
- Enemy: Respawn nach 5s am Spawnpunkt, XP-Vergabe beim Tod
- Kamera: LMB frei drehen (umschauen) auch mit markiertem Ziel
- Kamera: RMB Lock-On temporär aufheben zum Weglaufen
- Kamera: LMB-Klick auf freie Fläche visiert Ziel ab
- Kamera: Drag vs Klick Unterscheidung (< 5px Bewegung = Klick)
- Autoattack greift automatisch wieder an wenn Ziel zurück in Range
- Player zur Gruppe "player" hinzugefügt für Enemy-Detection
- Dokumentation vollständig aktualisiert

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 00:56:14 +01:00

413 lines
15 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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()