PlayerDialogue.cs defines the football match's speech catalogue and delivery logic. It holds Russian/English phrase banks for player callouts, taunts, fouls, injuries and goals, maps source strings to speech cues, and drives timed dialogue events, replies and goal celebrations from match counters.
using System;
using System.Linq;
namespace Socer;
public enum PlayerSpeechCue
{
Request,Through,Support,Pressure,Shield,CrossNear,CrossFar,LineDown,LineUp,Distribution,
Wall,ShortRestart,Finish,Goal,Miss,IgnoredPass,TeamRally,OpponentTaunt,FoulReaction,
Scuffle,ScuffleReply,RefereeComplaint,CoachAcknowledge,Scheme,ControlMistake,Claim,
PassPraise,DefensivePraise,DribbleTaunt,AssistThanks,InjuryPain,InjurySerious,
SelfReproach,DefenderIdle,GoalTeammateJoke
}
public enum SpeechAudience { Teammate,Team,Opponent,Referee,Coach,Self }
public enum SpeechTone { Call,Encourage,Frustrated,Taunt,Joy }
public enum PlayerTemperament { Calm,Leader,Playful,Fiery }
// Speech is presentation only. Its hash stream never consumes football RNG.
public static class PlayerDialogue
{
public readonly record struct Phrase(int Id,string Russian,string English,string CleanRussian="",string CleanEnglish="");
static Phrase[] Bank(int first,params string[] pairs)
{
var result=new Phrase[pairs.Length/2];
for(int i=0;i<result.Length;i++)result[i]=new(first+i,pairs[i*2],pairs[i*2+1]);
return result;
}
static readonly Phrase[][] Banks={
Bank(0,"Мне!","Here!","Я открыт!","I'm open!","Дай в ноги!","To feet!","Я здесь!","Over here!","Сюда, под пас!","Here, for the pass!","Есть вариант!","I've got space!"),
Bank(10,"Вразрез!","Through the gap!","За спину!","In behind!","Пусти на ход!","Play me through!","Между ними!","Between them!","Я побежал!","I'm going!","Режь линию!","Split the line!"),
Bank(20,"Ближе, помоги!","Closer, help me!","Есть стенка!","We can play a one-two!","Сыграй назад!","Play it back!","Поддержи!","Give me support!","Через меня!","Through me!","Верни в касание!","One touch back!"),
Bank(30,"Сзади, помоги!","Man on, help!","Накрывают!","They're closing me!","Дай выход!","Give me an outlet!","Прими под давлением!","Help me under pressure!","Справа страхуй!","Cover the right!","Подойди под пас!","Show for the pass!"),
Bank(40,"Держу, открывайся!","I've got it, get open!","Прикрою мяч!","I'll shield it!","Обойди справа!","Go round the right!","Зайди за спину!","Go in behind!","Спокойно, держу!","Easy, I've got it!","Не стой, помоги!","Don't stand, help!"),
Bank(50,"На ближнюю!","Near post!","Врывайся к ближней!","Attack the near post!","Подставь ногу!","Get a foot on it!","Замыкай ближнюю!","Finish at the near post!","Перед ним, быстрее!","Get in front, quick!","Первый столб!","First post!"),
Bank(60,"На дальнюю!","Far post!","Замыкай!","Get on the end of it!","Идёт навес!","Cross coming!","На дальний угол!","To the far side!","Я кладу в штрафную!","Into the box!","Подними голову, навес!","Heads up, cross!"),
Bank(70,"Линия ниже!","Drop the line!","Назад, компактнее!","Drop, stay compact!","Прикрой центр!","Cover the middle!","Не открывайте зону!","Don't open the gap!","Вместе назад!","Drop together!","В штрафной плотнее!","Tight in the box!"),
Bank(80,"Линия выше!","Push the line up!","Выходим вместе!","Step up together!","Не садимся!","Don't drop deep!","Поднимайте линию!","Bring the line up!","Шаг вперёд!","Step forward!","Держим компактно!","Stay compact!"),
Bank(90,"Вперёд, подбирайте!","Push up, win the second ball!","Открывайтесь по краям!","Show out wide!","Готовьтесь к выносу!","Ready for the clearance!","Первые на подбор!","First to the loose ball!","Раздвиньтесь!","Spread out!","Я начинаю атаку!","I'll start the attack!"),
Bank(100,"Стенка!","Wall!","Линию держать!","Hold the line!","Закройте угол!","Close that corner!","Вместе, не расходиться!","Stay together!","Не прячьте голову!","Keep your heads up!","Без щелей в стенке!","No gaps in the wall!"),
Bank(110,"Коротко!","Short one!","Дай и верни!","Give and go!","Подойди ближе!","Come short!","Вдвоём разыграем!","Let's play it short!","Я отдам в ноги!","To your feet!","Готовь обратный пас!","Ready for the return!"),
Bank(120,"Бью!","I'm shooting!","Сейчас!","Now!","На подбор!","Follow it in!","Закрой обзор!","Screen the keeper!","Дайте пространство!","Give me space!","На отскок готовы!","Ready for the rebound!"),
Bank(130,"Есть!","Yes!","Вот так!","That's it!","Красиво разыграли!","Great move!","Так и продолжаем!","Keep that going!","Залетел!","Get in!","Вот это комбинация!","What a move!"),
Bank(140,"Блин, мимо!","Damn, wide!","Чуть-чуть!","So close!","Надо было в угол!","Should have picked the corner!","Мой промах!","My miss!","Следующий забью!","I'll get the next one!","Да что ж такое!","Come on, really!"),
Bank(150,"Я же открыт!","I was open!","Почему без паса?!","Why no pass?!","Посмотри сюда!","Look this way!","Да отдай уже!","Just pass it!","Я три раза открылся!","I made the run three times!","Сыграй со мной!","Use me!"),
Bank(160,"Соберитесь!","Get it together!","Давайте, ещё раз!","Come on, again!","Играем вместе!","Play together!","Спокойнее с мячом!","Easy on the ball!","Не выключаемся!","Stay switched on!","Друг друга страхуем!","Cover each other!"),
Bank(170,"Ты мяч-то видишь?","Can you even see the ball?","Сначала в мяч попади!","Try getting the ball first!","Давай, попробуй догнать!","Go on, try to catch me!","Полегче, герой!","Easy there, hero!","Опять ноги ищешь?","Going for the legs again?","Много шума, мало отбора!","All noise, no tackle!"),
Bank(180,"Эй, по ногам же!","Hey, that was my legs!","В мяч играй!","Play the ball!","Аккуратнее!","Careful!","Полегче с локтями!","Easy with the elbows!","Нормально можно отбирать?","Can you tackle properly?","Судья, это фол!","Ref, that's a foul!"),
Bank(190,"Ты чего толкаешься?!","What are you shoving for?!","Руки убрал!","Hands off!","В футбол давай играть!","Let's play football!","В мяч, а не в меня!","The ball, not me!","Хватит толкаться!","Stop shoving!","Полегче, чёрт!","Easy, damn it!"),
Bank(200,"Отойди от меня!","Back off!","Не лезь!","Get off me!","Успокойся уже!","Calm down already!","Играй, хватит болтать!","Play, enough talking!","Не трогай меня!","Don't touch me!","Сам начал!","You started it!"),
Bank(210,"Судья, ты серьёзно?!","Ref, are you serious?!","Это же мяч был!","That was the ball!","За что карточка?!","What was the card for?!","Посмотри повтор!","Have another look!","Он сам упал!","He went down himself!","Так нельзя свистеть!","You can't call that!"),
Bank(220,"Понял, тренер!","Got it, coach!","Хорошо, соберусь!","Okay, I'll focus!","Да, играю спокойнее!","Yes, I'll settle down!","Услышал!","Heard you!","Ладно, без нервов!","Okay, no more nerves!","Исправлюсь!","I'll put it right!"),
Bank(230,"По схеме!","Stick to the plan!","Как отрабатывали!","Like we practised!","Начинаем розыгрыш!","Start the move!","На свои позиции!","Get into position!","В касание!","One touch!","Вместе разыграем!","Let's work it together!"),
Bank(240,"Неудобно!","Awkward ball!","Дай помягче!","A softer pass!","Под рабочую!","To my strong foot!","Отскочил, блин!","Damn, bad bounce!","Сейчас обработаю!","I'll bring it down!","Потише в ноги!","Easy into my feet!"),
Bank(250,"Мой!","Mine!","Я беру!","I've got it!","Оставь!","Leave it!","Руки, мой мяч!","Keeper's ball!","Не мешай, поймаю!","Leave it, I'll catch it!","Забрал!","Got it!"),
Bank(260,"Хороший пас!","Good pass!","Вот такой нужен!","That's the pass!","Вовремя!","Perfect timing!","Вижу, спасибо!","Seen it, thanks!","Классно нашёл!","Great vision!","Сыграно!","Nice link-up!"),
Bank(270,"Чисто забрал!","Clean tackle!","Хорошая страховка!","Good cover!","Вовремя закрыл!","Closed it just in time!","Вот так держим!","That's how we hold them!","Молодец, выручил!","Well played, saved us!","Центр наш!","We've got the middle!"),
Bank(280,"Не туда смотришь!","Looking the wrong way!","Потерял меня?","Lost me?","Ещё раз попробуешь?","Want another try?","Осторожно, разворот!","Watch the turn!","Догоняй теперь!","Catch me now!","Ноги закрывай!","Keep your legs closed!"),
Bank(290,"Твой пас — мой гол!","Your pass, my goal!","Спасибо за передачу!","Thanks for the assist!","Нашли друг друга!","We found each other!","Классно вывел!","Great ball through!","Вот так вдвоём!","That's how we do it!","Красавец, вовремя!","Brilliant, perfect timing!"),
Bank(300,"Ай, по ноге!","Ow, my leg!","Дайте секунду…","Give me a second…","Ушиб, сейчас встану!","Just a knock, I'll get up!","Больно, но продолжу!","It hurts, but I'm staying on!","Не трогай, сейчас…","Don't touch it, a moment…","Фух, зацепили…","Oof, caught me there…"),
Bank(310,"Медиков! Не могу встать!","Medics! I can't get up!","Нужна замена, не смогу…","Need a sub, I can't go on…","Не могу подняться…","I can't get up…","Тренер, заменяй меня…","Coach, take me off…","Не могу продолжать!","I can't continue!","Дайте врача сюда!","Get a medic over here!"),
Bank(320,"Это я сейчас сам себе помешал.","Just got in my own way.","Ноги взял, голову забыл.","Brought my legs, forgot my head.","Так, себе ставлю неуд.","Right, failed my own test.","Моя ошибка, тут без вариантов.","My mistake, no arguments there.","Сам придумал, сам испортил.","My idea, my disaster.","Я сейчас даже себя освистал.","Even I just booed myself."),
Bank(330,"Мы тут для комплекта стоим?","Are we here to complete the set?","Тихо у нас. Подозрительно.","Quiet round here. Suspiciously quiet.","Газон уже начал меня узнавать.","The grass is starting to recognise me.","Впереди игра, а у нас дежурство.","Game up front, guard duty back here.","Я уже все травинки пересчитал.","I've counted every blade of grass.","Главное — от скуки не уснуть.","Main thing is not to fall asleep."),
Bank(340,"Забил! Теперь можно тебя слушать.","Scored! Now we can listen to you.","Красавец! Даже мне бежать захотелось!","Lovely! Even I feel like running now!","Вот теперь твои финты официальные.","Your tricks are officially approved now.","Гол есть! Возвращай корону потом.","Goal! Return the crown afterwards.","Я не плачу, это радость вспотела.","Not crying, my joy's just sweating.","Запомните момент, он попал!","Remember this moment, he hit the target!")
};
public static int PhraseCount
{
get {int count=PlayerDialogueFlavor.PhraseCount+PlayerDialogueCatalog.PhraseCount;foreach(var bank in Banks)count+=bank.Length;return count;}
}
public static int CueCount=>Banks.Length;
public static PlayerSpeechCue FromSource(string text)=>text switch
{
"Вразрез!" or "Вперёд!"=>PlayerSpeechCue.Through,
"Мне!"=>PlayerSpeechCue.Request,"Ближе!"=>PlayerSpeechCue.Support,
"Сзади!"=>PlayerSpeechCue.Pressure,"Держу!"=>PlayerSpeechCue.Shield,
"На ближнюю!"=>PlayerSpeechCue.CrossNear,"На дальнюю!" or "В штрафную!"=>PlayerSpeechCue.CrossFar,
"Линия ниже!"=>PlayerSpeechCue.LineDown,"Линия выше!"=>PlayerSpeechCue.LineUp,
"Вперёд, подбирайте!"=>PlayerSpeechCue.Distribution,"Стенка! Линию держать!"=>PlayerSpeechCue.Wall,
"Коротко!"=>PlayerSpeechCue.ShortRestart,"Бью!" or "Сейчас!"=>PlayerSpeechCue.Finish,
"Есть!"=>PlayerSpeechCue.Goal,"Да блин, мимо!"=>PlayerSpeechCue.Miss,
"Да блин, я же открыт!" or "Почему без паса?!"=>PlayerSpeechCue.IgnoredPass,
"Да какого чёрта?!" or "Соберитесь, блин!"=>PlayerSpeechCue.TeamRally,
"Ты охренел?!"=>PlayerSpeechCue.Scuffle,"Отойди от меня!"=>PlayerSpeechCue.ScuffleReply,
"Эй! По ногам же!"=>PlayerSpeechCue.FoulReaction,"Судья, ты издеваешься?!"=>PlayerSpeechCue.RefereeComplaint,
"Понял, тренер."=>PlayerSpeechCue.CoachAcknowledge,"По схеме!"=>PlayerSpeechCue.Scheme,
"Неудобно!"=>PlayerSpeechCue.ControlMistake,"Мой!"=>PlayerSpeechCue.Claim,
_=>PlayerSpeechCue.TeamRally
};
public static PlayerTemperament Temperament(Footballer p)
{
if(p.Profile.Aggression>.70f && p.Profile.Composure<.62f)return PlayerTemperament.Fiery;
if(p.Profile.Composure>.78f)return PlayerTemperament.Calm;
return (StableHash(p.Profile.CatalogId)+p.Id)%3==0?PlayerTemperament.Playful:PlayerTemperament.Leader;
}
internal static uint StableHash(string text)
{
uint hash=2166136261;
foreach(char c in text??"")hash=(hash^c)*16777619;
return hash;
}
static readonly Phrase[][] VarietyBanks=Enumerable.Range(0,Banks.Length).Select(i=>Banks[i].Concat(PlayerDialogueCatalog.For((PlayerSpeechCue)i)).Concat(PlayerDialogueFlavor.Funny.Where(l=>(int)l.Cue==i).Select(l=>l.Phrase)).ToArray()).ToArray();
public static int NormalCount(PlayerSpeechCue cue)=>VarietyBanks[(int)cue].Length;
public static Phrase Select(PlayerSpeechCue cue,Footballer p,int seed,DialogueMemory memory=null)
{
var bank=VarietyBanks[(int)cue];
uint hash=StableHash(p.Profile.CatalogId)^(uint)(seed*397+p.Id*31+p.SpeechSerial*17);
if(PlayerDialogueFlavor.TryPhrase(cue,p,hash,out var flavour,memory))return flavour;
int start=(int)(hash%(uint)bank.Length),chosen=start,bestCount=int.MaxValue,bestLast=int.MaxValue,bestAge=-1;
for(int step=0;step<bank.Length;step++)
{
int index=(start+step)%bank.Length,id=bank[index].Id;
int count=memory?.Uses(id)??0,last=memory?.Last(id)??-1000000,age=DialogueMemory.PersonalAge(p,id);
if(count<bestCount || count==bestCount && (last<bestLast || last==bestLast && age>bestAge))
{chosen=index;bestCount=count;bestLast=last;bestAge=age;}
}
return bank[chosen];
}
public static string Text(Footballer p,bool russian)=>russian?p.Speech:p.SpeechEnglish;
public static string CleanText(Footballer p,bool russian)=>russian?p.SpeechCleanRussian:p.SpeechCleanEnglish;
public static SpeechTone Tone(PlayerSpeechCue cue)=>cue switch
{
PlayerSpeechCue.Goal or PlayerSpeechCue.AssistThanks or PlayerSpeechCue.GoalTeammateJoke=>SpeechTone.Joy,
PlayerSpeechCue.PassPraise or PlayerSpeechCue.DefensivePraise or PlayerSpeechCue.TeamRally=>SpeechTone.Encourage,
PlayerSpeechCue.OpponentTaunt or PlayerSpeechCue.DribbleTaunt or PlayerSpeechCue.Scuffle or PlayerSpeechCue.ScuffleReply=>SpeechTone.Taunt,
PlayerSpeechCue.Miss or PlayerSpeechCue.IgnoredPass or PlayerSpeechCue.FoulReaction or PlayerSpeechCue.RefereeComplaint or PlayerSpeechCue.ControlMistake or PlayerSpeechCue.InjuryPain or PlayerSpeechCue.InjurySerious or PlayerSpeechCue.SelfReproach=>SpeechTone.Frustrated,
_=>SpeechTone.Call
};
public static SpeechAudience Audience(PlayerSpeechCue cue)=>cue switch
{
PlayerSpeechCue.OpponentTaunt or PlayerSpeechCue.DribbleTaunt or PlayerSpeechCue.Scuffle or PlayerSpeechCue.ScuffleReply or PlayerSpeechCue.FoulReaction=>SpeechAudience.Opponent,
PlayerSpeechCue.RefereeComplaint=>SpeechAudience.Referee,
PlayerSpeechCue.CoachAcknowledge=>SpeechAudience.Coach,
PlayerSpeechCue.Miss or PlayerSpeechCue.InjuryPain or PlayerSpeechCue.SelfReproach=>SpeechAudience.Self,
PlayerSpeechCue.InjurySerious=>SpeechAudience.Coach,
PlayerSpeechCue.LineDown or PlayerSpeechCue.LineUp or PlayerSpeechCue.Distribution or PlayerSpeechCue.Wall or PlayerSpeechCue.Finish or PlayerSpeechCue.Goal or PlayerSpeechCue.TeamRally or PlayerSpeechCue.Scheme or PlayerSpeechCue.Claim=>SpeechAudience.Team,
_=>SpeechAudience.Teammate
};
}
public sealed partial class MatchSimulation
{
public bool DialogueEnabled {get;set;}=true;
readonly int[] dialogueCompleted=new int[2],dialogueTackles=new int[2],dialogueDribbles=new int[2];
readonly int[] lastDialoguePasser={-1,-1};
readonly int[] lastDialogueTackler={-1,-1};
readonly float[] lastDialoguePassAt={-10,-10};
float nextDialogueEvent;
public int DialogueLines,OpponentDialogueLines;
public int DialogueExchanges,DialogueReplies,ForeignDialogueExchanges;
public DialogueMemory SpeechMemory {get;}=new();
float nextExchangeAt;
PendingDialogueReply pendingDialogueReply;
sealed class PendingDialogueReply
{
public int Speaker,Listener,SpeakerSerial,ListenerSerial;
public float Due,Expires;
public PlayerSpeechCue Cue;
public PlayerDialogueFlavor.Exchange Exchange;
}
int DialogueTarget(Footballer speaker,SpeechAudience audience,int preferred)
{
bool Suitable(Footballer q)=>q.Id!=speaker.Id && !q.SentOff &&
(audience==SpeechAudience.Opponent?q.Team!=speaker.Team:q.Team==speaker.Team);
if(audience is SpeechAudience.Coach or SpeechAudience.Referee or SpeechAudience.Self or SpeechAudience.Team)return -1;
float reach=audience==SpeechAudience.Opponent?10:32;
if(preferred>=0 && preferred<Players.Count && Suitable(Players[preferred]) &&
(Players[preferred].Position-speaker.Position).Length<=reach)return preferred;
if(audience==SpeechAudience.Teammate && Owner>=0 && Suitable(Players[Owner]) &&
(Players[Owner].Position-speaker.Position).Length<=reach)return Owner;
int result=-1;float distance=reach;
foreach(var q in Players)
{
float gap=(q.Position-speaker.Position).Length;
if(Suitable(q) && gap<distance){distance=gap;result=q.Id;}
}
return result;
}
int DialogueListener(Footballer speaker,SpeechAudience audience,int preferred)
{
bool Valid(Footballer q)=>q.Id!=speaker.Id && q.InPlay && q.SpeechUntil<=Seconds && q.SpeechCooldown<=0 && Seconds-q.LastSpeechAt>=1.6f &&
(audience==SpeechAudience.Opponent?q.Team!=speaker.Team:q.Team==speaker.Team) && (q.Position-speaker.Position).Length<=18;
if(preferred>=0 && preferred<Players.Count && Valid(Players[preferred]))return preferred;
int best=-1;float distance=18;
foreach(var q in Players)if(Valid(q)){float gap=(q.Position-speaker.Position).Length;if(gap<distance){best=q.Id;distance=gap;}}
return best;
}
public bool SpeakPlayer(Footballer p,PlayerSpeechCue cue,bool important=false,bool emotional=false,int target=-1)
{
if(!DialogueEnabled || p==null || p.SentOff || p.InjuryRemovedFromField)return false;
cue=RareSelfCue(p,cue);
bool incident=cue is PlayerSpeechCue.Goal or PlayerSpeechCue.AssistThanks or PlayerSpeechCue.GoalTeammateJoke or PlayerSpeechCue.Scuffle or PlayerSpeechCue.ScuffleReply or PlayerSpeechCue.FoulReaction or PlayerSpeechCue.InjuryPain or PlayerSpeechCue.InjurySerious;
if(cue is PlayerSpeechCue.InjuryPain or PlayerSpeechCue.InjurySerious)pendingDialogueReply=null;
if(Seconds-p.LastSpeechAt<1.6f || p.SpeechCooldown>0 && !incident)return false;
if(!important && Seconds<nextSpeech)return false;
var audience=PlayerDialogue.Audience(cue);
int receiver=DialogueTarget(p,audience,target);
if(audience==SpeechAudience.Opponent && receiver<0)return false;
int active=0;Footballer oldest=null;
foreach(var q in Players)
{
if(q.SentOff || q.Id==p.Id || q.SpeechUntil<=Seconds)continue;
active++;
if(oldest==null || q.SpeechUntil<oldest.SpeechUntil)oldest=q;
}
if(active>=2)
{
if(!incident && !emotional)return false;
oldest.SpeechUntil=Seconds;
}
uint banter=PlayerDialogue.StableHash(p.Profile.CatalogId+":banter")^(uint)(Seed*193+p.Id*73+p.SpeechSerial*109);
PlayerDialogueFlavor.Exchange exchange=null;
if(pendingDialogueReply!=null && Seconds>pendingDialogueReply.Expires)pendingDialogueReply=null;
int listenerId=DialogueListener(p,audience,receiver);
bool celebration=cue is PlayerSpeechCue.Goal or PlayerSpeechCue.AssistThanks or PlayerSpeechCue.GoalTeammateJoke;
int replyChance=cue==PlayerSpeechCue.DefenderIdle?60:38;
if(pendingDialogueReply==null && ((!suppressGoalDialogueReply && celebration) || !celebration && Seconds>=nextExchangeAt && banter%100<replyChance) && listenerId>=0)
{
exchange=PlayerDialogueFlavor.PickExchange(p,Players[listenerId],cue,banter/100,SpeechMemory);
}
var phrase=exchange?.Opening??PlayerDialogue.Select(cue,p,Seed,SpeechMemory);
EmitDialogue(p,phrase,cue,audience,receiver,emotional,exchange?.Language??"");
RareDialogueSpoken(cue);
nextSpeech=Seconds+(important?1.5f:2.2f);
if(exchange!=null)
{
var listener=Players[listenerId];
pendingDialogueReply=new(){Speaker=p.Id,Listener=listenerId,SpeakerSerial=p.SpeechSerial,ListenerSerial=listener.SpeechSerial,Due=Seconds+1.2f,Expires=Seconds+2.1f,Cue=cue,Exchange=exchange};
nextExchangeAt=Seconds+10;DialogueExchanges++;if(exchange.Language!="")ForeignDialogueExchanges++;
}
return true;
}
void EmitDialogue(Footballer p,PlayerDialogue.Phrase phrase,PlayerSpeechCue cue,SpeechAudience audience,int receiver,bool emotional,string language="")
{
p.Speech=phrase.Russian;p.SpeechEnglish=phrase.English;
p.SpeechCleanRussian=phrase.CleanRussian;p.SpeechCleanEnglish=phrase.CleanEnglish;p.SpeechLanguage=language;
p.SpeechCue=cue;p.SpeechTone=PlayerDialogue.Tone(cue);p.SpeechAudience=audience;p.SpeechTargetId=receiver;
p.RecentSpeechIds[p.SpeechHistoryCursor++%p.RecentSpeechIds.Length]=phrase.Id;
SpeechMemory.Record(phrase.Id);
p.SpeechSerial++;p.LastSpeechAt=Seconds;
p.SpeechUntil=Seconds+FieldSpeechPresentation.ActiveDuration;
p.SpeechCooldown=emotional?7.5f:9;
DialogueLines++;if(audience==SpeechAudience.Opponent)OpponentDialogueLines++;
}
void AdvanceDialogueReply()
{
var reply=pendingDialogueReply;if(reply==null)return;
bool goalReply=reply.Cue is PlayerSpeechCue.Goal or PlayerSpeechCue.AssistThanks or PlayerSpeechCue.GoalTeammateJoke;
if(!DialogueEnabled || Seconds>reply.Expires || GoalHoldRemaining>0 && !goalReply || RestartRemaining>0 || MedicalActive)
{pendingDialogueReply=null;return;}
if(Seconds<reply.Due)return;
var source=Players[reply.Speaker];var listener=Players[reply.Listener];
if(!source.InPlay || !listener.InPlay || source.SpeechSerial!=reply.SpeakerSerial || source.SpeechUntil<reply.Due || listener.SpeechSerial!=reply.ListenerSerial ||
(source.Position-listener.Position).Length>18)
{pendingDialogueReply=null;return;}
int active=0;foreach(var p in Players)if(!p.SentOff && p.SpeechUntil>Seconds)active++;
if(active>=2)return;
pendingDialogueReply=null;
var audience=source.Team==listener.Team?SpeechAudience.Teammate:SpeechAudience.Opponent;
EmitDialogue(listener,reply.Exchange.Reply,reply.Cue,audience,source.Id,false);
nextSpeech=Math.Max(nextSpeech,Seconds+1.5f);DialogueReplies++;
}
void Say(Footballer p,string text,bool important)=>SpeakPlayer(p,PlayerDialogue.FromSource(text),important);
void SpeakEmotion(Footballer p,string text,int target=-1)
{
var cue=PlayerDialogue.FromSource(text);
// Frustration and individual temperament determine who gets the outburst.
// An actual nearby opponent is required; unrelated players are never blamed.
if(cue==PlayerSpeechCue.TeamRally && p.Frustration>.66f &&
(PlayerDialogue.Temperament(p)==PlayerTemperament.Fiery || p.Frustration>.85f) &&
DialogueTarget(p,SpeechAudience.Opponent,-1)>=0)cue=PlayerSpeechCue.OpponentTaunt;
SpeakPlayer(p,cue,true,true,target);
}
void NoteDialoguePass(Footballer receiver,int passer)
{
if(passer>=0 && passer<Players.Count && passer!=receiver.Id && Players[passer].Team==receiver.Team)
{lastDialoguePasser[receiver.Team]=passer;lastDialoguePassAt[receiver.Team]=Seconds;}
}
void CelebrateDialogue(Footballer scorer)
{
pendingDialogueReply=null;
foreach(var q in Players)q.SpeechUntil=Seconds;
scorer.SpeechCooldown=0;scorer.LastSpeechAt=-10;
int passer=lastDialoguePasser[scorer.Team];
bool assisted=passer>=0 && passer!=scorer.Id && Seconds-lastDialoguePassAt[scorer.Team]<12;
if(assisted)
{
var partner=Players[passer];
partner.SpeechCooldown=0;partner.LastSpeechAt=-10;
}
if(TryTeammateGoalJoke(scorer,assisted,passer))return;
suppressGoalDialogueReply=true;
try{SpeakPlayer(scorer,assisted?PlayerSpeechCue.AssistThanks:PlayerSpeechCue.Goal,true,true,assisted?passer:-1);}
finally{suppressGoalDialogueReply=false;}
}
void AdvanceGoalDialogue(float extra)
{
if(extra<=0)return;
foreach(var p in Players)if(p.SpeechUntil>Seconds){p.LastSpeechAt-=extra;p.SpeechUntil-=extra;}
if(pendingDialogueReply!=null){pendingDialogueReply.Due-=extra;pendingDialogueReply.Expires-=extra;}
}
// Observe actual football counters once per step. No decisions or RNG calls
// are made here, and new dialogue can be disabled for an exact replay audit.
void ObservePlayerDialogue()
{
AdvanceDialogueReply();
for(int team=0;team<2;team++)
{
bool pass=Completed[team]>dialogueCompleted[team],tackle=Tackles[team]>dialogueTackles[team],dribble=DribblesWonByTeam[team]>dialogueDribbles[team];
dialogueCompleted[team]=Completed[team];dialogueTackles[team]=Tackles[team];dialogueDribbles[team]=DribblesWonByTeam[team];
if(!DialogueEnabled || Seconds<nextDialogueEvent || GoalHoldRemaining>0 || RestartRemaining>0)continue;
if(dribble && Owner>=0 && Players[Owner].Team==team)
{
var carrier=Players[Owner];var character=PlayerDialogue.Temperament(carrier);
if((character is PlayerTemperament.Playful or PlayerTemperament.Fiery || carrier.Frustration>.65f) &&
SpeakPlayer(carrier,PlayerSpeechCue.DribbleTaunt,true,false))nextDialogueEvent=Seconds+7;
}
else if(pass && Owner>=0 && Players[Owner].Team==team &&
SpeakPlayer(Players[Owner],PlayerSpeechCue.PassPraise,false,false,lastDialoguePasser[team]))nextDialogueEvent=Seconds+6;
else if(tackle && lastDialogueTackler[team]>=0)
{
var defender=Players[lastDialogueTackler[team]];int partner=DialogueTarget(defender,SpeechAudience.Teammate,-1);
if(partner>=0 && SpeakPlayer(Players[partner],PlayerSpeechCue.DefensivePraise,false,false,defender.Id))nextDialogueEvent=Seconds+7;
}
}
ObserveRareDialogue();
}
}