Part of the soccer match simulation that handles injuries. Rolls injury severity and body part, runs the staged medical treatment sequence (approach, assess, load, carry off, await substitute), resolves injury substitutions and abandonment when a team drops below seven players.
using System;
using System.Collections.Generic;
using System.Linq;
namespace Socer;
public enum InjurySeverity { None, Knock, Minor, Serious }
public enum InjuryBodyPart { Ankle, Knee, Shoulder }
public enum MedicalTreatmentStage { None, Approach, Assess, Load, CarryOff, AwaitSubstitute }
public sealed partial class MatchSimulation
{
public bool InjuriesEnabled {get;set;}=true;
// The controller manages both the broadcast choice and text-mode selection.
// A standalone simulation may resolve its own roster so seeded audits finish.
public bool ManualHomeInjurySubstitution {get;set;}
public int[] InjuryKnocks=new int[2],MinorInjuries=new int[2],SeriousInjuries=new int[2];
public int InjuryContacts {get;private set;}
public MedicalTreatmentStage MedicalStage {get;private set;}
public bool MedicalActive=>MedicalStage!=MedicalTreatmentStage.None;
public float MedicalElapsed {get;private set;}
public float MedicalStageElapsed {get;private set;}
public float MedicalLoadAnimationClock {get;private set;}
public int MedicalPatientId {get;private set;}=-1;
public Footballer MedicalPatient {get;private set;}
public Point MedicalIncidentPoint {get;private set;}
public Point MedicalExitPoint {get;private set;}
public int PendingInjurySubstitutionSlot=>MedicalStage==MedicalTreatmentStage.AwaitSubstitute?MedicalPatientId:-1;
public int PendingInjurySubstitutionTeam=>PendingInjurySubstitutionSlot>=0?MedicalPatient.Team:-1;
public int[] InjurySubstitutionsUsed=new int[2];
readonly List<Footballer>[] injuryReserves={new(),new()};
bool medicalRetainsRestart;
int medicalRestartTeam;
Point medicalRestartPoint;
static string InjuryIdentity(Footballer p)=>!string.IsNullOrEmpty(p.Profile.CatalogId)?p.Profile.CatalogId:p.Profile.Id;
public float InjuryAnimationSeconds(Footballer player)=>Seconds+(MedicalActive && ReferenceEquals(player,MedicalPatient)?MedicalElapsed:0);
public float MedicalStageDuration=>MedicalStage switch
{
MedicalTreatmentStage.Approach=>Math.Clamp((MedicalExitPoint-MedicalIncidentPoint).Length/4.2f,2.8f,11f),
MedicalTreatmentStage.Assess=>2.5f,
MedicalTreatmentStage.Load=>2.4f,
MedicalTreatmentStage.CarryOff=>Math.Clamp((MedicalExitPoint-MedicalIncidentPoint).Length/2f,4.5f,19f),
_=>0
};
public float MedicalStageProgress=>MedicalStageDuration>0?Math.Clamp(MedicalStageElapsed/MedicalStageDuration,0,1):0;
public Point MedicalCarryingPosition=>MedicalStage==MedicalTreatmentStage.CarryOff
?MedicalIncidentPoint+(MedicalExitPoint-MedicalIncidentPoint)*MedicalStageProgress
:MedicalStage==MedicalTreatmentStage.AwaitSubstitute?MedicalExitPoint:MedicalIncidentPoint;
public void SetInjuryReserves(int team,IEnumerable<Footballer> candidates)
{
if(team<0 || team>1)return;
injuryReserves[team].Clear();
var activeIdentities=Players.Where(p=>p.Team==team).Select(InjuryIdentity).Where(id=>!string.IsNullOrEmpty(id)).ToHashSet();
foreach(var candidate in candidates??Array.Empty<Footballer>())
if(candidate?.Profile!=null && !activeIdentities.Contains(InjuryIdentity(candidate)) &&
(!string.IsNullOrEmpty(InjuryIdentity(candidate))?!injuryReserves[team].Any(p=>InjuryIdentity(p)==InjuryIdentity(candidate)):true))injuryReserves[team].Add(candidate);
}
// Injury rolls have their own deterministic stream. Cosmetic speech/pose
// variations never consume football decision randomness.
float InjuryRoll(int stream)
{
uint value=unchecked((uint)Seed^(uint)(InjuryContacts*747796405)^(uint)(stream*2891336453u));
value=(value^(value>>16))*2246822519u;value=(value^(value>>13))*3266489917u;
return ((value^(value>>16))&0xffffff)/16777216f;
}
void AssessFallInjury(Footballer p,float impact)
{
if(!InjuriesEnabled || p.SentOff || p.InjuryCannotContinue || MedicalActive || ShootoutActive || RehearsingStrategy)return;
InjuryContacts++;
float roll=InjuryRoll(0),load=Math.Clamp(impact,0,1);
float serious=load<.55f || IsMediaLeague?0:.004f+load*load*.012f;
float minor=.028f+load*.075f,knock=.11f+load*.15f;
var severity=roll<serious?InjurySeverity.Serious:roll<serious+minor?InjurySeverity.Minor:
roll<serious+minor+knock?InjurySeverity.Knock:InjurySeverity.None;
if(severity==InjurySeverity.None)return;
var part=load>.65f && InjuryRoll(1)<.35f?InjuryBodyPart.Knee:
p.FallVariant is 2 or 3 && InjuryRoll(1)>.72f?InjuryBodyPart.Shoulder:InjuryBodyPart.Ankle;
RegisterInjury(p,severity,part);
}
// Also used by controlled laboratory fixtures; no random draw is required.
public bool RegisterInjury(Footballer p,InjurySeverity severity,InjuryBodyPart part=InjuryBodyPart.Ankle)
{
if(p==null || p.Id<0 || p.Id>=Players.Count || !ReferenceEquals(Players[p.Id],p) ||
p.SentOff || p.InjuryCannotContinue || severity==InjurySeverity.None || MedicalActive || Finished || ShootoutActive)return false;
if((int)p.InjurySeverity>(int)severity && Seconds<p.InjuryUntil)return false;
if(Seconds>=p.FallUntil)
{
p.FallAt=Seconds;p.FallDirection=new Point(MathF.Cos(p.Facing),MathF.Sin(p.Facing));
p.FallVelocity=p.Velocity*.4f;p.FallVariant=part==InjuryBodyPart.Shoulder?2:0;
p.FallSpeed=p.Velocity.Length;p.FallSeverity=severity==InjurySeverity.Serious?.9f:.5f;
}
p.InjurySeverity=severity;p.InjuryPart=part;p.InjuryStartedAt=Seconds;
float duration=severity switch {InjurySeverity.Knock=>3.4f+InjuryRoll(2)*1.8f,InjurySeverity.Minor=>6.5f+InjuryRoll(2)*3.5f,_=>3600};
p.InjuryUntil=Seconds+duration;p.FallUntil=Math.Max(p.FallUntil,p.InjuryUntil);
p.Recovery=Math.Max(p.Recovery,duration);p.Velocity=new();p.BurstRemaining=0;p.RunUntil=0;
p.Action=severity==InjurySeverity.Knock?"Приходит в себя после столкновения":"Держится за ушибленное место";
if(Owner==p.Id){Owner=-1;Ball=p.Position;BallVelocity=new();PickupDelay=Math.Max(PickupDelay,.4f);}
p.SpeechCooldown=0;p.LastSpeechAt=-10;
SpeakPlayer(p,severity==InjurySeverity.Serious?PlayerSpeechCue.InjurySerious:PlayerSpeechCue.InjuryPain,true,true);
if(severity==InjurySeverity.Knock)InjuryKnocks[p.Team]++;
if(severity==InjurySeverity.Minor)MinorInjuries[p.Team]++;
if(severity!=InjurySeverity.Serious)return true;
SeriousInjuries[p.Team]++;p.InjuryCannotContinue=true;
BeginMedicalTreatment(p);
return true;
}
void BeginMedicalTreatment(Footballer patient)
{
MedicalPatient=patient;MedicalPatientId=patient.Id;
MedicalIncidentPoint=patient.Position;
MedicalExitPoint=new Point(Math.Clamp(patient.Position.X,9,96),patient.Position.Y<34?-3.2f:71.2f);
MedicalStage=MedicalTreatmentStage.Approach;MedicalElapsed=MedicalStageElapsed=MedicalLoadAnimationClock=0;
medicalRetainsRestart=RestartRemaining>0;
medicalRestartTeam=Owner>=0?Players[Owner].Team:LastTouch;
medicalRestartPoint=Clamp(Ball);
ManualRestartWaiting=false;ManualRestartWait=0;
Owner=Passer=PassReceiver=-1;shot=false;BallVelocity=new();VerticalSpeed=0;
CancelWindup();KeeperKickPlayer=-1;ResetFlight();ClearOffside();ResetFootballContext();
DuelDefender=DuelCarrier=-1;DuelRemaining=0;BodyChallenge=false;
IncidentRemaining=GoalHoldRemaining=ShotMissFlightRemaining=0;
FieldAttackUntil=FieldShootUntil=0;
foreach(var player in Players){player.Velocity=new();player.BurstRemaining=0;player.RunUntil=0;player.FootballRunUntil=0;}
Signal("НУЖНЫ МЕДИКИ",patient.Position);
AddEvent($"{TeamName(patient.Team)} · {patient.Profile.Name} не может продолжить матч. Медики на поле.");
}
void AdvanceMedicalTreatment(float dt)
{
MedicalElapsed+=dt;MedicalStageElapsed+=dt;
foreach(var effect in Effects)effect.Age+=dt;
Effects.RemoveAll(effect=>effect.Age>effect.Duration);
foreach(var player in Players){player.Velocity=new();player.VisualSpeed=0;player.VisualVelocity=new();}
AdvanceFalls(dt);
var patient=MedicalPatient;
if(MedicalElapsed<.7f)MedicalIncidentPoint=patient.Position;
patient.Action=MedicalStage switch
{MedicalTreatmentStage.Approach=>"Ожидает медиков",MedicalTreatmentStage.Assess=>"Медики осматривают игрока",
MedicalTreatmentStage.Load=>"Медики готовят носилки",MedicalTreatmentStage.CarryOff=>"Уносят на носилках",_=>"Требуется замена"};
var refereeTarget=MedicalIncidentPoint+new Point(-2.2f,-1.6f);
var refereeBefore=RefereePosition;var gap=refereeTarget-RefereePosition;
RefereePosition=Clamp(RefereePosition+gap.Unit*Math.Min(gap.Length,dt*3.4f));
RefereeActor.VisualBefore=refereeBefore;RefereeActor.Position=RefereePosition;AdvanceGait(RefereeActor,dt);
RefereeActor.Velocity=RefereeActor.VisualVelocity;RefereeActor.Action="Позвал медиков";
if(gap.Length>.03f)UpdateFacing(RefereeActor,refereeTarget,dt);
RefereeSignal="МЕДИЦИНСКАЯ ПОМОЩЬ";RefereeSignalUntil=Seconds+2;
if(MedicalStage==MedicalTreatmentStage.AwaitSubstitute)
{
if(patient.Team!=0 || !ManualHomeInjurySubstitution)ResolveAutomaticInjurySubstitution();
return;
}
if(MedicalStageElapsed<MedicalStageDuration)return;
MedicalStageElapsed-=MedicalStageDuration;
MedicalStage=MedicalStage switch
{MedicalTreatmentStage.Approach=>MedicalTreatmentStage.Assess,MedicalTreatmentStage.Assess=>MedicalTreatmentStage.Load,
MedicalTreatmentStage.Load=>MedicalTreatmentStage.CarryOff,_=>MedicalTreatmentStage.AwaitSubstitute};
if(MedicalStage==MedicalTreatmentStage.Load)MedicalLoadAnimationClock=Seconds+MedicalElapsed;
if(MedicalStage==MedicalTreatmentStage.AwaitSubstitute)
{
patient.InjuryRemovedFromField=true;
AddEvent($"{TeamName(patient.Team)} · обязательная замена: {patient.Profile.Name} покинул поле на носилках.");
}
}
void ResolveAutomaticInjurySubstitution()
{
var outgoing=MedicalPatient;Footballer reserve=null;
if(InjurySubstitutionsUsed[outgoing.Team]<5)
reserve=injuryReserves[outgoing.Team].Where(p=>p.Keeper==outgoing.Keeper).OrderByDescending(p=>
(p.Profile.SlotRole==outgoing.Profile.SlotRole?100:0)+(p.Profile.Style==outgoing.Profile.Style?30:0)+p.Profile.Overall).FirstOrDefault();
if(reserve!=null)
{
injuryReserves[outgoing.Team].Remove(reserve);
var replacement=new Footballer{Id=outgoing.Id,Team=outgoing.Team,Number=outgoing.Number,Home=outgoing.Home,
Position=outgoing.Position,Facing=outgoing.Facing,Profile=reserve.Profile,Pace=reserve.Pace,Skill=reserve.Skill,
PreferredFoot=reserve.PreferredFoot,WeakFootRating=reserve.WeakFootRating,Instruction=outgoing.Instruction,
EmergencyKeeper=outgoing.Keeper && outgoing.Number!=1};
ResolveInjurySubstitution(outgoing.Id,replacement);
}
else ResolveInjurySubstitution(outgoing.Id);
}
public bool ResolveInjurySubstitution(int slot,Footballer replacement=null)
{
if(slot!=PendingInjurySubstitutionSlot)return false;
var outgoing=MedicalPatient;
if(replacement!=null && (replacement.Id!=slot || replacement.Team!=outgoing.Team || replacement.SentOff ||
replacement.InjuryCannotContinue || replacement.Keeper!=outgoing.Keeper || InjurySubstitutionsUsed[outgoing.Team]>=5))return false;
string identity=replacement!=null?InjuryIdentity(replacement):"";
if(replacement!=null && !string.IsNullOrEmpty(identity) && Players.Any(p=>p.Id!=slot && p.Team==outgoing.Team && p.InPlay && InjuryIdentity(p)==identity))return false;
if(replacement!=null)
{
Players[slot]=replacement;InjurySubstitutionsUsed[outgoing.Team]++;
AddEvent($"{TeamName(outgoing.Team)} · {replacement.Profile.Name} вместо травмированного {outgoing.Profile.Name}.");
}
else
{
if(outgoing.Keeper)
{
var emergency=ActivePlayers.Where(p=>p.Team==outgoing.Team).OrderByDescending(p=>p.Profile.Goalkeeping).FirstOrDefault();
if(emergency!=null){emergency.EmergencyKeeper=true;emergency.Home=new Point(5,34);
AddEvent($"{TeamName(outgoing.Team)} · {emergency.Profile.Name} вынужденно встаёт в ворота.");}
}
AddEvent($"{TeamName(outgoing.Team)} продолжает без травмированного игрока: доступной замены нет.");
}
MedicalStage=MedicalTreatmentStage.None;MedicalStageElapsed=0;MedicalElapsed=0;
if(ActivePlayers.Count(p=>p.Team==outgoing.Team)<7)
{
Abandoned=true;Goals[outgoing.Team]=0;Goals[1-outgoing.Team]=Math.Max(3,Goals[1-outgoing.Team]);
AddEvent("Матч прекращён: менее семи игроков · техническое поражение");return true;
}
if(medicalRetainsRestart)
{
if(SetPieceTaker<0 || !Players[SetPieceTaker].InPlay)SetPieceTaker=ChooseTaker().Id;
RestartRemaining=Math.Max(RestartRemaining,1.6f);
Status="Игра возобновляется после медицинской помощи";
}
else Restart(medicalRestartTeam,medicalRestartPoint,"Спорный мяч после медицинской остановки",kind:RestartKind.DropBall);
return true;
}
void AdvanceRecoverableInjuries()
{
foreach(var player in Players)
if(!player.InjuryCannotContinue && player.InjurySeverity!=InjurySeverity.None && Seconds>=player.InjuryUntil)
{player.InjurySeverity=InjurySeverity.None;player.InjuryUntil=0;}
}
}