The FeedbackPanel UI component manages and animates transient visual feedback elements like confetti, banners, and payout receipts.
using CitySim.Feel;
using CitySim.Match;
using Sandbox.UI;
namespace CitySim;
/// <summary>Animation updates touch label styles, never the match or the rest of the HUD.</summary>
public sealed class FeedbackPanel : Panel
{
[Parameter] public CityGame City { get; set; }
Feedback feedback;
CityPlayer player;
CityGame boundCity;
SceneLabel preview;
sealed class Floater { public Panel Label; public VisualCue Cue; public float Age; public Vector2 FlightStart; }
readonly List<Floater> active = new();
protected override void OnParametersSet()
{
base.OnParametersSet();
if (feedback == City?.Feedback) return;
if (feedback != null) { SceneLabel.ClearTransient(boundCity); preview?.Destroy(); preview = null; feedback.Cue -= Show; feedback.TallySkipped -= ClearPayout; feedback.Cleared -= ClearAll; }
if (player != null) player.PreviewChanged -= Preview;
if (boundCity != null) boundCity.UiChanged -= Preview;
boundCity = City;
feedback = City?.Feedback;
player = City?.Player;
if (feedback != null) { feedback.Cue += Show; feedback.TallySkipped += ClearPayout; feedback.Cleared += ClearAll; }
if (player != null) player.PreviewChanged += Preview;
if (City != null) City.UiChanged += Preview;
Preview();
}
void Preview()
{
if (City == null || !City.IsValid()) return;
preview ??= new SceneLabel(City, GameText.Get("ui.feedback_panel.placement_preview"), 0.12f);
// The ghost tint shows validity; only a nonzero VP change needs a label.
Vector3? position = City.BoardInput && player?.Hovered is { } tile && City.SelectedCard != null && player.HoveredValid && player.HoveredGain != 0
? SceneLabel.AboveVisual(player.PlacementGhost, SceneLabel.AboveTile(tile, tile.Center))+Vector3.Up*18 : null;
preview.Set(position, player?.HoveredGain != 0 ? GameText.Get("ui.feedback_panel.vp", player.HoveredGain) : "");
}
void Show( VisualCue cue )
{
if (cue.Confetti)
{
foreach (var old in active) Remove(old); active.Clear();
preview?.Set(null, "");
if (City.Feedback.ReduceFlashing) return;
for (var i = 0; i < 20; i++)
{
var part = AddChild<Label>("confetti"); part.Text = new[] { "✦", "●", "■" }[i%3];
part.Style.Set("color",i%2 == 0 ? "#ffda78" : "#8ed7ff");
active.Add(new() { Label = part, Cue = cue, FlightStart = new Vector2(0.05f+i*0.047f,-0.15f-(i%4)*0.05f) });
}
return;
}
if (cue.Region != null) return;
if (!cue.Banner && cue.CardId == null) { SceneLabel.Spawn(City, cue); return; }
if (cue.Banner && !cue.PayoutReceipt && active.Any(f => f.Cue.Banner && !f.Cue.PayoutReceipt && f.Cue.Tier > cue.Tier)) return;
if (cue.Banner)
foreach (var old in active.Where(f => f.Cue.Banner && f.Cue.PayoutReceipt == cue.PayoutReceipt).ToArray()) { Remove(old); active.Remove(old); }
if (active.Count >= 30) { Remove(active[0]); active.RemoveAt(0); }
Panel label;
if (cue.PayoutReceipt) label = AddChild<Panel>();
else { var textLabel = AddChild<Label>(); textLabel.Text = GameText.Plain(cue.Text); label = textLabel; }
label.AddClass(cue.CardId != null ? "auction-flight" : cue.Banner ? "phase-toast" : "floater");
if (cue.PlacementVp) label.AddClass("placement-vp");
if (cue.PayoutReceipt)
{
label.AddClass("payout-receipt");
var lines = GameText.Plain(cue.Text).Split('\n');
label.AddChild<Label>("receipt-title").Text = lines[0];
foreach (var line in lines.Skip(1))
{
var columns = line.Split('\t');
var row = label.AddChild<Panel>("receipt-row");
row.AddChild<Label>("receipt-source").Text = columns[0];
row.AddChild<Label>("receipt-amount").Text = columns.Length > 1 ? columns[1] : "";
}
}
var source = City.Scene.GetAllComponents<Hud>().FirstOrDefault()?.Panel.Descendants.OfType<AuctionLotPanel>().FirstOrDefault(p => p.LotIndex == cue.Lot);
var floater = new Floater { Label = label, Cue = cue, FlightStart = source != null ? source.Box.Rect.Center / Screen.Size : new Vector2(0.5f,0.45f) };
active.Add(floater);
}
void Remove(Floater item) { item.Label.Delete(); }
void ClearPayout()
{
SceneLabel.ClearTransient(City);
foreach (var item in active.Where(f=>f.Cue.PayoutCoin || f.Cue.PayoutReceipt).ToArray()) { Remove(item); active.Remove(item); }
}
void ClearAll()
{
foreach (var item in active) Remove(item);
active.Clear(); preview?.Set(null, "");
SceneLabel.ClearTransient(City);
}
public override void Tick()
{
base.Tick();
foreach (var item in active.ToArray())
{
item.Age += Time.Delta;
var life = item.Cue.PayoutReceipt ? 6.5f : item.Cue.PayoutCoin ? 0.85f : item.Cue.Confetti ? 3f : item.Cue.Region != null ? 0.7f : item.Cue.Banner ? 2.2f : 1.4f;
if (item.Age >= life) { Remove(item); active.Remove(item); continue; }
item.Label.Style.Opacity = Math.Clamp((life-item.Age)*3,0,1);
if (item.Cue.Confetti)
{
var t = item.Age/life;
item.Label.Style.Left = Length.Percent((item.FlightStart.x+MathF.Sin(t*5+item.FlightStart.x*12)*0.035f)*100);
item.Label.Style.Top = Length.Percent((item.FlightStart.y+t*t*1.4f)*100); continue;
}
if (item.Cue.CardId != null)
{
var t = Math.Clamp(item.Age/1.1f,0,1);
var ease = 1-MathF.Pow(1-t,3);
var flightPoint = item.FlightStart+(new Vector2(0.5f,0.87f)-item.FlightStart)*ease;
item.Label.Style.Left = Length.Percent(flightPoint.x*100);
item.Label.Style.Top = Length.Percent(flightPoint.y*100-MathF.Sin(t*MathF.PI)*12);
continue;
}
if (item.Cue.Banner)
{
if (item.Cue.PayoutReceipt)
{
item.Label.Style.Left=Length.Percent(35); item.Label.Style.Right=Length.Percent(35);
item.Label.Style.Top=Length.Percent(25);
continue;
}
var inspecting = City.Screens.Top == GameScreen.Goals;
item.Label.Style.Left = Length.Percent(inspecting ? 45 : 40); item.Label.Style.Right = Length.Percent(inspecting ? 15 : 40);
item.Label.Style.Top = Length.Percent(16+3*MathF.Pow(1-Math.Clamp(item.Age/0.3f,0,1),3));
}
}
}
public override void OnDeleted() { if (feedback != null) { feedback.Cue -= Show; feedback.TallySkipped -= ClearPayout; feedback.Cleared -= ClearAll; } if (player != null) player.PreviewChanged -= Preview; if (boundCity != null) boundCity.UiChanged -= Preview; preview?.Destroy(); SceneLabel.ClearTransient(boundCity); base.OnDeleted(); }
}