Part of the football match simulation. Holds each team's tactical style, maps styles to approach and tactics settings, and modifies pass values and off-ball movement based on the chosen style. Also runs a per-second update that turns pressing surges on and off based on score, minute, cup stage and stamina.
using System;
using System.Linq;
namespace Socer;
public enum TacticalCharacter { None, Possession, Wide, Counter, HighPress, Compact }
public sealed partial class MatchSimulation
{
public TacticalCharacter[] TacticalCharacters={TacticalCharacter.None,TacticalCharacter.None};
readonly bool[] manualTacticalPlan=new bool[2];
public int CupStage;
public bool[] TacticalSurge=new bool[2];
public int[] TacticalSurges=new int[2];
readonly float[] counterAttackUntil=new float[2];
public int[] CounterAttacks=new int[2];
float nextTacticalPlan;
public static string TacticalName(TacticalCharacter style,bool russian)=>style switch
{
TacticalCharacter.Possession=>russian?"Контроль и комбинации":"Possession & combinations",
TacticalCharacter.Wide=>russian?"Фланги и навесы":"Wing play & crosses",
TacticalCharacter.Counter=>russian?"Быстрые контратаки":"Fast counterattacks",
TacticalCharacter.HighPress=>russian?"Высокий прессинг":"High press",
TacticalCharacter.Compact=>russian?"Компактная оборона":"Compact defence",
_=>russian?"Сбалансированная игра":"Balanced play"
};
public void SetTacticalIdentity(int team,TacticalCharacter style)
{
TacticalCharacters[team]=style;
manualTacticalPlan[team]=false;
Approach[team]=style switch{TacticalCharacter.Wide=>1,TacticalCharacter.Counter=>2,TacticalCharacter.HighPress=>3,_=>0};
Tactics[team]=style is TacticalCharacter.Counter or TacticalCharacter.Compact?0:style==TacticalCharacter.HighPress?2:1;
nextTacticalPlan=0;
}
public void SetClubPlan(int plan,int team=0)
{
plan=Math.Clamp(plan,0,2);
SetTacticalIdentity(team,plan==0?TacticalCharacter.Counter:plan==2?TacticalCharacter.HighPress:TacticalCharacter.None);
manualTacticalPlan[team]=true;Tactics[team]=plan;Approach[team]=plan==0?2:plan==2?3:0;
}
// Tactical preparation changes risks and positioning, never card attributes
// or the result. Short pressing waves require enough stamina to execute.
void UpdateTacticalCharacters()
{
if(Seconds<nextTacticalPlan)return;
nextTacticalPlan=Seconds+1;
for(int team=0;team<2;team++)
{
var style=TacticalCharacters[team];if(style==TacticalCharacter.None || manualTacticalPlan[team])continue;
bool losing=Goals[team]<Goals[1-team],leading=Goals[team]>Goals[1-team];
float energy=ActivePlayers.Where(p=>p.Team==team && !p.Keeper).Select(p=>p.Stamina).DefaultIfEmpty().Average();
int period=CupStage>0?Math.Max(9,18-CupStage*3):20;
int phase=(Minute+(Seed%5)+team*3)%period;
bool surge=energy>.34f && !leading &&
(style==TacticalCharacter.HighPress?phase<4: CupStage>0 && phase<2 || losing && Minute>=70 && phase<4);
if(surge && !TacticalSurge[team])TacticalSurges[team]++;
TacticalSurge[team]=surge;
Tactics[team]=surge?2:style is TacticalCharacter.Counter or TacticalCharacter.Compact?0:1;
Approach[team]=style switch{TacticalCharacter.Wide=>1,TacticalCharacter.Counter=>2,TacticalCharacter.HighPress when surge=>3,_=>0};
if(losing && Minute>=75){Tactics[team]=2;if(style!=TacticalCharacter.Wide)Approach[team]=2;}
if(leading && Minute>=80){Tactics[team]=0;if(style==TacticalCharacter.HighPress)Approach[team]=0;}
}
}
static bool BackLine(Footballer p)=>p.Profile.SlotRole is "CB" or "LB" or "RB" ||
p.Profile.SlotRole=="" && p.Number<=5;
static bool AttackUnit(Footballer p)=>!p.Keeper && !BackLine(p);
static bool StrikerRole(Footballer p)=>p.Profile.SlotRole=="ST" || p.Profile.SlotRole=="" && p.Number>=10;
Point RoleMovement(Footballer p,Point target,int possessionTeam)
{
// Unfamiliar positions cost recognition time, not sprint speed. Keep
// emergencies, receiving the actual ball and explicit field orders responsive.
if(p.Keeper || p.Id==Owner || p.Profile.RoleFamiliarity>=.98f || Owner<0 ||
p.DirectOrder!=DirectOrderKind.None || DuelDefender==p.Id || WindupPlayer==p.Id ||
(Ball-p.Position).Length<2.3f)return target;
if(p.RolePossession!=possessionTeam || Seconds>=p.RoleThinkingUntil)
{
p.RoleMovementTarget=target;p.RolePossession=possessionTeam;
p.RoleThinkingUntil=Seconds+.08f+(1-p.Profile.RoleFamiliarity)*1.6f;
}
return p.RoleMovementTarget;
}
float TacticalPassValue(Footballer p,Footballer q,float distance,float gain,float safety)
{
float value=TacticalCharacters[p.Team] switch
{
TacticalCharacter.Possession=>distance<19 && safety>.55f?2.4f:distance>30?-3:0,
TacticalCharacter.Wide=>Math.Abs(q.Position.Y-34)>18 && gain>0?3:0,
TacticalCharacter.Counter=>gain>7 && safety>.5f?(counterAttackUntil[p.Team]>Seconds?5.5f:3.5f):gain< -3?-2:0,
TacticalCharacter.Compact=>safety<.55f?-3:gain>5?1:0,
_=>0
};
if(p.Team==0 && (FieldAttackUntil>Seconds || CoachOrder==2 && CoachUntil>Seconds) &&
gain>2 && safety>.55f && (q.FootballRunUntil>Seconds || q.RunUntil>Seconds))value+=3;
return value;
}
Point TacticalMovement(Footballer p,Point target,int possessionTeam)
{
if(p.Keeper || p.Id==Owner || Owner<0 || WindupPlayer==p.Id || DuelDefender==p.Id ||
p.BoxRole>0 || p.RunUntil>Seconds || p.FootballRunUntil>Seconds)return target;
var style=TacticalCharacters[p.Team];
if(possessionTeam==p.Team)
{
if(style==TacticalCharacter.Wide && Math.Abs(p.Home.Y-34)>18)
target.Y=World(new Point(0,p.Home.Y<34?7:61),p.Team).Y;
if(style==TacticalCharacter.Compact && BackLine(p))
target.X-=Direction(p.Team)*3;
}
else if(style is TacticalCharacter.Compact or TacticalCharacter.Counter && p.Id!=chasers[p.Team] && p.Id!=secondChasers[p.Team])
{
var local=World(target,p.Team);var ball=World(Ball,p.Team);
if(StrikerRole(p))
{
// A low block still keeps a forward outlet. Pulling every player
// behind the ball removed the counterattack from a counter team.
local.X=Math.Clamp(ball.X+(style==TacticalCharacter.Counter?20:15),38,72);
local.Y=Math.Clamp(local.Y,20,48);
return World(local,p.Team);
}
local.X=Math.Min(local.X,Math.Max(12,ball.X-4));
local.Y=34+(local.Y-34)*.78f;
target=World(local,p.Team);
}
return target;
}
}