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.
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;
}
}