DungeonCrawler/player.gd
Andre c81ba9760b feat: Wirbelwind implementiert (Krieger Level 20)
- AoE Drehangriff, Radius 3.5m, kostet 40 Wut
- Schaden: 15-25 + 70% Stärke an alle Gegner im Radius
- Kein Slow — pure DPS, Counterpart zu Tektonischem Schlag
- Cooldown 6s

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

1325 lines
53 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.

# Player.gd
# ─────────────────────────────────────────────────────────────────────────────
# Hauptskript des Spielers RPG/Souls-Hybrid
#
# Bewegungssystem:
# • Kein Ziel + kein Walk → Souls-Modus: Charakter dreht sich zur Laufrichtung
# (relativ zu camera_pivot.world_yaw), Animation = "run"
# RMB gehalten: A/D = Strafe statt Drehung
# • Kein Ziel + Walk (NumLock) → RPG-Modus: Strafe-Animationen, langsame Geschwindigkeit
# • Ziel markiert → Lock-On: Kamera dreht Spieler zum Ziel,
# WASD = Strafe/Rückwärts relativ zur Spielerausrichtung
#
# Kamera-Referenz:
# dir3 (Bewegungsvektor) nutzt camera_pivot.world_yaw im Souls-Modus, damit sich
# der Vektor NICHT mit der Spielerrotation dreht (kein Feedback-Loop).
#
# Kampf:
# Autoattack, Heavy Strike (Krieger), Quick Strike (Schurke), Frostbolt (Magier)
# Global Cooldown, Skill Cooldowns, Cast-System
#
# Animationen:
# Werden zur Laufzeit aus FBX-Dateien geladen (_load_anim_from_fbx).
# Root Motion wird automatisch entfernt (_strip_root_motion).
# ─────────────────────────────────────────────────────────────────────────────
extends CharacterBody3D
# ═══════════════════════════════════════════════════════════════
# KONSTANTEN
# ═══════════════════════════════════════════════════════════════
const SPEED = 5.0
const SPRINT_SPEED = 9.0
const JUMP_VELOCITY = 4.5
const GRAVITY = 9.8
# Scenes für UI-Panels (werden zur Laufzeit erstellt)
const LOOT_WINDOW_SCENE = preload("res://loot_window.tscn")
const INVENTORY_PANEL_SCENE = preload("res://inventory_panel.tscn")
const SKILL_PANEL_SCENE = preload("res://skill_panel.tscn")
const CHARACTER_PANEL_SCENE = preload("res://character_panel.tscn")
# Animations-FBX Pfade
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_JUMP = "res://assets/Warrior+Animation/jump.fbx"
const ANIM_AUTOATTACK = "res://assets/Warrior+Animation/Autoattack.fbx"
const ANIM_HEAVY_STRIKE = "res://assets/Warrior+Animation/Heavy Strike.fbx"
const ANIM_DEATH = "res://assets/Warrior+Animation/Dying Backwards.fbx"
const ANIM_WALK_BACK = "res://assets/Warrior+Animation/Walking Backwards.fbx"
const ANIM_STRAFE_LEFT = "res://assets/Warrior+Animation/left strafe walking.fbx"
const ANIM_STRAFE_RIGHT = "res://assets/Warrior+Animation/right strafe walking.fbx"
const ANIM_RUN_STRAFE_LEFT = "res://assets/Warrior+Animation/Running Left Strafe.fbx"
const ANIM_RUN_STRAFE_RIGHT = "res://assets/Warrior+Animation/Running Right Strafe run.fbx"
const ANIM_WALK_JUMP = "res://assets/Warrior+Animation/Walking Jump.fbx"
const ANIM_RUN_JUMP = "res://assets/Warrior+Animation/Running Jump.fbx"
const ANIM_ROLL = "res://assets/Warrior+Animation/Quick Roll To Run.fbx"
const ANIM_TURN_180 = "res://assets/Warrior+Animation/Running Turn 180.fbx"
const ANIM_DRINKING = "res://assets/Animations/Drinking.fbx"
# ═══════════════════════════════════════════════════════════════
# CHARAKTER-STATS
# ═══════════════════════════════════════════════════════════════
var character_class: CharacterClass = null
var level: int = 1
var current_xp: int = 0
var xp_to_next_level: int = 100
# Basis-Stats (aus Klasse + Items berechnet)
var strength: int = 10
var agility: int = 10
var intelligence: int = 10
var stamina: int = 10
var armor: int = 0
# HP und Ressource
var max_hp: int = 100
var current_hp: int = 100
var max_resource: int = 0
var current_resource: int = 0
# Mana-Aliases (für Consumable-Kompatibilität)
var max_mana: int:
get: return max_resource
var current_mana: int:
get: return current_resource
# Wut-Verfall
const RAGE_DECAY_DELAY: float = 5.0 # Sekunden nach letztem Kampfkontakt
const RAGE_DECAY_RATE: float = 10.0 # Wut pro Sekunde Verfall
var _rage_decay_timer: float = 0.0
# Durchbeißen / Schildwall / Trotz
var is_defending: bool = false
var _defend_timer: float = 0.0
var _defend_mode: String = "" # "schildwall" oder "trotz"
# Blutrausch — Blutungs-DOT
var _bleed_target = null
var _bleed_timer: float = 0.0 # verbleibende Dauer
var _bleed_tick_timer: float = 0.0 # bis zum nächsten Tick
# ═══════════════════════════════════════════════════════════════
# EQUIPMENT
# ═══════════════════════════════════════════════════════════════
var equipment: Dictionary = {
Equipment.Slot.HEAD: null,
Equipment.Slot.CHEST: null,
Equipment.Slot.HANDS: null,
Equipment.Slot.LEGS: null,
Equipment.Slot.FEET: null,
Equipment.Slot.WEAPON: null,
Equipment.Slot.OFFHAND: null,
}
# ═══════════════════════════════════════════════════════════════
# INVENTAR
# ═══════════════════════════════════════════════════════════════
var inventory: Inventory = Inventory.new()
# ═══════════════════════════════════════════════════════════════
# SKILL & AKTIONSLEISTE
# ═══════════════════════════════════════════════════════════════
var available_skills: Array = [] # Alle verfügbaren Skills der Klasse
var action_bar: Array = [] # 9 Slots: skill_id (String) oder Consumable oder null
var skill_cooldowns: Dictionary = {} # skill_id -> verbleibende Zeit
var consumable_cooldowns: Dictionary = {} # item_name -> verbleibende Zeit
# Global Cooldown
var global_cooldown: float = 0.0
const GLOBAL_COOLDOWN_TIME: float = 1.5
# Autoattack
var autoattack_active: bool = false
const HEAVY_STRIKE_COOLDOWN: float = 3.0
const HEAVY_STRIKE_RANGE: float = 2.0
const TEKTONISCHER_SCHLAG_COOLDOWN: float = 8.0
const TEKTONISCHER_SCHLAG_RADIUS: float = 4.0
const TEKTONISCHER_SCHLAG_RAGE: int = 30
const DURCHBEISSEN_COOLDOWN: float = 12.0
const DURCHBEISSEN_DURATION: float = 4.0
const SCHILDWALL_BLOCK: float = 0.85 # 85% Schadensreduktion
const TROTZ_MAX_REDUCTION: float = 0.60 # max 60% Reduktion bei 0 HP
const BLUTRAUSCH_COOLDOWN: float = 10.0
const BLUTRAUSCH_RANGE: float = 2.5
const BLUTRAUSCH_RAGE: int = 25
const BLUTRAUSCH_DOT_DURATION: float = 8.0 # Blutungsdauer in Sekunden
const BLUTRAUSCH_DOT_INTERVAL: float = 1.0 # Schaden alle 1s
const BLUTRAUSCH_DOT_DAMAGE: int = 4 # Schaden pro Tick
const WIRBELWIND_COOLDOWN: float = 6.0
const WIRBELWIND_RADIUS: float = 3.5
const WIRBELWIND_RAGE: int = 40
# Cast System
var is_casting: bool = false
var cast_time_remaining: float = 0.0
var cast_total: float = 0.0
var pending_skill_id: String = ""
# ═══════════════════════════════════════════════════════════════
# KAMPF
# ═══════════════════════════════════════════════════════════════
var target = null # Aktuell markierter Gegner
# ═══════════════════════════════════════════════════════════════
# ANIMATION
# ═══════════════════════════════════════════════════════════════
var anim_player: AnimationPlayer = null
var skeleton: Skeleton3D = null
var weapon_attachment: BoneAttachment3D = null
var current_weapon_model: Node3D = null
var is_attacking: bool = false
var is_dead: bool = false
var is_walking: bool = false
var is_rolling: bool = false
var is_drinking: bool = false
var roll_direction: Vector3 = Vector3.ZERO
var roll_cooldown: float = 0.0
const ROLL_COOLDOWN: float = 1.5
# Eingefrорener Kamerawinkel für Bewegungsberechnung:
# wird nur aktualisiert wenn LMB NICHT gedrückt ist, damit
# LMB-Kamerarotation die Laufrichtung nicht verändert
var _movement_yaw: float = 0.0
var _lmb_press_pos: Vector2 = Vector2.ZERO
# ═══════════════════════════════════════════════════════════════
# NODE-REFERENZEN
# ═══════════════════════════════════════════════════════════════
@onready var camera_pivot = $CameraPivot
@onready var camera = $CameraPivot/Camera3D
@onready var hud = $HUD
@onready var model = $Model
# UI-Panels (zur Laufzeit erstellt)
var loot_window = null
var inventory_panel = null
var skill_panel = null
var character_panel = null
# ═══════════════════════════════════════════════════════════════
# READY
# ═══════════════════════════════════════════════════════════════
func _ready():
add_to_group("player")
# Jolt Physics: Boden sicher erkennen
floor_snap_length = 0.3
floor_max_angle = deg_to_rad(50.0)
# Aktionsleiste mit 9 leeren Slots initialisieren
action_bar.resize(9)
for i in range(9):
action_bar[i] = null
# Animationen einrichten
_setup_animations()
# UI-Panels erstellen
_create_ui_panels()
# HUD Signale verbinden
hud.slot_clicked.connect(_on_slot_clicked)
hud.slot_drag_removed.connect(_on_slot_drag_removed)
hud.slot_drag_swapped.connect(_on_slot_drag_swapped)
hud.update_health(current_hp, max_hp)
# ═══════════════════════════════════════════════════════════════
# UI-PANELS
# ═══════════════════════════════════════════════════════════════
func _create_ui_panels():
loot_window = LOOT_WINDOW_SCENE.instantiate()
add_child(loot_window)
loot_window.setup(self)
inventory_panel = INVENTORY_PANEL_SCENE.instantiate()
add_child(inventory_panel)
inventory_panel.setup(self)
skill_panel = SKILL_PANEL_SCENE.instantiate()
add_child(skill_panel)
skill_panel.setup(self)
character_panel = CHARACTER_PANEL_SCENE.instantiate()
add_child(character_panel)
# Gold-Änderungen an HUD weiterleiten
inventory.gold_changed.connect(func(amount): hud.update_gold(amount))
# ═══════════════════════════════════════════════════════════════
# KLASSEN-INITIALISIERUNG
# ═══════════════════════════════════════════════════════════════
func _init_class_skills():
available_skills.clear()
# Autoattack ist für alle Klassen verfügbar
available_skills.append({
"id": "autoattack",
"name": "Autoattack",
"description": "Grundangriff mit ausgerüsteter Waffe.",
"icon": "res://icons/autoattack_icon.svg",
"cooldown": 0.0,
"cast_time": 0.0,
})
if character_class == null:
return
match character_class.main_stat:
CharacterClass.MainStat.STRENGTH: # Krieger
available_skills.append({
"id": "heavy_strike",
"name": "Mächtiger Schlag",
"description": "Schlägt den Gegner mit voller Kraft. Generiert Wut.",
"icon": "res://icons/heavy_strike_icon.svg",
"cooldown": HEAVY_STRIKE_COOLDOWN,
"cast_time": 0.0,
})
if level >= 5:
available_skills.append({
"id": "tektonischer_schlag",
"name": "Tektonischer Schlag",
"description": "Erschüttert den Boden um dich herum. Schlägt alle Gegner in der Nähe und verlangsamt sie.",
"icon": "res://icons/tektonischer_schlag_icon.svg",
"cooldown": TEKTONISCHER_SCHLAG_COOLDOWN,
"cast_time": 0.0,
})
if level >= 20:
available_skills.append({
"id": "wirbelwind",
"name": "Wirbelwind",
"description": "Drehangriff mit voller Wucht. Trifft alle Gegner in der Nähe für hohen Schaden.",
"icon": "res://icons/wirbelwind_icon.svg",
"cooldown": WIRBELWIND_COOLDOWN,
"cast_time": 0.0,
})
if level >= 15:
available_skills.append({
"id": "blutrausch",
"name": "Blutrausch",
"description": "Reißt eine tiefe Wunde. Sofortschaden + Blutung über 8 Sekunden.",
"icon": "res://icons/blutrausch_icon.svg",
"cooldown": BLUTRAUSCH_COOLDOWN,
"cast_time": 0.0,
})
if level >= 10:
var has_offhand = _has_shield()
available_skills.append({
"id": "durchbeissen",
"name": "Schildwall" if has_offhand else "Trotz",
"description": "Schildwall: Blockiert großen Teil des eingehenden Schadens." if has_offhand else "Trotz: Schadensreduktion skaliert mit fehlendem Leben.",
"icon": "res://icons/durchbeissen_icon.svg",
"cooldown": DURCHBEISSEN_COOLDOWN,
"cast_time": 0.0,
})
CharacterClass.MainStat.INTELLIGENCE: # Magier
available_skills.append({
"id": "frostbolt",
"name": "Frostblitz",
"description": "Schießt einen Frostblitz auf den Feind (2s Channeling).",
"icon": "res://icons/heavy_strike_icon.svg", # Temp-Icon
"cooldown": 0.0,
"cast_time": 2.0,
})
CharacterClass.MainStat.AGILITY: # Schurke
available_skills.append({
"id": "quick_strike",
"name": "Schnellschlag",
"description": "Blitzschneller Angriff (1s CD).",
"icon": "res://icons/autoattack_icon.svg", # Temp-Icon
"cooldown": 1.0,
"cast_time": 0.0,
})
# Standard-Belegung: Slot 0 = Autoattack, Slot 1 = erster Skill
action_bar[0] = "autoattack"
if available_skills.size() > 1:
action_bar[1] = available_skills[1]["id"]
# ═══════════════════════════════════════════════════════════════
# STAT-BERECHNUNG
# ═══════════════════════════════════════════════════════════════
func _calculate_stats():
if character_class == null:
return
# Basis-Stats aus Klasse + Level
strength = character_class.base_strength + int(character_class.strength_per_level * (level - 1))
agility = character_class.base_agility + int(character_class.agility_per_level * (level - 1))
intelligence = character_class.base_intelligence + int(character_class.intelligence_per_level * (level - 1))
stamina = character_class.base_stamina + int(character_class.stamina_per_level * (level - 1))
# Stats aus Equipment addieren
armor = 0
for slot in equipment:
var item = equipment[slot]
if item == null:
continue
strength += item.strength
agility += item.agility
intelligence += item.intelligence
stamina += item.stamina
armor += item.armor
# HP aus Stamina berechnen
var old_max = max_hp
max_hp = stamina * CharacterClass.HP_PER_STAMINA
# HP proportional anpassen
if old_max > 0:
current_hp = int(current_hp * float(max_hp) / float(old_max))
current_hp = clamp(current_hp, 0, max_hp)
# Ressource berechnen
match character_class.resource_type:
CharacterClass.ResourceType.MANA:
max_resource = character_class.base_resource + intelligence * CharacterClass.MANA_PER_INT
CharacterClass.ResourceType.RAGE, CharacterClass.ResourceType.ENERGY:
max_resource = character_class.base_resource
_:
max_resource = 0
current_resource = clamp(current_resource, 0, max_resource)
func get_resource_name() -> String:
if character_class == null:
return ""
match character_class.resource_type:
CharacterClass.ResourceType.MANA: return "Mana"
CharacterClass.ResourceType.RAGE: return "Wut"
CharacterClass.ResourceType.ENERGY: return "Energie"
return ""
# ═══════════════════════════════════════════════════════════════
# EQUIPMENT
# ═══════════════════════════════════════════════════════════════
# Item anlegen — gibt das alte Item zurück (oder null)
func equip_item(item: Equipment) -> Equipment:
var slot = item.slot
var old_item = equipment[slot]
equipment[slot] = item
_calculate_stats()
hud.update_health(current_hp, max_hp)
if max_resource > 0:
hud.update_resource(current_resource, max_resource, get_resource_name())
if character_panel and character_panel.panel_visible:
character_panel.update_stats(self)
# Waffen-Modell aktualisieren
if slot == Equipment.Slot.WEAPON and weapon_attachment != null:
_attach_weapon_model(item)
return old_item
func get_equipped_weapon() -> Equipment:
return equipment[Equipment.Slot.WEAPON]
func get_attack_damage() -> int:
var weapon = get_equipped_weapon()
if weapon == null:
if character_class:
return randi_range(character_class.unarmed_min_damage, character_class.unarmed_max_damage)
return 1
# Main-Stat-Bonus
var bonus = 0
if character_class:
match character_class.main_stat:
CharacterClass.MainStat.STRENGTH: bonus = int(strength * CharacterClass.DAMAGE_PER_MAIN_STAT)
CharacterClass.MainStat.AGILITY: bonus = int(agility * CharacterClass.DAMAGE_PER_MAIN_STAT)
CharacterClass.MainStat.INTELLIGENCE: bonus = int(intelligence * CharacterClass.DAMAGE_PER_MAIN_STAT)
return randi_range(weapon.min_damage, weapon.max_damage) + bonus
func get_attack_range() -> float:
var weapon = get_equipped_weapon()
if weapon == null:
return 1.5
return weapon.weapon_range
func get_attack_cooldown() -> float:
var weapon = get_equipped_weapon()
if weapon == null:
if character_class:
return character_class.unarmed_attack_speed
return 1.5
var speed = weapon.attack_speed
if character_class:
speed *= max(0.1, 1.0 - (agility * 0.002)) # Agilität reduziert Cooldown leicht
return speed
func get_dps() -> float:
var weapon = get_equipped_weapon()
var avg_dmg: float
if weapon:
avg_dmg = float(weapon.min_damage + weapon.max_damage) / 2.0
elif character_class:
avg_dmg = float(character_class.unarmed_min_damage + character_class.unarmed_max_damage) / 2.0
else:
avg_dmg = 1.5
var cooldown = get_attack_cooldown()
if cooldown <= 0:
return 0.0
return avg_dmg / cooldown
# ═══════════════════════════════════════════════════════════════
# HP / HEILUNG / TOD
# ═══════════════════════════════════════════════════════════════
func take_damage(amount: int):
var effective = max(1, amount - int(armor * 0.1))
if is_defending:
if _defend_mode == "schildwall":
effective = int(effective * (1.0 - SCHILDWALL_BLOCK))
elif _defend_mode == "trotz":
var missing_hp = 1.0 - (float(current_hp) / float(max_hp))
var reduction = missing_hp * TROTZ_MAX_REDUCTION
effective = int(effective * (1.0 - reduction))
effective = max(1, effective)
current_hp = clamp(current_hp - effective, 0, max_hp)
hud.update_health(current_hp, max_hp)
_gain_rage(15)
if current_hp <= 0:
die()
func heal(amount: int):
current_hp = clamp(current_hp + amount, 0, max_hp)
hud.update_health(current_hp, max_hp)
func restore_mana(amount: int):
current_resource = clamp(current_resource + amount, 0, max_resource)
hud.update_resource(current_resource, max_resource, get_resource_name())
func _gain_rage(amount: int):
if character_class == null or character_class.resource_type != CharacterClass.ResourceType.RAGE:
return
current_resource = clamp(current_resource + amount, 0, max_resource)
_rage_decay_timer = RAGE_DECAY_DELAY
hud.update_resource(current_resource, max_resource, get_resource_name())
func _spend_rage(amount: int) -> bool:
if current_resource < amount:
return false
current_resource -= amount
hud.update_resource(current_resource, max_resource, get_resource_name())
return true
func _update_rage_decay(delta: float):
if character_class == null or character_class.resource_type != CharacterClass.ResourceType.RAGE:
return
if current_resource <= 0:
return
if _rage_decay_timer > 0.0:
_rage_decay_timer -= delta
return
current_resource = max(0, current_resource - int(RAGE_DECAY_RATE * delta))
hud.update_resource(current_resource, max_resource, get_resource_name())
func die():
if is_dead:
return
is_dead = true
print("Spieler gestorben!")
_play_anim("death")
# ═══════════════════════════════════════════════════════════════
# XP / LEVELING
# ═══════════════════════════════════════════════════════════════
func gain_xp(amount: int):
current_xp += amount
print("+" + str(amount) + " XP")
while current_xp >= xp_to_next_level:
current_xp -= xp_to_next_level
_level_up()
hud.update_level(level, current_xp, xp_to_next_level)
func _level_up():
level += 1
xp_to_next_level = int(xp_to_next_level * 1.5)
_calculate_stats()
current_hp = max_hp
current_resource = max_resource
hud.update_health(current_hp, max_hp)
hud.update_resource(current_resource, max_resource, get_resource_name())
print("LEVEL UP! Jetzt Level " + str(level))
# ═══════════════════════════════════════════════════════════════
# LOOT
# ═══════════════════════════════════════════════════════════════
func receive_loot(loot: Dictionary, world_pos: Vector3):
if loot_window:
loot_window.show_loot(loot, world_pos)
# ═══════════════════════════════════════════════════════════════
# AKTIONSLEISTE
# ═══════════════════════════════════════════════════════════════
# Skill auf Slot legen
func assign_skill_to_action_bar(slot_index: int, skill_id: String):
if slot_index < 0 or slot_index >= 9:
return
action_bar[slot_index] = skill_id
_refresh_action_slot(slot_index)
# Consumable auf Slot legen
func assign_to_action_bar(slot_index: int, item: Consumable):
if slot_index < 0 or slot_index >= 9:
return
action_bar[slot_index] = item
_refresh_action_slot(slot_index)
# Slot-Icon und Cooldown im HUD aktualisieren
func _refresh_action_slot(slot_index: int):
if slot_index < 0 or slot_index >= 9:
return
var slot_content = action_bar[slot_index]
if slot_content == null:
hud.clear_slot_icon(slot_index)
hud.set_slot_stack_count(slot_index, 0)
return
if slot_content is String:
# Skill
var skill = _find_skill(slot_content)
if skill:
if skill["icon"] != "":
hud.set_slot_icon(slot_index, skill["icon"])
hud.set_slot_stack_count(slot_index, 0)
elif slot_content is Consumable:
# Consumable
if slot_content.icon:
hud.set_slot_icon_texture(slot_index, slot_content.icon)
hud.set_slot_stack_count(slot_index, slot_content.stack_size)
# Stack-Counts aller Consumable-Slots aktualisieren
func _update_action_bar_stacks():
for i in range(9):
if action_bar[i] is Consumable:
hud.set_slot_stack_count(i, action_bar[i].stack_size)
# Slot geleert per Drag
func _on_slot_drag_removed(slot_index: int):
action_bar[slot_index] = null
hud.clear_slot_icon(slot_index)
hud.set_slot_stack_count(slot_index, 0)
# Zwei Slots tauschen per Drag
func _on_slot_drag_swapped(from_slot: int, to_slot: int):
var temp = action_bar[from_slot]
action_bar[from_slot] = action_bar[to_slot]
action_bar[to_slot] = temp
_refresh_action_slot(from_slot)
_refresh_action_slot(to_slot)
func _find_skill(skill_id: String) -> Dictionary:
for s in available_skills:
if s["id"] == skill_id:
return s
return {}
# ═══════════════════════════════════════════════════════════════
# SKILL-AUSFÜHRUNG
# ═══════════════════════════════════════════════════════════════
func _on_slot_clicked(slot_index: int):
var slot_content = action_bar[slot_index]
if slot_content == null:
return
if slot_content is String:
execute_skill(slot_content)
elif slot_content is Consumable:
_use_consumable_slot(slot_index, slot_content)
func execute_skill(skill_id: String):
if is_dead or is_casting or is_drinking:
return
var skill = _find_skill(skill_id)
if skill.is_empty():
return
# GCD Check
if global_cooldown > 0:
print("GCD aktiv: %.1fs" % global_cooldown)
return
# Skill-CD Check
if skill_cooldowns.get(skill_id, 0.0) > 0:
print(skill["name"] + " im Cooldown: %.1fs" % skill_cooldowns[skill_id])
return
# Ziel-Check für Angriffe
var needs_target = (skill_id != "")
if skill_id in ["autoattack", "heavy_strike", "quick_strike", "frostbolt"]:
if target == null or not is_instance_valid(target):
print("Kein Ziel!")
return
# Wut-Check für Krieger-Skills
if skill_id in ["tektonischer_schlag"]:
if current_resource < TEKTONISCHER_SCHLAG_RAGE:
print("Zu wenig Wut!")
return
if skill_id == "blutrausch":
if current_resource < BLUTRAUSCH_RAGE:
print("Zu wenig Wut!")
return
if skill_id == "wirbelwind":
if current_resource < WIRBELWIND_RAGE:
print("Zu wenig Wut!")
return
# Cast-Zeit?
if skill["cast_time"] > 0:
_start_cast(skill_id, skill["cast_time"])
return
_apply_skill(skill_id)
func _start_cast(skill_id: String, cast_time: float):
is_casting = true
cast_total = cast_time
cast_time_remaining = cast_time
pending_skill_id = skill_id
hud.show_castbar(skill_id, cast_time)
func _cancel_cast():
is_casting = false
cast_time_remaining = 0.0
pending_skill_id = ""
hud.hide_castbar()
func _apply_skill(skill_id: String):
match skill_id:
"autoattack":
start_autoattack()
perform_autoattack()
"heavy_strike":
_do_heavy_strike()
"quick_strike":
_do_quick_strike()
"frostbolt":
_do_frostbolt()
"tektonischer_schlag":
_do_tektonischer_schlag()
"durchbeissen":
_do_durchbeissen()
"blutrausch":
_do_blutrausch()
"wirbelwind":
_do_wirbelwind()
# ─── Autoattack ───────────────────────────────────────────────
func start_autoattack():
autoattack_active = true
func stop_autoattack():
autoattack_active = false
func perform_autoattack():
if target == null or not is_instance_valid(target):
target = null
autoattack_active = false
return
var distance = global_position.distance_to(target.global_position)
if distance <= get_attack_range():
var dmg = get_attack_damage()
target.take_damage(dmg)
print("Autoattack: " + str(dmg) + " Schaden")
trigger_global_cooldown()
_play_anim_once("autoattack")
else:
print("Ziel zu weit entfernt")
# ─── Heavy Strike ─────────────────────────────────────────────
func _do_heavy_strike():
if target == null or not is_instance_valid(target):
return
var distance = global_position.distance_to(target.global_position)
if distance > HEAVY_STRIKE_RANGE:
print("Ziel zu weit für Heavy Strike!")
return
var damage = randi_range(10, 15) + int(strength * 0.5)
target.take_damage(damage)
_gain_rage(10)
skill_cooldowns["heavy_strike"] = HEAVY_STRIKE_COOLDOWN
trigger_global_cooldown()
start_autoattack()
print("Heavy Strike! " + str(damage) + " Schaden")
_play_anim_once("heavy_strike")
# ─── Tektonischer Schlag ──────────────────────────────────────
func _do_tektonischer_schlag():
if not _spend_rage(TEKTONISCHER_SCHLAG_RAGE):
return
var space_state = get_world_3d().direct_space_state
var shape = SphereShape3D.new()
shape.radius = TEKTONISCHER_SCHLAG_RADIUS
var query = PhysicsShapeQueryParameters3D.new()
query.shape = shape
query.transform = global_transform
query.exclude = [self]
var hits = space_state.intersect_shape(query)
var hit_count = 0
for hit in hits:
var body = hit.collider
if body.has_method("take_damage"):
var damage = randi_range(5, 10) + int(strength * 0.3)
body.take_damage(damage)
if body.has_method("apply_slow"):
body.apply_slow(0.4, 3.0) # 60% langsamer für 3s
hit_count += 1
skill_cooldowns["tektonischer_schlag"] = TEKTONISCHER_SCHLAG_COOLDOWN
trigger_global_cooldown()
print("Tektonischer Schlag! %d Gegner getroffen" % hit_count)
# ─── Wirbelwind ───────────────────────────────────────────────
func _do_wirbelwind():
if not _spend_rage(WIRBELWIND_RAGE):
return
var space_state = get_world_3d().direct_space_state
var shape = SphereShape3D.new()
shape.radius = WIRBELWIND_RADIUS
var query = PhysicsShapeQueryParameters3D.new()
query.shape = shape
query.transform = global_transform
query.exclude = [self]
var hits = space_state.intersect_shape(query)
var hit_count = 0
for hit in hits:
var body = hit.collider
if body.has_method("take_damage"):
var damage = randi_range(15, 25) + int(strength * 0.7)
body.take_damage(damage)
hit_count += 1
skill_cooldowns["wirbelwind"] = WIRBELWIND_COOLDOWN
trigger_global_cooldown()
print("Wirbelwind! %d Gegner getroffen" % hit_count)
# ─── Durchbeißen / Schildwall / Trotz ────────────────────────
# ─── Blutrausch ───────────────────────────────────────────────
func _do_blutrausch():
if target == null or not is_instance_valid(target):
return
if global_position.distance_to(target.global_position) > BLUTRAUSCH_RANGE:
print("Ziel zu weit für Blutrausch!")
return
if not _spend_rage(BLUTRAUSCH_RAGE):
return
var damage = randi_range(8, 14) + int(strength * 0.5)
target.take_damage(damage)
_bleed_target = target
_bleed_timer = BLUTRAUSCH_DOT_DURATION
_bleed_tick_timer = BLUTRAUSCH_DOT_INTERVAL
skill_cooldowns["blutrausch"] = BLUTRAUSCH_COOLDOWN
trigger_global_cooldown()
print("Blutrausch! %d Sofortschaden + Blutung" % damage)
func _has_shield() -> bool:
var offhand = equipment[Equipment.Slot.OFFHAND]
return offhand != null and offhand.item_type == Equipment.ItemType.SHIELD
func _do_durchbeissen():
var has_offhand = _has_shield()
_defend_mode = "schildwall" if has_offhand else "trotz"
is_defending = true
_defend_timer = DURCHBEISSEN_DURATION
skill_cooldowns["durchbeissen"] = DURCHBEISSEN_COOLDOWN
trigger_global_cooldown()
print("Durchbeißen! Modus: " + _defend_mode)
# ─── Quick Strike (Schurke) ───────────────────────────────────
func _do_quick_strike():
if target == null or not is_instance_valid(target):
return
var distance = global_position.distance_to(target.global_position)
if distance > get_attack_range():
print("Ziel zu weit für Quick Strike!")
return
var damage = get_attack_damage()
target.take_damage(damage)
skill_cooldowns["quick_strike"] = 1.0
trigger_global_cooldown()
start_autoattack()
print("Quick Strike! " + str(damage) + " Schaden")
_play_anim_once("autoattack")
# ─── Frostbolt (Magier) ───────────────────────────────────────
func _do_frostbolt():
if target == null or not is_instance_valid(target):
return
var damage = randi_range(8, 14) + int(intelligence * 0.7)
target.take_damage(damage)
trigger_global_cooldown()
start_autoattack()
print("Frostblitz! " + str(damage) + " Schaden")
hud.hide_castbar()
# ─── Consumable ───────────────────────────────────────────────
func use_consumable(item: Consumable) -> bool:
# Cooldown prüfen
if consumable_cooldowns.get(item.item_name, 0.0) > 0:
print(item.item_name + " im Cooldown!")
return false
if is_drinking:
return false
var used = item.use(self)
if used:
consumable_cooldowns[item.item_name] = item.cooldown
_play_drinking_anim()
return used
func _use_consumable_slot(slot_index: int, item: Consumable):
if use_consumable(item):
if item.stack_size <= 0:
# Stack leer: Item aus Inventar und Slot entfernen
inventory.remove_item(item)
action_bar[slot_index] = null
hud.clear_slot_icon(slot_index)
else:
hud.set_slot_stack_count(slot_index, item.stack_size)
# ─── GCD ──────────────────────────────────────────────────────
func trigger_global_cooldown():
global_cooldown = get_attack_cooldown()
# ═══════════════════════════════════════════════════════════════
# ZIELAUSWAHL
# ═══════════════════════════════════════════════════════════════
func set_target(new_target, start_attack: bool = false):
if target != null and is_instance_valid(target):
target.hide_health()
target = new_target
target.show_health()
print("Ziel: " + target.name)
if start_attack:
start_autoattack()
func clear_target():
if target != null and is_instance_valid(target):
target.hide_health()
target = null
autoattack_active = false
print("Ziel aufgehoben")
func _try_select_target(start_attack: bool = false, clear_on_miss: bool = true):
var space_state = get_world_3d().direct_space_state
var viewport = get_viewport()
var mouse_pos = viewport.get_mouse_position()
var ray_origin = camera.project_ray_origin(mouse_pos)
var ray_end = ray_origin + camera.project_ray_normal(mouse_pos) * 100.0
var query = PhysicsRayQueryParameters3D.create(ray_origin, ray_end)
query.exclude = [self]
var result = space_state.intersect_ray(query)
if result and result.collider.has_method("take_damage"):
set_target(result.collider, start_attack)
elif clear_on_miss:
# Klick auf freie Fläche → Target entfernen (nur bei LMB)
clear_target()
# ═══════════════════════════════════════════════════════════════
# ANIMATION SETUP
# ═══════════════════════════════════════════════════════════════
func _setup_animations():
# AnimationPlayer aus importiertem Modell suchen
if model:
anim_player = model.find_child("AnimationPlayer", true, false) as AnimationPlayer
if anim_player == null:
push_warning("Player: Kein AnimationPlayer gefunden! Prüfe die Modell-Struktur.")
return
_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_JUMP, "jump", false)
_load_anim_from_fbx(ANIM_AUTOATTACK, "autoattack", false)
_load_anim_from_fbx(ANIM_HEAVY_STRIKE, "heavy_strike", false)
_load_anim_from_fbx(ANIM_DEATH, "death", false)
_load_anim_from_fbx(ANIM_WALK_BACK, "walk_back", true)
_load_anim_from_fbx(ANIM_STRAFE_LEFT, "strafe_left", true)
_load_anim_from_fbx(ANIM_STRAFE_RIGHT, "strafe_right", true)
_load_anim_from_fbx(ANIM_RUN_STRAFE_LEFT, "run_strafe_left", true)
_load_anim_from_fbx(ANIM_RUN_STRAFE_RIGHT, "run_strafe_right", true)
_load_anim_from_fbx(ANIM_WALK_JUMP, "walk_jump", false)
_load_anim_from_fbx(ANIM_RUN_JUMP, "run_jump", false)
_load_anim_from_fbx(ANIM_ROLL, "roll", false)
_load_anim_from_fbx(ANIM_TURN_180, "turn_180", false)
_load_anim_from_fbx(ANIM_DRINKING, "drinking", false)
_play_anim("idle")
# Waffen-Attachment einrichten
_setup_weapon_attachment()
func _setup_weapon_attachment():
if model == null:
print("Player Weapon: Kein Model!")
return
skeleton = model.find_child("Skeleton3D", true, false) as Skeleton3D
if skeleton == null:
push_warning("Player: Kein Skeleton3D gefunden!")
return
# Alle Bones auflisten zum Debuggen
print("Player Bones: ", skeleton.get_bone_count())
for i in range(skeleton.get_bone_count()):
if "Hand" in skeleton.get_bone_name(i) or "hand" in skeleton.get_bone_name(i):
print(" Bone ", i, ": ", skeleton.get_bone_name(i))
var bone_idx = skeleton.find_bone("mixamorig_RightHand")
if bone_idx == -1:
push_warning("Player: Bone 'mixamorig_RightHand' nicht gefunden!")
return
print("Player Weapon: RightHand Bone gefunden (idx=", bone_idx, ")")
weapon_attachment = BoneAttachment3D.new()
weapon_attachment.bone_name = "mixamorig_RightHand"
skeleton.add_child(weapon_attachment)
func _attach_weapon_model(weapon: Equipment):
_remove_weapon_model()
if weapon == null or weapon.model_scene == null:
print("Player Weapon: Kein Model an Waffe! model_scene=", weapon.model_scene if weapon else "null")
return
if weapon_attachment == null:
print("Player Weapon: Kein weapon_attachment!")
return
current_weapon_model = weapon.model_scene.instantiate()
current_weapon_model.rotation_degrees = Vector3(90, 180, 0)
current_weapon_model.position = Vector3(0.5, 0.2, 0)
current_weapon_model.scale = Vector3(0.8, 0.8, 0.8)
weapon_attachment.add_child(current_weapon_model)
print("Player Weapon: Schwert angehängt! Scale=", current_weapon_model.scale)
func _remove_weapon_model():
if current_weapon_model != null and is_instance_valid(current_weapon_model):
current_weapon_model.queue_free()
current_weapon_model = null
func _load_anim_from_fbx(fbx_path: String, anim_name: String, loop: bool = false):
var scene = load(fbx_path)
if scene == null:
push_warning("Player: 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("Player: 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])
# Root Motion entfernen: verhindert Snapping beim Loop
_strip_root_motion(anim)
# Loop-Modus setzen
anim.loop_mode = Animation.LOOP_LINEAR if loop else Animation.LOOP_NONE
if not anim_player.has_animation_library(""):
anim_player.add_animation_library("", AnimationLibrary.new())
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):
# Mixamo speichert Root Motion entweder auf dem Armature-Node (kein Subname)
# ODER auf dem Hips-Knochen (hat Subname, aber bewegt sich in XZ vorwärts).
# Beide Fälle werden hier behandelt.
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-Track — prüfen ob er sich in XZ bewegt
var key_count = anim.track_get_key_count(i)
if key_count < 2:
continue
var first: Vector3 = anim.track_get_key_value(i, 0)
var last: Vector3 = anim.track_get_key_value(i, key_count - 1)
var xz_delta = Vector2(last.x - first.x, last.z - first.z).length()
if xz_delta > 0.01:
# Hat XZ-Bewegung (z.B. Hips-Knochen bei Mixamo) → XZ nullen, Y behalten
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))
# ═══════════════════════════════════════════════════════════════
# ANIMATION ABSPIELEN
# ═══════════════════════════════════════════════════════════════
func _play_anim(name: String, speed: float = 1.0):
if anim_player == null or not anim_player.has_animation(name):
return
if anim_player.current_animation == name and anim_player.speed_scale == speed:
return
anim_player.speed_scale = speed
anim_player.play(name, -1, 1.0, false)
func _play_anim_once(name: String):
if anim_player == null or not anim_player.has_animation(name):
return
is_attacking = true
anim_player.speed_scale = 1.0
anim_player.play(name, -1, 1.0, false)
await anim_player.animation_finished
is_attacking = false
func _play_drinking_anim():
if anim_player == null or not anim_player.has_animation("drinking"):
return
is_drinking = true
anim_player.speed_scale = 1.0
anim_player.play("drinking", 0.3, 0.7, false)
await anim_player.animation_finished
is_drinking = false
func _do_roll():
# Bewegungsrichtung beim Roll-Start erfassen
var input_dir = Input.get_vector("move_left", "move_right", "move_forward", "move_back")
var has_target = target != null and is_instance_valid(target)
if input_dir.length() > 0.1:
var ref_angle = rotation.y if (has_target or is_walking) else _movement_yaw
roll_direction = Vector3(input_dir.x, 0, input_dir.y).rotated(Vector3.UP, ref_angle).normalized()
# Spieler in Roll-Richtung drehen, Kamera dabei kompensieren
var old_rot = rotation.y
rotation.y = atan2(-roll_direction.x, -roll_direction.z)
camera_pivot.rotation.y -= (rotation.y - old_rot)
else:
# Kein Input: nach vorne rollen
roll_direction = -global_transform.basis.z.normalized()
is_rolling = true
roll_cooldown = ROLL_COOLDOWN
anim_player.speed_scale = 1.0
anim_player.play("roll", -1, 1.0, false)
await anim_player.animation_finished
is_rolling = false
func _update_movement_animation(is_moving: bool, input_dir: Vector2):
if is_attacking or is_dead or is_rolling or is_drinking:
return
var has_target = target != null and is_instance_valid(target)
if not is_on_floor():
if not is_walking:
_play_anim("run_jump")
elif is_moving:
_play_anim("walk_jump")
else:
_play_anim("jump")
elif is_moving:
if not has_target and not is_walking:
var rmb = Input.is_mouse_button_pressed(MOUSE_BUTTON_RIGHT)
if input_dir.y > 0.1:
_play_anim("walk_back")
elif rmb and input_dir.x < -0.1:
_play_anim("run_strafe_left")
elif rmb and input_dir.x > 0.1:
_play_anim("run_strafe_right")
else:
_play_anim("run")
elif input_dir.y > 0.1:
_play_anim("walk_back")
elif input_dir.x < -0.1:
_play_anim("strafe_left" if is_walking else "run_strafe_left")
elif input_dir.x > 0.1:
_play_anim("strafe_right" if is_walking else "run_strafe_right")
elif is_walking:
_play_anim("walk")
else:
_play_anim("run")
else:
_play_anim("idle")
# ═══════════════════════════════════════════════════════════════
# PHYSICS PROCESS (Hauptschleife)
# ═══════════════════════════════════════════════════════════════
func _physics_process(delta):
if is_dead:
return
# ── Wut-Verfall ───────────────────────────────────────────
_update_rage_decay(delta)
# ── Blutungs-DOT ──────────────────────────────────────────
if _bleed_timer > 0.0:
_bleed_timer -= delta
_bleed_tick_timer -= delta
if _bleed_tick_timer <= 0.0:
_bleed_tick_timer = BLUTRAUSCH_DOT_INTERVAL
if _bleed_target != null and is_instance_valid(_bleed_target):
_bleed_target.take_damage(BLUTRAUSCH_DOT_DAMAGE)
else:
_bleed_timer = 0.0
if _bleed_timer <= 0.0:
_bleed_target = null
# ── Defend-Timer ──────────────────────────────────────────
if is_defending:
_defend_timer -= delta
if _defend_timer <= 0.0:
is_defending = false
_defend_mode = ""
# ── Cooldowns herunterzählen ──────────────────────────────
var gcd_was_active = global_cooldown > 0
if global_cooldown > 0:
global_cooldown = max(0.0, global_cooldown - delta)
for key in skill_cooldowns.keys():
skill_cooldowns[key] = max(0.0, skill_cooldowns[key] - delta)
for key in consumable_cooldowns.keys():
consumable_cooldowns[key] = max(0.0, consumable_cooldowns[key] - delta)
# ── Autoattack nach GCD oder wenn in Range ───────────────
if autoattack_active and global_cooldown <= 0 and not is_casting:
perform_autoattack()
# ── Cast-System ───────────────────────────────────────────
if is_casting:
cast_time_remaining -= delta
hud.update_castbar(cast_total - cast_time_remaining, cast_total)
if cast_time_remaining <= 0:
is_casting = false
_apply_skill(pending_skill_id)
pending_skill_id = ""
# ── HUD Cooldowns aktualisieren ───────────────────────────
for i in range(9):
var slot_content = action_bar[i]
if slot_content is String:
var cd = max(global_cooldown, skill_cooldowns.get(slot_content, 0.0))
hud.set_slot_cooldown(i, cd)
elif slot_content is Consumable:
hud.set_slot_cooldown(i, consumable_cooldowns.get(slot_content.item_name, 0.0))
# ── Schwerkraft ───────────────────────────────────────────
if not is_on_floor():
velocity.y -= GRAVITY * delta
# ── Springen ─────────────────────────────────────────────
if Input.is_action_just_pressed("ui_accept") and is_on_floor():
velocity.y = JUMP_VELOCITY
if is_casting:
_cancel_cast()
# ── Zielauswahl (nur Klick, nicht Drag) ───────────────────
if Input.is_action_just_pressed("select_target"):
_lmb_press_pos = get_viewport().get_mouse_position()
if Input.is_action_just_released("select_target"):
var release_pos = get_viewport().get_mouse_position()
if _lmb_press_pos.distance_to(release_pos) < 5.0:
_try_select_target(false)
if Input.is_action_just_pressed("ui_right_mouse"):
_try_select_target(true, false)
# ── Aktionsleiste (Tasten 1-9) ────────────────────────────
for i in range(9):
if Input.is_action_just_pressed("action_" + str(i + 1)):
hud.set_active_slot(i)
_on_slot_clicked(i)
# ── Panel-Shortcuts ───────────────────────────────────────
if Input.is_action_just_pressed("toggle_inventory") and inventory_panel:
inventory_panel.toggle()
if Input.is_action_just_pressed("toggle_character") and character_panel:
character_panel.toggle()
if character_panel.panel_visible:
character_panel.update_stats(self)
if Input.is_action_just_pressed("toggle_skills") and skill_panel:
skill_panel.toggle()
# ── TEST ──────────────────────────────────────────────────
if Input.is_action_just_pressed("test_damage"):
take_damage(10)
# ── Roll Cooldown ─────────────────────────────────────────
if roll_cooldown > 0:
roll_cooldown = max(0.0, roll_cooldown - delta)
# ── Walk Toggle ───────────────────────────────────────────
if Input.is_action_just_pressed("walk_toggle"):
is_walking = !is_walking
# ── Bewegung ─────────────────────────────────────────────
# get_vector: x = links(-)/rechts(+), y = vor(-)/zurück(+)
var input_dir = Input.get_vector("move_left", "move_right", "move_forward", "move_back")
# Cast/Trinken unterbrechen bei Bewegung
if is_casting and input_dir.length() > 0.1:
_cancel_cast()
if is_drinking and input_dir.length() > 0.1:
is_drinking = false
var is_moving = input_dir.length() > 0.1
var has_target = target != null and is_instance_valid(target)
# ── Ausweichrolle ─────────────────────────────────────────
if Input.is_action_just_pressed("roll") and is_on_floor() and roll_cooldown <= 0 and not is_rolling:
_do_roll()
# ── Bewegungsrichtung ────────────────────────────────────────────
# Souls: world_yaw (stabil, dreht sich nicht mit Spieler mit)
# Lock-On/Walk: rotation.y (Strafe relativ zur Spielerausrichtung)
var lmb = Input.is_mouse_button_pressed(MOUSE_BUTTON_LEFT)
# Bewegungs-Referenzwinkel einfrieren wenn LMB gedrückt
if not lmb:
_movement_yaw = camera_pivot.world_yaw
var dir3: Vector3
if has_target or is_walking:
dir3 = Vector3(input_dir.x, 0, input_dir.y).rotated(Vector3.UP, rotation.y)
else:
dir3 = Vector3(input_dir.x, 0, input_dir.y).rotated(Vector3.UP, _movement_yaw)
# ── Souls-Rotation: Zielwinkel aus _movement_yaw berechnen ─────
# Nur vorwärts/diagonal (nicht S), nicht bei RMB, nicht Lock-On/Walk
if is_moving and not is_walking and not has_target and not is_rolling \
and input_dir.y < 0.1 and not Input.is_mouse_button_pressed(MOUSE_BUTTON_RIGHT):
var desired_angle = atan2(-dir3.x, -dir3.z)
var diff = abs(angle_difference(rotation.y, desired_angle))
if diff > deg_to_rad(150.0):
rotation.y = desired_angle
else:
rotation.y = lerp_angle(rotation.y, desired_angle, delta * 12.0)
# ── Velocity ─────────────────────────────────────────────
var current_speed = SPEED if is_walking else SPRINT_SPEED
if is_rolling:
velocity.x = roll_direction.x * SPRINT_SPEED
velocity.z = roll_direction.z * SPRINT_SPEED
else:
velocity.x = dir3.x * current_speed
velocity.z = dir3.z * current_speed
_update_movement_animation(is_moving, input_dir)
move_and_slide()