StreamPlayer.cs
/// <summary>
/// One viewer's avatar in the world.
///
/// Note that this component does NOT listen to the stream itself - it doesn't implement
/// <see cref="Streamer.IEvents"/>. Instead the <see cref="StreamGameManager"/> owns all the stream wiring
/// and, once it knows a chat message belongs to this viewer, calls <see cref="OnChatCommand"/>. That keeps
/// the avatar simple: it just knows how to act on a command and how to animate.
/// </summary>
public sealed class StreamPlayer : Component
{

	[Property]
	public SoundEvent PoopSound { get; set; }

	/// <summary>
	/// The viewer this avatar represents. Set in <see cref="Setup"/> when the manager spawns us.
	/// </summary>
	public Streamer.Viewer Viewer { get; private set; }

	NavMeshAgent _agent;
	SkinnedModelRenderer _renderer;

	Rotation _targetRotation = Rotation.Identity;
	bool _ducked;

	/// <summary>
	/// Called by <see cref="StreamGameManager"/> right after the avatar is cloned (and before it's enabled).
	/// Caches the components we'll use, points the name tag at the viewer, gives them a random facing, and
	/// randomises their outfit.
	/// </summary>
	public void Setup( Streamer.Viewer viewer )
	{
		Viewer = viewer;

		_agent = GetComponent<NavMeshAgent>( true );
		_renderer = GetComponentInChildren<SkinnedModelRenderer>( true );

		if ( GetComponentInChildren<PlayerTag>( true ) is PlayerTag tag )
			tag.Viewer = viewer;

		_targetRotation = new Angles( 0, Random.Shared.Float( -360, 360 ), 0 );
		WorldRotation = _targetRotation;

		if ( GetComponentInChildren<Dresser>( true ) is Dresser dresser )
			dresser.Randomize();
	}

	/// <summary>
	/// Act on a chat message aimed at this avatar. The manager has already matched the message to our
	/// <see cref="Viewer"/>, so all we do here is map the text to an action. Unknown text is ignored.
	/// </summary>
	public void OnChatCommand( string command )
	{
		switch ( command )
		{
			case "go":
			case "forward":
				MoveBy( WorldRotation.Forward.WithZ( 0 ) * 100 );
				break;

			case "back":
				MoveBy( WorldRotation.Forward.WithZ( 0 ) * -100 );
				break;

			case "move":
				MoveBy( Vector3.Random * 100 );
				break;

			case "left":
				_targetRotation *= new Angles( 0, 45, 0 );
				break;

			case "right":
				_targetRotation *= new Angles( 0, -45, 0 );
				break;

			case "turn":
				_targetRotation *= new Angles( 0, 180, 0 );
				break;

			case "duck":
				_ducked = !_ducked;
				break;

			case "poop":
				Poop();
				break;
		}
	}

	protected override void OnUpdate()
	{
		// Ease toward whatever rotation the latest turn command asked for...
		WorldRotation = Rotation.Lerp( WorldRotation, _targetRotation, Time.Delta * 5f );

		// ...and keep the animation graph in step with the NavMeshAgent's movement.
		Animate();
	}

	/// <summary>
	/// Walk to a point relative to where we are now.
	/// </summary>
	void MoveBy( Vector3 offset )
	{
		_agent.MoveTo( WorldPosition + offset );
	}

	/// <summary>
	/// Drop a little pile of physics cubes behind the avatar and bump this viewer's score. The score is
	/// stored on the viewer's <see cref="Streamer.Viewer.Data"/> bag rather than on this component, so any
	/// UI (here, the ScoreBoard) can read it straight off the viewer without needing a reference to us.
	/// </summary>
	void Poop()
	{
		for ( int i = 0; i < 10; i++ )
		{
			var poop = new GameObject( true, "Poop" );
			poop.WorldPosition = WorldPosition + WorldRotation.Up * 40 + WorldRotation.Backward * 10;
			poop.WorldScale = new Vector3(
				Random.Shared.Float( 0.05f, 0.1f ),
				Random.Shared.Float( 0.05f, 0.2f ),
				Random.Shared.Float( 0.05f, 0.07f ) );

			var renderer = poop.AddComponent<ModelRenderer>();
			renderer.Model = Model.Cube;
			renderer.Tint = "#070402";

			poop.AddComponent<BoxCollider>().Elasticity = 0.6f;
			poop.AddComponent<Rigidbody>().Velocity = WorldRotation.Backward * Random.Shared.Float( 10, 500 );
		}

		GameObject.PlaySound( PoopSound );

		Viewer.Data.Set( "poops", Viewer.Data.Get( "poops", 0 ) + 1 );
	}

	/// <summary>
	/// Feed the NavMeshAgent's movement into the animation graph so the citizen model walks, turns and ducks.
	/// </summary>
	void Animate()
	{
		// The citizen animgraph takes two velocity sets: "move_*" for actual movement and "wish_*" for
		// intended movement (used for things like leaning and arm-swing).
		SetMovement( "move", _agent.Velocity );
		SetMovement( "wish", _agent.WishVelocity );

		_renderer.Set( "duck", _ducked ? 1 : 0 );
	}

	/// <summary>
	/// Push a velocity into the animation graph under one of its parameter sets ("move" or "wish").
	/// </summary>
	void SetMovement( string set, Vector3 velocity )
	{
		var forward = _renderer.WorldRotation.Forward.Dot( velocity );
		var sideward = _renderer.WorldRotation.Right.Dot( velocity );
		var angle = MathF.Atan2( sideward, forward ).RadianToDegree().NormalizeDegrees();

		_renderer.Set( $"{set}_direction", angle );
		_renderer.Set( $"{set}_speed", velocity.Length );
		_renderer.Set( $"{set}_groundspeed", velocity.WithZ( 0 ).Length );
		_renderer.Set( $"{set}_x", forward );
		_renderer.Set( $"{set}_y", sideward );
		_renderer.Set( $"{set}_z", velocity.z );
	}
}