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)