- Zieht Gegner in Reichweite 12m heran (Pull-Effekt 0.6s) - Nach dem Pull: automatischer Schlag 20-30 + 80% Stärke - enemy.gd: apply_pull() + Pull überschreibt KI-Bewegung - Kostet 35 Wut, Cooldown 14s Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
505 lines
19 KiB
GDScript
505 lines
19 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 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
|
||
|
||
# Pull (Zornfesseln)
|
||
var _pull_target: Node3D = null
|
||
var _pull_timer: float = 0.0
|
||
const PULL_SPEED: float = 18.0
|
||
|
||
# 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
|
||
|
||
# Pull-Effekt (Zornfesseln)
|
||
if _pull_timer > 0.0 and _pull_target != null and is_instance_valid(_pull_target):
|
||
_pull_timer -= delta
|
||
var dir = (_pull_target.global_position - global_position)
|
||
dir.y = 0
|
||
if dir.length() > 1.0:
|
||
dir = dir.normalized()
|
||
velocity.x = dir.x * PULL_SPEED
|
||
velocity.z = dir.z * PULL_SPEED
|
||
else:
|
||
_pull_timer = 0.0 # Nah genug — Pull beendet
|
||
|
||
# Pull aktiv → normale KI überspringen
|
||
if _pull_timer > 0.0:
|
||
move_and_slide()
|
||
return
|
||
|
||
# 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_pull(source: Node3D, duration: float):
|
||
_pull_target = source
|
||
_pull_timer = duration
|
||
state = State.CHASING # Aggro auslösen
|
||
|
||
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()
|