Code/Studio/CharacterPlaza.cs

A scene component that lays out a paged plaza of CharacterAvatar game objects driven by the navmesh, plus a PlazaWalker component that gives each avatar simple wandering and animation control. CharacterPlaza creates avatars from a StudioSession library, positions them, manages paging, camera follow/overview, and Refresh; PlazaWalker drives NavMeshAgent pathfinding and animation for each avatar.

NetworkingFile Access
using System;
using Sandbox.Citizen;
using Sandbox.Navigation;

namespace CharacterEditor;
/// <summary>A local, bounded, navmesh-driven showcase of the saved collection.</summary>
public sealed class CharacterPlaza : Component
{
	[Property]
	public CameraComponent Camera { get; set; }

	[Property]
	public int PageSize { get; set; } = 12;
	public List<CharacterAvatar> Residents { get; } = new();
	public int Page { get; private set; }
	public int PageCount => Math.Max(1, (Session?.Library.Items.Count + Math.Clamp(PageSize, 1, 24) - 1 ?? 0) / Math.Clamp(PageSize, 1, 24));
	public CharacterAvatar Follow { get; private set; }
	public bool Paused { get; set; }
	public float Yaw { get; set; } = 35;
	public float Distance { get; set; } = 600;

	StudioSession Session => Scene.GetAllComponents<StudioSession>().FirstOrDefault();

	bool started;
	protected override void OnStart()
	{
		Scene.NavMesh.SetDirty();
		Refresh();
	}

	public void ChangePage(int delta)
	{
		Page = (Page + delta + PageCount) % PageCount;
		Refresh();
	}

	public void Focus(string id)
	{
		Follow = Residents.FirstOrDefault(x => x.Preset.Id == id);
	}

	public void Overview()
	{
		Follow = null;
		Distance = 600;
		Yaw = 35;
	}

	public void Refresh()
	{
		var session = Session;
		if (!session.IsValid())
			return;
		session.EnsureLoaded();
		foreach (var avatar in Residents)
			if (avatar.IsValid())
				avatar.GameObject.Destroy();
		Residents.Clear();
		Follow = null;
		Page = Math.Clamp(Page, 0, PageCount - 1);
		var index = 0;
		using var scope = Scene.Push();
		foreach (var preset in session.Library.Items.OrderBy(x => x.Name).Skip(Page * Math.Clamp(PageSize, 1, 24)).Take(Math.Clamp(PageSize, 1, 24)))
		{
			var go = new GameObject
			{
				Name = preset.Name,
				NetworkMode = NetworkMode.Never
			};
			go.WorldPosition = new((index % 4 - 1.5f) * 85, (index / 4 - 1) * 85, 2);
			index++;
			var avatar = go.Components.Create<CharacterAvatar>();
			avatar.ShowEditButton = false;
			avatar.Apply(preset);
			var agent = go.Components.Create<NavMeshAgent>();
			agent.Radius = 18;
			agent.Height = 72;
			agent.MaxSpeed = 35 + index % 4 * 5;
			agent.Acceleration = 100;
			agent.UpdateRotation = true;
			var walker = go.Components.Create<PlazaWalker>();
			walker.Plaza = this;
			Residents.Add(avatar);
		}

		started = true;
	}

	protected override void OnUpdate()
	{
		if (!started)
			Refresh();
		if (!Camera.IsValid())
			return;
		var focus = Follow.IsValid() ? Follow.WorldPosition + Vector3.Up * 42 : new Vector3(0, 0, 20);
		var distance = Follow.IsValid() ? Math.Clamp(Distance * .4f, 110, 350) : Math.Clamp(Distance, 280, 950);
		Camera.WorldPosition = focus + Rotation.FromYaw(Yaw) * new Vector3(distance, -distance * .35f, distance * .72f);
		Camera.WorldRotation = Rotation.LookAt(focus - Camera.WorldPosition);
	}
}

public sealed class PlazaWalker : Component
{
	public CharacterPlaza Plaza { get; set; }

	NavMeshAgent Agent => Components.Get<NavMeshAgent>();

	CitizenAnimationHelper animation;
	Vector3 previous;
	float wait = 1;
	bool wasPaused;
	protected override void OnStart()
	{
		previous = WorldPosition;
	}

	protected override void OnUpdate()
	{
		var avatar = Components.Get<CharacterAvatar>();
		if (!avatar.Body.IsValid())
			return;
		animation ??= Components.Create<CitizenAnimationHelper>();
		animation.Target = avatar.Body;
		var velocity = (WorldPosition - previous) / MathF.Max(Time.Delta, .001f);
		previous = WorldPosition;
		animation.WithVelocity(velocity);
		animation.IsGrounded = true;
		animation.IsLongIdle = velocity.Length < 1;
		if (Plaza?.Paused == true)
		{
			if (!wasPaused)
			{
				Agent.Stop();
				wasPaused = true;
			}

			return;
		}

		wasPaused = false;
		wait -= Time.Delta;
		if (wait > 0)
			return;
		var target = new Vector3(Game.Random.Float(-225, 225), Game.Random.Float(-225, 225), 0);
		var path = Scene.NavMesh.CalculatePath(new CalculatePathRequest { Start = Agent.AgentPosition, Target = target, Agent = Agent });
		if (path.IsValid)
		{
			Agent.SetPath(path);
			wait = Game.Random.Float(5, 11);
		}
		else
			wait = 1;
	}
}