saumwacht-godot/scripts/presentation/skeletal_figure.gd
Martin Stickelbroeck c2b447610c Baseline: Saumwacht 0.15.0 (Phase 20A) vor dem Umbau zu Champions of Luneraria
Unveränderter Ausgangsstand inklusive Masterprompt und Konzeptbildern
unter assets/. .godot/ und docs/screenshots/ bleiben laut .gitignore
unversioniert; letztere sind unter ../backups gesichert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 20:00:36 +02:00

156 lines
5.5 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.

extends Node3D
## Imported, weighted character. The battle clock owns playback, including pause/2×.
var skeleton: Skeleton3D
var player: AnimationPlayer
var bounds: AABB
var clips: Dictionary = {}
var attack_cycle: Array[String] = ["attack_a", "attack_b"]
var current_clip: String = ""
var dead: bool = false
var _clock: float = 0.0
var _walk_clock: float = 0.0
var _event_left: float = 0.0
var _last_hits: int = 0
var _had_attack: bool = false
var _last_ability: float = 0.0
var _last_hit: float = 0.0
var _moving: bool = false
var _preview_time: float = 0.0
var _ability_time: float = -1.0
var _hit_time: float = -1.0
func setup(source: Node3D, box: AABB) -> void:
bounds = box
add_child(source)
for node: Node in source.find_children("*", "Skeleton3D", true, false):
skeleton = node as Skeleton3D
for node: Node in source.find_children("*", "AnimationPlayer", true, false):
player = node as AnimationPlayer
if player != null:
player.callback_mode_process = AnimationMixer.ANIMATION_CALLBACK_MODE_PROCESS_MANUAL
player.playback_default_blend_time = .12
for key: String in player.get_animation_list():
var short_name: String = key.get_slice("/",key.get_slice_count("/")-1)
clips[short_name] = key
func get_aabb() -> AABB:
return bounds
func clip_length(clip: String) -> float:
return player.get_animation(clips[clip]).length if clips.has(clip) else 0.0
func _sample(clip: String, at: float, delta: float = 0.0, blend: float = .12) -> void:
if player == null or not clips.has(clip) or not is_inside_tree():
return
if current_clip != clip:
player.play(clips[clip], blend)
current_clip = clip
player.advance(delta)
player.seek(clampf(at,0,clip_length(clip)),true)
func preview(clip: String, at: float) -> void:
_sample(clip, at, 0.0, 0.0)
func pose(time: float, moving: bool, attack: float, ability: float, hit: float) -> void:
# Legacy preview entry point, retained for model galleries and callers.
var dt: float = clampf(time-_preview_time,0,.1)
_preview_time = time
if attack > 0:
_sample("attack_a",.90+(.28-attack),dt)
elif ability > 0:
_sample("ability",fmod(time,clip_length("ability")),dt)
elif hit > 0:
_sample("hit",.18-hit,dt)
else:
var clip: String = "walk" if moving else "idle"
_sample(clip,fmod(time,clip_length(clip)),dt)
func update_combat(delta: float, distance: float, in_range: bool, progress: float, hits: int, ability: float, hit: float, living: bool) -> void:
if not living:
if not dead:
dead = true
_clock = 0.0
_clock += delta
_sample("death",_clock,delta,.06)
return
_clock += delta
var moving: bool = delta > 0 and distance / delta > .04
# Direct damage stays in AttackComponent. Pose reaches contact exactly on its event.
if in_range:
if hits > _last_hits:
_had_attack = true
_last_hits = hits
var recovering: bool = _had_attack and progress < .30
var index: int = maxi(0,hits-1) if recovering else hits
var clip: String = attack_cycle[index % attack_cycle.size()]
var at: float = .90+.35*progress/.30 if recovering else .90*maxf(0,progress-(.30 if _had_attack else 0.0))/(.70 if _had_attack else 1.0)
_sample(clip,at,delta,.055)
_event_left = 0
else:
_had_attack = false
_last_hits = hits
if ability > _last_ability + .05:
_event_left = clip_length("ability")
_clock = 0.0
_sample("ability",0,delta)
elif hit > _last_hit + .05 and _event_left <= 0.0:
_event_left = clip_length("hit")
_clock = 0.0
_sample("hit",0,delta,.045)
if _event_left > 0:
_event_left -= delta
_sample(current_clip,_clock,delta)
elif moving:
if not _moving:
_clock = 0.0
# One stride travels 0.52 metres. Separation and slow effects affect cadence.
_walk_clock += distance / .52 * clip_length("walk")
if _clock < clip_length("start"):
_sample("start",_clock,delta)
else:
_sample("walk",fmod(_walk_clock,clip_length("walk")),delta)
else:
if _moving:
_clock = 0.0
if current_clip in ["walk","start","stop"] and _clock < clip_length("stop"):
_sample("stop",_clock,delta)
else:
_sample("idle",fmod(_clock,clip_length("idle")),delta)
if ability > _last_ability + .05:
_ability_time = 0.0
if hit > _last_hit + .05:
_hit_time = 0.0
if _ability_time >= 0:
_ability_time += delta
if in_range:
_overlay("ability",_ability_time,.45,["Head","Forearm.L","Forearm.R"])
if _ability_time >= clip_length("ability"):
_ability_time = -1.0
if _hit_time >= 0:
_hit_time += delta
if in_range:
_overlay("hit",_hit_time,.65,["Spine","Head"])
if _hit_time >= clip_length("hit"):
_hit_time = -1.0
_last_ability = ability
_last_hit = hit
_moving = moving
func _overlay(clip: String, at: float, weight: float, bones: Array) -> void:
# Only selected joints react while the primary swing keeps its contact time.
if not is_inside_tree() or not clips.has(clip):
return
var animation: Animation = player.get_animation(clips[clip])
for track: int in range(animation.get_track_count()):
if animation.track_get_type(track) != Animation.TYPE_ROTATION_3D:
continue
var path: NodePath = animation.track_get_path(track)
if path.get_subname_count() == 0 or not str(path.get_subname(0)) in bones:
continue
var index: int = skeleton.find_bone(path.get_subname(0))
if index < 0:
continue
var rest: Quaternion = skeleton.get_bone_rest(index).basis.get_rotation_quaternion()
var value: Quaternion = animation.rotation_track_interpolate(track,minf(at,animation.length))
var offset: Quaternion = Quaternion.IDENTITY.slerp(rest.inverse()*value,weight)
skeleton.set_bone_pose_rotation(index,skeleton.get_bone_pose_rotation(index)*offset)