DungeonCrawler/enemy.gd
Andre 217ce63a28 feat: Tektonischer Schlag implementiert (Krieger Level 5)
- AoE um Spieler (Radius 4.0), kostet 30 Wut
- Schaden: 5-10 + 30% Stärke an alle Gegner im Radius
- Slow-Effekt: 60% langsamer für 3s
- enemy.gd: apply_slow() + _slow_factor in _move_toward()
- Skill erscheint ab Level 5 in der Skillliste

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 21:27:52 +01:00

478 lines
18 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 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
# Debuffs
var _slow_timer: float = 0.0
var _slow_factor: float = 1.0 # 1.0 = normal, 0.5 = 50% langsamer
# 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
# Slow-Debuff herunterzählen
if _slow_timer > 0.0:
_slow_timer -= delta
if _slow_timer <= 0.0:
_slow_factor = 1.0
# 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 * _slow_factor
velocity.z = direction.z * speed * _slow_factor
_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 apply_slow(factor: float, duration: float):
_slow_factor = clamp(factor, 0.1, 1.0)
_slow_timer = duration
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()