Extends the MatchSimulation partial class with a 22-slot emotion cue board: events (misses, woodwork, blocks, saves, cards, fouls, substitutions) queue prioritized, cooled-down emotion cues per footballer, and UpdateFootballEmotions advances their pending/active/cooldown state. Also adds ReplayFigure.ApplyFootballEmotion, which poses the replay body parts (crouch, pitch, yaw, roll, arm FK, hand contact, claps) for each emotion without touching the simulation.
using System;
using System.Collections.Generic;
using NVector=System.Numerics.Vector3;
using NQuaternion=System.Numerics.Quaternion;
namespace Socer;
public enum FootballEmotion
{
None,NearMiss,Woodwork,BlockedShot,GreatSave,DefensiveApplause,
FoulAppeal,BookedRestraint,Encourage,SubstitutionApplause
}
public readonly record struct FootballEmotionCue(FootballEmotion Kind,float Started,float Duration,
float Intensity,int Variant,Point Attention,int Source)
{
public bool Active=>Kind!=FootballEmotion.None && Started>=0;
}
public sealed partial class MatchSimulation
{
sealed class EmotionTrack
{
public FootballEmotionCue Cue;
public float PendingUntil,CooldownUntil;
public int Fouls;
public Footballer Player;
}
readonly EmotionTrack[] footballEmotionTracks=new EmotionTrack[22];
float footballEmotionPreviousSeconds=-1;
int footballEmotionSerial,footballEmotionVariation;
public float FootballEmotionClock {get;private set;}
public FootballEmotionCue FootballEmotionFor(int id)=>(uint)id<22 && footballEmotionTracks[id]!=null?
footballEmotionTracks[id].Cue:default;
public void QueueFootballEmotion(int id,FootballEmotion kind,Point attention,float intensity=1,int source=0)
{
if((uint)id>=22 || id>=Players.Count || kind==FootballEmotion.None)return;
var player=Players[id];
if(!player.InPlay || player.InjuryRemovedFromField)return;
var track=footballEmotionTracks[id]??=new EmotionTrack{Player=player,Fouls=player.FoulsCommitted};
float remaining=track.Cue.Active?track.Cue.Started+track.Cue.Duration-FootballEmotionClock:track.PendingUntil-FootballEmotionClock;
if(remaining>0 && EmotionPriority(track.Cue.Kind)>EmotionPriority(kind))return;
if(FootballEmotionClock<track.CooldownUntil && EmotionPriority(kind)<65)return;
uint variation=unchecked(PlayerDialogue.StableHash(player.Profile.CatalogId)+(uint)player.Id*2654435761u+(uint)source*2246822519u);
var temperament=PlayerDialogue.Temperament(player);
float expression=temperament==PlayerTemperament.Fiery?1.08f:temperament==PlayerTemperament.Calm?.72f:.90f;
expression*=Math.Clamp(.75f+player.Frustration*.25f+player.Confidence*.15f,.75f,1.10f);
track.Cue=new(kind,-1,EmotionDuration(kind),Math.Clamp(intensity*expression,.45f,1.1f),(int)(variation%3),attention,source);
track.PendingUntil=FootballEmotionClock+(kind==FootballEmotion.GreatSave?5:3.2f);
}
public void NotifyFootballSubstitution(int team,int incomingSlot)
{
if((uint)incomingSlot>=Players.Count)return;
var incoming=Players[incomingSlot];
footballEmotionTracks[incomingSlot]=new EmotionTrack{Player=incoming,Fouls=incoming.FoulsCommitted};
QueueFootballEmotion(incomingSlot,FootballEmotion.SubstitutionApplause,RefereePosition,.8f,++footballEmotionVariation);
int first=FindEmotionPartner(incoming,team,-1);
if(first>=0)QueueFootballEmotion(first,FootballEmotion.SubstitutionApplause,incoming.Position,.85f,footballEmotionVariation);
}
// Called once per presentation step, including frozen restart/medical waits. It owns
// a fixed 22-slot cue board, and never calls Next(), moves a player or changes AI.
public void UpdateFootballEmotions(float dt)
{
if(dt<=0)return;
if(Seconds<footballEmotionPreviousSeconds)
{Array.Clear(footballEmotionTracks);FootballEmotionClock=0;footballEmotionSerial=footballEmotionVariation=0;}
footballEmotionPreviousSeconds=Seconds;
FootballEmotionClock+=dt;
for(int i=0;i<Math.Min(22,Players.Count);i++)
{
var player=Players[i];var track=footballEmotionTracks[i];
if(track==null || track.Player!=player)
footballEmotionTracks[i]=track=new EmotionTrack{Player=player};
if(player.FoulsCommitted>track.Fouls)
{
track.Fouls=player.FoulsCommitted;
QueueFootballEmotion(i,FootballEmotion.FoulAppeal,RefereePosition,.75f+(1-player.Profile.Composure)*.25f,footballEmotionSerial);
}
if(track.Cue.Kind==FootballEmotion.None)continue;
if(!player.InPlay || player.InjuryRemovedFromField || GoalHoldRemaining>0)
{track.Cue=default;continue;}
if(!track.Cue.Active)
{
if(FootballEmotionClock>track.PendingUntil){track.Cue=default;continue;}
if(CanPlayFootballEmotion(player))track.Cue=track.Cue with{Started=FootballEmotionClock};
}
else if(FootballEmotionClock>=track.Cue.Started+track.Cue.Duration)
{track.Cue=default;track.CooldownUntil=FootballEmotionClock+3.5f;}
}
foreach(var effect in Effects)
{
if(effect.Id<=footballEmotionSerial)continue;
footballEmotionSerial=effect.Id;
if(effect.Age>.25f || GoalHoldRemaining>0)continue;
int actor=effect.Player;
switch(effect.Kind)
{
case "miss" when actor<0 && (uint)lastShooter<Players.Count && Seconds-Players[lastShooter].LastKickAt<4:
QueueFootballEmotion(lastShooter,FootballEmotion.NearMiss,effect.Position,1,effect.Id);
EncourageEmotionPartner(Players[lastShooter],effect.Id);break;
case "post" when (uint)lastShooter<Players.Count && Seconds-Players[lastShooter].LastKickAt<4:
QueueFootballEmotion(lastShooter,FootballEmotion.Woodwork,effect.Position,1.05f,effect.Id);
EncourageEmotionPartner(Players[lastShooter],effect.Id);break;
case "block" when (uint)actor<Players.Count:
if((uint)lastShooter<Players.Count && Players[lastShooter].Team!=Players[actor].Team && Seconds-Players[lastShooter].LastKickAt<3)
QueueFootballEmotion(lastShooter,FootballEmotion.BlockedShot,effect.Position,.8f,effect.Id);
break;
case "save" when (uint)actor<Players.Count && effect.Text!="ЗАБРАЛ!":
QueueFootballEmotion(actor,FootballEmotion.GreatSave,Ball,.85f,effect.Id);
int partner=FindEmotionPartner(Players[actor],Players[actor].Team,-1);
if(partner>=0)QueueFootballEmotion(partner,FootballEmotion.DefensiveApplause,Players[actor].Position,.8f,effect.Id);
break;
case "yellow-card" when (uint)actor<Players.Count:
QueueFootballEmotion(actor,FootballEmotion.BookedRestraint,RefereePosition,.9f,effect.Id);break;
case "offside" when (uint)actor<Players.Count:
QueueFootballEmotion(actor,FootballEmotion.FoulAppeal,RefereePosition,.65f,effect.Id);break;
}
}
}
void EncourageEmotionPartner(Footballer player,int source)
{
int partner=FindEmotionPartner(player,player.Team,-1);
if(partner>=0)QueueFootballEmotion(partner,FootballEmotion.Encourage,player.Position,.8f,source);
}
int FindEmotionPartner(Footballer source,int team,int excluded)
{
int best=-1;float score=float.MaxValue;
for(int i=0;i<Math.Min(22,Players.Count);i++)
{
var p=Players[i];if(p.Id==source.Id || p.Id==excluded || p.Team!=team || !p.InPlay || p.Keeper)continue;
float distance=(p.Position-source.Position).Length;
if(distance>22)continue;
float value=distance-(PlayerDialogue.Temperament(p)==PlayerTemperament.Leader?4:0);
if(value<score){best=i;score=value;}
}
return best;
}
public bool CanPlayFootballEmotion(Footballer p)
{
if(p.Team>=2 || !p.InPlay || p.InjuryRemovedFromField || MedicalActive || GoalHoldRemaining>0 || p.IsAcrobaticCommitted(Seconds) ||
Seconds<p.FallUntil || Seconds<p.ImpactUntil || Seconds-p.LastSlideAt<2.1f ||
p.DiveRemaining>0 || p.Keeper && Seconds-p.DiveEndedAt<HumanFigure.DiveRecoverySeconds(p,this) ||
WindupPlayer==p.Id || KeeperKickPlayer==p.Id || Seconds-p.LastKickAt<.65f ||
Seconds-p.LastPuntAt<.8f || Seconds-p.HeaderAt<.7f || Seconds<p.FeintUntil+.20f ||
Seconds<p.InterceptUntil || Seconds<p.BlockUntil || Seconds<p.StandingTackleAt+.72f ||
Owner==p.Id || p.VisualSpeed>2.4f || p.Velocity.Length>2.4f)return false;
return FootballEmotionBallClearance(p)>4;
}
public float FootballEmotionBallClearance(Footballer p)
{
if(RestartRemaining>0)return 10;
var relative=Ball-p.Position;
float speed2=Point.Dot(BallVelocity,BallVelocity);
float meeting=Math.Clamp(-Point.Dot(relative,BallVelocity)/Math.Max(1,speed2),0,.65f);
return Math.Min(relative.Length,(relative+BallVelocity*meeting).Length);
}
static int EmotionPriority(FootballEmotion kind)=>kind switch
{FootballEmotion.Woodwork=>80,FootballEmotion.NearMiss=>75,FootballEmotion.GreatSave=>70,
FootballEmotion.BookedRestraint=>68,FootballEmotion.FoulAppeal=>65,FootballEmotion.BlockedShot=>55,
FootballEmotion.SubstitutionApplause=>45,FootballEmotion.DefensiveApplause=>40,_=>35};
public static float EmotionDuration(FootballEmotion kind)=>kind switch
{FootballEmotion.NearMiss=>2.5f,FootballEmotion.Woodwork=>2.25f,FootballEmotion.BlockedShot=>1.75f,
FootballEmotion.GreatSave=>1.95f,FootballEmotion.FoulAppeal=>2.15f,FootballEmotion.BookedRestraint=>2.0f,
FootballEmotion.Encourage=>2.2f,_=>2.45f};
}
public static partial class ReplayFigure
{
public static bool ApplyFootballEmotion(Footballer p,MatchSimulation m,List<Part> parts,Point forward,Point side)
{
var cue=m.FootballEmotionFor(p.Id);
if(!cue.Active || !m.CanPlayFootballEmotion(p) || parts.Count<17)return false;
float age=m.FootballEmotionClock-cue.Started;
if(age<0 || age>=cue.Duration)return false;
float weight=Smooth(age/.22f)*(1-Smooth((age-(cue.Duration-.38f))/.38f));
weight*=Math.Clamp((2.4f-p.VisualSpeed)/1.1f,0,1);
weight*=Math.Clamp((m.FootballEmotionBallClearance(p)-4)/3,0,1);
float presence=weight;
weight*=Math.Clamp(cue.Intensity,.45f,1);
float strength=cue.Intensity;
float lead=cue.Variant==1?-1:1;
float load=EmotionPulse(age,.02f,.26f,.58f);
switch(cue.Kind)
{
case FootballEmotion.NearMiss:
{
float dismay=Smooth((age-.20f)/.38f)*(1-Smooth((age-1.65f)/.45f));
float bow=EmotionPulse(age,.65f,1.24f,2.25f);
EmotionBody(parts,forward,side,(.055f*load+.025f*bow)*strength*weight,
(-.10f*load+.19f*bow)*weight,0,.025f*lead*bow*weight,0);
EmotionHead(parts,forward,side,.16f*bow*weight,.10f*lead*EmotionPulse(age,.48f,1.0f,1.85f)*weight);
for(int arm=0;arm<2;arm++)
{
ApplyArmFk(parts,7+arm*3,new ArmAngles(132,42,-12,112,15),weight*dismay);
// A genuine self-contact, reached only after the arms have loaded.
var head=parts[13];int sign=arm==0?-1:1;
float touch=Smooth((age-.48f)/.25f)*(1-Smooth((age-1.53f)/.40f))*weight;
ApplyArmContact(parts,7+arm*3,head.A+side*(sign*.115f)+forward*.035f,head.ZA+.055f,
side*sign,-.18f,touch);
}
break;
}
case FootballEmotion.Woodwork:
{
float recoil=EmotionPulse(age,.02f,.26f,.68f);
float disbelief=EmotionPulse(age,.32f,.82f,1.70f);
EmotionBody(parts,forward,side,.075f*recoil*weight,-.15f*recoil*weight,
lead*.08f*disbelief*weight,0,0);
EmotionHead(parts,forward,side,-.10f*recoil*weight,
MathF.Sin(Math.Clamp((age-.55f)/1.25f,0,1)*MathF.Tau)*.12f*weight);
for(int arm=0;arm<2;arm++)ApplyArmFk(parts,7+arm*3,
new ArmAngles(46+disbelief*45,28+recoil*15,-5,70+disbelief*30,65,-8),weight);
break;
}
case FootballEmotion.BlockedShot:
{
float exhale=EmotionPulse(age,.10f,.53f,1.45f);
EmotionBody(parts,forward,side,.035f*exhale*weight,.15f*exhale*weight,
-.045f*lead*exhale*weight,.035f*lead*exhale*weight,0);
EmotionHead(parts,forward,side,.16f*exhale*weight,.07f*lead*weight);
ApplyArmFk(parts,lead<0?7:10,new ArmAngles(28,22,0,58,75,-10),exhale*weight);
ApplyArmFk(parts,lead<0?10:7,new ArmAngles(-4,7,0,18),weight);
break;
}
case FootballEmotion.GreatSave:
{
float fist=EmotionPulse(age,.13f,.44f,.95f);
float direct=Smooth((age-.85f)/.30f)*(1-Smooth((age-1.55f)/.35f));
EmotionBody(parts,forward,side,.05f*load*weight,-.07f*fist*weight,
lead*.12f*direct*weight,0,0);
EmotionHead(parts,forward,side,-.08f*fist*weight,.15f*lead*direct*weight);
ApplyArmFk(parts,lead<0?7:10,new ArmAngles(45,16,-10,112,20),fist*weight);
ApplyArmFk(parts,lead<0?10:7,new ArmAngles(76,12,0,22,-5,-5),direct*weight);
break;
}
case FootballEmotion.DefensiveApplause:
EmotionBody(parts,forward,side,.018f*load*weight,.055f*EmotionPulse(age,.2f,.65f,1.35f)*weight,0,0,0);
EmotionHead(parts,forward,side,.10f*EmotionPulse(age,.12f,.33f,.62f)*weight,AttentionYaw(p,forward,cue.Attention)*.35f*weight);
EmotionClap(parts,forward,side,age-.32f,weight,2,presence);break;
case FootballEmotion.FoulAppeal:
{
float ask=EmotionPulse(age,.05f,.52f,1.75f);
EmotionBody(parts,forward,side,.028f*ask*weight,-.05f*ask*weight,
lead*.08f*ask*weight,lead*.045f*ask*weight,0);
EmotionHead(parts,forward,side,-.035f*ask*weight,AttentionYaw(p,forward,cue.Attention)*.6f*weight);
ApplyArmFk(parts,7,new ArmAngles(46,27,-5,88,80,-8),ask*weight);
ApplyArmFk(parts,10,new ArmAngles(38,19,5,72,80,-8),ask*weight);
break;
}
case FootballEmotion.BookedRestraint:
{
float stop=EmotionPulse(age,.15f,.57f,1.50f);
EmotionBody(parts,forward,side,.04f*load*weight,.06f*stop*weight,
-.07f*lead*stop*weight,0,0);
EmotionHead(parts,forward,side,.10f*EmotionPulse(age,.4f,.8f,1.55f)*weight,AttentionYaw(p,forward,cue.Attention)*.45f*weight);
ApplyArmFk(parts,lead<0?7:10,new ArmAngles(54,12,0,45,-10,-18),stop*weight);
ApplyArmFk(parts,lead<0?10:7,new ArmAngles(2,10,0,22),weight);
break;
}
case FootballEmotion.Encourage:
{
float beckon=EmotionPulse(age,.10f,.42f,.79f);
EmotionBody(parts,forward,side,.025f*beckon*weight,.075f*beckon*weight,
lead*.055f*beckon*weight,0,0);
EmotionHead(parts,forward,side,.13f*EmotionPulse(age,.18f,.38f,.65f)*weight,AttentionYaw(p,forward,cue.Attention)*.45f*weight);
ApplyArmFk(parts,lead<0?7:10,new ArmAngles(53,12,0,55+beckon*45,60,-8),beckon*weight);
EmotionClap(parts,forward,side,age-.90f,weight*Smooth((age-.75f)/.25f),2,
presence*Smooth((age-.75f)/.25f));break;
}
case FootballEmotion.SubstitutionApplause:
EmotionBody(parts,forward,side,.015f*load*weight,.045f*EmotionPulse(age,.2f,.6f,1.1f)*weight,
lead*.09f*EmotionPulse(age,.35f,1.0f,1.9f)*weight,0,0);
EmotionHead(parts,forward,side,.10f*EmotionPulse(age,.15f,.4f,.7f)*weight,AttentionYaw(p,forward,cue.Attention)*.4f*weight);
EmotionClap(parts,forward,side,age-.23f,weight,3,presence);break;
}
return true;
}
static float EmotionPulse(float t,float start,float peak,float end)=>
t<peak?Smooth((t-start)/(peak-start)):1-Smooth((t-peak)/(end-peak));
static float AttentionYaw(Footballer p,Point forward,Point attention)
{
var at=attention-p.Position;
if(at.Length<.05f)return 0;
return Math.Clamp(MathF.Atan2(forward.X*at.Y-forward.Y*at.X,Point.Dot(forward,at)),-.65f,.65f);
}
// Body rotations are rigid; planted ankles are solved afterwards. No bone
// grows, and the expression never offsets the football simulation's root.
static void EmotionBody(List<Part> parts,Point forward,Point side,float crouch,float pitch,float yaw,float roll,float lift,float stance=0)
{
var left=parts[1];var right=parts[4];
if(crouch!=0)for(int i=0;i<parts.Count;i++)
{var v=parts[i];parts[i]=v with{ZA=v.ZA-crouch,ZB=v.ZB-crouch};}
var hip=parts[6].A;float hipZ=parts[6].ZA;
(Point,float) Turn(Point at,float z)
{
var d=at-hip;float u=Point.Dot(d,forward),v=Point.Dot(d,side),w=z-hipZ;
float x=u*MathF.Cos(pitch)+w*MathF.Sin(pitch),a=w*MathF.Cos(pitch)-u*MathF.Sin(pitch);
float y=v*MathF.Cos(roll)-a*MathF.Sin(roll),b=a*MathF.Cos(roll)+v*MathF.Sin(roll);
return(hip+forward*(x*MathF.Cos(yaw)-y*MathF.Sin(yaw))+side*(x*MathF.Sin(yaw)+y*MathF.Cos(yaw)),hipZ+b);
}
for(int i=6;i<parts.Count;i++)
{var v=parts[i];var a=Turn(v.A,v.ZA);var b=Turn(v.B,v.ZB);parts[i]=v with{A=a.Item1,ZA=a.Item2,B=b.Item1,ZB=b.Item2};}
KeeperLeg(parts,0,left.B-side*stance,left.ZB,forward,0,forward,0);
KeeperLeg(parts,1,right.B+side*stance,right.ZB,forward,0,forward,0);
if(lift!=0)for(int i=0;i<parts.Count;i++)
{var v=parts[i];parts[i]=v with{ZA=v.ZA+lift,ZB=v.ZB+lift};}
}
static void EmotionHead(List<Part> parts,Point forward,Point side,float pitch,float yaw)
{
var head=parts[13];
(Point,float) Turn(Point at,float z)
{
var d=at-head.A;float x=Point.Dot(d,forward),y=Point.Dot(d,side),h=z-head.ZA;
float u=x*MathF.Cos(pitch)+h*MathF.Sin(pitch),v=h*MathF.Cos(pitch)-x*MathF.Sin(pitch);
return(head.A+forward*(u*MathF.Cos(yaw)-y*MathF.Sin(yaw))+side*(u*MathF.Sin(yaw)+y*MathF.Cos(yaw)),head.ZA+v);
}
for(int i=13;i<parts.Count;i++)
{var v=parts[i];var a=Turn(v.A,v.ZA);var b=Turn(v.B,v.ZB);parts[i]=v with{A=a.Item1,ZA=a.Item2,B=b.Item1,ZB=b.Item2};}
}
static void EmotionClap(List<Part> parts,Point forward,Point side,float age,float weight,int count,float contactPresence=-1)
{
if(weight<=0)return;
float contact=0;
for(int n=0;n<count;n++)contact=Math.Max(contact,EmotionPulse(age,n*.59f+.08f,n*.59f+.23f,n*.59f+.45f));
float ready=Smooth((age+.10f)/.22f)*(1-Smooth((age-(count*.59f+.05f))/.35f));
// Personality changes the load and volume, not whether two palms can
// meet. Only an actual beat earns full contact; entry/release and ball
// safety still attenuate both arms together.
float contactWeight=Math.Max(weight,Math.Max(0,contactPresence)*contact)*ready;
for(int arm=0;arm<2;arm++)
{
int sign=arm==0?-1:1;int i=7+arm*3;
ApplyArmFk(parts,i,new ArmAngles(40,22,-12,95,0,-5),weight*ready);
var chest=parts[6];var target=chest.B+forward*.33f+side*(sign*(.23f-.2025f*contact));
ApplyArmContact(parts,i,target,chest.ZB-.13f,side*(sign*.12f),-1,contactWeight);
EmotionHandFrame(parts,i,forward*.72f,.694f,side*(-sign),0,contactWeight);
}
}
static void EmotionHandFrame(List<Part> parts,int arm,Point fingers,float fingersZ,Point palm,float palmZ,float weight)
{
if(weight<=0)return;
var frame=AnatomicalFrame(parts);var hand=parts[arm+2];
var lower=UnitArm(AnatomicalB(parts[arm+1])-AnatomicalA(parts[arm+1]),-frame.Up);
var wanted=UnitArm(AnatomicalPoint(fingers,fingersZ),lower);
float bend=ArmAngle(lower,wanted);
if(bend>38)wanted=UnitArm(BlendFallBoneDirection(lower,wanted,38/bend,1,frame.Forward),lower);
var normal=UnitArm(ArmPlane(AnatomicalPoint(palm,palmZ),wanted),PalmReference(frame,wanted));
var oldPalm=AnatomicalPalmNormal(parts,arm);
var old=ArmHandRotation(AnatomicalB(hand)-AnatomicalA(hand),AnatomicalPoint(oldPalm.direction,oldPalm.z));
var rotation=NQuaternion.Slerp(old,ArmHandRotation(wanted,normal),Math.Clamp(weight,0,1));
var direction=NVector.Transform(NVector.UnitX,rotation);
normal=NVector.Transform(NVector.UnitZ,rotation);
float roll=SignedArmAngle(PalmReference(frame,direction),normal,direction);
var tip=AnatomicalA(hand)+direction*.075f;
parts[arm+2]=hand with{B=AnatomicalGround(tip),ZB=tip.Z,PalmRoll=roll,ArticulatedHand=true};
}
}