refactor(M03): extract M2 animation playback

This commit is contained in:
2026-07-18 01:25:54 +04:00
parent 5be36376c2
commit 9b73571dcb
12 changed files with 615 additions and 85 deletions
@@ -0,0 +1,114 @@
class_name M2AnimationPlaybackController
extends RefCounted
## Applies deterministic playback to one animated M2 instance and copies the
## runtime fields of exact-script native animators. The caller owns instance
## duplication, SceneTree attachment and diagnostic log formatting.
## Copies the five historical runtime fields between matching native animators
## in depth-first order. References are intentionally retained exactly.
func copy_native_animator_data(
source_root: Node,
target_root: Node,
native_animator_script: Script
) -> void:
var source_animators := native_animators_in_subtree(source_root, native_animator_script)
var target_animators := native_animators_in_subtree(target_root, native_animator_script)
for animator_index in range(mini(source_animators.size(), target_animators.size())):
var source := source_animators[animator_index]
var target := target_animators[animator_index]
target.mesh_instance_path = source.mesh_instance_path
target.bones = source.bones
target.surfaces = source.surfaces
target.animation_length = source.animation_length
target.playback_speed = source.playback_speed
## Prepares native animators, applies deterministic phase, and starts supplied
## AnimationPlayers with the historical loop/name/seek rules. Runtime debug
## state is sampled only when requested and returned as detached Dictionaries.
func start_instance_playback(
root: Node,
relative_path: String,
instance_index: int,
native_animator_script: Script,
animation_players: Array[AnimationPlayer],
collect_native_diagnostics: bool
) -> Array[Dictionary]:
var phase := phase_for_instance(relative_path, instance_index)
var native_diagnostics: Array[Dictionary] = []
for animator in native_animators_in_subtree(root, native_animator_script):
if animator.has_method("prepare_runtime"):
animator.prepare_runtime()
animator.set_phase(phase)
if collect_native_diagnostics and animator.has_method("runtime_debug_state"):
var diagnostic_variant = animator.runtime_debug_state()
if diagnostic_variant is Dictionary:
native_diagnostics.append((diagnostic_variant as Dictionary).duplicate(true))
for player in animation_players:
if player == null:
continue
var animation_name := choose_default_animation(player, relative_path)
if animation_name.is_empty():
continue
for available_name in player.get_animation_list():
var available_animation := player.get_animation(available_name)
if available_animation != null:
available_animation.loop_mode = Animation.LOOP_LINEAR
player.play(animation_name)
var selected_animation := player.get_animation(animation_name)
if selected_animation != null and selected_animation.length > 0.0:
player.seek(selected_animation.length * phase, true)
return native_diagnostics
## Returns exact-script native animators in depth-first preorder.
func native_animators_in_subtree(
root: Node,
native_animator_script: Script
) -> Array[Node]:
var native_animators: Array[Node] = []
if root != null and native_animator_script != null:
_collect_native_animators(root, native_animator_script, native_animators)
return native_animators
## Returns the existing stable path/index phase in the inclusive-lower,
## exclusive-upper range [0, 1).
func phase_for_instance(relative_path: String, instance_index: int) -> float:
return float(abs(("%s:%d" % [relative_path, instance_index]).hash()) % 1000) / 1000.0
## Chooses the historical default animation name for ordinary, fish and bird
## paths, then case-insensitive substring and first-name fallbacks.
func choose_default_animation(player: AnimationPlayer, relative_path: String = "") -> String:
if player == null:
return ""
var lower_path := relative_path.to_lower()
var candidates: Array[String] = ["Stand", "Idle", "Run", "Walk"]
if lower_path.contains("fish"):
candidates = ["Run", "Walk", "Swim", "Stand", "Idle", "Death"]
elif lower_path.contains("eagle") or lower_path.contains("bird") or lower_path.contains("gull"):
candidates = ["Run", "Walk", "Swim", "Stand", "Idle"]
for candidate in candidates:
if player.has_animation(candidate):
return candidate
var available_names := player.get_animation_list()
for candidate in ["run", "walk", "swim", "stand", "idle", "loop"]:
for available_name in available_names:
if String(available_name).to_lower().contains(candidate):
return String(available_name)
return String(available_names[0]) if not available_names.is_empty() else ""
func _collect_native_animators(
node: Node,
native_animator_script: Script,
result: Array[Node]
) -> void:
if node.get_script() == native_animator_script:
result.append(node)
for child in node.get_children():
_collect_native_animators(child, native_animator_script, result)
@@ -0,0 +1 @@
uid://c6oln217sml80