Code/RigControl/RigAnimPlayerComponent.cs
using Sandbox;
using System;
using System.Collections.Generic;

namespace Marionette;

/// <summary>
/// Plays a RigAnimDocument back on a live in-game SkinnedModelRenderer - the runtime half of the
/// RigControlEditor recorder. The editor tool only ever plays a clip back inside its own preview
/// scene; this is what makes a recorded clip actually visible in the real game.
///
/// Posing works the same way the editor viewport's own EvaluatePose does - direct
/// LocalPosition/LocalRotation writes on the per-bone GameObjects SkinnedModelRenderer.
/// CreateBoneObjects spawns - except BreakProceduralBone() isn't called, because that extension
/// method lives in the editor-only tools addon and can't be referenced from game code. UseAnimGraph
/// = false is what actually stops anything else from fighting these writes at runtime; nothing
/// else in this project drives the ProceduralBone flag once that's off, so there's nothing to
/// break free from the way there is in the scene editor.
///
/// Also forwards the current frame to a sibling RigEventPlayerComponent if one is on the same
/// GameObject, so a single Anim assignment drives both bone poses and AnimEvents together.
/// </summary>
public sealed class RigAnimPlayerComponent : Component
{
	[Property] public RigAnimDocument Anim { get; set; }
	[Property] public SkinnedModelRenderer Target { get; set; }
	[Property] public bool Loop { get; set; } = true;
	[Property] public bool PlayOnStart { get; set; } = true;

	public bool IsPlaying { get; private set; }
	public float Frame { get; private set; }

	private RigEventPlayerComponent _events;
	private bool _rigReady;

	private float LastFrame => Anim is null ? 0f : MathF.Max( Anim.FrameCount - 1, 1f );
	private float FrameRate => Anim is { AnimationSpeed: > 0 } ? Anim.AnimationSpeed : 30f;

	protected override void OnEnabled()
	{
		Target ??= GetComponent<SkinnedModelRenderer>();
		_events ??= GetComponent<RigEventPlayerComponent>();

		if ( PlayOnStart )
			Play();
	}

	protected override void OnUpdate()
	{
		if ( !Target.IsValid() || Anim is null )
			return;

		EnsureRig();

		if ( IsPlaying )
		{
			Frame += Time.Delta * FrameRate;

			if ( Frame > LastFrame )
			{
				if ( Loop )
					Frame %= MathF.Max( LastFrame, 0.0001f );
				else
				{
					Frame = LastFrame;
					IsPlaying = false;
				}
			}
		}

		ApplyFrame( Frame );
	}

	public void Play() => IsPlaying = true;
	public void Pause() => IsPlaying = false;

	public void Stop()
	{
		IsPlaying = false;
		Frame = 0f;
	}

	public void Seek( float frame )
	{
		Frame = frame.Clamp( 0f, LastFrame );
		ApplyFrame( Frame );
	}

	private void EnsureRig()
	{
		if ( _rigReady )
			return;

		Target.CreateBoneObjects = true;
		Target.UseAnimGraph = false;
		_rigReady = true;
	}

	private void ApplyFrame( float frame )
	{
		foreach ( var track in Anim.BoneTracks )
		{
			if ( track.Keyframes.Count == 0 )
				continue;

			var bone = FindBone( track.BoneName );
			if ( !bone.IsValid() )
				continue;

			var local = track.Evaluate( frame );
			bone.LocalPosition = local.Position;
			bone.LocalRotation = local.Rotation;
		}

		_events?.SetFrame( frame );
	}

	private GameObject FindBone( string name )
	{
		if ( !Target.IsValid() )
			return null;

		var queue = new Queue<GameObject>();
		queue.Enqueue( Target.GameObject );

		while ( queue.Count > 0 )
		{
			var go = queue.Dequeue();

			foreach ( var child in go.Children )
			{
				if ( child.Name == name && child.Flags.HasFlag( GameObjectFlags.ProceduralBone ) )
					return child;

				queue.Enqueue( child );
			}
		}

		return null;
	}
}