Bewegung: - Souls-Modus (kein Ziel): Charakter dreht sich zur Laufrichtung relativ zu camera_pivot.world_yaw; bei RMB gehalten → Strafe statt Drehung - Walk-Toggle (NumLock): RPG-Strafe-Modus mit langsamerer Geschwindigkeit - Lock-On (Ziel markiert): Spieler dreht sich smooth zum Gegner, WASD = Strafe - Ausweichrolle (Shift): rollt in Eingaberichtung (world_yaw-relativ im Souls-Modus) - Sofort-180°-Snap statt animierter Drehung bei >150° Winkelunterschied Kamera (camera_pivot.gd): - world_yaw: absolute Weltausrichtung, unabhängig von Spielerrotation (kein Feedback-Loop) - LMB gehalten: Kamera orbitet, Spieler dreht sich nicht - RMB gehalten: Spieler + Kamera drehen sich gemeinsam - Soft Lock-On: camera_pivot dreht Spieler smooth zum Ziel Animationen: - Neue FBX-Animationen: Quick Roll, Running Jump, Walking Jump, Running Strafe L/R, Running Turn 180 - Animationen im Souls-Modus: immer "run" vorwärts; S = walk_back - Root-Motion-Strip: XZ-Bewegung auf Knochen-Tracks wird genullt Welt: - Boden-Shader: Schachbrettmuster in World-Space (INV_VIEW_MATRIX) - ProceduralSkyMaterial + WorldEnvironment per Code - Alte assets/animations und assets/models durch Warrior+Animation ersetzt Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
205 lines
8.1 KiB
GDScript
205 lines
8.1 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 = 3.0
|
||
@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, CHASING, ATTACKING, DEAD }
|
||
var state: State = State.IDLE
|
||
|
||
var attack_cooldown: float = 0.0
|
||
var is_dead: bool = false
|
||
|
||
const GRAVITY = 9.8
|
||
|
||
# ═══════════════════════════════════════════════════════════════
|
||
# NODE-REFERENZEN
|
||
# ═══════════════════════════════════════════════════════════════
|
||
|
||
@onready var nav_agent: NavigationAgent3D = $NavigationAgent3D
|
||
@onready var health_label: Label3D = $HealthDisplay/Label3D
|
||
|
||
# ═══════════════════════════════════════════════════════════════
|
||
# 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
|
||
|
||
# ═══════════════════════════════════════════════════════════════
|
||
# 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 → Idle
|
||
if target == null or not is_instance_valid(target):
|
||
state = State.IDLE
|
||
velocity.x = 0
|
||
velocity.z = 0
|
||
move_and_slide()
|
||
return
|
||
|
||
var distance = global_position.distance_to(target.global_position)
|
||
|
||
match state:
|
||
State.IDLE:
|
||
if distance <= detection_range:
|
||
state = State.CHASING
|
||
State.CHASING:
|
||
if distance <= attack_range:
|
||
state = State.ATTACKING
|
||
velocity.x = 0
|
||
velocity.z = 0
|
||
else:
|
||
_move_toward_target()
|
||
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()
|
||
|
||
move_and_slide()
|
||
|
||
func _move_toward_target():
|
||
if target == null:
|
||
return
|
||
nav_agent.target_position = target.global_position
|
||
if nav_agent.is_navigation_finished():
|
||
return
|
||
var next_pos = nav_agent.get_next_path_position()
|
||
var direction = (next_pos - global_position).normalized()
|
||
direction.y = 0
|
||
velocity.x = direction.x * move_speed
|
||
velocity.z = direction.z * move_speed
|
||
_face_direction(direction)
|
||
|
||
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
|
||
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()
|
||
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():
|
||
is_dead = true
|
||
state = State.DEAD
|
||
velocity = Vector3.ZERO
|
||
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
|
||
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(1.5).timeout
|
||
queue_free()
|