Ui/DesignSystem/World/WorldPieces.cs

The file provides static methods to construct world-space UI elements and a component to manage their transient animation. It contains factory methods for creating printed labels, layered cardboard markers, and rising feedback effects.

namespace CitySim.Ui.DesignSystem.World;

/// <summary>Local-only, disposable world-space presentation pieces. The caller owns their lifetime.</summary>
public static class WorldPieces
{
 public static readonly Color Ink = new(41f / 255, 58f / 255, 59f / 255);
 public static readonly Color Paper = new(242f / 255, 232f / 255, 211f / 255);
 public static readonly Color Mustard = new(213f / 255, 168f / 255, 61f / 255);
 public static readonly Color Board = new(0.48f, 0.36f, 0.24f);

 /// <summary>Create a camera-facing printed label. Position it in world or local space after creation.</summary>
 public static GameObject Label(GameObject parent, string value, float scale = 0.04f)
 {
  var obj = Child(parent, "World label");
  var text = obj.Components.Create<TextRenderer>();
  text.Text = GameText.Plain(value);
  text.Scale = scale;
  text.FontSize = 48;
  text.FontWeight = 700;
  text.HorizontalAlignment = TextRenderer.HAlignment.Center;
  text.Billboard = TextRenderer.BillboardMode.Always;
  text.BlendMode = BlendMode.Normal;
  text.FogStrength = 0;
  text.RenderOptions.Overlay = false;
  text.RenderOptions.AfterUI = false;
  text.Color = Paper;
  var scope = text.TextScope;
  scope.Outline = new TextRendering.Outline { Enabled = true, Size = 3, Color = Ink };
  text.TextScope = scope;
  return obj;
 }

 /// <summary>A flat, layered cardboard marker. The shape provides a cue independent of its accent colour.</summary>
 public static GameObject Marker(GameObject parent, float size = 48f, Color? accent = null)
 {
  var obj = Child(parent, "World marker");
  // Each layer uses a white mesh and renderer tint: board_flat supports tint,
  // while the simple material does not reliably display vertex colours.
  void Layer(string name, Vector3 center, Vector3 dimensions, Color color)
  {
   var vertices = new VertexBuffer();
   vertices.Init(true);
   vertices.AddCube(center, dimensions, Rotation.Identity, Color.White);
   var mesh = new Mesh(Material.Load("materials/board/board_flat.vmat"));
   mesh.CreateBuffers(vertices);
   var layer = Child(obj, name);
   layer.LocalPosition = Vector3.Zero;
   var renderer = layer.Components.Create<ModelRenderer>();
   renderer.Model = new ModelBuilder().AddMesh(mesh).Create();
   renderer.Tint = color;
  }
  Layer("Chipboard", new Vector3(0, 0, 0.8f), new Vector3(size, size, 1.6f), Board);
  Layer("Ink edge", new Vector3(0, 0, 1.8f), new Vector3(size, size, 0.5f), Ink);
  Layer("Printed face", new Vector3(0, 0, 2.2f), new Vector3(size * 0.86f, size * 0.86f, 0.4f), accent ?? Mustard);
  Layer("Cross horizontal", new Vector3(0, 0, 2.5f), new Vector3(size * 0.48f, size * 0.1f, 0.25f), Ink);
  Layer("Cross vertical", new Vector3(0, 0, 2.5f), new Vector3(size * 0.1f, size * 0.48f, 0.25f), Ink);
  return obj;
 }

 /// <summary>Create rising, fading feedback that destroys itself after its duration.</summary>
 public static GameObject Feedback(GameObject parent, string value, Vector3 worldPosition, float duration = 1.4f, float scale = 0.04f, bool pop = false)
 {
  var obj = Label(parent, value, scale);
  obj.Name = "World feedback";
  obj.WorldPosition = worldPosition;
  var lifetime = obj.Components.Create<WorldFeedbackLifetime>();
  lifetime.Duration = Math.Max(0.01f, duration);

  lifetime.StartPosition = worldPosition;
  lifetime.BaseScale = scale;
  lifetime.Pop = pop;
  return obj;
 }

 static GameObject Child(GameObject parent, string name)
 {
  return new GameObject(true, name) { Parent = parent, NetworkMode = NetworkMode.Never };
 }
}

/// <summary>Animation and cleanup for a transient world label.</summary>
public sealed class WorldFeedbackLifetime : Component
{
 public Vector3 StartPosition { get; set; }
 public float Duration { get; set; } = 1.4f;
 public float BaseScale { get; set; } = 0.04f;
 public bool Pop { get; set; }

 float age;
 protected override void OnUpdate()
 {
  age += Time.Delta;
  if (age >= Duration) { GameObject.Destroy(); return; }
  var t = Math.Clamp(age / Math.Max(0.01f, Duration), 0, 1);
  WorldPosition = StartPosition + Vector3.Up * (12 * (1 - MathF.Pow(1 - t, 3)));
  var text = Components.Get<TextRenderer>();
  if (!text.IsValid()) return;
  if (Pop)
  {
   var p = Math.Clamp(age / 0.24f, 0, 1) - 1;
   var ease = 1 + 2.70158f * p * p * p + 1.70158f * p * p;
   var exit = Math.Clamp((Duration - age) / 0.22f, 0, 1);
   text.Scale = BaseScale * (0.65f + 0.35f * ease) * (exit * exit * (3 - 2 * exit));
  }
  else text.Color = new Color(WorldPieces.Paper.r, WorldPieces.Paper.g, WorldPieces.Paper.b, Math.Clamp((Duration - age) * 3, 0, 1));
 }
}