- 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>
413 lines
15 KiB
GDScript
413 lines
15 KiB
GDScript
# 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()
|