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();
}
}