Ui/SceneLabel.cs

Helper for non-interactive world-space text labels in a city sim, wrapping a TextRenderer child object and positioning it above tiles or building meshes. Also spawns transient feedback labels on board events, capped at 30, with a SceneLabelLifetime component that destroys them when the player leaves the match.

using CitySim.Feel;
using CitySim.Match;
using CitySim.Ui.DesignSystem.World;

namespace CitySim;

/// <summary>Non-interactive text rendered in the scene, below screen-space HUD panels.</summary>
public sealed class SceneLabel
{
 readonly GameObject obj;
 readonly TextRenderer text;
 public SceneLabel(CityGame city, string name, float scale)
 {
  obj = WorldPieces.Label(city.GameObject, "", scale * 3f);
  obj.Name = name;
  text = obj.Components.Get<TextRenderer>();
  text.Enabled = false;
 }
 public void Set(Vector3? position, string value)
 {
  if (!obj.IsValid()) return;
  text.Enabled = position.HasValue && !string.IsNullOrEmpty(value);
  if (!position.HasValue) return;
  obj.WorldPosition = position.Value;
  value = GameText.Plain(value);
  if (text.Text != value) text.Text = value;
 }
 public void Destroy() { if (obj.IsValid()) obj.Destroy(); }
 public static void Spawn(CityGame city, VisualCue cue)
 {
  if (city == null || city.State != GameState.Playing || city.Screens.Top != GameScreen.Match) return;
  var existing = city.Scene.GetAllComponents<SceneLabelLifetime>().Where(e => e.Owner == city).ToArray();
  if (existing.Length >= 30) existing[0].GameObject.Destroy();
  var tile = city.Grid.WorldToTile(cue.Position);
  var anchor = AboveTile(tile, cue.Position);
  var sourceOffset = tile == null ? 0 : Math.Max(0, cue.Position.z - tile.Center.z);
  var position = anchor + Vector3.Up * (18 + sourceOffset);
  var obj = WorldPieces.Feedback(city.GameObject, GameText.Plain(cue.Text), position, cue.PayoutCoin ? 0.85f : 1.4f, 0.36f, cue.PlacementVp);
  obj.Name = "Board feedback";
  obj.Components.Create<SceneLabelLifetime>().Owner = city;
 }
 public static Vector3 AboveTile(Tile tile, Vector3 fallback)
 {
  if (tile == null) return fallback;
  return AboveVisual(tile.Building, tile.Center + Vector3.Up * CityGrid.SurfaceHeight);
 }
 public static Vector3 AboveVisual(GameObject visual, Vector3 fallback)
 {
  var top = fallback.z;
  void Visit(GameObject part)
  {
   if (!part.IsValid()) return;
   var renderer = part.Components.Get<ModelRenderer>();
   if (renderer.IsValid() && renderer.Enabled && renderer.Model != null)
    top = Math.Max(top, part.WorldPosition.z + renderer.Model.Bounds.Maxs.z * part.WorldScale.z);
   foreach (var child in part.Children) Visit(child);
  }
  Visit(visual);
  return new Vector3(fallback.x, fallback.y, top);
 }
 public static void ClearTransient(CityGame city)
 {
  if (city == null || !city.IsValid()) return;
  foreach (var effect in city.Scene.GetAllComponents<SceneLabelLifetime>().ToArray())
   if (effect.Owner == city && effect.GameObject.IsValid()) effect.GameObject.Destroy();
 }
}

public sealed class SceneLabelLifetime : Component
{
 public CityGame Owner { get; set; }
 protected override void OnUpdate()
 {
  if (!Owner.IsValid() || Owner.State != GameState.Playing || Owner.Screens.Top != GameScreen.Match) GameObject.Destroy();
 }
}