- Kenney-Modelle durch Mixamo Warrior (warrior.fbx) für Player und Enemy ersetzt - Animations-System: Lädt Walking, Strafe, Jump, Autoattack, Heavy Strike, Dying aus separaten FBX-Dateien - Player: Bewegungsanimationen (walk/strafe/jump) + Kampfanimationen (autoattack/heavy_strike/die) - Enemy: Walk-Animation für Patrol/Chase, Autoattack-Animation, Death-Animation mit Verzögerung - Zauberstab-Icon (wand_icon.svg) erstellt und in Magier-Skills verknüpft - Frostblitz und Zauberstab als klassenspezifische Magier-Skills dokumentiert - Castbar-System, Gegner-Aggro bei Schaden und Drag&Drop in Dokumentation ergänzt - Enemy patrouilliert jetzt auch ohne Spieler-Referenz Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
339 lines
11 KiB
GDScript
339 lines
11 KiB
GDScript
# Enemy.gd
|
|
# Steuert den Gegner: KI-Bewegung zum Spieler, Angriff, HP, Zielanzeige
|
|
extends CharacterBody3D
|
|
|
|
signal enemy_died(spawn_position: Vector3, xp_reward: int)
|
|
signal enemy_dropped_loot(loot: Dictionary, world_position: Vector3)
|
|
|
|
const SPEED = 3.0
|
|
const PATROL_SPEED = 1.5
|
|
const GRAVITY = 9.8
|
|
const ATTACK_RANGE = 1.5
|
|
const ATTACK_COOLDOWN = 2.0
|
|
const AGGRO_RANGE = 8.0 # Entfernung ab der der Gegner angreift
|
|
const PATROL_RADIUS = 5.0 # Radius um Spawn-Position für Patrol
|
|
const PATROL_WAIT_TIME = 2.0 # Wartezeit am Patrol-Punkt
|
|
|
|
# Level-Differenz Konstanten
|
|
const LEVEL_DIFF_DAMAGE_MOD = 0.1 # 10% mehr/weniger Schaden pro Level-Differenz
|
|
const MAX_LEVEL_DIFF_MOD = 0.5 # Maximal 50% Modifikation
|
|
|
|
enum State { PATROL, CHASE, ATTACK }
|
|
|
|
# Stats-System
|
|
@export var level: int = 1
|
|
@export var base_strength: int = 8
|
|
@export var base_stamina: int = 10
|
|
@export var base_armor: int = 5 # Rüstung reduziert Nahkampfschaden
|
|
|
|
# Berechnete Stats
|
|
var strength: int = 8
|
|
var stamina: int = 10
|
|
var armor: int = 5
|
|
var max_hp: int = 100
|
|
var current_hp: int = 100
|
|
var attack_damage: int = 5
|
|
|
|
# XP-Belohnung (skaliert mit Level)
|
|
var xp_reward: int = 25
|
|
|
|
# Loot-System
|
|
@export var loot_table: LootTable
|
|
|
|
var target = null # Spieler-Referenz (wird von World gesetzt)
|
|
var can_attack = true
|
|
var spawn_position: Vector3 # Ursprüngliche Spawn-Position
|
|
var current_state = State.PATROL
|
|
var patrol_target: Vector3 # Aktuelles Patrol-Ziel
|
|
var is_waiting = false # Ob Gegner am Patrol-Punkt wartet
|
|
|
|
# Animation System
|
|
const ANIMATION_FILES = {
|
|
"walk": "res://assets/animations/Walking.fbx",
|
|
"autoattack": "res://assets/animations/Autoattack.fbx",
|
|
"die": "res://assets/animations/Dying Backwards.fbx",
|
|
}
|
|
var anim_player: AnimationPlayer = null
|
|
var current_anim: String = ""
|
|
|
|
@onready var health_label = $HealthLabel
|
|
|
|
func _ready():
|
|
_calculate_stats()
|
|
current_hp = max_hp
|
|
health_label.visible = false
|
|
_update_label()
|
|
spawn_position = global_position
|
|
_pick_new_patrol_target()
|
|
_setup_animations()
|
|
|
|
# Animationen laden
|
|
func _setup_animations():
|
|
# Debug: Alle Kinder ausgeben um den Modell-Node zu finden
|
|
print("Enemy Kinder: ")
|
|
for child in get_children():
|
|
print(" - ", child.name, " (", child.get_class(), ")")
|
|
var model = get_node_or_null("EnemyModel")
|
|
if model == null:
|
|
print("Enemy: EnemyModel nicht gefunden!")
|
|
return
|
|
anim_player = _find_node_by_class(model, "AnimationPlayer")
|
|
if anim_player == null:
|
|
anim_player = AnimationPlayer.new()
|
|
anim_player.name = "AnimationPlayer"
|
|
model.add_child(anim_player)
|
|
var lib: AnimationLibrary
|
|
if anim_player.has_animation_library(""):
|
|
lib = anim_player.get_animation_library("")
|
|
else:
|
|
lib = AnimationLibrary.new()
|
|
anim_player.add_animation_library("", lib)
|
|
for anim_id in ANIMATION_FILES:
|
|
var scene = load(ANIMATION_FILES[anim_id]) as PackedScene
|
|
if scene == null:
|
|
print("Enemy: Animation nicht gefunden: ", ANIMATION_FILES[anim_id])
|
|
continue
|
|
var instance = scene.instantiate()
|
|
var source_ap = _find_node_by_class(instance, "AnimationPlayer")
|
|
if source_ap:
|
|
var names = source_ap.get_animation_list()
|
|
if names.size() > 0:
|
|
var anim = source_ap.get_animation(names[0])
|
|
if lib.has_animation(anim_id):
|
|
lib.remove_animation(anim_id)
|
|
lib.add_animation(anim_id, anim)
|
|
print("Enemy: Animation geladen: ", anim_id)
|
|
else:
|
|
print("Enemy: Kein AnimationPlayer in ", ANIMATION_FILES[anim_id])
|
|
instance.queue_free()
|
|
print("Enemy: Verfügbare Animationen: ", anim_player.get_animation_list())
|
|
# Debug: Modell-Hierarchie ausgeben
|
|
_print_tree(model, 0)
|
|
|
|
func _print_tree(node: Node, depth: int):
|
|
print(" ".repeat(depth), node.name, " (", node.get_class(), ")")
|
|
for child in node.get_children():
|
|
_print_tree(child, depth + 1)
|
|
|
|
func _find_node_by_class(node: Node, class_name_str: String) -> Node:
|
|
for child in node.get_children():
|
|
if child.get_class() == class_name_str:
|
|
return child
|
|
var result = _find_node_by_class(child, class_name_str)
|
|
if result:
|
|
return result
|
|
return null
|
|
|
|
func _play_anim(anim_name: String):
|
|
if anim_player == null:
|
|
print("Enemy: anim_player ist null!")
|
|
return
|
|
if anim_name != current_anim:
|
|
current_anim = anim_name
|
|
if anim_name != "" and anim_player.has_animation(anim_name):
|
|
anim_player.play(anim_name)
|
|
print("Enemy: Spiele Animation: ", anim_name)
|
|
# Debug: Track-Pfade und root_node ausgeben
|
|
var anim = anim_player.get_animation(anim_name)
|
|
print(" AnimPlayer root_node: ", anim_player.root_node)
|
|
print(" AnimPlayer absoluter Pfad: ", anim_player.get_path())
|
|
for t in range(mini(3, anim.get_track_count())):
|
|
print(" Track ", t, ": ", anim.track_get_path(t))
|
|
elif anim_name != "":
|
|
print("Enemy: Animation nicht gefunden: ", anim_name, " | Verfügbar: ", anim_player.get_animation_list())
|
|
else:
|
|
anim_player.stop()
|
|
|
|
# Stats basierend auf Level berechnen
|
|
func _calculate_stats():
|
|
var levels_gained = level - 1
|
|
strength = base_strength + levels_gained * 2
|
|
stamina = base_stamina + levels_gained * 3
|
|
armor = base_armor + levels_gained * 2
|
|
|
|
# HP = Stamina * 10
|
|
max_hp = stamina * 10
|
|
# Schaden = Stärke / 2
|
|
attack_damage = int(strength * 0.5) + 2
|
|
# XP = 25 * Level
|
|
xp_reward = 25 * level
|
|
|
|
print("Enemy Stats (Lv", level, ") - STR:", strength, " STA:", stamina, " ARM:", armor, " HP:", max_hp, " DMG:", attack_damage)
|
|
|
|
# Schaden mit Rüstung und Level-Differenz berechnen
|
|
func calculate_incoming_damage(raw_damage: int, attacker_level: int, is_melee: bool) -> int:
|
|
var damage = float(raw_damage)
|
|
|
|
# Rüstung reduziert nur Nahkampfschaden
|
|
if is_melee:
|
|
# Rüstungsreduktion: armor / (armor + 50) = Prozent Reduktion
|
|
# Bei 5 Rüstung: 5/55 = ~9% Reduktion
|
|
# Bei 20 Rüstung: 20/70 = ~29% Reduktion
|
|
var armor_reduction = float(armor) / (float(armor) + 50.0)
|
|
damage = damage * (1.0 - armor_reduction)
|
|
|
|
# Level-Differenz Modifikator
|
|
var level_diff = attacker_level - level
|
|
var level_mod = clamp(level_diff * LEVEL_DIFF_DAMAGE_MOD, -MAX_LEVEL_DIFF_MOD, MAX_LEVEL_DIFF_MOD)
|
|
damage = damage * (1.0 + level_mod)
|
|
|
|
return maxi(1, int(damage)) # Mindestens 1 Schaden
|
|
|
|
# HP-Label Text aktualisieren
|
|
func _update_label():
|
|
health_label.text = "Lv" + str(level) + " " + str(current_hp) + "/" + str(max_hp)
|
|
|
|
# HP-Label anzeigen (wenn Gegner markiert wird)
|
|
func show_health():
|
|
health_label.visible = true
|
|
|
|
# HP-Label verstecken (wenn Markierung aufgehoben wird)
|
|
func hide_health():
|
|
health_label.visible = false
|
|
|
|
# Schaden nehmen und Label aktualisieren
|
|
func take_damage(amount):
|
|
current_hp -= amount
|
|
_update_label()
|
|
# Aggro bei Schaden — sofort angreifen
|
|
if current_state == State.PATROL:
|
|
current_state = State.CHASE
|
|
is_waiting = false
|
|
print("Gegner wurde angegriffen und verfolgt den Spieler!")
|
|
if current_hp <= 0:
|
|
die()
|
|
|
|
# Schaden mit vollem Schadenssystem (Rüstung, Level-Differenz)
|
|
func take_damage_from(raw_damage: int, attacker_level: int, is_melee: bool = true):
|
|
var final_damage = calculate_incoming_damage(raw_damage, attacker_level, is_melee)
|
|
print("Eingehender Schaden: ", raw_damage, " -> ", final_damage, " (nach Rüstung/Level)")
|
|
take_damage(final_damage)
|
|
|
|
# Gegner aus der Szene entfernen
|
|
func die():
|
|
print("Gegner besiegt! +", xp_reward, " XP")
|
|
# XP an Spieler geben
|
|
if target and target.has_method("gain_xp"):
|
|
target.gain_xp(xp_reward)
|
|
# Loot generieren und droppen
|
|
_drop_loot()
|
|
enemy_died.emit(spawn_position, xp_reward)
|
|
# Death-Animation abspielen, dann entfernen
|
|
if anim_player and anim_player.has_animation("die"):
|
|
_play_anim("die")
|
|
# Kollision deaktivieren damit der Gegner nicht mehr im Weg ist
|
|
set_physics_process(false)
|
|
$CollisionShape3D.set_deferred("disabled", true)
|
|
await anim_player.animation_finished
|
|
queue_free()
|
|
|
|
# Loot generieren basierend auf LootTable
|
|
func _drop_loot():
|
|
if loot_table == null:
|
|
# Standard-Gold-Drop wenn keine LootTable zugewiesen
|
|
var gold = randi_range(1, 3) * level
|
|
var loot = {"gold": gold, "items": []}
|
|
enemy_dropped_loot.emit(loot, global_position)
|
|
return
|
|
|
|
var loot = loot_table.generate_loot()
|
|
# Gold mit Level skalieren
|
|
loot["gold"] = loot["gold"] * level
|
|
enemy_dropped_loot.emit(loot, global_position)
|
|
|
|
func _physics_process(delta):
|
|
if not is_on_floor():
|
|
velocity.y -= GRAVITY * delta
|
|
|
|
if target == null:
|
|
# Ohne Spieler-Referenz nur patrouillieren
|
|
_do_patrol()
|
|
move_and_slide()
|
|
return
|
|
|
|
# Prüfe Distanz zum Spieler für Aggro
|
|
var distance_to_player = global_position.distance_to(target.global_position)
|
|
|
|
# State-Wechsel basierend auf Distanz
|
|
match current_state:
|
|
State.PATROL:
|
|
if distance_to_player <= AGGRO_RANGE:
|
|
current_state = State.CHASE
|
|
print("Gegner hat Spieler entdeckt!")
|
|
else:
|
|
_do_patrol()
|
|
State.CHASE:
|
|
if distance_to_player <= ATTACK_RANGE:
|
|
current_state = State.ATTACK
|
|
else:
|
|
_chase_player()
|
|
State.ATTACK:
|
|
if distance_to_player > ATTACK_RANGE:
|
|
current_state = State.CHASE
|
|
else:
|
|
velocity.x = 0
|
|
velocity.z = 0
|
|
_play_anim("") # Idle beim Angriff
|
|
if can_attack:
|
|
_attack()
|
|
|
|
move_and_slide()
|
|
|
|
# Neues Patrol-Ziel in der Nähe der Spawn-Position wählen
|
|
func _pick_new_patrol_target():
|
|
var angle = randf() * TAU # Zufälliger Winkel
|
|
var distance = randf_range(2.0, PATROL_RADIUS)
|
|
patrol_target = spawn_position + Vector3(cos(angle) * distance, 0, sin(angle) * distance)
|
|
|
|
# Patrol-Verhalten: Zufällig herumlaufen
|
|
func _do_patrol():
|
|
if is_waiting:
|
|
return
|
|
|
|
var distance_to_patrol = global_position.distance_to(patrol_target)
|
|
|
|
if distance_to_patrol <= 0.5:
|
|
# Am Ziel angekommen, warten und neues Ziel wählen
|
|
velocity.x = 0
|
|
velocity.z = 0
|
|
_play_anim("")
|
|
_wait_at_patrol_point()
|
|
else:
|
|
# Zum Patrol-Ziel laufen
|
|
var direction = (patrol_target - global_position)
|
|
direction.y = 0
|
|
direction = direction.normalized()
|
|
velocity.x = direction.x * PATROL_SPEED
|
|
velocity.z = direction.z * PATROL_SPEED
|
|
_play_anim("walk")
|
|
look_at(Vector3(patrol_target.x, global_position.y, patrol_target.z))
|
|
|
|
# Am Patrol-Punkt warten
|
|
func _wait_at_patrol_point():
|
|
is_waiting = true
|
|
await get_tree().create_timer(PATROL_WAIT_TIME).timeout
|
|
is_waiting = false
|
|
_pick_new_patrol_target()
|
|
|
|
# Spieler verfolgen
|
|
func _chase_player():
|
|
_play_anim("walk")
|
|
var direction = (target.global_position - global_position)
|
|
direction.y = 0
|
|
direction = direction.normalized()
|
|
velocity.x = direction.x * SPEED
|
|
velocity.z = direction.z * SPEED
|
|
look_at(Vector3(target.global_position.x, global_position.y, target.global_position.z))
|
|
|
|
# Angriff mit Cooldown
|
|
func _attack():
|
|
can_attack = false
|
|
_play_anim("autoattack")
|
|
# Gegner verwendet auch das Schadenssystem mit Level-Differenz
|
|
if target.has_method("take_damage_from"):
|
|
target.take_damage_from(attack_damage, level, true)
|
|
else:
|
|
target.take_damage(attack_damage)
|
|
print("Gegner (Lv", level, ") greift an: ", attack_damage, " Schaden")
|
|
await get_tree().create_timer(ATTACK_COOLDOWN).timeout
|
|
can_attack = true
|