Feel/Feedback.cs

The Feedback component manages transient audio and visual feedback during a match. It subscribes to match events to trigger visual cues like score tallies, placement results, and phase transitions.

using CitySim.Match;

namespace CitySim.Feel;

public enum FeedbackTier { Small, Medium, Large }
public sealed record FeedbackPreset( float Trauma, float Duration, string Sound );
public sealed record VisualCue( string Text, Vector3 Position, FeedbackTier Tier, bool Banner = false, string CardId = null, int Lot = -1, IReadOnlyList<BoardCell> Region = null, bool Confetti = false, Vector3? Partner = null, bool PlacementVp = false, bool PayoutCoin = false, bool PayoutReceipt = false );

/// <summary>Transient presentation only: no waits, time scale changes or rule mutations.</summary>
public sealed class Feedback : Component
{
 public static FeedbackPreset Preset(FeedbackTier tier) => tier switch { FeedbackTier.Small => new(0,0.18f,"sounds/feedback/tick.sound"), FeedbackTier.Medium => new(0.3f,0.35f,"sounds/feedback/place.sound"), _ => new(0.5f,0.6f,"sounds/feedback/chime.sound") };
 public bool ReduceShake { get; set; }
 public bool ReduceFlashing { get; set; }
 public event Action<VisualCue> Cue;
 public event Action TallySkipped;
 public event Action Cleared;
 public void ClearPresentation() { ResetPayout(); Cleared?.Invoke(); }
 CityGame game;
 MatchState match;
 readonly Queue<(PayoutEntry Entry, int Index)> tally = new();
 public int? PresentedCoins { get; private set; }
 readonly Queue<(float At,int Coins)> arrivals = new();
 readonly List<(GameObject Object,Vector3 Scale,float At)> payoutPops = new();
 float payoutAge;
 string payoutReceipt;
 float untilTally, tallyInterval, sincePlace = 100;
 int streak, lastCountdown = -1, lastPurchaseMarket = -1;
 MatchPhase? previousPhase;
 public void Bind(CityGame city) => game = city;
 public void Notice(string text) { Sfx.Play("sounds/feedback/deny.sound",game.Player.WorldPosition); Cue?.Invoke(new(text,default,FeedbackTier.Small,true)); }
 public void Attach(MatchState state)
 {
  if (match != null) {
   match.Events.PlacementResolved -= Placed; match.Events.PlacementRejected -= Denied;
   match.Events.ParkScored -= Park; match.Events.BuildingGrown -= Grown; match.Events.MatchScored -= Scored;
   match.Events.EventPlayed -= EventPlayed; match.Events.GoalsClaimed -= Goals; match.Events.HospitalEmergency -= Emergency;
   match.Events.PhaseChanged -= Phase; match.Events.PayoutResolved -= Payout;
   match.Events.BidChanged -= Bid; match.Events.PlayerReadyChanged -= Ready; match.Events.AuctionResolved -= Auction;
  }
  ClearPresentation(); match=state; lastCountdown=-1; lastPurchaseMarket=-1; previousPhase=null;
  if (match != null) {
   match.Events.PlacementResolved += Placed; match.Events.PlacementRejected += Denied;
   match.Events.ParkScored += Park; match.Events.BuildingGrown += Grown; match.Events.MatchScored += Scored;
   match.Events.EventPlayed += EventPlayed; match.Events.GoalsClaimed += Goals; match.Events.HospitalEmergency += Emergency;
   match.Events.PhaseChanged += Phase; match.Events.PayoutResolved += Payout;
   match.Events.BidChanged += Bid; match.Events.PlayerReadyChanged += Ready; match.Events.AuctionResolved += Auction;
  }
 }
 void Shake(float trauma) { if (!ReduceShake) game.Player.View.AddTrauma(trauma); }
 void Placed(PlacementEffects effects)
 {
  var cell=effects.Cell; if (cell.PlacedRound==0) return;
  streak=sincePlace<2f ? Math.Min(streak+1,6) : 0; sincePlace=0;
  var position=game.Grid.CellToWorld(cell.X,cell.Y);
  TabletopAudio.Place(1+streak*0.025f);
  if (effects.Park==null) Shake(Preset(FeedbackTier.Medium).Trauma);
  var vp=effects.Awards.Where(a=>a.Player==cell.Owner && !(effects.Park?.Awards.Contains(a) ?? false)).Sum(a=>a.Vp);
  var coins=effects.Awards.Where(a=>a.Player==cell.Owner).Sum(a=>a.Coins);
  if (vp != 0) Cue?.Invoke(new(GameText.Get("ui.feedback_panel.vp", vp),position,FeedbackTier.Small,PlacementVp:true));
  if (coins != 0) Cue?.Invoke(new($"{coins:+#;-#} 🪙",position+Vector3.Up*15,FeedbackTier.Small));
  foreach(var award in effects.Awards.Where(a=>a.Player!=cell.Owner && a.Vp!=0).GroupBy(a=>a.Player))
  {
   var source=award.First().Source ?? cell;
   Cue?.Invoke(new(GameText.Get("ui.feedback_panel.vp", award.Sum(a=>a.Vp)),game.Grid.CellToWorld(source.X,source.Y),FeedbackTier.Small,PlacementVp:true));
  }
 }
 void Park(ParkScoreResult park)
 {
  var position=game.Grid.CellToWorld(park.Added.X,park.Added.Y);
  Shake(Math.Min(0.3f,0.12f+park.ScoringSize*0.015f)); Cue?.Invoke(new("",position,FeedbackTier.Medium,Region:park.Cells));
  var index=0; foreach (var award in park.Awards.GroupBy(a=>a.Player)) Cue?.Invoke(new(GameText.Get("ui.feedback_panel.vp", award.Sum(a=>a.Vp)),position+Vector3.Up*(index++*20),FeedbackTier.Small,PlacementVp:true));
 }
 void Grown(GrowthResult result) { Sfx.Play("sounds/feedback/chime.sound",game.Grid.CellToWorld(result.After.X,result.After.Y)); Cue?.Invoke(new(GameText.Get("ui.feedback.forest_grown"),game.Grid.CellToWorld(result.After.X,result.After.Y)+Vector3.Up*30,FeedbackTier.Medium)); }
 void EventPlayed(CardEventResult result)
 {
  var position=result.Claim is { } claim ? game.Grid.CellToWorld(claim.X,claim.Y) : game.Grid.WorldPosition;
  Sfx.Play(Preset(FeedbackTier.Small).Sound,position); Cue?.Invoke(new(result.Claim != null ? GameText.Get("ui.feedback.reserved") : result.CardId == "renovation" ? GameText.Get("ui.feedback.renovated") : result.CardId == "rush-hour" ? GameText.Get("cards.rush_hour.name") : result.CardId == "ribbon-cutting" ? GameText.Get("ui.feedback_panel.vp", result.Vp) : $"+{result.Coins} 🪙",position,FeedbackTier.Small));
 }
 void Emergency(HospitalEmergencyResult result)
 {
  if (result?.House==null) return;
  var target=result.Hospital ?? result.House; var position=game.Grid.CellToWorld(target.X,target.Y);
  Sfx.Play("sounds/feedback/chime.sound",position);
  if (result.Answered) Cue?.Invoke(new(GameText.Get("ui.feedback.hospital_response_vp", result.Award),position,FeedbackTier.Medium));
 }
 void Scored(EndScore result) => ClearPresentation();
 public void EndRevealBeat(bool contract=false) => Sfx.Play(contract ? "sounds/feedback/chime.sound" : "sounds/feedback/tick.sound",game.Player.WorldPosition);
 public void CelebrateEnd(bool won)
 {
  Sfx.Play("sounds/feedback/chime.sound",game.Player.WorldPosition,won ? 1f : 0.85f);
  if (won) Cue?.Invoke(new("✦",default,FeedbackTier.Large,Confetti:true));
 }
 void Goals(GoalsResult result)
 {
  var names=string.Join(" · ",result.Claims.Select(c=>GoalLibrary.Get(c.GoalId).Name));
  var vp=result.Claims.SelectMany(c=>c.Awards).Where(a=>a.Player==CityGame.Me).Sum(a=>a.Vp);
  Shake(Preset(FeedbackTier.Large).Trauma); Sfx.Play(Preset(FeedbackTier.Large).Sound,game.Player.WorldPosition);
  Cue?.Invoke(new($"✓ {names}"+(vp>0 ? GameText.Get("ui.feedback.vp", vp) : GameText.Get("ui.feedback.claimed")),default,FeedbackTier.Large,true));
 }
 public void RevealContract() => TabletopAudio.Place();
 void Denied(PlacementResult result) { Sfx.Play("sounds/feedback/deny.sound",game.Player.WorldPosition); game.Player.Deny(); Shake(0.1f); Cue?.Invoke(new(result.Reason,game.Player.WorldPosition,FeedbackTier.Small,true)); }
 void Phase(PhaseChange phase)
 {
  if (phase.Phase is MatchPhase.RoundStart or MatchPhase.EndGame) return;
  if (phase.Phase == MatchPhase.Bid) { ResetPayout(); TallySkipped?.Invoke(); }
  lastCountdown=-1; if (previousPhase==phase.Phase) return; previousPhase=phase.Phase;
  if (phase.Phase == MatchPhase.Bid) { TabletopAudio.Fan(); return; }
  if (phase.Phase == MatchPhase.Payout) return;
  var text=phase.Phase switch { MatchPhase.Bid => GameText.Get("ui.feedback.round_market", phase.Round), MatchPhase.Build => GameText.Get("ui.feedback.build_your_city"), MatchPhase.EndGame => GameText.Get("ui.feedback.eight_rounds_complete"), _ => GameText.EnumLabel(phase.Phase) };
  Cue?.Invoke(new(text,default,FeedbackTier.Small,true)); if (phase.Phase == MatchPhase.Bid) TabletopAudio.Fan(); else TabletopAudio.Switch();
 }
 void Bid(Guid owner)
 {
  if (owner!=CityGame.Me || game.OwnAuction is not { } view || view.Resolving || view.Players.FirstOrDefault(p=>p.Player==owner)?.HasBid != true) return;
  if (lastPurchaseMarket == view.Id) return; lastPurchaseMarket = view.Id;
  TabletopAudio.Purchase();
 }
 void Ready(Guid owner) { if (owner==CityGame.Me) TabletopAudio.Switch(); }
 void Auction(AuctionResult result)
 {
  var own=result.Wins.FirstOrDefault(w=>w.Player==CityGame.Me);
  if (own!=null) { var card=Cards.CardLibrary.Get(own.CardId); Cue?.Invoke(new($"{card.Icon} {card.Name}",default,FeedbackTier.Medium,CardId:own.CardId,Lot:own.Lot)); TabletopAudio.Card(); }
  if (result.Accounts.FirstOrDefault(a=>a.Player==CityGame.Me)?.Refunded>0) TabletopAudio.Income(0.95f);
 }
 public void RevealCard(Cards.Rarity rarity) { if (rarity==Cards.Rarity.Legendary) { Shake(Preset(FeedbackTier.Large).Trauma); Sfx.Play(Preset(FeedbackTier.Large).Sound,game.Player.WorldPosition,0.85f); } else TabletopAudio.Card(); }
 void Payout(PayoutResult result)
 {
  ResetPayout();
  // Every player has base income. Only narrate the local player's entries on this client.
  var own=result.Entries.Where(e=>e.Player==CityGame.Me && e.Coins!=0).ToArray();
  if (game.LocalPlayer != null)
  {
   var total=own.Sum(e=>e.Coins);
   PresentedCoins=game.LocalPlayer.Coins-total;
   var groups=own.GroupBy(e=>(e.Reason,e.Source,e.Partner))
    .Select(g=>g.First() with { Coins=g.Sum(e=>e.Coins) }).ToList();
   // Guaranteed income is one beat, followed by the sources that earned the rest.
   groups=groups.OrderBy(e=>e.Reason=="#rules.payout.base_income" ? 0 : e.Source==null ? 2 : 1).ToList();
   if (groups.Count>7)
   {
    var tail=groups.Skip(6).ToArray(); groups=groups.Take(6).ToList();
    groups.Add(new(CityGame.Me,tail.Sum(e=>e.Coins),0,GameText.Get("ui.feedback.other_income_sources", tail.Length)));
   }
   var index=0; foreach (var entry in groups) tally.Enqueue((entry,index++));
   tallyInterval=(float)Math.Min(0.48,Math.Max(0.05,(match.Settings.PayoutSeconds-1.8)/Math.Max(1,tally.Count)));
   untilTally=0.35f;
   var breakdown=string.Join("\n",own.GroupBy(e=>e.Reason).Select(g=>$"{PayoutReason(g.Key)}\t{g.Sum(e=>e.Coins):+#;-#;0}"));
   payoutReceipt=GameText.Get("ui.feedback.round_coins", result.Round, total, breakdown);
  }
 }
 static string PayoutReason(string reason) => GameText.Plain(reason);
 void ResetPayout()
 {
  foreach (var pop in payoutPops) if (pop.Object.IsValid()) pop.Object.WorldScale=pop.Scale;
  payoutPops.Clear(); arrivals.Clear(); tally.Clear();
  PresentedCoins=null; payoutReceipt=null; payoutAge=0;
 }
 public void SkipTally() { var receipt=payoutReceipt; ResetPayout(); TallySkipped?.Invoke(); if (receipt!=null) Cue?.Invoke(new(receipt,default,FeedbackTier.Medium,true,PayoutReceipt:true)); }
 protected override void OnUpdate()
 {
  sincePlace+=Time.Delta;
  payoutAge+=Time.Delta;
  for (var i=payoutPops.Count-1;i>=0;i--)
  {
   var pop=payoutPops[i]; var t=Math.Clamp((payoutAge-pop.At)/0.4f,0,1);
   if (pop.Object.IsValid()) pop.Object.WorldScale=pop.Scale*(1+0.09f*MathF.Sin(t*MathF.PI)*(1-t));
   if (t>=1 || !pop.Object.IsValid()) payoutPops.RemoveAt(i);
  }
  while (arrivals.Count>0 && arrivals.Peek().At<=payoutAge)
  {
   var arrival=arrivals.Dequeue(); PresentedCoins+=arrival.Coins;
   TabletopAudio.Income(arrival.Coins>=0 ? 1.05f : 0.95f);
  }
  if (PresentedCoins.HasValue && tally.Count==0 && arrivals.Count==0)
  {
   PresentedCoins=null;
   if (payoutReceipt!=null) { Cue?.Invoke(new(payoutReceipt,default,FeedbackTier.Medium,true,PayoutReceipt:true)); payoutReceipt=null; Sfx.Play("sounds/feedback/chime.sound",game.Player.WorldPosition); }
  }
  if (PresentedCoins.HasValue && match?.Phase!=MatchPhase.Payout) ResetPayout();
  if (tally.Count>0) { untilTally-=Time.Delta; while (tally.Count>0 && untilTally<=0) {
   var (entry,index)=tally.Dequeue(); var position=entry.Source is { } cell ? game.Grid.CellToWorld(cell.X,cell.Y) : game.Grid.WorldPosition;
   Vector3? partner=null;
   var reason=entry.Reason;
   Cue?.Invoke(new($"{entry.Coins:+#;-#;0} 🪙 · {reason}",position,FeedbackTier.Small,Partner:partner,PayoutCoin:true));
   arrivals.Enqueue((payoutAge+0.85f,entry.Coins));
   if (entry.Source is { } source)
   {
    var building=game.Grid.Get(source.X,source.Y)?.Building;
    if (building.IsValid() && !payoutPops.Any(p=>p.Object==building)) payoutPops.Add((building,building.WorldScale,payoutAge));
   }
   untilTally+=tallyInterval;
  } }
  if (match==null || game.Screens.Top!=GameScreen.Match || match.Phase is not (MatchPhase.Bid or MatchPhase.Build)) return;
  var seconds=(int)Math.Ceiling(match.PhaseEndsAt-game.Clock); if (seconds is >0 and <=5 && seconds!=lastCountdown) { lastCountdown=seconds; Sfx.Play("sounds/feedback/tick.sound",game.Player.WorldPosition); }
 }
 protected override void OnDestroy() => Attach(null);
}