4644 lines
225 KiB
C#
4644 lines
225 KiB
C#
using Barotrauma.Extensions;
|
|
using Barotrauma.Items.Components;
|
|
using Barotrauma.Networking;
|
|
using FarseerPhysics;
|
|
using FarseerPhysics.Dynamics;
|
|
using Microsoft.Xna.Framework;
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Xml.Linq;
|
|
using System.Linq;
|
|
|
|
namespace Barotrauma
|
|
{
|
|
public enum AIState { Idle, Attack, Escape, Eat, Flee, Avoid, Aggressive, PassiveAggressive, Protect, Observe, Freeze, Follow, FleeTo, Patrol, PlayDead, HideTo, Hiding }
|
|
|
|
public enum AttackPattern { Straight, Sweep, Circle }
|
|
|
|
public enum CirclePhase { Start, CloseIn, FallBack, Advance, Strike }
|
|
|
|
public enum WallTargetingMethod
|
|
{
|
|
Target = 0x1,
|
|
Heading = 0x2,
|
|
Steering = 0x4
|
|
}
|
|
|
|
[Flags]
|
|
public enum EnemyTargetingRestrictions
|
|
{
|
|
None = 0x0,
|
|
PlayerCharacters = 0x1,
|
|
PlayerSubmarines = 0x2
|
|
}
|
|
|
|
partial class EnemyAIController : AIController
|
|
{
|
|
public static bool DisableEnemyAI;
|
|
|
|
public static EnemyTargetingRestrictions TargetingRestrictions = EnemyTargetingRestrictions.None;
|
|
private EnemyTargetingRestrictions previousTargetingRestrictions;
|
|
|
|
private AIState _state;
|
|
public AIState State
|
|
{
|
|
get { return _state; }
|
|
set
|
|
{
|
|
if (_state == value) { return; }
|
|
if (_state == AIState.PlayDead && value == AIState.Idle)
|
|
{
|
|
// Don't allow to switch to Idle from PlayDead.
|
|
return;
|
|
}
|
|
PreviousState = _state;
|
|
OnStateChanged(_state, value);
|
|
_state = value;
|
|
}
|
|
}
|
|
|
|
public AIState PreviousState { get; private set; }
|
|
|
|
/// <summary>
|
|
/// Enable the character to attack the outposts and the characters inside them. Disabled by default in normal levels, enabled in outpost levels.
|
|
/// </summary>
|
|
public bool TargetOutposts;
|
|
|
|
private readonly float updateTargetsInterval = 1;
|
|
private readonly float updateMemoriesInverval = 1;
|
|
private readonly float attackLimbSelectionInterval = 3;
|
|
// Min priority for the memorized targets. The actual value fades gradually, unless kept fresh by selecting the target.
|
|
private const float minPriority = 10;
|
|
|
|
private IndoorsSteeringManager PathSteering => insideSteering as IndoorsSteeringManager;
|
|
private SteeringManager outsideSteering, insideSteering;
|
|
|
|
private float updateTargetsTimer;
|
|
private float updateMemoriesTimer;
|
|
private float attackLimbSelectionTimer;
|
|
|
|
private bool IsAttackRunning => AttackLimb != null && AttackLimb.attack.IsRunning;
|
|
private bool IsCoolDownRunning => AttackLimb != null && AttackLimb.attack.CoolDownTimer > 0 || _previousAttackLimb != null && _previousAttackLimb.attack.CoolDownTimer > 0;
|
|
public float CombatStrength => AIParams.CombatStrength;
|
|
private float Sight => GetPerceptionRange(AIParams.Sight);
|
|
private float Hearing => GetPerceptionRange(AIParams.Hearing);
|
|
|
|
private float GetPerceptionRange(float range)
|
|
{
|
|
// TODO: make adjustable
|
|
if (State is AIState.PlayDead or AIState.Hiding)
|
|
{
|
|
// Intentionally constant
|
|
return 0.2f;
|
|
}
|
|
if (PreviousState is AIState.PlayDead or AIState.Hiding)
|
|
{
|
|
// Significantly buffed
|
|
return range * 1.5f;
|
|
}
|
|
return range;
|
|
}
|
|
|
|
private float FleeHealthThreshold => AIParams.FleeHealthThreshold;
|
|
private bool IsAggressiveBoarder => AIParams.AggressiveBoarding;
|
|
|
|
private FishAnimController FishAnimController => Character.AnimController as FishAnimController;
|
|
|
|
private Limb _attackLimb;
|
|
private Limb _previousAttackLimb;
|
|
public Limb AttackLimb
|
|
{
|
|
get { return _attackLimb; }
|
|
private set
|
|
{
|
|
if (_attackLimb != value)
|
|
{
|
|
_previousAttackLimb = _attackLimb;
|
|
if (_previousAttackLimb != null)
|
|
{
|
|
Character.DeselectCharacter();
|
|
if (_previousAttackLimb.attack.SnapRopeOnNewAttack)
|
|
{
|
|
_previousAttackLimb.AttachedRope?.Snap();
|
|
}
|
|
}
|
|
}
|
|
else if (_attackLimb != null && _attackLimb.attack.CoolDownTimer <= 0)
|
|
{
|
|
Character.DeselectCharacter();
|
|
if (_attackLimb.attack.SnapRopeOnNewAttack)
|
|
{
|
|
_attackLimb.AttachedRope?.Snap();
|
|
}
|
|
}
|
|
_attackLimb = value;
|
|
attackVector = null;
|
|
Reverse = _attackLimb != null && _attackLimb.attack.Reverse;
|
|
}
|
|
}
|
|
|
|
private double lastAttackUpdateTime;
|
|
|
|
private Attack _activeAttack;
|
|
public Attack ActiveAttack
|
|
{
|
|
get
|
|
{
|
|
if (_activeAttack == null) { return null; }
|
|
return lastAttackUpdateTime > Timing.TotalTime - _activeAttack.Duration ? _activeAttack : null;
|
|
}
|
|
private set
|
|
{
|
|
_activeAttack = value;
|
|
lastAttackUpdateTime = Timing.TotalTime;
|
|
}
|
|
}
|
|
|
|
public AITargetMemory CurrentTargetMemory => currentTargetMemory;
|
|
private AITargetMemory currentTargetMemory;
|
|
private float targetValue;
|
|
private CharacterParams.TargetParams currentTargetingParams;
|
|
|
|
private Dictionary<AITarget, AITargetMemory> targetMemories;
|
|
|
|
private readonly int requiredHoleCount;
|
|
private bool canAttackWalls;
|
|
public bool CanAttackDoors => canAttackDoors;
|
|
private bool canAttackDoors;
|
|
private bool canAttackItems;
|
|
private bool canAttackCharacters;
|
|
|
|
public float PriorityFearIncrement => priorityFearIncreasement;
|
|
private readonly float priorityFearIncreasement = 2;
|
|
private readonly float memoryFadeTime = 0.5f;
|
|
|
|
private float avoidTimer;
|
|
private float observeTimer;
|
|
private float sweepTimer;
|
|
private float circleRotation;
|
|
private float circleDir;
|
|
private bool inverseDir;
|
|
private bool breakCircling;
|
|
private float circleRotationSpeed;
|
|
private Vector2 circleOffset;
|
|
private float circleFallbackDistance;
|
|
private float strikeTimer;
|
|
private float aggressionIntensity;
|
|
private CirclePhase CirclePhase;
|
|
private float currentAttackIntensity;
|
|
|
|
private float playDeadTimer;
|
|
/// <summary>
|
|
/// How long the character has to idle without a target before it can start playing dead (again).
|
|
/// </summary>
|
|
private const float PlayDeadCoolDown = 60;
|
|
|
|
private CoroutineHandle disableTailCoroutine;
|
|
|
|
private readonly List<Body> myBodies;
|
|
|
|
public LatchOntoAI LatchOntoAI { get; private set; }
|
|
public SwarmBehavior SwarmBehavior { get; private set; }
|
|
public PetBehavior PetBehavior { get; private set; }
|
|
|
|
public CharacterParams.TargetParams CurrentTargetingParams => currentTargetingParams;
|
|
|
|
public bool AttackHumans
|
|
{
|
|
get
|
|
{
|
|
return GetTargetParams(Tags.Human).Any(static tp => tp is { Priority: > 0.0f, State: AIState.Attack or AIState.Aggressive });
|
|
}
|
|
}
|
|
|
|
public bool AttackRooms
|
|
{
|
|
get
|
|
{
|
|
return GetTargetParams(Tags.Room).Any(static tp => tp is { Priority: > 0.0f, State: AIState.Attack or AIState.Aggressive });
|
|
}
|
|
}
|
|
|
|
public override CanEnterSubmarine CanEnterSubmarine
|
|
{
|
|
get
|
|
{
|
|
//can't enter a submarine when attached to one
|
|
if (LatchOntoAI is { IsAttachedToSub: true }) { return CanEnterSubmarine.False; }
|
|
return Character.AnimController.CanEnterSubmarine;
|
|
}
|
|
}
|
|
|
|
public override bool CanFlip
|
|
{
|
|
get
|
|
{
|
|
//can't flip when attached to something, when eating, or reversing or in a (relatively) small room
|
|
return !Reverse &&
|
|
(State != AIState.Eat || Character.SelectedCharacter == null) &&
|
|
(LatchOntoAI == null || !LatchOntoAI.IsAttachedToSub) &&
|
|
(Character.CurrentHull == null || !Character.AnimController.InWater || Math.Min(Character.CurrentHull.Size.X, Character.CurrentHull.Size.Y) > ConvertUnits.ToDisplayUnits(Math.Max(colliderLength, colliderWidth)));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// The monster won't try to damage these submarines
|
|
/// </summary>
|
|
private readonly HashSet<Submarine> unattackableSubmarines = new HashSet<Submarine>();
|
|
|
|
public void SetUnattackableSubmarines(Submarine submarine, bool includeOwnSub = true, bool includeConnectedSubs = true, bool clearExisting = true)
|
|
{
|
|
if (clearExisting)
|
|
{
|
|
unattackableSubmarines.Clear();
|
|
}
|
|
if (submarine != null)
|
|
{
|
|
AddSubs(submarine);
|
|
}
|
|
if (includeOwnSub && Character.Submarine is Submarine ownSub && ownSub != submarine)
|
|
{
|
|
AddSubs(ownSub);
|
|
}
|
|
|
|
void AddSubs(Submarine sub)
|
|
{
|
|
unattackableSubmarines.Add(sub);
|
|
if (includeConnectedSubs)
|
|
{
|
|
foreach (Submarine connectedSub in sub.DockedTo)
|
|
{
|
|
unattackableSubmarines.Add(connectedSub);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
public static bool IsTargetBeingChasedBy(Character target, Character character)
|
|
=> character?.AIController is EnemyAIController enemyAI && enemyAI.SelectedAiTarget?.Entity == target && enemyAI.State is AIState.Attack or AIState.Aggressive;
|
|
|
|
public bool IsBeingChasedBy(Character c) => IsTargetBeingChasedBy(Character, c);
|
|
private bool IsBeingChased => IsBeingChasedBy(SelectedAiTarget?.Entity as Character);
|
|
|
|
private static bool IsTargetInPlayerTeam(AITarget target) => target?.Entity?.Submarine != null && target.Entity.Submarine.Info.IsPlayer || target?.Entity is Character { IsOnPlayerTeam: true };
|
|
|
|
private bool IsAttackingOwner(Character other) =>
|
|
PetBehavior != null && PetBehavior.Owner != null &&
|
|
!other.IsUnconscious && !other.IsHandcuffed &&
|
|
other.AIController is HumanAIController humanAI &&
|
|
humanAI.ObjectiveManager.CurrentObjective is AIObjectiveCombat combat &&
|
|
combat.Enemy != null && combat.Enemy == PetBehavior.Owner;
|
|
|
|
private bool reverse;
|
|
public bool Reverse
|
|
{
|
|
get { return reverse; }
|
|
private set
|
|
{
|
|
reverse = value;
|
|
if (FishAnimController != null)
|
|
{
|
|
FishAnimController.Reverse = reverse;
|
|
}
|
|
}
|
|
}
|
|
|
|
private readonly float maxSteeringBuffer = 5000;
|
|
private readonly float minSteeringBuffer = 500;
|
|
private readonly float steeringBufferIncreaseSpeed = 100;
|
|
private float steeringBuffer;
|
|
|
|
public EnemyAIController(Character c, string seed) : base(c)
|
|
{
|
|
if (c.IsHuman)
|
|
{
|
|
throw new Exception($"Tried to create an enemy ai controller for human!");
|
|
}
|
|
var mainElement = c.Params.OriginalElement.IsOverride() ? c.Params.OriginalElement.FirstElement() : c.Params.OriginalElement;
|
|
targetMemories = new Dictionary<AITarget, AITargetMemory>();
|
|
steeringManager = outsideSteering;
|
|
//allow targeting outposts and outpost NPCs in outpost levels
|
|
TargetOutposts =
|
|
(Level.Loaded != null && Level.Loaded.Type == LevelData.LevelType.Outpost) ||
|
|
//the main sub can be an outpost in the editor
|
|
Submarine.MainSub is { Info.Type: SubmarineType.Outpost };
|
|
|
|
List<XElement> aiElements = new List<XElement>();
|
|
List<float> aiCommonness = new List<float>();
|
|
foreach (var element in mainElement.Elements())
|
|
{
|
|
if (!element.Name.ToString().Equals("ai", StringComparison.OrdinalIgnoreCase)) { continue; }
|
|
aiElements.Add(element);
|
|
aiCommonness.Add(element.GetAttributeFloat("commonness", 1.0f));
|
|
}
|
|
|
|
if (aiElements.Count == 0)
|
|
{
|
|
DebugConsole.ThrowError("Error in file \"" + c.Params.File.Path + "\" - no AI element found.",
|
|
contentPackage: c.Prefab?.ContentPackage);
|
|
outsideSteering = new SteeringManager(this);
|
|
insideSteering = new IndoorsSteeringManager(this, false, false);
|
|
return;
|
|
}
|
|
|
|
//choose a random ai element
|
|
MTRandom random = new MTRandom(ToolBox.StringToInt(seed));
|
|
XElement aiElement = aiElements.Count == 1 ? aiElements[0] : ToolBox.SelectWeightedRandom(aiElements, aiCommonness, random);
|
|
foreach (var subElement in aiElement.Elements())
|
|
{
|
|
switch (subElement.Name.ToString().ToLowerInvariant())
|
|
{
|
|
case "chooserandom":
|
|
var subElements = subElement.Elements();
|
|
if (subElements.Any())
|
|
{
|
|
LoadSubElement(subElements.ToArray().GetRandom(random));
|
|
}
|
|
break;
|
|
default:
|
|
LoadSubElement(subElement);
|
|
break;
|
|
}
|
|
}
|
|
|
|
void LoadSubElement(XElement subElement)
|
|
{
|
|
switch (subElement.Name.ToString().ToLowerInvariant())
|
|
{
|
|
case "latchonto":
|
|
LatchOntoAI = new LatchOntoAI(subElement, this);
|
|
break;
|
|
case "swarm":
|
|
case "swarmbehavior":
|
|
SwarmBehavior = new SwarmBehavior(subElement, this);
|
|
break;
|
|
case "petbehavior":
|
|
PetBehavior = new PetBehavior(subElement, this);
|
|
break;
|
|
}
|
|
}
|
|
//pets are friendly!
|
|
if (PetBehavior != null || Character.Group == "human")
|
|
{
|
|
Character.TeamID = CharacterTeamType.FriendlyNPC;
|
|
}
|
|
ReevaluateAttacks();
|
|
outsideSteering = new SteeringManager(this);
|
|
insideSteering = new IndoorsSteeringManager(this, AIParams.CanOpenDoors, canAttackDoors);
|
|
steeringManager = outsideSteering;
|
|
State = AIState.Idle;
|
|
requiredHoleCount = (int)Math.Ceiling(ConvertUnits.ToDisplayUnits(colliderWidth) / Structure.WallSectionSize);
|
|
myBodies = Character.AnimController.Limbs.Select(l => l.body.FarseerBody).ToList();
|
|
myBodies.Add(Character.AnimController.Collider.FarseerBody);
|
|
if (AIParams.PlayDeadProbability > 0)
|
|
{
|
|
Character.EvaluatePlayDeadProbability();
|
|
}
|
|
CreatureMetrics.UnlockInEditor(Character.SpeciesName);
|
|
}
|
|
|
|
private CharacterParams.AIParams _aiParams;
|
|
/// <summary>
|
|
/// Shorthand for <see cref="Character.Params.AI"/> with null checking.
|
|
/// </summary>
|
|
/// <returns><see cref="Character.Params.AI"/> or an empty params. Does not return nulls.</returns>
|
|
public CharacterParams.AIParams AIParams
|
|
{
|
|
get
|
|
{
|
|
if (_aiParams == null)
|
|
{
|
|
_aiParams = Character.Params.AI;
|
|
if (_aiParams == null)
|
|
{
|
|
DebugConsole.ThrowError($"No AI Params defined for {Character.SpeciesName}. AI disabled.",
|
|
contentPackage: Character.Prefab.ContentPackage);
|
|
Enabled = false;
|
|
_aiParams = new CharacterParams.AIParams(null, Character.Params);
|
|
}
|
|
}
|
|
return _aiParams;
|
|
}
|
|
}
|
|
private IEnumerable<CharacterParams.TargetParams> GetTargetParams(Identifier targetTag) => AIParams.GetTargets(targetTag);
|
|
|
|
private IEnumerable<CharacterParams.TargetParams> GetTargetParams(IEnumerable<Identifier> targetingTags)
|
|
{
|
|
foreach (Identifier tag in targetingTags)
|
|
{
|
|
foreach (var tp in GetTargetParams(tag))
|
|
{
|
|
yield return tp;
|
|
}
|
|
}
|
|
}
|
|
|
|
private readonly List<Identifier> _targetingTags = new List<Identifier>();
|
|
|
|
private IEnumerable<Identifier> GetTargetingTags(AITarget aiTarget)
|
|
{
|
|
_targetingTags.Clear();
|
|
if (aiTarget?.Entity == null) { return _targetingTags; }
|
|
if (aiTarget.Entity is Character targetCharacter)
|
|
{
|
|
if (targetCharacter.IsDead)
|
|
{
|
|
_targetingTags.Add(Tags.Dead);
|
|
}
|
|
else if (AIParams.TryGetHighestPriorityTarget(targetCharacter.CharacterHealth.GetActiveAfflictionTags(), out CharacterParams.TargetParams tp) && tp.Threshold >= Character.GetDamageDoneByAttacker(targetCharacter))
|
|
{
|
|
_targetingTags.Add(tp.Tag);
|
|
}
|
|
else if (PetBehavior != null && aiTarget.Entity == PetBehavior.Owner)
|
|
{
|
|
_targetingTags.Add(Tags.Owner);
|
|
}
|
|
else if (PetBehavior != null && (!Character.IsOnFriendlyTeam(targetCharacter) || IsAttackingOwner(targetCharacter)))
|
|
{
|
|
_targetingTags.Add(Tags.Hostile);
|
|
}
|
|
else if (AIParams.TryGetHighestPriorityTarget(targetCharacter, out CharacterParams.TargetParams tP))
|
|
{
|
|
_targetingTags.Add(tP.Tag);
|
|
}
|
|
else if (targetCharacter.AIController is EnemyAIController enemy)
|
|
{
|
|
if (enemy.PetBehavior != null && (PetBehavior != null || AIParams.HasTag(Tags.Pet)))
|
|
{
|
|
// Pets see other pets as pets by default.
|
|
// Monsters see them only as pet only when they have a matching ai target. Otherwise they use the other tags, specified below.
|
|
_targetingTags.Add(Tags.Pet);
|
|
}
|
|
else if (targetCharacter.IsHusk && AIParams.HasTag(Tags.Husk))
|
|
{
|
|
_targetingTags.Add(Tags.Husk);
|
|
}
|
|
else if (!Character.IsSameSpeciesOrGroup(targetCharacter))
|
|
{
|
|
if (enemy.CombatStrength > CombatStrength)
|
|
{
|
|
_targetingTags.Add(Tags.Stronger);
|
|
}
|
|
else if (enemy.CombatStrength < CombatStrength)
|
|
{
|
|
_targetingTags.Add(Tags.Weaker);
|
|
}
|
|
else
|
|
{
|
|
_targetingTags.Add(Tags.Equal);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
else if (aiTarget.Entity is Item targetItem)
|
|
{
|
|
foreach (var prio in AIParams.Targets)
|
|
{
|
|
if (targetItem.HasTag(prio.Tag))
|
|
{
|
|
_targetingTags.Add(prio.Tag);
|
|
}
|
|
}
|
|
if (_targetingTags.None())
|
|
{
|
|
if (targetItem.GetComponent<Sonar>() != null)
|
|
{
|
|
_targetingTags.Add(Tags.Sonar);
|
|
}
|
|
if (targetItem.GetComponent<Door>() != null)
|
|
{
|
|
_targetingTags.Add(Tags.Door);
|
|
}
|
|
}
|
|
}
|
|
else if (aiTarget.Entity is Structure)
|
|
{
|
|
_targetingTags.Add(Tags.Wall);
|
|
}
|
|
else if (aiTarget.Entity is Hull)
|
|
{
|
|
_targetingTags.Add(Tags.Room);
|
|
}
|
|
return _targetingTags;
|
|
}
|
|
|
|
public override void SelectTarget(AITarget target) => SelectTarget(target, 100);
|
|
|
|
public void SelectTarget(AITarget target, float priority)
|
|
{
|
|
SelectedAiTarget = target;
|
|
currentTargetMemory = GetTargetMemory(target, addIfNotFound: true);
|
|
currentTargetMemory.Priority = priority;
|
|
ignoredTargets.Remove(target);
|
|
}
|
|
|
|
private float movementMargin;
|
|
|
|
private void ReleaseDragTargets()
|
|
{
|
|
AttackLimb?.AttachedRope?.Snap();
|
|
if (Character.Params.CanInteract && Character.Inventory != null)
|
|
{
|
|
Character.HeldItems.ForEach(i => i.GetComponent<Holdable>()?.GetRope()?.Snap());
|
|
}
|
|
}
|
|
|
|
public void EvaluatePlayDeadProbability(float? probability = null)
|
|
{
|
|
if (probability.HasValue)
|
|
{
|
|
AIParams.PlayDeadProbability = probability.Value;
|
|
}
|
|
Character.AllowPlayDead = Rand.Value() <= AIParams.PlayDeadProbability;
|
|
}
|
|
|
|
public override void Update(float deltaTime)
|
|
{
|
|
if (DisableEnemyAI) { return; }
|
|
base.Update(deltaTime);
|
|
UpdateTriggers(deltaTime);
|
|
Character.ClearInputs();
|
|
IsTryingToSteerThroughGap = false;
|
|
Reverse = false;
|
|
|
|
//doesn't do anything usually, but events may sometimes change monsters' (or pets' that use enemy AI) teams
|
|
Character.UpdateTeam();
|
|
|
|
HandleLaddersAndPlatforms(deltaTime);
|
|
|
|
if (Math.Abs(Character.AnimController.movement.X) > 0.1f && !Character.AnimController.InWater &&
|
|
(GameMain.NetworkMember == null || GameMain.NetworkMember.IsServer || Character.Controlled == Character))
|
|
{
|
|
if (SelectedAiTarget?.Entity != null || EscapeTarget != null)
|
|
{
|
|
Entity t = SelectedAiTarget?.Entity ?? EscapeTarget;
|
|
float referencePos = Vector2.DistanceSquared(Character.WorldPosition, t.WorldPosition) > 100 * 100 && HasValidPath() ? PathSteering.CurrentPath.CurrentNode.WorldPosition.X : t.WorldPosition.X;
|
|
Character.AnimController.TargetDir = Character.WorldPosition.X < referencePos ? Direction.Right : Direction.Left;
|
|
}
|
|
else
|
|
{
|
|
Character.AnimController.TargetDir = Character.AnimController.movement.X > 0.0f ? Direction.Right : Direction.Left;
|
|
}
|
|
}
|
|
if (isStateChanged)
|
|
{
|
|
if (State == AIState.Idle || State == AIState.Patrol)
|
|
{
|
|
stateResetTimer -= deltaTime;
|
|
if (stateResetTimer <= 0)
|
|
{
|
|
ResetOriginalState();
|
|
}
|
|
}
|
|
}
|
|
if (targetIgnoreTimer > 0)
|
|
{
|
|
targetIgnoreTimer -= deltaTime;
|
|
}
|
|
else
|
|
{
|
|
ignoredTargets.Clear();
|
|
targetIgnoreTimer = targetIgnoreTime;
|
|
}
|
|
avoidTimer -= deltaTime;
|
|
if (avoidTimer < 0)
|
|
{
|
|
avoidTimer = 0;
|
|
}
|
|
UpdateCurrentMemoryLocation();
|
|
if (updateMemoriesTimer > 0)
|
|
{
|
|
updateMemoriesTimer -= deltaTime;
|
|
}
|
|
else
|
|
{
|
|
FadeMemories(updateMemoriesInverval);
|
|
updateMemoriesTimer = updateMemoriesInverval;
|
|
}
|
|
if (Math.Max(Character.HealthPercentage, 0) < FleeHealthThreshold && SelectedAiTarget != null)
|
|
{
|
|
Character target = SelectedAiTarget.Entity as Character;
|
|
if (target == null && SelectedAiTarget.Entity is Item targetItem)
|
|
{
|
|
target = GetOwner(targetItem);
|
|
}
|
|
bool shouldFlee = false;
|
|
if (target != null)
|
|
{
|
|
// Keep fleeing if being chased or if we see a human target (that don't have enemy ai).
|
|
shouldFlee = target.IsHuman && CanPerceive(SelectedAiTarget) || IsBeingChasedBy(target);
|
|
}
|
|
// If we should not flee, just idle. Don't allow any other AI state when below the health threshold.
|
|
State = shouldFlee ? AIState.Flee : AIState.Idle;
|
|
wallTarget = null;
|
|
if (State != AIState.Flee)
|
|
{
|
|
SelectedAiTarget = null;
|
|
_lastAiTarget = null;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
if (TargetingRestrictions != previousTargetingRestrictions)
|
|
{
|
|
previousTargetingRestrictions = TargetingRestrictions;
|
|
// update targeting instantly when there's a change in targeting restrictions
|
|
updateTargetsTimer = 0;
|
|
SelectedAiTarget = null;
|
|
}
|
|
|
|
if (updateTargetsTimer > 0)
|
|
{
|
|
updateTargetsTimer -= deltaTime;
|
|
}
|
|
else if (avoidTimer <= 0 || activeTriggers.Any() && returnTimer <= 0)
|
|
{
|
|
UpdateTargets();
|
|
}
|
|
}
|
|
|
|
if (Character.Params.UsePathFinding && AIParams.UsePathFindingToGetInside && AIParams.CanOpenDoors)
|
|
{
|
|
// Meant for monsters outside the player sub that target something inside the sub and can use the doors to access the sub (Husk).
|
|
bool IsCloseEnoughToTargetSub(float threshold) => SelectedAiTarget?.Entity?.Submarine is Submarine sub && sub != null && Vector2.DistanceSquared(Character.WorldPosition, sub.WorldPosition) < MathUtils.Pow(Math.Max(sub.Borders.Size.X, sub.Borders.Size.Y) / 2 + threshold, 2);
|
|
|
|
if (Character.Submarine != null || HasValidPath() && IsCloseEnoughToTargetSub(maxSteeringBuffer) || IsCloseEnoughToTargetSub(steeringBuffer))
|
|
{
|
|
if (steeringManager != insideSteering)
|
|
{
|
|
insideSteering.Reset();
|
|
}
|
|
steeringManager = insideSteering;
|
|
steeringBuffer += steeringBufferIncreaseSpeed * deltaTime;
|
|
}
|
|
else
|
|
{
|
|
if (steeringManager != outsideSteering)
|
|
{
|
|
outsideSteering.Reset();
|
|
}
|
|
steeringManager = outsideSteering;
|
|
steeringBuffer = minSteeringBuffer;
|
|
}
|
|
steeringBuffer = Math.Clamp(steeringBuffer, minSteeringBuffer, maxSteeringBuffer);
|
|
}
|
|
else
|
|
{
|
|
// Normally the monsters only use pathing inside submarines, not outside.
|
|
if (Character.Submarine != null && Character.Params.UsePathFinding)
|
|
{
|
|
if (steeringManager != insideSteering)
|
|
{
|
|
insideSteering.Reset();
|
|
}
|
|
steeringManager = insideSteering;
|
|
}
|
|
else
|
|
{
|
|
if (steeringManager != outsideSteering)
|
|
{
|
|
outsideSteering.Reset();
|
|
}
|
|
steeringManager = outsideSteering;
|
|
}
|
|
}
|
|
|
|
bool useSteeringLengthAsMovementSpeed = State == AIState.Idle && Character.AnimController.InWater;
|
|
bool run = false;
|
|
switch (State)
|
|
{
|
|
case AIState.Freeze:
|
|
SteeringManager.Reset();
|
|
break;
|
|
case AIState.Idle:
|
|
UpdateIdle(deltaTime);
|
|
break;
|
|
case AIState.PlayDead:
|
|
Character.IsRagdolled = true;
|
|
break;
|
|
case AIState.Patrol:
|
|
UpdatePatrol(deltaTime);
|
|
break;
|
|
case AIState.Attack:
|
|
run = !IsCoolDownRunning || AttackLimb != null && AttackLimb.attack.FullSpeedAfterAttack;
|
|
UpdateAttack(deltaTime);
|
|
break;
|
|
case AIState.Eat:
|
|
UpdateEating(deltaTime);
|
|
break;
|
|
case AIState.Escape:
|
|
case AIState.Flee:
|
|
run = true;
|
|
Escape(deltaTime);
|
|
break;
|
|
case AIState.Avoid:
|
|
case AIState.PassiveAggressive:
|
|
case AIState.Aggressive:
|
|
if (SelectedAiTarget?.Entity == null || SelectedAiTarget.Entity.Removed)
|
|
{
|
|
State = AIState.Idle;
|
|
return;
|
|
}
|
|
float squaredDistance = Vector2.DistanceSquared(WorldPosition, SelectedAiTarget.WorldPosition);
|
|
var attackLimb = AttackLimb ?? GetAttackLimb(SelectedAiTarget.WorldPosition);
|
|
if (attackLimb != null && squaredDistance <= Math.Pow(attackLimb.attack.Range, 2))
|
|
{
|
|
run = true;
|
|
if (State == AIState.Avoid)
|
|
{
|
|
Escape(deltaTime);
|
|
}
|
|
else
|
|
{
|
|
UpdateAttack(deltaTime);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
bool isBeingChased = IsBeingChased;
|
|
float reactDistance = !isBeingChased && currentTargetingParams is { ReactDistance: > 0 } ? currentTargetingParams.ReactDistance : GetPerceivingRange(SelectedAiTarget);
|
|
if (squaredDistance <= Math.Pow(reactDistance, 2))
|
|
{
|
|
float halfReactDistance = reactDistance / 2;
|
|
float attackDistance = currentTargetingParams is { AttackDistance: > 0 } ? currentTargetingParams.AttackDistance : halfReactDistance;
|
|
if (State == AIState.Aggressive || State == AIState.PassiveAggressive && squaredDistance < Math.Pow(attackDistance, 2))
|
|
{
|
|
run = true;
|
|
UpdateAttack(deltaTime);
|
|
}
|
|
else
|
|
{
|
|
run = isBeingChased || squaredDistance < Math.Pow(halfReactDistance, 2);
|
|
State = AIState.Escape;
|
|
avoidTimer = AIParams.AvoidTime * 0.5f * Rand.Range(0.75f, 1.25f);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
UpdateIdle(deltaTime);
|
|
}
|
|
}
|
|
break;
|
|
case AIState.Protect:
|
|
case AIState.Follow:
|
|
case AIState.FleeTo:
|
|
case AIState.HideTo:
|
|
case AIState.Hiding:
|
|
if (SelectedAiTarget?.Entity == null || SelectedAiTarget.Entity.Removed)
|
|
{
|
|
State = AIState.Idle;
|
|
return;
|
|
}
|
|
if (State == AIState.Protect)
|
|
{
|
|
if (SelectedAiTarget.Entity is Character targetCharacter)
|
|
{
|
|
bool ShouldRetaliate(Character.Attacker a)
|
|
{
|
|
Character c = a.Character;
|
|
if (c == null || c.IsUnconscious || c.Removed) { return false; }
|
|
// Can't target characters of same species/group because that would make us hostile to all friendly characters in the same species/group.
|
|
if (Character.IsSameSpeciesOrGroup(c)) { return false; }
|
|
if (targetCharacter.IsSameSpeciesOrGroup(c)) { return false; }
|
|
//don't try to attack targets in a sub that belongs to a different team
|
|
//(for example, targets in an outpost if we're in the main sub)
|
|
if (c.Submarine?.TeamID != Character.Submarine?.TeamID) { return false; }
|
|
if (c.IsPlayer || Character.IsOnFriendlyTeam(c))
|
|
{
|
|
return a.Damage >= currentTargetingParams.Threshold;
|
|
}
|
|
return true;
|
|
}
|
|
Character attacker = targetCharacter.LastAttackers.LastOrDefault(ShouldRetaliate)?.Character;
|
|
if (attacker?.AiTarget != null)
|
|
{
|
|
ChangeTargetState(attacker, AIState.Attack, currentTargetingParams.Priority * 2);
|
|
SelectTarget(attacker.AiTarget);
|
|
State = AIState.Attack;
|
|
UpdateWallTarget(requiredHoleCount);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
float distX = Math.Abs(WorldPosition.X - SelectedAiTarget.WorldPosition.X);
|
|
float distY = Math.Abs(WorldPosition.Y - SelectedAiTarget.WorldPosition.Y);
|
|
if (Character.Submarine != null && distY > 50 && SelectedAiTarget.Entity is Character targetC && !VisibleHulls.Contains(targetC.CurrentHull))
|
|
{
|
|
// Target not visible, and possibly on a different floor.
|
|
distY *= 3;
|
|
}
|
|
float dist = distX + distY;
|
|
float reactDist = GetPerceivingRange(SelectedAiTarget);
|
|
Vector2 offset = Vector2.Zero;
|
|
if (currentTargetingParams != null)
|
|
{
|
|
if (currentTargetingParams.ReactDistance > 0)
|
|
{
|
|
reactDist = currentTargetingParams.ReactDistance;
|
|
}
|
|
offset = currentTargetingParams.Offset;
|
|
}
|
|
if (offset != Vector2.Zero)
|
|
{
|
|
reactDist += offset.Length();
|
|
}
|
|
if (dist > reactDist + movementMargin)
|
|
{
|
|
movementMargin = State is AIState.FleeTo or AIState.HideTo or AIState.Hiding ? 0 : reactDist;
|
|
if (State == AIState.Hiding)
|
|
{
|
|
// Too far to hide.
|
|
State = AIState.HideTo;
|
|
}
|
|
run = true;
|
|
UpdateFollow(deltaTime);
|
|
}
|
|
else
|
|
{
|
|
if (State == AIState.HideTo)
|
|
{
|
|
// Close enough to hide.
|
|
State = AIState.Hiding;
|
|
}
|
|
movementMargin = MathHelper.Clamp(movementMargin -= deltaTime, 0, reactDist);
|
|
if (State is AIState.FleeTo or AIState.Hiding)
|
|
{
|
|
SteeringManager.Reset();
|
|
Character.AnimController.TargetMovement = Vector2.Zero;
|
|
if (Character.AnimController.InWater)
|
|
{
|
|
float force = Character.AnimController.Collider.Mass / 10;
|
|
Character.AnimController.Collider.MoveToPos(SelectedAiTarget.Entity.SimPosition + ConvertUnits.ToSimUnits(offset), force);
|
|
if (SelectedAiTarget.Entity is Item item)
|
|
{
|
|
float rotation = item.Rotation;
|
|
Character.AnimController.Collider.SmoothRotate(rotation, Character.AnimController.SwimFastParams.SteerTorque);
|
|
var mainLimb = Character.AnimController.MainLimb;
|
|
if (mainLimb.type == LimbType.Head)
|
|
{
|
|
mainLimb.body.SmoothRotate(rotation, Character.AnimController.SwimFastParams.HeadTorque);
|
|
}
|
|
else
|
|
{
|
|
mainLimb.body.SmoothRotate(rotation, Character.AnimController.SwimFastParams.TorsoTorque);
|
|
}
|
|
}
|
|
if (disableTailCoroutine == null && SelectedAiTarget.Entity is Item i && i.HasTag(Tags.GuardianShelter))
|
|
{
|
|
if (!CoroutineManager.IsCoroutineRunning(disableTailCoroutine))
|
|
{
|
|
disableTailCoroutine = CoroutineManager.Invoke(() =>
|
|
{
|
|
if (Character is { Removed: false })
|
|
{
|
|
Character.AnimController.HideAndDisable(LimbType.Tail, ignoreCollisions: false);
|
|
}
|
|
}, 1f);
|
|
}
|
|
}
|
|
Character.AnimController.ApplyPose(
|
|
new Vector2(0, -1),
|
|
new Vector2(0, -1),
|
|
new Vector2(0, -1),
|
|
new Vector2(0, -1), footMoveForce: 1);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
UpdateIdle(deltaTime);
|
|
}
|
|
}
|
|
break;
|
|
case AIState.Observe:
|
|
if (SelectedAiTarget?.Entity == null || SelectedAiTarget.Entity.Removed)
|
|
{
|
|
State = AIState.Idle;
|
|
return;
|
|
}
|
|
run = false;
|
|
float sqrDist = Vector2.DistanceSquared(WorldPosition, SelectedAiTarget.WorldPosition);
|
|
reactDist = currentTargetingParams is { ReactDistance: > 0 } ? currentTargetingParams.ReactDistance : GetPerceivingRange(SelectedAiTarget);
|
|
float halfReactDist = reactDist / 2;
|
|
float attackDist = currentTargetingParams is { AttackDistance: > 0 } ? currentTargetingParams.AttackDistance : halfReactDist;
|
|
if (sqrDist > Math.Pow(reactDist, 2))
|
|
{
|
|
// Too far to react
|
|
UpdateIdle(deltaTime);
|
|
}
|
|
else if (sqrDist < Math.Pow(attackDist + movementMargin, 2))
|
|
{
|
|
movementMargin = attackDist;
|
|
SteeringManager.Reset();
|
|
if (Character.AnimController.InWater)
|
|
{
|
|
useSteeringLengthAsMovementSpeed = true;
|
|
Vector2 dir = Vector2.Normalize(SelectedAiTarget.WorldPosition - Character.WorldPosition);
|
|
if (sqrDist < Math.Pow(attackDist * 0.75f, 2))
|
|
{
|
|
// Keep the distance, if too close
|
|
dir = -dir;
|
|
useSteeringLengthAsMovementSpeed = false;
|
|
Reverse = true;
|
|
run = true;
|
|
}
|
|
SteeringManager.SteeringManual(deltaTime, dir * 0.2f);
|
|
}
|
|
else
|
|
{
|
|
// TODO: doesn't work right here
|
|
FaceTarget(SelectedAiTarget.Entity);
|
|
}
|
|
observeTimer -= deltaTime;
|
|
if (observeTimer < 0)
|
|
{
|
|
IgnoreTarget(SelectedAiTarget);
|
|
State = AIState.Idle;
|
|
ResetAITarget();
|
|
}
|
|
}
|
|
else
|
|
{
|
|
run = sqrDist > Math.Pow(attackDist * 2, 2);
|
|
movementMargin = MathHelper.Clamp(movementMargin -= deltaTime, 0, attackDist);
|
|
UpdateFollow(deltaTime);
|
|
}
|
|
break;
|
|
default:
|
|
throw new NotImplementedException();
|
|
}
|
|
|
|
if (!Character.AnimController.SimplePhysicsEnabled)
|
|
{
|
|
LatchOntoAI?.Update(this, deltaTime);
|
|
}
|
|
IsSteeringThroughGap = false;
|
|
if (SwarmBehavior != null)
|
|
{
|
|
SwarmBehavior.IsActive = SwarmBehavior.ForceActive || State == AIState.Idle && Character.CurrentHull == null;
|
|
SwarmBehavior.Refresh();
|
|
SwarmBehavior.UpdateSteering(deltaTime);
|
|
}
|
|
// Ensure that the creature keeps inside the level
|
|
SteerInsideLevel(deltaTime);
|
|
float speed = Character.AnimController.GetCurrentSpeed(run && Character.CanRun);
|
|
// Doesn't work if less than 1, when we use steering length as movement speed.
|
|
steeringManager.Update(Math.Max(speed, 1.0f));
|
|
float movementSpeed = useSteeringLengthAsMovementSpeed ? Steering.Length() : speed;
|
|
Character.AnimController.TargetMovement = Character.ApplyMovementLimits(Steering, movementSpeed);
|
|
if (Character.CurrentHull != null && Character.AnimController.InWater)
|
|
{
|
|
// Limit the swimming speed inside the sub.
|
|
Character.AnimController.TargetMovement = Character.AnimController.TargetMovement.ClampLength(5);
|
|
}
|
|
}
|
|
|
|
//how often the character can try ragdolling to drop down
|
|
private const float MaxDroppingInterval = 5.0f;
|
|
|
|
//last time the character tried ragdolling to drop down
|
|
private double lastDroppingTime;
|
|
|
|
//how long the character can stay ragdolled to drop down
|
|
private const float MaxDroppingTime = 1.0f;
|
|
|
|
//timer for the duration of the ragdolling
|
|
private float droppingTimer;
|
|
|
|
private void HandleLaddersAndPlatforms(float deltaTime)
|
|
{
|
|
bool ignorePlatforms = Character.AnimController.TargetMovement.Y < -0.5f && (-Character.AnimController.TargetMovement.Y > Math.Abs(Character.AnimController.TargetMovement.X));
|
|
if (steeringManager == insideSteering)
|
|
{
|
|
var currPath = PathSteering.CurrentPath;
|
|
if (currPath is { CurrentNode: WayPoint currentNode })
|
|
{
|
|
Vector2 colliderBottom = Character.AnimController.GetColliderBottom();
|
|
if (Character.Submarine != currentNode.Submarine)
|
|
{
|
|
colliderBottom = Submarine.GetRelativeSimPosition(colliderBottom, currentNode.Submarine, Character.Submarine);
|
|
}
|
|
if (currentNode.SimPosition.Y < colliderBottom.Y)
|
|
{
|
|
// Don't allow to jump from too high.
|
|
float allowedJumpHeight = Character.AnimController.ImpactTolerance / 2;
|
|
Vector2 diff = currentNode.WorldPosition - Character.WorldPosition;
|
|
float height = ConvertUnits.ToSimUnits(Math.Abs(diff.Y));
|
|
ignorePlatforms = height < allowedJumpHeight;
|
|
|
|
//trying to head down ladders, but can't climb -> periodically try ragdolling to get down
|
|
//(may be required by large monsters like mudraptors to fit through hatches)
|
|
if (ignorePlatforms && !Character.CanClimb && PathSteering.IsCurrentNodeLadder &&
|
|
ConvertUnits.ToSimUnits(Math.Abs(diff.X)) < Character.AnimController.Collider.GetMaxExtent())
|
|
{
|
|
if (lastDroppingTime < Timing.TotalTime - MaxDroppingInterval)
|
|
{
|
|
Character.IsRagdolled = true;
|
|
Character.SetInput(InputType.Ragdoll, hit: false, held: true);
|
|
droppingTimer += deltaTime;
|
|
if (droppingTimer > MaxDroppingTime)
|
|
{
|
|
lastDroppingTime = Timing.TotalTime;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
droppingTimer = 0.0f;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (Character.IsClimbing && PathSteering.IsNextLadderSameAsCurrent)
|
|
{
|
|
Character.AnimController.TargetMovement = new Vector2(0.0f, Math.Sign(Character.AnimController.TargetMovement.Y));
|
|
}
|
|
}
|
|
Character.AnimController.IgnorePlatforms = ignorePlatforms;
|
|
}
|
|
|
|
#region Idle
|
|
|
|
private void UpdateIdle(float deltaTime, bool followLastTarget = true)
|
|
{
|
|
if (Character.AllowPlayDead && Character.Submarine != null)
|
|
{
|
|
if (playDeadTimer > 0)
|
|
{
|
|
playDeadTimer -= deltaTime;
|
|
}
|
|
else
|
|
{
|
|
State = AIState.PlayDead;
|
|
}
|
|
}
|
|
else if (AIParams.PatrolFlooded || AIParams.PatrolDry)
|
|
{
|
|
State = AIState.Patrol;
|
|
}
|
|
var pathSteering = SteeringManager as IndoorsSteeringManager;
|
|
if (pathSteering == null)
|
|
{
|
|
if (Level.Loaded != null && Level.Loaded.GetRealWorldDepth(WorldPosition.Y) > Character.CharacterHealth.CrushDepth * 0.75f)
|
|
{
|
|
// Steer straight up if very deep
|
|
SteeringManager.SteeringManual(deltaTime, Vector2.UnitY);
|
|
SteeringManager.SteeringAvoid(deltaTime, lookAheadDistance: avoidLookAheadDistance, weight: 5);
|
|
return;
|
|
}
|
|
}
|
|
if (followLastTarget)
|
|
{
|
|
var target = SelectedAiTarget ?? _lastAiTarget;
|
|
if (target?.Entity is { Removed: false } &&
|
|
PreviousState == AIState.Attack && Character.CurrentHull == null &&
|
|
(_previousAttackLimb?.attack == null ||
|
|
_previousAttackLimb?.attack is Attack previousAttack && (previousAttack.AfterAttack != AIBehaviorAfterAttack.FallBack || previousAttack.CoolDownTimer <= 0)))
|
|
{
|
|
// Keep heading to the last known position of the target
|
|
var memory = GetTargetMemory(target);
|
|
if (memory != null)
|
|
{
|
|
var location = memory.Location;
|
|
float dist = Vector2.DistanceSquared(WorldPosition, location);
|
|
if (dist < 50 * 50 || !IsPositionInsideAllowedZone(WorldPosition, out _))
|
|
{
|
|
// Target is gone
|
|
ResetAITarget();
|
|
}
|
|
else
|
|
{
|
|
// Steer towards the target
|
|
SteeringManager.SteeringSeek(Character.GetRelativeSimPosition(target.Entity, location), 5);
|
|
SteeringManager.SteeringAvoid(deltaTime, lookAheadDistance: avoidLookAheadDistance, weight: 15);
|
|
return;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
ResetAITarget();
|
|
}
|
|
}
|
|
}
|
|
if (!Character.IsClimbing)
|
|
{
|
|
if (pathSteering != null && !Character.AnimController.InWater)
|
|
{
|
|
// Wander around inside
|
|
pathSteering.Wander(deltaTime, Math.Max(ConvertUnits.ToDisplayUnits(colliderLength), 100.0f), stayStillInTightSpace: false);
|
|
}
|
|
else
|
|
{
|
|
// Wander around outside or swimming
|
|
steeringManager.SteeringWander(avoidWanderingOutsideLevel: true);
|
|
if (Character.AnimController.InWater)
|
|
{
|
|
SteeringManager.SteeringAvoid(deltaTime, lookAheadDistance: avoidLookAheadDistance, weight: 5);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private readonly List<Hull> targetHulls = new List<Hull>();
|
|
private readonly List<float> hullWeights = new List<float>();
|
|
|
|
private Hull patrolTarget;
|
|
private float newPatrolTargetTimer;
|
|
private float patrolTimerMargin;
|
|
private readonly float newPatrolTargetIntervalMin = 5;
|
|
private readonly float newPatrolTargetIntervalMax = 30;
|
|
private bool searchingNewHull;
|
|
|
|
private void UpdatePatrol(float deltaTime, bool followLastTarget = true)
|
|
{
|
|
if (SteeringManager is IndoorsSteeringManager pathSteering)
|
|
{
|
|
if (patrolTarget == null || IsCurrentPathUnreachable || IsCurrentPathFinished)
|
|
{
|
|
newPatrolTargetTimer = Math.Min(newPatrolTargetTimer, newPatrolTargetIntervalMin);
|
|
}
|
|
if (newPatrolTargetTimer > 0)
|
|
{
|
|
newPatrolTargetTimer -= deltaTime;
|
|
}
|
|
else
|
|
{
|
|
if (!searchingNewHull)
|
|
{
|
|
searchingNewHull = true;
|
|
FindTargetHulls();
|
|
}
|
|
else if (targetHulls.Any())
|
|
{
|
|
patrolTarget = ToolBox.SelectWeightedRandom(targetHulls, hullWeights, Rand.RandSync.Unsynced);
|
|
var path = PathSteering.PathFinder.FindPath(Character.SimPosition, patrolTarget.SimPosition, Character.Submarine, minGapSize: minGapSize * 1.5f, nodeFilter: n => PatrolNodeFilter(n));
|
|
if (path.Unreachable)
|
|
{
|
|
//can't go to this room, remove it from the list and try another room
|
|
int index = targetHulls.IndexOf(patrolTarget);
|
|
targetHulls.RemoveAt(index);
|
|
hullWeights.RemoveAt(index);
|
|
PathSteering.Reset();
|
|
patrolTarget = null;
|
|
patrolTimerMargin += 0.5f;
|
|
patrolTimerMargin = Math.Min(patrolTimerMargin, newPatrolTargetIntervalMin);
|
|
newPatrolTargetTimer = Math.Min(newPatrolTargetIntervalMin, patrolTimerMargin);
|
|
}
|
|
else
|
|
{
|
|
PathSteering.SetPath(patrolTarget.SimPosition, path);
|
|
patrolTimerMargin = 0;
|
|
newPatrolTargetTimer = newPatrolTargetIntervalMax * Rand.Range(0.5f, 1.5f);
|
|
searchingNewHull = false;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// Couldn't find a valid hull
|
|
newPatrolTargetTimer = newPatrolTargetIntervalMax;
|
|
searchingNewHull = false;
|
|
}
|
|
}
|
|
if (patrolTarget != null && pathSteering.CurrentPath is { Finished: false, Unreachable: false })
|
|
{
|
|
pathSteering.SteeringSeek(Character.GetRelativeSimPosition(patrolTarget), weight: 1, minGapWidth: minGapSize * 1.5f, nodeFilter: PatrolNodeFilter);
|
|
return;
|
|
}
|
|
}
|
|
|
|
bool PatrolNodeFilter(PathNode n) =>
|
|
AIParams.PatrolFlooded && (Character.CurrentHull == null || n.Waypoint.CurrentHull == null || n.Waypoint.CurrentHull.WaterPercentage >= 80) ||
|
|
AIParams.PatrolDry && Character.CurrentHull != null && n.Waypoint.CurrentHull != null && n.Waypoint.CurrentHull.WaterPercentage <= 50;
|
|
|
|
UpdateIdle(deltaTime, followLastTarget);
|
|
}
|
|
|
|
private void FindTargetHulls()
|
|
{
|
|
if (Character.Submarine == null) { return; }
|
|
if (Character.CurrentHull == null) { return; }
|
|
targetHulls.Clear();
|
|
hullWeights.Clear();
|
|
float hullMinSize = ConvertUnits.ToDisplayUnits(Math.Max(colliderLength, colliderWidth) * 2);
|
|
bool checkWaterLevel = !AIParams.PatrolFlooded || !AIParams.PatrolDry;
|
|
foreach (var hull in Hull.HullList)
|
|
{
|
|
if (hull.Submarine == null) { continue; }
|
|
if (hull.Submarine.TeamID != Character.Submarine.TeamID) { continue; }
|
|
if (!Character.Submarine.IsConnectedTo(hull.Submarine)) { continue; }
|
|
if (hull.RectWidth < hullMinSize || hull.RectHeight < hullMinSize) { continue; }
|
|
if (checkWaterLevel)
|
|
{
|
|
if (AIParams.PatrolDry)
|
|
{
|
|
if (hull.WaterPercentage > 50) { continue; }
|
|
}
|
|
if (AIParams.PatrolFlooded)
|
|
{
|
|
if (hull.WaterPercentage < 80) { continue; }
|
|
}
|
|
}
|
|
if (AIParams.PatrolDry && hull.WaterPercentage < 80)
|
|
{
|
|
if (Math.Abs(Character.CurrentHull.WorldPosition.Y - hull.WorldPosition.Y) > Character.CurrentHull.CeilingHeight / 2)
|
|
{
|
|
// Ignore dry hulls that are on a different level
|
|
continue;
|
|
}
|
|
}
|
|
if (!targetHulls.Contains(hull))
|
|
{
|
|
targetHulls.Add(hull);
|
|
float weight = hull.Size.Combine();
|
|
float dist = Vector2.Distance(Character.WorldPosition, hull.WorldPosition);
|
|
float optimal = 1000;
|
|
float max = 3000;
|
|
// Prefer rooms that are far but not too far.
|
|
float distanceFactor = dist > optimal ? MathHelper.Lerp(1, 0, MathUtils.InverseLerp(optimal, max, dist)) : MathHelper.Lerp(0, 1, MathUtils.InverseLerp(0, optimal, dist));
|
|
float waterFactor = 1;
|
|
if (checkWaterLevel)
|
|
{
|
|
waterFactor = AIParams.PatrolDry ? MathHelper.Lerp(1, 0, MathUtils.InverseLerp(0, 100, hull.WaterPercentage)) : MathHelper.Lerp(0, 1, MathUtils.InverseLerp(0, 100, hull.WaterPercentage));
|
|
}
|
|
weight *= distanceFactor * waterFactor;
|
|
hullWeights.Add(weight);
|
|
}
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Attack
|
|
|
|
private Vector2 attackWorldPos;
|
|
private Vector2 attackSimPos;
|
|
private float reachTimer;
|
|
// How long the monster tries to reach out for the target when it's close to it before ignoring it.
|
|
private const float reachTimeOut = 10;
|
|
|
|
private bool IsSameTarget(AITarget target, AITarget otherTarget)
|
|
{
|
|
if (target?.Entity == otherTarget?.Entity) { return true; }
|
|
if (IsItemInCharacterInventory(target, otherTarget) || IsItemInCharacterInventory(otherTarget, target)) { return true; }
|
|
return false;
|
|
|
|
bool IsItemInCharacterInventory(AITarget potentialItem, AITarget potentialCharacter)
|
|
{
|
|
if (potentialItem?.Entity is Item item && potentialCharacter?.Entity is Character character)
|
|
{
|
|
return item.ParentInventory?.Owner == character;
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private void UpdateAttack(float deltaTime)
|
|
{
|
|
if (SelectedAiTarget?.Entity == null || SelectedAiTarget.Entity.Removed || currentTargetingParams == null)
|
|
{
|
|
State = AIState.Idle;
|
|
return;
|
|
}
|
|
|
|
if (Character.IsAttachedToController()) { return; }
|
|
|
|
attackWorldPos = SelectedAiTarget.WorldPosition;
|
|
attackSimPos = SelectedAiTarget.SimPosition;
|
|
|
|
if (SelectedAiTarget.Entity is Item item)
|
|
{
|
|
// If the item is held by a character, attack the character instead.
|
|
Character owner = GetOwner(item);
|
|
if (owner != null)
|
|
{
|
|
if (Character.IsFriendly(owner) || owner.HasAbilityFlag(AbilityFlags.IgnoredByEnemyAI))
|
|
{
|
|
ResetAITarget();
|
|
State = AIState.Idle;
|
|
return;
|
|
}
|
|
else
|
|
{
|
|
SelectedAiTarget = owner.AiTarget;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (wallTarget != null)
|
|
{
|
|
attackWorldPos = wallTarget.Position;
|
|
if (wallTarget.Structure.Submarine != null)
|
|
{
|
|
attackWorldPos += wallTarget.Structure.Submarine.Position;
|
|
}
|
|
attackSimPos = Character.Submarine == wallTarget.Structure.Submarine ? wallTarget.Position : attackWorldPos;
|
|
attackSimPos = ConvertUnits.ToSimUnits(attackSimPos);
|
|
}
|
|
else
|
|
{
|
|
attackSimPos = Character.GetRelativeSimPosition(SelectedAiTarget.Entity);
|
|
}
|
|
|
|
if (Character.AnimController.CanEnterSubmarine == CanEnterSubmarine.True)
|
|
{
|
|
if (TrySteerThroughGaps(deltaTime))
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
else if (SelectedAiTarget.Entity is Structure w && wallTarget == null)
|
|
{
|
|
bool isBroken = true;
|
|
for (int i = 0; i < w.Sections.Length; i++)
|
|
{
|
|
if (!w.SectionBodyDisabled(i))
|
|
{
|
|
isBroken = false;
|
|
Vector2 sectionPos = w.SectionPosition(i, world: true);
|
|
attackWorldPos = sectionPos;
|
|
attackSimPos = ConvertUnits.ToSimUnits(attackWorldPos);
|
|
break;
|
|
}
|
|
}
|
|
if (isBroken)
|
|
{
|
|
IgnoreTarget(SelectedAiTarget);
|
|
State = AIState.Idle;
|
|
ResetAITarget();
|
|
return;
|
|
}
|
|
}
|
|
attackLimbSelectionTimer -= deltaTime;
|
|
if (AttackLimb == null || attackLimbSelectionTimer <= 0)
|
|
{
|
|
attackLimbSelectionTimer = attackLimbSelectionInterval * Rand.Range(0.9f, 1.1f);
|
|
if (!IsAttackRunning && !IsCoolDownRunning)
|
|
{
|
|
AttackLimb = GetAttackLimb(attackWorldPos);
|
|
}
|
|
}
|
|
Character targetCharacter = SelectedAiTarget.Entity as Character;
|
|
IDamageable damageTarget = wallTarget != null ? wallTarget.Structure : SelectedAiTarget.Entity as IDamageable;
|
|
bool canAttack = !Character.IsClimbing;
|
|
bool pursue = false;
|
|
if (IsCoolDownRunning && (_previousAttackLimb == null || AttackLimb == null || AttackLimb.attack.CoolDownTimer > 0))
|
|
{
|
|
var currentAttackLimb = AttackLimb ?? _previousAttackLimb;
|
|
if (currentAttackLimb.attack.CoolDownTimer >=
|
|
currentAttackLimb.attack.CoolDown + currentAttackLimb.attack.CurrentRandomCoolDown - currentAttackLimb.attack.AfterAttackDelay)
|
|
{
|
|
return;
|
|
}
|
|
currentAttackLimb.attack.AfterAttackTimer += deltaTime;
|
|
AIBehaviorAfterAttack activeBehavior =
|
|
currentAttackLimb.attack.AfterAttackSecondaryDelay > 0 && currentAttackLimb.attack.AfterAttackTimer > currentAttackLimb.attack.AfterAttackSecondaryDelay ?
|
|
currentAttackLimb.attack.AfterAttackSecondary :
|
|
currentAttackLimb.attack.AfterAttack;
|
|
switch (activeBehavior)
|
|
{
|
|
case AIBehaviorAfterAttack.Eat:
|
|
if (currentAttackLimb.IsSevered)
|
|
{
|
|
ReleaseEatingTarget();
|
|
}
|
|
else
|
|
{
|
|
UpdateEating(deltaTime);
|
|
}
|
|
return;
|
|
case AIBehaviorAfterAttack.Pursue:
|
|
case AIBehaviorAfterAttack.PursueIfCanAttack:
|
|
if (currentAttackLimb.attack.SecondaryCoolDown <= 0)
|
|
{
|
|
// No (valid) secondary cooldown defined.
|
|
if (activeBehavior == AIBehaviorAfterAttack.Pursue)
|
|
{
|
|
canAttack = false;
|
|
pursue = true;
|
|
}
|
|
else
|
|
{
|
|
UpdateFallBack(attackWorldPos, deltaTime, followThrough: true);
|
|
return;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
if (currentAttackLimb.attack.SecondaryCoolDownTimer <= 0)
|
|
{
|
|
// Don't allow attacking when the attack target has just changed.
|
|
if (_previousAiTarget != null && !IsSameTarget(SelectedAiTarget, _previousAiTarget))
|
|
{
|
|
canAttack = false;
|
|
if (activeBehavior == AIBehaviorAfterAttack.PursueIfCanAttack)
|
|
{
|
|
// Fall back if cannot attack.
|
|
UpdateFallBack(attackWorldPos, deltaTime, followThrough: true);
|
|
return;
|
|
}
|
|
AttackLimb = null;
|
|
}
|
|
else
|
|
{
|
|
// If the secondary cooldown is defined and expired, check if we can switch the attack
|
|
var newLimb = GetAttackLimb(attackWorldPos, currentAttackLimb);
|
|
if (newLimb != null)
|
|
{
|
|
// Attack with the new limb
|
|
AttackLimb = newLimb;
|
|
}
|
|
else
|
|
{
|
|
// No new limb was found.
|
|
if (activeBehavior == AIBehaviorAfterAttack.Pursue)
|
|
{
|
|
canAttack = false;
|
|
pursue = true;
|
|
}
|
|
else
|
|
{
|
|
UpdateFallBack(attackWorldPos, deltaTime, followThrough: true);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// Cooldown not yet expired, cannot attack -> steer towards the target
|
|
canAttack = false;
|
|
}
|
|
}
|
|
break;
|
|
case AIBehaviorAfterAttack.FallBackUntilCanAttack:
|
|
case AIBehaviorAfterAttack.FollowThroughUntilCanAttack:
|
|
case AIBehaviorAfterAttack.ReverseUntilCanAttack:
|
|
if (activeBehavior == AIBehaviorAfterAttack.ReverseUntilCanAttack)
|
|
{
|
|
Reverse = true;
|
|
}
|
|
if (currentAttackLimb.attack.SecondaryCoolDown <= 0)
|
|
{
|
|
// No (valid) secondary cooldown defined.
|
|
UpdateFallBack(attackWorldPos, deltaTime, activeBehavior == AIBehaviorAfterAttack.FollowThroughUntilCanAttack);
|
|
return;
|
|
}
|
|
else
|
|
{
|
|
if (currentAttackLimb.attack.SecondaryCoolDownTimer <= 0)
|
|
{
|
|
// Don't allow attacking when the attack target has just changed.
|
|
if (_previousAiTarget != null && !IsSameTarget(SelectedAiTarget, _previousAiTarget))
|
|
{
|
|
UpdateFallBack(attackWorldPos, deltaTime, activeBehavior == AIBehaviorAfterAttack.FollowThroughUntilCanAttack);
|
|
return;
|
|
}
|
|
// If the secondary cooldown is defined and expired, check if we can switch the attack
|
|
var newLimb = GetAttackLimb(attackWorldPos, currentAttackLimb);
|
|
if (newLimb != null)
|
|
{
|
|
// Attack with the new limb
|
|
AttackLimb = newLimb;
|
|
}
|
|
else
|
|
{
|
|
// No new limb was found.
|
|
UpdateFallBack(attackWorldPos, deltaTime, activeBehavior == AIBehaviorAfterAttack.FollowThroughUntilCanAttack);
|
|
return;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// Cooldown not yet expired -> steer away from the target
|
|
UpdateFallBack(attackWorldPos, deltaTime, activeBehavior == AIBehaviorAfterAttack.FollowThroughUntilCanAttack);
|
|
return;
|
|
}
|
|
}
|
|
break;
|
|
case AIBehaviorAfterAttack.IdleUntilCanAttack:
|
|
if (currentAttackLimb.attack.SecondaryCoolDown <= 0)
|
|
{
|
|
// No (valid) secondary cooldown defined.
|
|
UpdateIdle(deltaTime, followLastTarget: false);
|
|
return;
|
|
}
|
|
else
|
|
{
|
|
if (currentAttackLimb.attack.SecondaryCoolDownTimer <= 0)
|
|
{
|
|
// Don't allow attacking when the attack target has just changed.
|
|
if (_previousAiTarget != null && !IsSameTarget(SelectedAiTarget, _previousAiTarget))
|
|
{
|
|
UpdateIdle(deltaTime, followLastTarget: false);
|
|
return;
|
|
}
|
|
// If the secondary cooldown is defined and expired, check if we can switch the attack
|
|
var newLimb = GetAttackLimb(attackWorldPos, currentAttackLimb);
|
|
if (newLimb != null)
|
|
{
|
|
// Attack with the new limb
|
|
AttackLimb = newLimb;
|
|
}
|
|
else
|
|
{
|
|
// No new limb was found.
|
|
UpdateIdle(deltaTime, followLastTarget: false);
|
|
return;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// Cooldown not yet expired
|
|
UpdateIdle(deltaTime, followLastTarget: false);
|
|
return;
|
|
}
|
|
}
|
|
break;
|
|
case AIBehaviorAfterAttack.FollowThrough:
|
|
UpdateFallBack(attackWorldPos, deltaTime, followThrough: true);
|
|
return;
|
|
case AIBehaviorAfterAttack.FollowThroughWithoutObstacleAvoidance:
|
|
UpdateFallBack(attackWorldPos, deltaTime, followThrough: true, avoidObstacles: false);
|
|
return;
|
|
case AIBehaviorAfterAttack.FallBack:
|
|
case AIBehaviorAfterAttack.Reverse:
|
|
default:
|
|
if (activeBehavior == AIBehaviorAfterAttack.Reverse)
|
|
{
|
|
Reverse = true;
|
|
}
|
|
UpdateFallBack(attackWorldPos, deltaTime, followThrough: false);
|
|
return;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
attackVector = null;
|
|
}
|
|
|
|
if (canAttack)
|
|
{
|
|
if (AttackLimb is { IsSevered: true })
|
|
{
|
|
AttackLimb = null;
|
|
}
|
|
if (AttackLimb == null || !IsValidAttack(AttackLimb, Character.GetAttackContexts(), SelectedAiTarget?.Entity))
|
|
{
|
|
AttackLimb = GetAttackLimb(attackWorldPos);
|
|
}
|
|
canAttack = AttackLimb != null && AttackLimb.attack.CoolDownTimer <= 0;
|
|
}
|
|
|
|
if (!AIParams.CanOpenDoors)
|
|
{
|
|
if (!Character.AnimController.SimplePhysicsEnabled && SelectedAiTarget.Entity.Submarine != null && Character.Submarine == null && (!canAttackDoors || !canAttackWalls || !AIParams.TargetOuterWalls))
|
|
{
|
|
if (wallTarget == null && Vector2.DistanceSquared(Character.WorldPosition, attackWorldPos) < 2000 * 2000)
|
|
{
|
|
// Check that we are not bumping into a door or a wall
|
|
Vector2 rayStart = SimPosition;
|
|
if (Character.Submarine == null)
|
|
{
|
|
rayStart -= SelectedAiTarget.Entity.Submarine.SimPosition;
|
|
}
|
|
Vector2 dir = SelectedAiTarget.WorldPosition - WorldPosition;
|
|
Vector2 rayEnd = rayStart + dir.ClampLength(Character.AnimController.Collider.GetLocalFront().Length() * 2);
|
|
Body closestBody = Submarine.CheckVisibility(rayStart, rayEnd, ignoreSubs: true);
|
|
if (Submarine.LastPickedFraction != 1.0f && closestBody != null &&
|
|
((!AIParams.TargetOuterWalls || !canAttackWalls) && closestBody.UserData is Structure s && s.Submarine != null || !canAttackDoors && closestBody.UserData is Item i && i.Submarine != null && i.GetComponent<Door>() != null))
|
|
{
|
|
// Target is unreachable, there's a door or wall ahead
|
|
State = AIState.Idle;
|
|
IgnoreTarget(SelectedAiTarget);
|
|
ResetAITarget();
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
float distance = 0;
|
|
Limb attackTargetLimb = null;
|
|
if (canAttack)
|
|
{
|
|
if (!Character.AnimController.SimplePhysicsEnabled)
|
|
{
|
|
// Target a specific limb instead of the target center position
|
|
if (wallTarget == null && targetCharacter != null)
|
|
{
|
|
var targetLimbType = AttackLimb.Params.Attack.Attack.TargetLimbType;
|
|
attackTargetLimb = GetTargetLimb(AttackLimb, targetCharacter, targetLimbType);
|
|
if (attackTargetLimb == null)
|
|
{
|
|
State = AIState.Idle;
|
|
IgnoreTarget(SelectedAiTarget);
|
|
ResetAITarget();
|
|
return;
|
|
}
|
|
attackWorldPos = attackTargetLimb.WorldPosition;
|
|
attackSimPos = Character.GetRelativeSimPosition(attackTargetLimb);
|
|
}
|
|
}
|
|
Vector2 attackLimbPos = Character.AnimController.SimplePhysicsEnabled ? Character.WorldPosition : AttackLimb.WorldPosition;
|
|
Vector2 toTarget = attackWorldPos - attackLimbPos;
|
|
Vector2 toTargetOffset = toTarget;
|
|
// Add a margin when the target is moving away, because otherwise it might be difficult to reach it if the attack takes some time to execute
|
|
if (Character.Submarine == null)
|
|
{
|
|
if (wallTarget != null)
|
|
{
|
|
if (wallTarget.Structure.Submarine != null)
|
|
{
|
|
Vector2 margin = CalculateMargin(wallTarget.Structure.Submarine.Velocity);
|
|
toTargetOffset += margin;
|
|
}
|
|
}
|
|
else if (targetCharacter != null)
|
|
{
|
|
Vector2 margin = CalculateMargin(targetCharacter.AnimController.Collider.LinearVelocity);
|
|
toTargetOffset += margin;
|
|
}
|
|
else if (SelectedAiTarget.Entity is MapEntity { Submarine: not null } e)
|
|
{
|
|
Vector2 margin = CalculateMargin(e.Submarine.Velocity);
|
|
toTargetOffset += margin;
|
|
}
|
|
}
|
|
|
|
Vector2 CalculateMargin(Vector2 targetVelocity)
|
|
{
|
|
if (targetVelocity == Vector2.Zero) { return Vector2.Zero; }
|
|
float diff = AttackLimb.attack.Range - AttackLimb.attack.DamageRange;
|
|
if (diff <= 0 || toTargetOffset.LengthSquared() <= MathUtils.Pow2(AttackLimb.attack.DamageRange)) { return Vector2.Zero; }
|
|
float dot = Vector2.Dot(Vector2.Normalize(targetVelocity), Vector2.Normalize(Character.AnimController.Collider.LinearVelocity));
|
|
if (dot <= 0 || !MathUtils.IsValid(dot)) { return Vector2.Zero; }
|
|
float distanceOffset = diff * AttackLimb.attack.Duration;
|
|
// Intentionally omit the unit conversion because we use distanceOffset as a multiplier.
|
|
return targetVelocity * distanceOffset * dot;
|
|
}
|
|
|
|
// Check that we can reach the target
|
|
distance = toTargetOffset.Length();
|
|
if (canAttack)
|
|
{
|
|
canAttack = distance < AttackLimb.attack.Range;
|
|
}
|
|
if (canAttack && !Character.InWater && Character.AnimController.CanWalk)
|
|
{
|
|
// On ground, ensure that the monster is facing the target, so that they don't hit the target while standing with their back towards it.
|
|
// In water, we don't want such checks, because it's ok for the monsters to attack targets on their sides and even behind them.
|
|
if (!Character.IsFacing(attackWorldPos))
|
|
{
|
|
canAttack = false;
|
|
}
|
|
}
|
|
if (canAttack)
|
|
{
|
|
reachTimer = 0;
|
|
if (IsAggressiveBoarder)
|
|
{
|
|
if (SelectedAiTarget.Entity is Item i && i.GetComponent<Door>() is Door { CanBeTraversed: true })
|
|
{
|
|
// Don't attack open doors, just steer through them.
|
|
canAttack = false;
|
|
}
|
|
}
|
|
}
|
|
else if (currentTargetingParams.AttackPattern == AttackPattern.Straight && distance < AttackLimb.attack.Range * 5)
|
|
{
|
|
Vector2 targetVelocity = Vector2.Zero;
|
|
Submarine targetSub = SelectedAiTarget.Entity.Submarine;
|
|
if (targetSub != null)
|
|
{
|
|
targetVelocity = targetSub.Velocity;
|
|
}
|
|
else if (targetCharacter != null)
|
|
{
|
|
targetVelocity = targetCharacter.AnimController.Collider.LinearVelocity;
|
|
}
|
|
else if (SelectedAiTarget.Entity is Item i && i.body != null)
|
|
{
|
|
targetVelocity = i.body.LinearVelocity;
|
|
}
|
|
float mySpeed = Character.AnimController.Collider.LinearVelocity.LengthSquared();
|
|
float targetSpeed = targetVelocity.LengthSquared();
|
|
if (mySpeed < 0.1f || mySpeed > targetSpeed)
|
|
{
|
|
reachTimer += deltaTime;
|
|
if (reachTimer > reachTimeOut)
|
|
{
|
|
reachTimer = 0;
|
|
IgnoreTarget(SelectedAiTarget);
|
|
State = AIState.Idle;
|
|
ResetAITarget();
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Crouch if the target is down (only humanoids), so that we can reach it.
|
|
if (Character.AnimController is HumanoidAnimController humanoidAnimController && distance < AttackLimb.attack.Range * 2)
|
|
{
|
|
if (targetCharacter?.CurrentHull is Hull targetHull && targetHull == Character.CurrentHull && toTarget.Y < 0)
|
|
{
|
|
if (Math.Abs(toTarget.Y) > AttackLimb.attack.Range / 2 && Math.Abs(toTarget.X) <= AttackLimb.attack.Range)
|
|
{
|
|
humanoidAnimController.Crouch();
|
|
}
|
|
}
|
|
}
|
|
|
|
if (canAttack)
|
|
{
|
|
if (AttackLimb.attack.Ranged)
|
|
{
|
|
// Check that is facing the target
|
|
float offset = AttackLimb.Params.GetSpriteOrientation() - MathHelper.PiOver2;
|
|
Vector2 forward = VectorExtensions.Forward(AttackLimb.body.TransformedRotation - offset * Character.AnimController.Dir);
|
|
float angle = VectorExtensions.Angle(forward, toTarget);
|
|
canAttack = angle < MathHelper.ToRadians(AttackLimb.attack.RequiredAngle);
|
|
if (canAttack && AttackLimb.attack.AvoidFriendlyFire)
|
|
{
|
|
canAttack = !IsBlocked(Character.GetRelativeSimPosition(SelectedAiTarget.Entity));
|
|
bool IsBlocked(Vector2 targetPosition)
|
|
{
|
|
foreach (var body in Submarine.PickBodies(AttackLimb.SimPosition, targetPosition, myBodies, Physics.CollisionCharacter))
|
|
{
|
|
Character hitTarget = null;
|
|
if (body.UserData is Character c)
|
|
{
|
|
hitTarget = c;
|
|
}
|
|
else if (body.UserData is Limb limb)
|
|
{
|
|
hitTarget = limb.character;
|
|
}
|
|
if (hitTarget != null && !hitTarget.IsDead && Character.IsFriendly(hitTarget) && !IsAttackingOwner(hitTarget))
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
else if (wallTarget == null && Character.CurrentHull != null && targetCharacter != null)
|
|
{
|
|
canAttack = Submarine.PickBody(SimPosition, attackSimPos, collisionCategory: Physics.CollisionWall) == null;
|
|
}
|
|
}
|
|
}
|
|
Limb steeringLimb = canAttack && !AttackLimb.attack.Ranged ? AttackLimb : null;
|
|
bool updateSteering = true;
|
|
if (steeringLimb == null)
|
|
{
|
|
// If the attacking limb is a hand or claw, for example, using it as the steering limb can end in the result where the character circles around the target.
|
|
steeringLimb = Character.AnimController.GetLimb(LimbType.Head) ?? Character.AnimController.GetLimb(LimbType.Torso);
|
|
}
|
|
if (steeringLimb == null)
|
|
{
|
|
State = AIState.Idle;
|
|
return;
|
|
}
|
|
// Note that this returns null when we don't currently use IndoorsSteeringManager, even if we are able to path!
|
|
// -> Don't use PathSteering, because that returns the reference even if we currently don't use it.
|
|
var pathSteering = SteeringManager as IndoorsSteeringManager;
|
|
if (AttackLimb != null && AttackLimb.attack.Retreat)
|
|
{
|
|
UpdateFallBack(attackWorldPos, deltaTime, followThrough: false);
|
|
}
|
|
else
|
|
{
|
|
if (pathSteering != null)
|
|
{
|
|
// Attack doors
|
|
if (canAttackDoors && HasValidPath())
|
|
{
|
|
// If the target is in the same hull, there shouldn't be any doors blocking the path
|
|
if (targetCharacter == null || targetCharacter.CurrentHull != Character.CurrentHull)
|
|
{
|
|
var door = pathSteering.CurrentPath.CurrentNode?.ConnectedDoor ?? pathSteering.CurrentPath.NextNode?.ConnectedDoor;
|
|
if (door is { CanBeTraversed: false } && (!Character.IsInFriendlySub || !door.HasAccess(Character)))
|
|
{
|
|
if (door.Item.AiTarget != null && SelectedAiTarget != door.Item.AiTarget)
|
|
{
|
|
SelectTarget(door.Item.AiTarget, currentTargetMemory.Priority);
|
|
State = AIState.Attack;
|
|
AttackLimb = null;
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// When pursuing, we don't want to pursue too close
|
|
float max = 300;
|
|
float margin = AttackLimb != null ? Math.Min(AttackLimb.attack.Range * 0.9f, max) : max;
|
|
if ((!canAttack || distance > margin) && !IsTryingToSteerThroughGap)
|
|
{
|
|
bool useManualSteering = false;
|
|
// Steer towards the target if in the same room and swimming
|
|
// Ruins have walls/pillars inside hulls and therefore we should navigate around them using the path steering.
|
|
if (Character.CurrentHull != null &&
|
|
Character.Submarine != null && !Character.Submarine.Info.IsRuin &&
|
|
(Character.AnimController.InWater || pursue || !Character.AnimController.CanWalk) &&
|
|
targetCharacter != null && VisibleHulls.Contains(targetCharacter.CurrentHull))
|
|
{
|
|
if (CanSeeTarget(targetCharacter))
|
|
{
|
|
useManualSteering = true;
|
|
}
|
|
}
|
|
if (useManualSteering)
|
|
{
|
|
Vector2 myPos = Character.AnimController.SimplePhysicsEnabled ? Character.SimPosition : steeringLimb.SimPosition;
|
|
SteeringManager.SteeringManual(deltaTime, Vector2.Normalize(attackSimPos - myPos));
|
|
}
|
|
else
|
|
{
|
|
Func<PathNode, bool> nodeFilter = null;
|
|
float outsideNodePenalty = 0;
|
|
if (Character.CurrentHull != null && Character.IsInPlayerSub)
|
|
{
|
|
// Prefer not to path back outside, if we are inside player sub.
|
|
// Fixes monsters sometimes pathing from the airlock to another airlock on the other side of the sub, because the path is technically cheaper than the path through the interiors.
|
|
outsideNodePenalty = 50;
|
|
}
|
|
pathSteering.SteeringSeek(Character.GetRelativeSimPosition(SelectedAiTarget.Entity), weight: 2,
|
|
minGapWidth: minGapSize,
|
|
startNodeFilter: n => (n.Waypoint.CurrentHull == null) == (Character.CurrentHull == null),
|
|
nodeFilter: nodeFilter,
|
|
checkVisiblity: true,
|
|
outsideNodePenalty: outsideNodePenalty);
|
|
|
|
if (pathSteering.CurrentPath != null)
|
|
{
|
|
if (pathSteering.IsPathDirty)
|
|
{
|
|
if (Character.CurrentHull is Hull hull && hull.ConnectedGaps.Any(static g => !g.IsRoomToRoom && g.Open >= 1.0f && g.ConnectedDoor != null))
|
|
{
|
|
// Reset in the airlock, because otherwise the character may be too slow to change the steering and keep moving outside.
|
|
SteeringManager.Reset();
|
|
}
|
|
// Steer towards the target
|
|
SteeringManager.SteeringManual(deltaTime, Vector2.Normalize(SelectedAiTarget.Entity.WorldPosition - Character.WorldPosition));
|
|
}
|
|
else if (pathSteering.CurrentPath.Unreachable)
|
|
{
|
|
State = AIState.Idle;
|
|
IgnoreTarget(SelectedAiTarget);
|
|
ResetAITarget();
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
else if (!IsTryingToSteerThroughGap)
|
|
{
|
|
if (AttackLimb.attack.Ranged)
|
|
{
|
|
float dir = Character.AnimController.Dir;
|
|
if (dir > 0 && attackWorldPos.X > AttackLimb.WorldPosition.X + margin || dir < 0 && attackWorldPos.X < AttackLimb.WorldPosition.X - margin)
|
|
{
|
|
SteeringManager.Reset();
|
|
}
|
|
else
|
|
{
|
|
// Too close
|
|
UpdateFallBack(attackWorldPos, deltaTime, followThrough: false);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// Close enough
|
|
SteeringManager.Reset();
|
|
}
|
|
}
|
|
else
|
|
{
|
|
SteeringManager.SteeringManual(deltaTime, Vector2.Normalize(SelectedAiTarget.Entity.WorldPosition - Character.WorldPosition));
|
|
}
|
|
}
|
|
else
|
|
{
|
|
Vector2 steerPos = attackSimPos;
|
|
if (!Character.AnimController.SimplePhysicsEnabled)
|
|
{
|
|
// Offset so that we don't overshoot the movement
|
|
Vector2 offset = Character.SimPosition - steeringLimb.SimPosition;
|
|
steerPos += offset;
|
|
}
|
|
// Sweeping and circling doesn't work well inside
|
|
if (Character.CurrentHull == null)
|
|
{
|
|
switch (currentTargetingParams.AttackPattern)
|
|
{
|
|
case AttackPattern.Sweep:
|
|
if (currentTargetingParams.SweepDistance > 0)
|
|
{
|
|
if (distance <= 0)
|
|
{
|
|
distance = (attackWorldPos - WorldPosition).Length();
|
|
}
|
|
float amplitude = MathHelper.Lerp(0, currentTargetingParams.SweepStrength, MathUtils.InverseLerp(currentTargetingParams.SweepDistance, 0, distance));
|
|
if (amplitude > 0)
|
|
{
|
|
sweepTimer += deltaTime * currentTargetingParams.SweepSpeed;
|
|
float sin = (float)Math.Sin(sweepTimer) * amplitude;
|
|
steerPos = MathUtils.RotatePointAroundTarget(attackSimPos, SimPosition, sin);
|
|
}
|
|
else
|
|
{
|
|
sweepTimer = Rand.Range(-1000f, 1000f) * currentTargetingParams.SweepSpeed;
|
|
}
|
|
}
|
|
break;
|
|
case AttackPattern.Circle:
|
|
if (IsCoolDownRunning) { break; }
|
|
if (IsAttackRunning && CirclePhase != CirclePhase.Strike) { break; }
|
|
if (currentTargetingParams == null) { break; }
|
|
var targetSub = SelectedAiTarget.Entity?.Submarine;
|
|
ISpatialEntity spatialTarget = targetSub ?? SelectedAiTarget.Entity;
|
|
float targetSize = 0;
|
|
if (!currentTargetingParams.IgnoreTargetSize)
|
|
{
|
|
targetSize =
|
|
targetSub != null ? Math.Max(targetSub.Borders.Width, targetSub.Borders.Height) / 2 :
|
|
targetCharacter != null ? ConvertUnits.ToDisplayUnits(targetCharacter.AnimController.Collider.GetSize().X) : 100;
|
|
}
|
|
float sqrDistToTarget = Vector2.DistanceSquared(WorldPosition, spatialTarget.WorldPosition);
|
|
bool isProgressive = AIParams.MaxAggression - AIParams.StartAggression > 0;
|
|
switch (CirclePhase)
|
|
{
|
|
case CirclePhase.Start:
|
|
currentAttackIntensity = MathUtils.InverseLerp(AIParams.StartAggression, AIParams.MaxAggression, ClampIntensity(aggressionIntensity));
|
|
inverseDir = false;
|
|
circleDir = GetDirFromHeadingInRadius();
|
|
circleRotation = 0;
|
|
strikeTimer = 0;
|
|
blockCheckTimer = 0;
|
|
breakCircling = false;
|
|
float minFallBackDistance = currentTargetingParams.CircleStartDistance * 0.5f;
|
|
float maxFallBackDistance = currentTargetingParams.CircleStartDistance;
|
|
float maxRandomOffset = currentTargetingParams.CircleMaxRandomOffset;
|
|
// The lower the rotation speed, the slower the progression. Also the distance to the target stays longer.
|
|
// So basically if the value is higher, the creature will strike the sub more quickly and with more precision.
|
|
float ClampIntensity(float intensity) => MathHelper.Clamp(intensity * Rand.Range(0.9f, 1.1f), AIParams.StartAggression, AIParams.MaxAggression);
|
|
if (isProgressive)
|
|
{
|
|
float intensity = ClampIntensity(currentAttackIntensity);
|
|
float minRotationSpeed = 0.01f * currentTargetingParams.CircleRotationSpeed;
|
|
float maxRotationSpeed = 0.5f * currentTargetingParams.CircleRotationSpeed;
|
|
circleRotationSpeed = MathHelper.Lerp(minRotationSpeed, maxRotationSpeed, intensity);
|
|
circleFallbackDistance = MathHelper.Lerp(maxFallBackDistance, minFallBackDistance, intensity);
|
|
circleOffset = Rand.Vector(MathHelper.Lerp(maxRandomOffset, 0, intensity));
|
|
}
|
|
else
|
|
{
|
|
circleRotationSpeed = currentTargetingParams.CircleRotationSpeed;
|
|
circleFallbackDistance = maxFallBackDistance;
|
|
circleOffset = Rand.Vector(maxRandomOffset);
|
|
}
|
|
circleRotationSpeed *= Rand.Range(1 - currentTargetingParams.CircleRandomRotationFactor, 1 + currentTargetingParams.CircleRandomRotationFactor);
|
|
aggressionIntensity = Math.Clamp(aggressionIntensity, AIParams.StartAggression, AIParams.MaxAggression);
|
|
DisableAttacksIfLimbNotRanged();
|
|
if (targetSub is { Borders.Width: < 1000 } && AttackLimb?.attack is { Ranged: false })
|
|
{
|
|
breakCircling = true;
|
|
CirclePhase = CirclePhase.CloseIn;
|
|
}
|
|
else if (sqrDistToTarget > MathUtils.Pow2(targetSize + currentTargetingParams.CircleStartDistance))
|
|
{
|
|
CirclePhase = CirclePhase.CloseIn;
|
|
}
|
|
else if (sqrDistToTarget < MathUtils.Pow2(targetSize + circleFallbackDistance))
|
|
{
|
|
CirclePhase = CirclePhase.FallBack;
|
|
}
|
|
else
|
|
{
|
|
CirclePhase = CirclePhase.Advance;
|
|
}
|
|
break;
|
|
case CirclePhase.CloseIn:
|
|
Vector2 targetVelocity = GetTargetVelocity();
|
|
float targetDistance = currentTargetingParams.IgnoreTargetSize ? currentTargetingParams.CircleStartDistance * 0.9f:
|
|
targetSize + currentTargetingParams.CircleStartDistance / 2;
|
|
if (AttackLimb != null && distance > 0 && distance < AttackLimb.attack.Range * GetStrikeDistanceMultiplier(targetVelocity))
|
|
{
|
|
strikeTimer = AttackLimb.attack.CoolDown;
|
|
CirclePhase = CirclePhase.Strike;
|
|
}
|
|
else if (!breakCircling && sqrDistToTarget <= MathUtils.Pow2(targetDistance) && targetVelocity.LengthSquared() <= MathUtils.Pow2(GetTargetMaxSpeed()))
|
|
{
|
|
CirclePhase = CirclePhase.Advance;
|
|
}
|
|
DisableAttacksIfLimbNotRanged();
|
|
break;
|
|
case CirclePhase.FallBack:
|
|
updateSteering = false;
|
|
bool isBlocked = !UpdateFallBack(attackWorldPos, deltaTime, followThrough: false, checkBlocking: true);
|
|
if (isBlocked || sqrDistToTarget > MathUtils.Pow2(targetSize + circleFallbackDistance))
|
|
{
|
|
CirclePhase = CirclePhase.Advance;
|
|
break;
|
|
}
|
|
DisableAttacksIfLimbNotRanged();
|
|
break;
|
|
case CirclePhase.Advance:
|
|
Vector2 targetVel = GetTargetVelocity();
|
|
// If the target is moving fast, just steer towards the target
|
|
if (breakCircling || targetVel.LengthSquared() > MathUtils.Pow2(GetTargetMaxSpeed()))
|
|
{
|
|
CirclePhase = CirclePhase.CloseIn;
|
|
}
|
|
else if (sqrDistToTarget > MathUtils.Pow2(targetSize + currentTargetingParams.CircleStartDistance * 1.2f))
|
|
{
|
|
if (currentTargetingParams.DynamicCircleRotationSpeed && circleRotationSpeed < 100)
|
|
{
|
|
circleRotationSpeed *= 1 + deltaTime;
|
|
}
|
|
else
|
|
{
|
|
CirclePhase = CirclePhase.CloseIn;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
float rotationStep = circleRotationSpeed * deltaTime * circleDir;
|
|
if (isProgressive)
|
|
{
|
|
circleRotation += rotationStep;
|
|
}
|
|
else
|
|
{
|
|
circleRotation = rotationStep;
|
|
}
|
|
Vector2 targetPos = attackSimPos + circleOffset;
|
|
float targetDist = targetSize;
|
|
if (targetDist <= 0)
|
|
{
|
|
targetDist = circleFallbackDistance;
|
|
}
|
|
if (targetSub != null && AttackLimb?.attack is { Ranged: true })
|
|
{
|
|
targetDist += circleFallbackDistance / 2;
|
|
}
|
|
if (Vector2.DistanceSquared(SimPosition, targetPos) < ConvertUnits.ToSimUnits(targetDist))
|
|
{
|
|
// Too close to the target point
|
|
// When the offset position is outside of the sub it happens that the creature sometimes reaches the target point,
|
|
// which makes it continue circling around the point (as supposed)
|
|
// But when there is some offset and the offset is too near, this is not what we want.
|
|
if (canAttack && AttackLimb?.attack is { Ranged: false } && sqrDistToTarget < MathUtils.Pow2(targetSize + circleFallbackDistance))
|
|
{
|
|
CirclePhase = CirclePhase.Strike;
|
|
strikeTimer = AttackLimb.attack.CoolDown;
|
|
}
|
|
else
|
|
{
|
|
CirclePhase = CirclePhase.Start;
|
|
}
|
|
break;
|
|
}
|
|
steerPos = MathUtils.RotatePointAroundTarget(SimPosition, targetPos, circleRotation);
|
|
if (IsBlocked(deltaTime, steerPos))
|
|
{
|
|
if (!inverseDir)
|
|
{
|
|
// First try changing the direction
|
|
circleDir = -circleDir;
|
|
inverseDir = true;
|
|
}
|
|
else if (circleRotationSpeed < 1)
|
|
{
|
|
// Then try increasing the rotation speed to change the movement curve
|
|
circleRotationSpeed *= 1 + deltaTime;
|
|
}
|
|
else if (circleOffset.LengthSquared() > 0.1f)
|
|
{
|
|
// Then try removing the offset
|
|
circleOffset = Vector2.Zero;
|
|
}
|
|
else
|
|
{
|
|
// If we still fail, just steer towards the target
|
|
breakCircling = AttackLimb?.attack is { Ranged: false };
|
|
if (!breakCircling)
|
|
{
|
|
CirclePhase = CirclePhase.FallBack;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (AttackLimb?.attack is { Ranged: false })
|
|
{
|
|
canAttack = false;
|
|
float requiredDistMultiplier = GetStrikeDistanceMultiplier(targetVel);
|
|
if (distance > 0 && distance < AttackLimb.attack.Range * requiredDistMultiplier && IsFacing(margin: MathHelper.Lerp(0.5f, 0.9f, currentAttackIntensity)))
|
|
{
|
|
strikeTimer = AttackLimb.attack.CoolDown;
|
|
CirclePhase = CirclePhase.Strike;
|
|
}
|
|
}
|
|
break;
|
|
case CirclePhase.Strike:
|
|
strikeTimer -= deltaTime;
|
|
// just continue the movement forward to make it possible to evade the attack
|
|
steerPos = SimPosition + Steering;
|
|
if (strikeTimer <= 0)
|
|
{
|
|
CirclePhase = CirclePhase.Start;
|
|
aggressionIntensity += AIParams.AggressionCumulation;
|
|
}
|
|
break;
|
|
}
|
|
break;
|
|
|
|
bool IsFacing(float margin)
|
|
{
|
|
float offset = steeringLimb.Params.GetSpriteOrientation() - MathHelper.PiOver2;
|
|
Vector2 forward = VectorExtensions.Forward(steeringLimb.body.TransformedRotation - offset * Character.AnimController.Dir);
|
|
return Vector2.Dot(Vector2.Normalize(attackWorldPos - WorldPosition), forward) > margin;
|
|
}
|
|
|
|
float GetStrikeDistanceMultiplier(Vector2 targetVelocity)
|
|
{
|
|
if (currentTargetingParams.CircleStrikeDistanceMultiplier < 1) { return 0; }
|
|
float requiredDistMultiplier = 2;
|
|
bool isHeading = Vector2.Dot(Vector2.Normalize(attackWorldPos - WorldPosition), Vector2.Normalize(Steering)) > 0.9f;
|
|
if (isHeading)
|
|
{
|
|
requiredDistMultiplier = currentTargetingParams.CircleStrikeDistanceMultiplier;
|
|
float targetVelocityHorizontal = Math.Abs(targetVelocity.X);
|
|
if (targetVelocityHorizontal > 1)
|
|
{
|
|
// Reduce the required distance if the target is moving.
|
|
requiredDistMultiplier -= MathHelper.Lerp(0, Math.Max(currentTargetingParams.CircleStrikeDistanceMultiplier - 1, 1), Math.Clamp(targetVelocityHorizontal / 10, 0, 1));
|
|
if (requiredDistMultiplier < 2)
|
|
{
|
|
requiredDistMultiplier = 2;
|
|
}
|
|
}
|
|
}
|
|
return requiredDistMultiplier;
|
|
}
|
|
|
|
float GetDirFromHeadingInRadius()
|
|
{
|
|
Vector2 heading = VectorExtensions.Forward(Character.AnimController.Collider.Rotation);
|
|
float angle = MathUtils.VectorToAngle(heading);
|
|
return angle > MathHelper.Pi || angle < -MathHelper.Pi ? -1 : 1;
|
|
}
|
|
|
|
Vector2 GetTargetVelocity()
|
|
{
|
|
if (targetSub != null)
|
|
{
|
|
return targetSub.Velocity;
|
|
}
|
|
else if (targetCharacter != null)
|
|
{
|
|
return targetCharacter.AnimController.Collider.LinearVelocity;
|
|
}
|
|
return Vector2.Zero;
|
|
}
|
|
|
|
float GetTargetMaxSpeed() => Character.ApplyTemporarySpeedLimits(Character.AnimController.SwimFastParams.MovementSpeed * (targetSub != null ? 0.3f : 0.5f));
|
|
}
|
|
}
|
|
if (updateSteering)
|
|
{
|
|
if (currentTargetingParams.AttackPattern == AttackPattern.Straight && AttackLimb is Limb attackLimb && attackLimb.attack.Ranged)
|
|
{
|
|
bool advance = !canAttack && Character.CurrentHull == null || distance > attackLimb.attack.Range * 0.9f;
|
|
bool fallBack = canAttack && distance < Math.Min(250, attackLimb.attack.Range * 0.25f);
|
|
if (fallBack)
|
|
{
|
|
Reverse = true;
|
|
UpdateFallBack(attackWorldPos, deltaTime, followThrough: false);
|
|
}
|
|
else if (advance)
|
|
{
|
|
SteeringManager.SteeringSeek(steerPos, 10);
|
|
}
|
|
else
|
|
{
|
|
if (Character.CurrentHull == null && !canAttack)
|
|
{
|
|
SteeringManager.SteeringWander(avoidWanderingOutsideLevel: true);
|
|
SteeringManager.SteeringAvoid(deltaTime, lookAheadDistance: avoidLookAheadDistance, weight: 5);
|
|
}
|
|
else
|
|
{
|
|
SteeringManager.Reset();
|
|
FaceTarget(SelectedAiTarget.Entity);
|
|
}
|
|
}
|
|
}
|
|
else if (!canAttack || distance > Math.Min(AttackLimb.attack.Range * 0.9f, 100))
|
|
{
|
|
if (pathSteering != null)
|
|
{
|
|
pathSteering.SteeringSeek(steerPos, weight: 10, minGapWidth: minGapSize);
|
|
}
|
|
else
|
|
{
|
|
SteeringManager.SteeringSeek(steerPos, 10);
|
|
}
|
|
}
|
|
if (Character.CurrentHull == null && (SelectedAiTarget?.Entity is Character c && c.Submarine == null ||
|
|
distance == 0 ||
|
|
distance > ConvertUnits.ToDisplayUnits(avoidLookAheadDistance * 2) ||
|
|
AttackLimb != null && AttackLimb.attack.Ranged))
|
|
{
|
|
SteeringManager.SteeringAvoid(deltaTime, lookAheadDistance: avoidLookAheadDistance, weight: 30);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Entity targetEntity = wallTarget?.Structure ?? SelectedAiTarget?.Entity;
|
|
if (AttackLimb?.attack is Attack { Ranged: true } attack)
|
|
{
|
|
AimRangedAttack(attack, attackTargetLimb as ISpatialEntity ?? targetEntity);
|
|
}
|
|
if (canAttack)
|
|
{
|
|
if (!UpdateLimbAttack(deltaTime, attackSimPos, damageTarget, distance, attackTargetLimb))
|
|
{
|
|
IgnoreTarget(SelectedAiTarget);
|
|
}
|
|
}
|
|
else if (IsAttackRunning)
|
|
{
|
|
AttackLimb.attack.ResetAttackTimer();
|
|
}
|
|
|
|
void DisableAttacksIfLimbNotRanged()
|
|
{
|
|
if (AttackLimb?.attack is { Ranged: false })
|
|
{
|
|
canAttack = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
public void AimRangedAttack(Attack attack, ISpatialEntity targetEntity)
|
|
{
|
|
if (attack is not { Ranged: true }) { return; }
|
|
if (targetEntity is Entity { Removed: true }) { return; }
|
|
Character.SetInput(InputType.Aim, false, true);
|
|
if (attack.AimRotationTorque <= 0) { return; }
|
|
Limb limb = GetLimbToRotate(attack);
|
|
if (limb != null)
|
|
{
|
|
Vector2 toTarget = targetEntity.WorldPosition - limb.WorldPosition;
|
|
float offset = limb.Params.GetSpriteOrientation() - MathHelper.PiOver2;
|
|
limb.body.SuppressSmoothRotationCalls = false;
|
|
float angle = MathUtils.VectorToAngle(toTarget);
|
|
limb.body.SmoothRotate(angle + offset, attack.AimRotationTorque);
|
|
limb.body.SuppressSmoothRotationCalls = true;
|
|
}
|
|
}
|
|
|
|
private bool IsValidAttack(Limb attackingLimb, IEnumerable<AttackContext> currentContexts, Entity target)
|
|
{
|
|
if (attackingLimb == null) { return false; }
|
|
if (target == null) { return false; }
|
|
var attack = attackingLimb.attack;
|
|
if (attack == null) { return false; }
|
|
if (attack.CoolDownTimer > 0) { return false; }
|
|
if (!attack.IsValidContext(currentContexts)) { return false; }
|
|
if (!attack.IsValidTarget(target)) { return false; }
|
|
if (!attackingLimb.attack.Ranged)
|
|
{
|
|
switch (target)
|
|
{
|
|
case Item when attackingLimb.attack.ItemDamage <= 0:
|
|
case Structure when attackingLimb.attack.StructureDamage <= 0:
|
|
return false;
|
|
}
|
|
}
|
|
if (target is ISerializableEntity se and Barotrauma.Character)
|
|
{
|
|
if (attack.Conditionals.Any(c => !c.TargetSelf && !c.Matches(se))) { return false; }
|
|
}
|
|
if (attack.Conditionals.Any(c => c.TargetSelf && !c.Matches(Character))) { return false; }
|
|
if (attack.Ranged)
|
|
{
|
|
// Check that is approximately facing the target
|
|
Vector2 attackLimbPos = Character.AnimController.SimplePhysicsEnabled ? Character.WorldPosition : attackingLimb.WorldPosition;
|
|
Vector2 toTarget = attackWorldPos - attackLimbPos;
|
|
if (attack.MinRange > 0 && toTarget.LengthSquared() < MathUtils.Pow2(attack.MinRange)) { return false; }
|
|
float offset = attackingLimb.Params.GetSpriteOrientation() - MathHelper.PiOver2;
|
|
Vector2 forward = VectorExtensions.Forward(attackingLimb.body.TransformedRotation - offset * Character.AnimController.Dir);
|
|
float angle = MathHelper.ToDegrees(VectorExtensions.Angle(forward, toTarget));
|
|
if (angle > attack.RequiredAngle) { return false; }
|
|
}
|
|
if (attack.RootForceWorldEnd.LengthSquared() > 1)
|
|
{
|
|
// Don't allow root motion attacks, if we are not on the same level with the target, because it can cause warping.
|
|
switch (target)
|
|
{
|
|
case Character targetCharacter when Character.CurrentHull != targetCharacter.CurrentHull || targetCharacter.IsKnockedDownOrRagdolled:
|
|
case Item targetItem when Character.CurrentHull != targetItem.CurrentHull:
|
|
return false;
|
|
}
|
|
float verticalDistance = Math.Abs(attackWorldPos.Y - Character.WorldPosition.Y);
|
|
if (verticalDistance > 50)
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
private readonly List<Limb> attackLimbs = new List<Limb>();
|
|
private readonly List<float> weights = new List<float>();
|
|
private Limb GetAttackLimb(Vector2 attackWorldPos, Limb ignoredLimb = null)
|
|
{
|
|
var currentContexts = Character.GetAttackContexts();
|
|
Entity target = wallTarget != null ? wallTarget.Structure : SelectedAiTarget?.Entity;
|
|
if (target == null) { return null; }
|
|
Limb selectedLimb = null;
|
|
float currentPriority = -1;
|
|
foreach (Limb limb in Character.AnimController.Limbs)
|
|
{
|
|
if (limb == ignoredLimb) { continue; }
|
|
if (limb.IsSevered || limb.IsStuck) { continue; }
|
|
if (!IsValidAttack(limb, currentContexts, target)) { continue; }
|
|
if (AIParams.RandomAttack)
|
|
{
|
|
attackLimbs.Add(limb);
|
|
weights.Add(limb.attack.Priority);
|
|
}
|
|
else
|
|
{
|
|
float priority = CalculatePriority(limb, attackWorldPos);
|
|
if (priority > currentPriority)
|
|
{
|
|
currentPriority = priority;
|
|
selectedLimb = limb;
|
|
}
|
|
}
|
|
}
|
|
if (AIParams.RandomAttack)
|
|
{
|
|
selectedLimb = ToolBox.SelectWeightedRandom(attackLimbs, weights, Rand.RandSync.Unsynced);
|
|
attackLimbs.Clear();
|
|
weights.Clear();
|
|
}
|
|
return selectedLimb;
|
|
|
|
float CalculatePriority(Limb limb, Vector2 attackPos)
|
|
{
|
|
float prio = 1 + limb.attack.Priority;
|
|
if (Character.AnimController.SimplePhysicsEnabled) { return prio; }
|
|
float distance = Vector2.Distance(limb.WorldPosition, attackPos);
|
|
float maxDistance = Math.Max(limb.attack.Range * 3, 1000);
|
|
if (distance > maxDistance)
|
|
{
|
|
// Far enough to ignore the attack.
|
|
return 0;
|
|
}
|
|
// Not in range, but relatively close. Let's use the distance factor as a multiplier.
|
|
float distanceFactor;
|
|
if (limb.attack.Ranged)
|
|
{
|
|
float min = 100;
|
|
if (distance < min)
|
|
{
|
|
// Too close -> smoothly but steeply reduce the preference (and prefer other attacks, like melee instead)
|
|
float t = MathUtils.InverseLerp(0, min, distance);
|
|
distanceFactor = MathHelper.Lerp(0.01f, 1, t * t);
|
|
}
|
|
else
|
|
{
|
|
distanceFactor = MathHelper.Lerp(1, 0, MathUtils.InverseLerp(min, maxDistance, distance));
|
|
}
|
|
}
|
|
else
|
|
{
|
|
if (distance <= limb.attack.Range)
|
|
{
|
|
// In range.
|
|
if (!Character.InWater)
|
|
{
|
|
// On dry land vertical distance works a bit differently, as we can't necessarily reach the target above/below us.
|
|
float verticalDistance = Math.Abs(limb.WorldPosition.Y - attackPos.Y);
|
|
if (verticalDistance > limb.attack.DamageRange)
|
|
{
|
|
// Most likely can't reach.
|
|
return 0;
|
|
}
|
|
}
|
|
// Highly prefer attacks which we can use to hit immediately.
|
|
return prio * 10;
|
|
}
|
|
float min = limb.attack.Range;
|
|
distanceFactor = MathHelper.Lerp(1, 0, MathUtils.InverseLerp(min, maxDistance, distance));
|
|
}
|
|
return prio * distanceFactor;
|
|
}
|
|
}
|
|
|
|
public override void OnAttacked(Character attacker, AttackResult attackResult)
|
|
{
|
|
float reactionTime = Rand.Range(0.1f, 0.3f);
|
|
updateTargetsTimer = Math.Min(updateTargetsTimer, reactionTime);
|
|
bool wasLatched = IsLatchedOnSub;
|
|
Character.AnimController.ReleaseStuckLimbs();
|
|
if (attackResult.Damage > 0)
|
|
{
|
|
LatchOntoAI?.DeattachFromBody(reset: true, cooldown: 1);
|
|
}
|
|
if (attacker?.AiTarget == null || attacker.Removed || attacker.IsDead) { return; }
|
|
AITargetMemory targetMemory = GetTargetMemory(attacker.AiTarget, addIfNotFound: true, keepAlive: true);
|
|
targetMemory.Priority += GetRelativeDamage(attackResult.Damage, Character.Vitality) * AIParams.AggressionHurt;
|
|
if (attackResult.Damage >= AIParams.DamageThreshold)
|
|
{
|
|
ReleaseDragTargets();
|
|
}
|
|
bool isFriendly = Character.IsFriendly(attacker);
|
|
if (wasLatched)
|
|
{
|
|
State = AIState.Escape;
|
|
avoidTimer = AIParams.AvoidTime * 0.5f * Rand.Range(0.75f, 1.25f);
|
|
if (!isFriendly)
|
|
{
|
|
SelectTarget(attacker.AiTarget);
|
|
}
|
|
return;
|
|
}
|
|
if (State == AIState.Flee)
|
|
{
|
|
if (!isFriendly)
|
|
{
|
|
SelectTarget(attacker.AiTarget);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!isFriendly && attackResult.Damage > 0.0f)
|
|
{
|
|
bool canAttack = attacker.Submarine == Character.Submarine && canAttackCharacters || attacker.Submarine != null && canAttackWalls;
|
|
if (canAttack)
|
|
{
|
|
switch (State)
|
|
{
|
|
case AIState.PlayDead when Rand.Value() < 0.5f:
|
|
// 50% chance of not reacting when playing dead.
|
|
return;
|
|
case AIState.PlayDead:
|
|
case AIState.Hiding:
|
|
SelectTarget(attacker.AiTarget);
|
|
State = AIState.Attack;
|
|
break;
|
|
}
|
|
}
|
|
if (AIParams.AttackWhenProvoked && canAttack)
|
|
{
|
|
if (ignoredTargets.Contains(attacker.AiTarget))
|
|
{
|
|
ignoredTargets.Remove(attacker.AiTarget);
|
|
}
|
|
if (attacker.IsHusk)
|
|
{
|
|
ChangeTargetState(Tags.Husk, AIState.Attack, 100);
|
|
}
|
|
else
|
|
{
|
|
ChangeTargetState(attacker, AIState.Attack, 100);
|
|
}
|
|
}
|
|
else if (!AIParams.HasTag(attacker.SpeciesName))
|
|
{
|
|
if (attacker.IsHusk)
|
|
{
|
|
ChangeTargetState(Tags.Husk, canAttack ? AIState.Attack : AIState.Escape, 100);
|
|
}
|
|
else if (attacker.AIController is EnemyAIController enemyAI)
|
|
{
|
|
if (enemyAI.CombatStrength > CombatStrength)
|
|
{
|
|
if (!AIParams.HasTag("stronger"))
|
|
{
|
|
ChangeTargetState(attacker, canAttack ? AIState.Attack : AIState.Escape, 100);
|
|
}
|
|
}
|
|
else if (enemyAI.CombatStrength < CombatStrength)
|
|
{
|
|
if (!AIParams.HasTag("weaker"))
|
|
{
|
|
ChangeTargetState(attacker, canAttack ? AIState.Attack : AIState.Escape, 100);
|
|
}
|
|
}
|
|
else if (!AIParams.HasTag("equal"))
|
|
{
|
|
ChangeTargetState(attacker, canAttack ? AIState.Attack : AIState.Escape, 100);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
ChangeTargetState(attacker, canAttack ? AIState.Attack : AIState.Escape, 100);
|
|
}
|
|
}
|
|
else if (canAttack && attacker.IsHuman &&
|
|
AIParams.TryGetTargets(attacker, out IEnumerable<CharacterParams.TargetParams> targetingParams))
|
|
{
|
|
//use a temporary list, because changing the state may change the targetingParams returned by TryGetTargets,
|
|
//which would cause a "collection was modified" exception
|
|
tempParamsList.Clear();
|
|
tempParamsList.AddRange(targetingParams);
|
|
foreach (var tp in tempParamsList)
|
|
{
|
|
if (tp.State is AIState.Aggressive or AIState.PassiveAggressive)
|
|
{
|
|
ChangeTargetState(attacker, AIState.Attack, 100);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Only allow to react once. Otherwise would attack the target with only a fraction of a cooldown
|
|
bool retaliate = !isFriendly && SelectedAiTarget != attacker.AiTarget && attacker.Submarine == Character.Submarine;
|
|
bool avoidGunFire = AIParams.AvoidGunfire && attacker.Submarine != Character.Submarine;
|
|
|
|
if (State == AIState.Attack && (IsAttackRunning || IsCoolDownRunning))
|
|
{
|
|
retaliate = false;
|
|
if (IsAttackRunning)
|
|
{
|
|
avoidGunFire = false;
|
|
}
|
|
}
|
|
if (retaliate)
|
|
{
|
|
// Reduce the cooldown so that the character can react
|
|
foreach (var limb in Character.AnimController.Limbs)
|
|
{
|
|
if (limb.attack != null)
|
|
{
|
|
limb.attack.CoolDownTimer *= reactionTime;
|
|
}
|
|
}
|
|
}
|
|
else if (avoidGunFire && attackResult.Damage >= AIParams.DamageThreshold)
|
|
{
|
|
State = AIState.Escape;
|
|
avoidTimer = AIParams.AvoidTime * Rand.Range(0.75f, 1.25f);
|
|
SelectTarget(attacker.AiTarget);
|
|
}
|
|
if (Math.Max(Character.HealthPercentage, 0) < FleeHealthThreshold)
|
|
{
|
|
State = AIState.Flee;
|
|
avoidTimer = AIParams.MinFleeTime * Rand.Range(0.75f, 1.25f);
|
|
SelectTarget(attacker.AiTarget);
|
|
}
|
|
}
|
|
|
|
private Item GetEquippedItem(Limb limb)
|
|
{
|
|
InvSlotType GetInvSlotForLimb()
|
|
{
|
|
return limb.type switch
|
|
{
|
|
LimbType.RightHand => InvSlotType.RightHand,
|
|
LimbType.LeftHand => InvSlotType.LeftHand,
|
|
LimbType.Head => InvSlotType.Head,
|
|
_ => InvSlotType.None,
|
|
};
|
|
}
|
|
var slot = GetInvSlotForLimb();
|
|
if (slot != InvSlotType.None)
|
|
{
|
|
return Character.Inventory.GetItemInLimbSlot(slot);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// 10 dmg, 100 health -> 0.1
|
|
private static float GetRelativeDamage(float dmg, float vitality) => dmg / Math.Max(vitality, 1.0f);
|
|
|
|
private bool UpdateLimbAttack(float deltaTime, Vector2 attackSimPos, IDamageable damageTarget, float distance = -1, Limb targetLimb = null)
|
|
{
|
|
if (SelectedAiTarget?.Entity == null) { return false; }
|
|
if (AttackLimb?.attack == null) { return false; }
|
|
ISpatialEntity spatialTarget = wallTarget != null ? wallTarget.Structure : SelectedAiTarget.Entity as ISpatialEntity;
|
|
if (spatialTarget == null) { return false; }
|
|
ActiveAttack = AttackLimb.attack;
|
|
if (wallTarget != null)
|
|
{
|
|
// If the selected target is not the wall target, make the wall target the selected target.
|
|
var aiTarget = wallTarget.Structure.AiTarget;
|
|
if (aiTarget != null && SelectedAiTarget != aiTarget)
|
|
{
|
|
SelectTarget(aiTarget, GetTargetMemory(SelectedAiTarget, addIfNotFound: true).Priority);
|
|
State = AIState.Attack;
|
|
AttackLimb = null;
|
|
return true;
|
|
}
|
|
}
|
|
if (damageTarget == null) { return false; }
|
|
ActiveAttack = AttackLimb.attack;
|
|
if (ActiveAttack.Ranged && ActiveAttack.RequiredAngleToShoot > 0)
|
|
{
|
|
Limb referenceLimb = GetLimbToRotate(ActiveAttack);
|
|
if (referenceLimb != null)
|
|
{
|
|
Vector2 toTarget = attackWorldPos - referenceLimb.WorldPosition;
|
|
float offset = referenceLimb.Params.GetSpriteOrientation() - MathHelper.PiOver2;
|
|
Vector2 forward = VectorExtensions.Forward(referenceLimb.body.TransformedRotation - offset * referenceLimb.Dir);
|
|
float angle = MathHelper.ToDegrees(VectorExtensions.Angle(forward, toTarget));
|
|
if (angle > ActiveAttack.RequiredAngleToShoot)
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
if (Character.Params.CanInteract && Character.Inventory != null)
|
|
{
|
|
// Use equipped items (weapons)
|
|
Item item = GetEquippedItem(AttackLimb);
|
|
if (item != null)
|
|
{
|
|
if (item.RequireAimToUse)
|
|
{
|
|
if (!Aim(deltaTime, spatialTarget, item))
|
|
{
|
|
// Valid target, but can't shoot -> return true so that it will not be ignored.
|
|
return true;
|
|
}
|
|
}
|
|
Character.SetInput(item.IsShootable ? InputType.Shoot : InputType.Use, false, true);
|
|
item.Use(deltaTime, user: Character);
|
|
}
|
|
}
|
|
//simulate attack input to get the character to attack client-side
|
|
Character.SetInput(InputType.Attack, true, true);
|
|
if (!ActiveAttack.IsRunning)
|
|
{
|
|
#if SERVER
|
|
GameMain.NetworkMember.CreateEntityEvent(Character, new Character.SetAttackTargetEventData(
|
|
AttackLimb,
|
|
damageTarget,
|
|
targetLimb,
|
|
SimPosition));
|
|
#else
|
|
Character.PlaySound(CharacterSound.SoundType.Attack, maxInterval: 3);
|
|
#endif
|
|
}
|
|
if (AttackLimb.UpdateAttack(deltaTime, attackSimPos, damageTarget, out AttackResult attackResult, distance, targetLimb))
|
|
{
|
|
if (ActiveAttack.CoolDownTimer > 0)
|
|
{
|
|
SetAimTimer(Math.Min(ActiveAttack.CoolDown, 1.5f));
|
|
}
|
|
if (LatchOntoAI != null && SelectedAiTarget.Entity is Character targetCharacter)
|
|
{
|
|
LatchOntoAI.SetAttachTarget(targetCharacter);
|
|
}
|
|
if (!ActiveAttack.Ranged)
|
|
{
|
|
if (damageTarget.Health > 0 && attackResult.Damage > 0)
|
|
{
|
|
// Managed to hit a living/non-destroyed target. Increase the priority more if the target is low in health -> dies easily/soon
|
|
float greed = AIParams.AggressionGreed;
|
|
if (damageTarget is not Barotrauma.Character)
|
|
{
|
|
// Halve the greed for attacking non-characters.
|
|
greed /= 2;
|
|
}
|
|
currentTargetMemory.Priority += GetRelativeDamage(attackResult.Damage, damageTarget.Health) * greed;
|
|
}
|
|
else
|
|
{
|
|
currentTargetMemory.Priority -= Math.Max(currentTargetMemory.Priority / 2, 1);
|
|
return currentTargetMemory.Priority > 1;
|
|
}
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
private const float VisibilityCheckStep = 0.2f;
|
|
private double lastVisibilityCheckTime;
|
|
private bool canSeeTarget;
|
|
/// <summary>
|
|
/// This method uses <see cref="Character.CanSeeTarget"/> and caches the results.
|
|
/// </summary>
|
|
private bool CanSeeTarget(ISpatialEntity target)
|
|
{
|
|
if (Timing.TotalTime > lastVisibilityCheckTime + VisibilityCheckStep)
|
|
{
|
|
canSeeTarget = Character.CanSeeTarget(target);
|
|
lastVisibilityCheckTime = Timing.TotalTime;
|
|
}
|
|
return canSeeTarget;
|
|
}
|
|
|
|
private float aimTimer;
|
|
private float sinTime;
|
|
private bool Aim(float deltaTime, ISpatialEntity target, Item weapon)
|
|
{
|
|
if (target == null || weapon == null) { return false; }
|
|
if (AttackLimb == null) { return false; }
|
|
Vector2 toTarget = target.WorldPosition - weapon.WorldPosition;
|
|
float dist = toTarget.Length();
|
|
Character.CursorPosition = target.WorldPosition;
|
|
if (AttackLimb.attack.SwayAmount > 0)
|
|
{
|
|
sinTime += deltaTime * AttackLimb.attack.SwayFrequency;
|
|
Character.CursorPosition += VectorExtensions.Forward(weapon.body.TransformedRotation + (float)Math.Sin(sinTime) / 2, dist / 2 * AttackLimb.attack.SwayAmount);
|
|
}
|
|
if (Character.Submarine != null)
|
|
{
|
|
Character.CursorPosition -= Character.Submarine.Position;
|
|
}
|
|
if (!CanSeeTarget(target))
|
|
{
|
|
SetAimTimer();
|
|
return false;
|
|
}
|
|
Character.SetInput(InputType.Aim, false, true);
|
|
if (aimTimer > 0)
|
|
{
|
|
aimTimer -= deltaTime;
|
|
return false;
|
|
}
|
|
float angle = VectorExtensions.Angle(VectorExtensions.Forward(weapon.body.TransformedRotation), toTarget);
|
|
float minDistance = 300;
|
|
float distanceFactor = MathHelper.Lerp(1.0f, 0.1f, MathUtils.InverseLerp(minDistance, 1000, dist));
|
|
float margin = MathHelper.PiOver4 * distanceFactor;
|
|
if (angle < margin || dist < minDistance)
|
|
{
|
|
var collisionCategories = Physics.CollisionCharacter | Physics.CollisionWall | Physics.CollisionLevel | Physics.CollisionItemBlocking;
|
|
var pickedBody = Submarine.PickBody(weapon.SimPosition, Character.GetRelativeSimPosition(target), myBodies, collisionCategories, allowInsideFixture: true);
|
|
if (pickedBody != null)
|
|
{
|
|
if (target is MapEntity)
|
|
{
|
|
if (pickedBody.UserData is Submarine sub && sub == target.Submarine)
|
|
{
|
|
return true;
|
|
}
|
|
else if (target == pickedBody.UserData)
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
Character t = null;
|
|
if (pickedBody.UserData is Character c)
|
|
{
|
|
t = c;
|
|
}
|
|
else if (pickedBody.UserData is Limb limb)
|
|
{
|
|
t = limb.character;
|
|
}
|
|
if (t != null && (t == target || (!Character.IsFriendly(t) || IsAttackingOwner(t))))
|
|
{
|
|
return true;
|
|
}
|
|
if (pickedBody.UserData is Item item && item.Prefab.DamagedByProjectiles)
|
|
{
|
|
// Target behind an item -> allow shooting.
|
|
return true;
|
|
}
|
|
if (pickedBody.UserData is Holdable holdable && holdable.Item.Prefab.DamagedByProjectiles)
|
|
{
|
|
// Target behind a blocking but destructible item -> allow shooting.
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private void SetAimTimer(float timer = 1.5f) => aimTimer = timer * Rand.Range(0.75f, 1.25f);
|
|
|
|
private readonly float blockCheckInterval = 0.1f;
|
|
private float blockCheckTimer;
|
|
private bool isBlocked;
|
|
private bool IsBlocked(float deltaTime, Vector2 steerPos, Category collisionCategory = Physics.CollisionLevel)
|
|
{
|
|
blockCheckTimer -= deltaTime;
|
|
if (blockCheckTimer <= 0)
|
|
{
|
|
blockCheckTimer = blockCheckInterval;
|
|
isBlocked = Submarine.PickBodies(SimPosition, steerPos, collisionCategory: collisionCategory).Any();
|
|
}
|
|
return isBlocked;
|
|
}
|
|
|
|
private Vector2? attackVector = null;
|
|
private bool UpdateFallBack(Vector2 attackWorldPos, float deltaTime, bool followThrough, bool checkBlocking = false, bool avoidObstacles = true)
|
|
{
|
|
if (attackVector == null)
|
|
{
|
|
attackVector = attackWorldPos - WorldPosition;
|
|
}
|
|
Vector2 dir = Vector2.Normalize(followThrough ? attackVector.Value : -attackVector.Value);
|
|
if (!MathUtils.IsValid(dir))
|
|
{
|
|
dir = Vector2.UnitY;
|
|
}
|
|
steeringManager.SteeringManual(deltaTime, dir);
|
|
if (Character.AnimController.InWater && !Reverse && avoidObstacles)
|
|
{
|
|
SteeringManager.SteeringAvoid(deltaTime, lookAheadDistance: avoidLookAheadDistance, weight: 15);
|
|
}
|
|
if (checkBlocking)
|
|
{
|
|
return !IsBlocked(deltaTime, SimPosition + dir * (avoidLookAheadDistance / 2));
|
|
}
|
|
return true;
|
|
}
|
|
|
|
private Limb GetLimbToRotate(Attack attack)
|
|
{
|
|
Limb limb = AttackLimb;
|
|
if (attack.RotationLimbIndex > -1 && attack.RotationLimbIndex < Character.AnimController.Limbs.Length)
|
|
{
|
|
limb = Character.AnimController.Limbs[attack.RotationLimbIndex];
|
|
}
|
|
return limb;
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Eat
|
|
|
|
private void UpdateEating(float deltaTime)
|
|
{
|
|
if (SelectedAiTarget?.Entity == null || SelectedAiTarget.Entity.Removed)
|
|
{
|
|
ReleaseEatingTarget();
|
|
return;
|
|
}
|
|
if (SelectedAiTarget.Entity is Barotrauma.Character or Item)
|
|
{
|
|
Limb mouthLimb = Character.AnimController.GetLimb(LimbType.Head);
|
|
if (mouthLimb == null)
|
|
{
|
|
DebugConsole.ThrowError("Character \"" + Character.SpeciesName + "\" failed to eat a target (No head limb found)",
|
|
contentPackage: Character.Prefab.ContentPackage);
|
|
IgnoreTarget(SelectedAiTarget);
|
|
ReleaseEatingTarget();
|
|
ResetAITarget();
|
|
return;
|
|
}
|
|
Vector2 mouthPos = Character.AnimController.SimplePhysicsEnabled ? SimPosition : Character.AnimController.GetMouthPosition() ?? Vector2.Zero;
|
|
Vector2 attackSimPosition = Character.GetRelativeSimPosition(SelectedAiTarget.Entity);
|
|
Vector2 limbDiff = attackSimPosition - mouthPos;
|
|
float extent = Math.Max(mouthLimb.body.GetMaxExtent(), 2);
|
|
bool tooFar = Character.InWater ? limbDiff.LengthSquared() > extent * extent : limbDiff.X > extent;
|
|
if (tooFar)
|
|
{
|
|
steeringManager.SteeringSeek(attackSimPosition - (mouthPos - SimPosition), 2);
|
|
if (Character.InWater)
|
|
{
|
|
SteeringManager.SteeringAvoid(deltaTime, lookAheadDistance: avoidLookAheadDistance, weight: 15);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
if (SelectedAiTarget.Entity is Character targetCharacter)
|
|
{
|
|
Character.SelectCharacter(targetCharacter);
|
|
}
|
|
else if (SelectedAiTarget.Entity is Item item)
|
|
{
|
|
if (!item.Removed && item.body != null)
|
|
{
|
|
float itemBodyExtent = item.body.GetMaxExtent() * 2;
|
|
if (Math.Abs(limbDiff.X) < itemBodyExtent &&
|
|
Math.Abs(limbDiff.Y) < Character.AnimController.Collider.GetMaxExtent() + Character.AnimController.ColliderHeightFromFloor)
|
|
{
|
|
Vector2 velocity = limbDiff;
|
|
if (limbDiff.LengthSquared() > 0.01f) { velocity = Vector2.Normalize(velocity); }
|
|
item.body.LinearVelocity *= 0.9f;
|
|
item.body.LinearVelocity -= velocity * 0.25f;
|
|
bool wasBroken = item.Condition <= 0.0f;
|
|
item.LastEatenTime = (float)Timing.TotalTimeUnpaused;
|
|
item.AddDamage(Character,
|
|
item.WorldPosition,
|
|
new Attack(0.0f, 0.0f, 0.0f, 0.0f, 0.02f * Character.Params.EatingSpeed),
|
|
impulseDirection: Vector2.Zero,
|
|
deltaTime);
|
|
Character.ApplyStatusEffects(ActionType.OnEating, deltaTime);
|
|
if (item.Condition <= 0.0f)
|
|
{
|
|
if (!wasBroken) { PetBehavior?.OnEat(item); }
|
|
Entity.Spawner.AddItemToRemoveQueue(item);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
steeringManager.SteeringManual(deltaTime, Vector2.Normalize(limbDiff) * 3);
|
|
if (Character.AnimController.OnGround || Character.InWater)
|
|
{
|
|
Character.AnimController.Collider.ApplyForce(limbDiff * mouthLimb.Mass * 50.0f, maxVelocity: 10.0f);
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
IgnoreTarget(SelectedAiTarget);
|
|
ReleaseEatingTarget();
|
|
ResetAITarget();
|
|
}
|
|
}
|
|
|
|
private void ReleaseEatingTarget()
|
|
{
|
|
State = AIState.Idle;
|
|
Character.DeselectCharacter();
|
|
}
|
|
|
|
#endregion
|
|
|
|
private void UpdateFollow(float deltaTime)
|
|
{
|
|
if (SelectedAiTarget == null || SelectedAiTarget.Entity == null || SelectedAiTarget.Entity.Removed)
|
|
{
|
|
State = AIState.Idle;
|
|
return;
|
|
}
|
|
if (Character.CurrentHull != null && steeringManager == insideSteering)
|
|
{
|
|
// Inside, but not inside ruins
|
|
if ((Character.AnimController.InWater || !Character.AnimController.CanWalk) &&
|
|
Character.Submarine != null && !Character.Submarine.Info.IsRuin &&
|
|
SelectedAiTarget.Entity is Character c && VisibleHulls.Contains(c.CurrentHull))
|
|
{
|
|
// Steer towards the target if in the same room and swimming
|
|
SteeringManager.SteeringManual(deltaTime, Vector2.Normalize(SelectedAiTarget.Entity.WorldPosition - Character.WorldPosition));
|
|
}
|
|
else
|
|
{
|
|
// Use path finding
|
|
PathSteering.SteeringSeek(Character.GetRelativeSimPosition(SelectedAiTarget.Entity), weight: 2, minGapWidth: minGapSize);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// Outside
|
|
SteeringManager.SteeringSeek(Character.GetRelativeSimPosition(SelectedAiTarget.Entity), 5);
|
|
}
|
|
if (steeringManager is IndoorsSteeringManager pathSteering)
|
|
{
|
|
if (!pathSteering.IsPathDirty && pathSteering.CurrentPath != null && pathSteering.CurrentPath.Unreachable)
|
|
{
|
|
// Can't reach
|
|
State = AIState.Idle;
|
|
IgnoreTarget(SelectedAiTarget);
|
|
}
|
|
}
|
|
else if (Character.AnimController.InWater)
|
|
{
|
|
SteeringManager.SteeringAvoid(deltaTime, lookAheadDistance: avoidLookAheadDistance, weight: 15);
|
|
}
|
|
}
|
|
|
|
#region Targeting
|
|
public static bool IsLatchedTo(Character target, Character character)
|
|
{
|
|
if (target.AIController is EnemyAIController enemyAI && enemyAI.LatchOntoAI != null)
|
|
{
|
|
return enemyAI.LatchOntoAI.IsAttached && enemyAI.LatchOntoAI.TargetCharacter == character;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
public static bool IsLatchedToSomeoneElse(Character target, Character character)
|
|
{
|
|
if (target.AIController is EnemyAIController enemyAI && enemyAI.LatchOntoAI != null)
|
|
{
|
|
return enemyAI.LatchOntoAI.IsAttached && enemyAI.LatchOntoAI.TargetCharacter != null && enemyAI.LatchOntoAI.TargetCharacter != character;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private bool IsLatchedOnSub => LatchOntoAI != null && LatchOntoAI.IsAttachedToSub;
|
|
|
|
//goes through all the AItargets, evaluates how preferable it is to attack the target,
|
|
//whether the Character can see/hear the target and chooses the most preferable target within
|
|
//sight/hearing range
|
|
public void UpdateTargets()
|
|
{
|
|
targetValue = 0;
|
|
AITarget newTarget = null;
|
|
CharacterParams.TargetParams selectedTargetParams = null;
|
|
AITargetMemory targetMemory = null;
|
|
bool isAnyTargetClose = false;
|
|
bool isBeingChased = IsBeingChased;
|
|
const float priorityValueMaxModifier = 5;
|
|
bool isCharacterInside = Character.CurrentHull != null;
|
|
bool tryToGetInside =
|
|
Character.AnimController.CanEnterSubmarine == CanEnterSubmarine.True ||
|
|
//characters that are aggressive boarders can partially enter the sub can attempt to push through holes
|
|
(Character.AnimController.CanEnterSubmarine == CanEnterSubmarine.Partial && IsAggressiveBoarder);
|
|
|
|
foreach (AITarget aiTarget in AITarget.List)
|
|
{
|
|
if (aiTarget.ShouldBeIgnored()) { continue; }
|
|
if (ignoredTargets.Contains(aiTarget)) { continue; }
|
|
if (aiTarget.Type == AITarget.TargetType.HumanOnly) { continue; }
|
|
if (!TargetOutposts && GameMain.GameSession?.GameMode is not TestGameMode)
|
|
{
|
|
if (aiTarget.Entity.Submarine != null && aiTarget.Entity.Submarine.Info.IsOutpost) { continue; }
|
|
}
|
|
Character targetCharacter = aiTarget.Entity as Character;
|
|
//ignore the aitarget if it is the Character itself
|
|
if (targetCharacter == Character) { continue; }
|
|
if (TargetingRestrictions.HasFlag(EnemyTargetingRestrictions.PlayerCharacters))
|
|
{
|
|
if (targetCharacter is { IsPlayer: true }) { continue; }
|
|
|
|
// monsters can ignore a player character, but attack a diving suit equipped by the character
|
|
if (aiTarget.Entity is Item item && item.GetRootInventoryOwner() is Character { IsPlayer: true}) { continue; }
|
|
}
|
|
if (TargetingRestrictions.HasFlag(EnemyTargetingRestrictions.PlayerSubmarines))
|
|
{
|
|
if (aiTarget.Entity.Submarine?.Info is { IsPlayer: true }) { continue; }
|
|
}
|
|
var targetingTags = GetTargetingTags(aiTarget);
|
|
|
|
#region Filter out targets by entity type, based on contextual information.
|
|
Door door = null;
|
|
if (targetCharacter != null)
|
|
{
|
|
// ignore if target is tagged to be explicitly ignored (Feign Death)
|
|
if (targetCharacter.HasAbilityFlag(AbilityFlags.IgnoredByEnemyAI)) { continue; }
|
|
if (AIParams.Targets.None() && Character.IsFriendly(targetCharacter))
|
|
{
|
|
continue;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// Ignore all structures, items, and hulls inside these subs.
|
|
if (aiTarget.Entity.Submarine != null)
|
|
{
|
|
if (aiTarget.Entity.Submarine.Info.IsWreck ||
|
|
aiTarget.Entity.Submarine.Info.IsBeacon ||
|
|
unattackableSubmarines.Contains(aiTarget.Entity.Submarine))
|
|
{
|
|
continue;
|
|
}
|
|
//ignore the megaruin in end levels
|
|
if (aiTarget.Entity.Submarine.Info.OutpostGenerationParams != null &&
|
|
aiTarget.Entity.Submarine.Info.OutpostGenerationParams.ForceToEndLocationIndex > -1)
|
|
{
|
|
continue;
|
|
}
|
|
}
|
|
if (aiTarget.Entity is Hull hull)
|
|
{
|
|
// Ignore the target if it's a room and the character is already inside a sub
|
|
if (Character.CurrentHull != null) { continue; }
|
|
// Ignore ruins
|
|
if (hull.Submarine == null) { continue; }
|
|
if (hull.Submarine.Info.IsRuin) { continue; }
|
|
}
|
|
else if (aiTarget.Entity is Item item)
|
|
{
|
|
door = item.GetComponent<Door>();
|
|
bool targetingFromOutsideToInside = item.CurrentHull != null && !isCharacterInside;
|
|
if (targetingFromOutsideToInside)
|
|
{
|
|
if (door != null && (!canAttackDoors && !AIParams.CanOpenDoors) || !canAttackWalls)
|
|
{
|
|
// Can't reach
|
|
continue;
|
|
}
|
|
}
|
|
if (door == null && targetingFromOutsideToInside)
|
|
{
|
|
if (item.Submarine?.Info is { IsRuin: true })
|
|
{
|
|
// Ignore ruin items when the creature is outside.
|
|
continue;
|
|
}
|
|
}
|
|
else if (targetingTags.Contains(Tags.Nasonov))
|
|
{
|
|
if ((item.Submarine == null || !item.Submarine.Info.IsPlayer) && item.ParentInventory == null)
|
|
{
|
|
// Only target nasonovartifacts when they are held be a player or inside the playersub
|
|
continue;
|
|
}
|
|
}
|
|
// Ignore the target if it's a decoy and the character is already inside a sub
|
|
if (Character.CurrentHull != null && targetingTags.Contains(Tags.Decoy))
|
|
{
|
|
continue;
|
|
}
|
|
}
|
|
else if (aiTarget.Entity is Structure s)
|
|
{
|
|
if (!s.HasBody)
|
|
{
|
|
// Ignore structures that doesn't have a body (not walls)
|
|
continue;
|
|
}
|
|
if (s.IsPlatform) { continue; }
|
|
if (s.Submarine == null) { continue; }
|
|
if (s.Submarine.Info.IsRuin) { continue; }
|
|
bool isInnerWall = s.Prefab.Tags.Contains("inner");
|
|
if (isInnerWall && !isCharacterInside)
|
|
{
|
|
// Ignore inner walls when outside (walltargets still work)
|
|
continue;
|
|
}
|
|
if (!tryToGetInside && IsWallDisabled(s))
|
|
{
|
|
continue;
|
|
}
|
|
}
|
|
if (door != null)
|
|
{
|
|
if (door.Item.Submarine == null) { continue; }
|
|
bool isOutdoor = door.LinkedGap is { FlowTargetHull: not null, IsRoomToRoom: false };
|
|
// Ignore inner doors when outside
|
|
if (Character.CurrentHull == null && !isOutdoor) { continue; }
|
|
bool isOpen = door.CanBeTraversed;
|
|
if (!isOpen)
|
|
{
|
|
if (!canAttackDoors) { continue; }
|
|
}
|
|
else if (Character.AnimController.CanEnterSubmarine != CanEnterSubmarine.True)
|
|
{
|
|
// Ignore broken and open doors, if cannot enter submarine
|
|
// Also ignore them if the monster can only partially enter the sub:
|
|
// these monsters tend to be too large to get through doors anyway.
|
|
continue;
|
|
}
|
|
}
|
|
else if (aiTarget.Entity is IDamageable { Health: <= 0.0f })
|
|
{
|
|
continue;
|
|
}
|
|
}
|
|
#endregion
|
|
|
|
#region Choose valid targeting params.
|
|
if (targetingTags.None()) { continue; }
|
|
CharacterParams.TargetParams matchingTargetParams = null;
|
|
foreach (var targetParams in GetTargetParams(targetingTags))
|
|
{
|
|
if (matchingTargetParams != null)
|
|
{
|
|
if (matchingTargetParams.Priority > targetParams.Priority)
|
|
{
|
|
// Valid higher priority params already found.
|
|
continue;
|
|
}
|
|
}
|
|
if (targetParams.IgnoreInside && Character.CurrentHull != null) { continue; }
|
|
if (targetParams.IgnoreOutside && Character.CurrentHull == null) { continue; }
|
|
if (targetParams.IgnoreIncapacitated && targetCharacter is { IsIncapacitated: true }) { continue; }
|
|
if (targetParams.IgnoreTargetInside && aiTarget.Entity.Submarine != null) { continue; }
|
|
if (targetParams.IgnoreTargetOutside && aiTarget.Entity.Submarine == null) { continue; }
|
|
if (aiTarget.Entity is ISerializableEntity se)
|
|
{
|
|
if (targetParams.Conditionals.Any(c => !c.TargetSelf && !c.Matches(se))) { continue; }
|
|
}
|
|
if (targetParams.Conditionals.Any(c => c.TargetSelf && !c.Matches(Character))) { continue; }
|
|
if (targetParams.IgnoreIfNotInSameSub)
|
|
{
|
|
if (aiTarget.Entity.Submarine != Character.Submarine) { continue; }
|
|
var targetHull = targetCharacter != null ? targetCharacter.CurrentHull : aiTarget.Entity is Item it ? it.CurrentHull : null;
|
|
if (targetHull == null != (Character.CurrentHull == null)) { continue; }
|
|
}
|
|
if (targetParams.State is AIState.Observe or AIState.Eat)
|
|
{
|
|
if (targetCharacter != null && targetCharacter.Submarine != Character.Submarine)
|
|
{
|
|
// Never allow observing or eating characters that are inside a different submarine / outside when we are inside.
|
|
continue;
|
|
}
|
|
}
|
|
if (aiTarget.Entity is Item targetItem)
|
|
{
|
|
if (targetParams.IgnoreContained && targetItem.ParentInventory != null) { continue; }
|
|
|
|
switch (targetParams.State)
|
|
{
|
|
case AIState.FleeTo:
|
|
{
|
|
float healthThreshold = targetParams.Threshold;
|
|
if (targetParams.ThresholdMin > 0 && targetParams.ThresholdMax > 0)
|
|
{
|
|
// If both min and max thresholds are defined, use ThresholdMax when currently fleeing to the target and ThresholdMin when doing something else.
|
|
// This is used to make the fractal guardians target the guardian repair pods with a different health threshold: when they are already targeting the pod, they will use ThresholdMax to keep them generating until (nearly) full health.
|
|
// And when the guardians are outside the pod, they use ThresholdMin, so that they don't target the pod before falling under certain level of health.
|
|
healthThreshold = currentTargetingParams == targetParams && State == AIState.FleeTo ? targetParams.ThresholdMax : targetParams.ThresholdMin;
|
|
}
|
|
if (Character.HealthPercentage > healthThreshold)
|
|
{
|
|
continue;
|
|
}
|
|
break;
|
|
}
|
|
case AIState.Attack or AIState.Aggressive:
|
|
if (!canAttackItems)
|
|
{
|
|
continue;
|
|
}
|
|
break;
|
|
}
|
|
if (targetItem.HasTag(Tags.GuardianShelter))
|
|
{
|
|
// Ignore guardian pods completely, if they are targeted by someone else (is or will be occupied).
|
|
bool ignore = false;
|
|
foreach (Character otherCharacter in Character.CharacterList)
|
|
{
|
|
if (otherCharacter == Character) { continue; }
|
|
if (otherCharacter.AIController?.SelectedAiTarget != aiTarget) { continue; }
|
|
if (!Character.IsFriendly(otherCharacter)) { continue; }
|
|
ignore = true;
|
|
break;
|
|
}
|
|
if (ignore) { continue; }
|
|
}
|
|
}
|
|
matchingTargetParams = targetParams;
|
|
}
|
|
if (matchingTargetParams == null) { continue; }
|
|
#endregion
|
|
|
|
#region Modify the priority dynamically
|
|
float valueModifier = 1.0f;
|
|
if (targetCharacter != null)
|
|
{
|
|
if (targetCharacter.AIController is EnemyAIController)
|
|
{
|
|
if (matchingTargetParams.Tag == Tags.Stronger && State is AIState.Avoid or AIState.Escape or AIState.Flee)
|
|
{
|
|
if (SelectedAiTarget == aiTarget)
|
|
{
|
|
// Frightened -> hold on to the target
|
|
valueModifier *= 2;
|
|
}
|
|
if (IsBeingChasedBy(targetCharacter))
|
|
{
|
|
valueModifier *= 2;
|
|
}
|
|
if (Character.CurrentHull != null && !VisibleHulls.Contains(targetCharacter.CurrentHull))
|
|
{
|
|
// Inside but in a different room
|
|
valueModifier /= 2;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
if (aiTarget.Entity is Structure s)
|
|
{
|
|
bool isInnerWall = s.Prefab.Tags.Contains("inner");
|
|
// Prefer weaker walls (200 is the default for normal hull walls)
|
|
valueModifier = 200f / s.MaxHealth;
|
|
for (int i = 0; i < s.Sections.Length; i++)
|
|
{
|
|
var section = s.Sections[i];
|
|
if (section.gap == null) { continue; }
|
|
bool leadsInside = !section.gap.IsRoomToRoom && section.gap.FlowTargetHull != null;
|
|
if (tryToGetInside)
|
|
{
|
|
if (!isCharacterInside)
|
|
{
|
|
if (CanPassThroughHole(s, i))
|
|
{
|
|
valueModifier *= leadsInside ? (IsAggressiveBoarder ? priorityValueMaxModifier : 1) : 0;
|
|
}
|
|
else if (IsAggressiveBoarder && leadsInside && canAttackWalls)
|
|
{
|
|
// Up to 100% priority increase for every gap in the wall when an aggressive boarder is outside
|
|
valueModifier *= 1 + section.gap.Open;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// Inside
|
|
if (IsAggressiveBoarder)
|
|
{
|
|
if (!isInnerWall)
|
|
{
|
|
// Only interested in getting inside (aggressive boarder) -> don't target outer walls when already inside
|
|
valueModifier = 0;
|
|
break;
|
|
}
|
|
else if (CanPassThroughHole(s, i))
|
|
{
|
|
valueModifier *= isInnerWall ? 0.5f : 0;
|
|
}
|
|
else if (!canAttackWalls)
|
|
{
|
|
valueModifier = 0;
|
|
break;
|
|
}
|
|
else
|
|
{
|
|
valueModifier = 0.1f;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
if (!canAttackWalls)
|
|
{
|
|
valueModifier = 0;
|
|
break;
|
|
}
|
|
// We are actually interested in breaking things -> reduce the priority when the wall is already broken
|
|
// (Terminalcells)
|
|
valueModifier *= 1 - section.gap.Open * 0.25f;
|
|
valueModifier = Math.Max(valueModifier, 0.1f);
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// Cannot enter
|
|
if (isInnerWall || !canAttackWalls)
|
|
{
|
|
// Ignore inner walls and all walls if cannot do damage on walls.
|
|
valueModifier = 0;
|
|
break;
|
|
}
|
|
else if (IsAggressiveBoarder)
|
|
{
|
|
// Up to 100% priority increase for every gap in the wall when an aggressive boarder is outside
|
|
// (Bonethreshers)
|
|
valueModifier *= 1 + section.gap.Open;
|
|
}
|
|
}
|
|
valueModifier = Math.Clamp(valueModifier, 0, priorityValueMaxModifier);
|
|
}
|
|
}
|
|
if (door != null)
|
|
{
|
|
if (IsAggressiveBoarder)
|
|
{
|
|
if (Character.CurrentHull == null)
|
|
{
|
|
// Increase the priority if the character is outside and the door is from outside to inside
|
|
if (door.CanBeTraversed)
|
|
{
|
|
valueModifier = priorityValueMaxModifier;
|
|
}
|
|
else if (door.LinkedGap != null)
|
|
{
|
|
valueModifier = 1 + door.LinkedGap.Open * (priorityValueMaxModifier - 1);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// Inside -> ignore open doors and outer doors
|
|
bool isOpen = door.CanBeTraversed;
|
|
bool isOutdoor = door.LinkedGap is { FlowTargetHull: not null, IsRoomToRoom: false };
|
|
valueModifier = isOpen || isOutdoor ? 0 : 1;
|
|
}
|
|
}
|
|
}
|
|
else if (aiTarget.Entity is IDamageable { Health: <= 0.0f })
|
|
{
|
|
continue;
|
|
}
|
|
}
|
|
//no need to eat if the character is already in full health (except if it's a pet - pets actually need to eat to stay alive, not just to regain health)
|
|
if (matchingTargetParams.State == AIState.Eat && Character.Params.Health.HealthRegenerationWhenEating > 0 && !Character.IsPet)
|
|
{
|
|
valueModifier *= MathHelper.Lerp(1f, 0.1f, Character.HealthPercentage / 100f);
|
|
}
|
|
valueModifier *= matchingTargetParams.Priority;
|
|
if (valueModifier == 0.0f) { continue; }
|
|
if (matchingTargetParams.Tag != Tags.Decoy)
|
|
{
|
|
if (SwarmBehavior != null && SwarmBehavior.Members.Any())
|
|
{
|
|
// Halve the priority for each swarm mate targeting the same target -> reduces stacking
|
|
foreach (AICharacter otherCharacter in SwarmBehavior.Members)
|
|
{
|
|
if (otherCharacter == Character) { continue; }
|
|
if (otherCharacter.AIController?.SelectedAiTarget != aiTarget) { continue; }
|
|
valueModifier /= 2;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// The same as above, but using all the friendly characters in the level.
|
|
foreach (Character otherCharacter in Character.CharacterList)
|
|
{
|
|
if (otherCharacter == Character) { continue; }
|
|
if (otherCharacter.AIController?.SelectedAiTarget != aiTarget) { continue; }
|
|
if (!Character.IsFriendly(otherCharacter)) { continue; }
|
|
valueModifier /= 2;
|
|
}
|
|
}
|
|
}
|
|
#endregion
|
|
|
|
if (!aiTarget.IsWithinSector(WorldPosition)) { continue; }
|
|
Vector2 toTarget = aiTarget.WorldPosition - Character.WorldPosition;
|
|
float dist = toTarget.Length();
|
|
float nonModifiedDist = dist;
|
|
//if the target has been within range earlier, the character will notice it more easily
|
|
if (targetMemories.ContainsKey(aiTarget))
|
|
{
|
|
dist *= 0.9f;
|
|
}
|
|
if (matchingTargetParams.PerceptionDistanceMultiplier > 0.0f)
|
|
{
|
|
dist /= matchingTargetParams.PerceptionDistanceMultiplier;
|
|
}
|
|
|
|
if (matchingTargetParams.MaxPerceptionDistance > 0.0f &&
|
|
dist * dist > matchingTargetParams.MaxPerceptionDistance * matchingTargetParams.MaxPerceptionDistance)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (State is AIState.PlayDead && targetCharacter == null)
|
|
{
|
|
// Only react to characters, when playing dead.
|
|
continue;
|
|
}
|
|
else if (!CanPerceive(aiTarget, dist, checkVisibility: SelectedAiTarget != aiTarget || State is AIState.PlayDead or AIState.Hiding))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (SelectedAiTarget == aiTarget)
|
|
{
|
|
if (Character.Submarine == null && aiTarget.Entity is ISpatialEntity { Submarine: not null } spatialEntity)
|
|
{
|
|
if (matchingTargetParams.Tag == Tags.Door || matchingTargetParams.Tag == Tags.Wall)
|
|
{
|
|
Vector2 rayStart = Character.SimPosition;
|
|
Vector2 rayEnd = aiTarget.SimPosition + spatialEntity.Submarine.SimPosition;
|
|
Body closestBody = Submarine.PickBody(rayStart, rayEnd, collisionCategory: Physics.CollisionWall | Physics.CollisionLevel, allowInsideFixture: true);
|
|
if (closestBody is { UserData: ISpatialEntity hit })
|
|
{
|
|
Vector2 hitPos = hit.SimPosition;
|
|
if (closestBody.UserData is Submarine)
|
|
{
|
|
hitPos = Submarine.LastPickedPosition;
|
|
}
|
|
else if (hit.Submarine != null)
|
|
{
|
|
hitPos += hit.Submarine.SimPosition;
|
|
}
|
|
float subHalfWidth = spatialEntity.Submarine.Borders.Width / 2f;
|
|
float subHalfHeight = spatialEntity.Submarine.Borders.Height / 2f;
|
|
Vector2 diff = ConvertUnits.ToDisplayUnits(rayEnd - hitPos);
|
|
bool isOtherSideOfTheSub = Math.Abs(diff.X) > subHalfWidth || Math.Abs(diff.Y) > subHalfHeight;
|
|
if (isOtherSideOfTheSub)
|
|
{
|
|
IgnoreTarget(aiTarget);
|
|
ResetAITarget();
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Stick to the current target
|
|
valueModifier *= 1.1f;
|
|
}
|
|
if (!isBeingChased)
|
|
{
|
|
if (matchingTargetParams.State is AIState.Avoid or AIState.PassiveAggressive or AIState.Aggressive)
|
|
{
|
|
float reactDistance = matchingTargetParams.ReactDistance;
|
|
if (reactDistance > 0 && reactDistance < dist)
|
|
{
|
|
// The target is too far and should be ignored.
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
|
|
//if the target is very close, the distance doesn't make much difference
|
|
// -> just ignore the distance and target whatever has the highest priority
|
|
dist = Math.Max(dist, 100.0f);
|
|
targetMemory = GetTargetMemory(aiTarget, addIfNotFound: true, keepAlive: SelectedAiTarget != aiTarget);
|
|
if (Character.Submarine != null && !Character.Submarine.Info.IsRuin && Character.CurrentHull != null)
|
|
{
|
|
float diff = Math.Abs(toTarget.Y) - Character.CurrentHull.Size.Y;
|
|
if (diff > 0)
|
|
{
|
|
// Inside the sub, treat objects that are up or down, as they were farther away.
|
|
dist *= MathHelper.Clamp(diff / 100, 2, 3);
|
|
}
|
|
}
|
|
|
|
if (Character.Submarine == null && aiTarget.Entity?.Submarine != null && targetCharacter == null)
|
|
{
|
|
if (matchingTargetParams.PrioritizeSubCenter || matchingTargetParams.AttackPattern is AttackPattern.Circle or AttackPattern.Sweep)
|
|
{
|
|
if (!isAnyTargetClose)
|
|
{
|
|
if (Submarine.MainSubs.Contains(aiTarget.Entity.Submarine))
|
|
{
|
|
// Prioritize targets that are near the horizontal center of the sub, but only when none of the targets is reachable.
|
|
float horizontalDistanceToSubCenter = Math.Abs(aiTarget.WorldPosition.X - aiTarget.Entity.Submarine.WorldPosition.X);
|
|
dist *= MathHelper.Lerp(1f, 5f, MathUtils.InverseLerp(0, 10000, horizontalDistanceToSubCenter));
|
|
}
|
|
else if (matchingTargetParams.AttackPattern == AttackPattern.Circle)
|
|
{
|
|
dist *= 5;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (targetCharacter != null && Character.CurrentHull != null && Character.CurrentHull == targetCharacter.CurrentHull)
|
|
{
|
|
// In the same room with the target character
|
|
dist /= 2;
|
|
}
|
|
|
|
// Don't target characters that are outside of the allowed zone, unless chasing or escaping.
|
|
switch (matchingTargetParams.State)
|
|
{
|
|
case AIState.Escape:
|
|
case AIState.Avoid:
|
|
break;
|
|
default:
|
|
if (matchingTargetParams.State == AIState.Attack)
|
|
{
|
|
// In the attack state allow going into non-allowed zone only when chasing a target.
|
|
if (State == matchingTargetParams.State && SelectedAiTarget == aiTarget) { break; }
|
|
}
|
|
bool insideSameSub = aiTarget?.Entity?.Submarine != null && aiTarget.Entity.Submarine == Character.Submarine;
|
|
if (!insideSameSub && !IsPositionInsideAllowedZone(aiTarget.WorldPosition, out _))
|
|
{
|
|
// If we have recently been damaged by the target (or another player/bot in the same team) allow targeting it even when we are in the idle state.
|
|
bool isTargetInPlayerTeam = IsTargetInPlayerTeam(aiTarget);
|
|
if (Character.LastAttackers.None(a => a.Damage > 0 && a.Character != null && (a.Character == aiTarget.Entity || a.Character.IsOnPlayerTeam && isTargetInPlayerTeam)))
|
|
{
|
|
continue;
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
|
|
valueModifier *=
|
|
targetMemory.Priority /
|
|
//sqrt = the further the target is, the less the distance matters
|
|
MathF.Sqrt(dist);
|
|
|
|
if (valueModifier > targetValue)
|
|
{
|
|
if (aiTarget.Entity is Item i)
|
|
{
|
|
Character owner = GetOwner(i);
|
|
if (owner == Character) { continue; }
|
|
if (owner != null)
|
|
{
|
|
if (owner.AiTarget != null && ignoredTargets.Contains(owner.AiTarget)) { continue; }
|
|
if (Character.IsFriendly(owner))
|
|
{
|
|
// Don't target items that we own. This is a rare case, and almost entirely related to Humanhusks (in the vanilla game).
|
|
continue;
|
|
}
|
|
if (owner.HasAbilityFlag(AbilityFlags.IgnoredByEnemyAI))
|
|
{
|
|
// ignore if owner is tagged to be explicitly ignored (Feign Death)
|
|
continue;
|
|
}
|
|
// if the enemy is configured to ignore the target character, ignore the provocative item they're holding/wearing too
|
|
if (GetTargetParams(GetTargetingTags(owner.AiTarget)).Any(t => t.State == AIState.Idle)) { continue; }
|
|
}
|
|
}
|
|
if (targetCharacter != null)
|
|
{
|
|
if (Character.CurrentHull != null && targetCharacter.CurrentHull != Character.CurrentHull)
|
|
{
|
|
if (matchingTargetParams.State is AIState.Observe or AIState.Eat ||
|
|
(matchingTargetParams.State is AIState.Follow or AIState.Protect && (!Character.CanClimb || !Character.CanInteract || !AIParams.CanOpenDoors || !Character.Params.UsePathFinding)))
|
|
{
|
|
if (!VisibleHulls.Contains(targetCharacter.CurrentHull))
|
|
{
|
|
// Probably can't get to the target -> ignore.
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
if (targetCharacter.Submarine != Character.Submarine || (targetCharacter.CurrentHull == null) != (Character.CurrentHull == null))
|
|
{
|
|
if (targetCharacter.Submarine != null)
|
|
{
|
|
if (Character.Submarine != null && !targetCharacter.Submarine.IsConnectedTo(Character.Submarine))
|
|
{
|
|
// Both inside different, unconnected submarines -> can ignore safely
|
|
continue;
|
|
}
|
|
else
|
|
{
|
|
// Target is inside a submarine that we are not -> reduce the priority
|
|
valueModifier *= 0.5f;
|
|
}
|
|
}
|
|
else if (Character.CurrentHull != null)
|
|
{
|
|
// Target outside, but we are inside -> Ignore the target but allow to keep target that is currently selected.
|
|
if (SelectedAiTarget?.Entity != targetCharacter)
|
|
{
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
else if (targetCharacter.Submarine == null && Character.Submarine == null)
|
|
{
|
|
// Ignore the target when it's far enough and blocked by the level geometry, because the steering avoidance probably can't get us to the target.
|
|
if (dist > Math.Clamp(ConvertUnits.ToDisplayUnits(colliderLength) * 10, 1000, 5000))
|
|
{
|
|
if (Submarine.PickBodies(SimPosition, targetCharacter.SimPosition, collisionCategory: Physics.CollisionLevel).Any())
|
|
{
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
newTarget = aiTarget;
|
|
selectedTargetParams = matchingTargetParams;
|
|
targetValue = valueModifier;
|
|
if (!isAnyTargetClose)
|
|
{
|
|
isAnyTargetClose = ConvertUnits.ToDisplayUnits(colliderLength) > nonModifiedDist;
|
|
}
|
|
}
|
|
}
|
|
|
|
currentTargetingParams = selectedTargetParams;
|
|
currentTargetMemory = targetMemory;
|
|
State = currentTargetingParams?.State ?? AIState.Idle;
|
|
SelectedAiTarget = newTarget;
|
|
if ((LatchOntoAI is not { IsAttached: true } || wallTarget != null) && State is AIState.Attack or AIState.Aggressive or AIState.PassiveAggressive)
|
|
{
|
|
UpdateWallTarget(requiredHoleCount);
|
|
}
|
|
updateTargetsTimer = updateTargetsInterval * Rand.Range(0.75f, 1.25f);
|
|
}
|
|
|
|
class WallTarget
|
|
{
|
|
public Vector2 Position;
|
|
public Structure Structure;
|
|
public int SectionIndex;
|
|
|
|
public WallTarget(Vector2 position, Structure structure = null, int sectionIndex = -1)
|
|
{
|
|
Position = position;
|
|
Structure = structure;
|
|
SectionIndex = sectionIndex;
|
|
}
|
|
}
|
|
|
|
private WallTarget wallTarget;
|
|
private readonly List<(Body, int, Vector2)> wallHits = new List<(Body, int, Vector2)>(3);
|
|
private void UpdateWallTarget(int requiredHoleCount)
|
|
{
|
|
wallTarget = null;
|
|
if (SelectedAiTarget == null) { return; }
|
|
if (SelectedAiTarget.Entity == null) { return; }
|
|
if (!canAttackWalls) { return; }
|
|
if (HasValidPath(requireNonDirty: true)) { return; }
|
|
wallHits.Clear();
|
|
Structure wall = null;
|
|
Vector2 refPos = AttackLimb != null ? AttackLimb.SimPosition : SimPosition;
|
|
if (AIParams.WallTargetingMethod.HasFlag(WallTargetingMethod.Target))
|
|
{
|
|
Vector2 rayStart = refPos;
|
|
Vector2 rayEnd = SelectedAiTarget.SimPosition;
|
|
if (SelectedAiTarget.Entity.Submarine != null && Character.Submarine == null)
|
|
{
|
|
rayStart -= SelectedAiTarget.Entity.Submarine.SimPosition;
|
|
}
|
|
else if (SelectedAiTarget.Entity.Submarine == null && Character.Submarine != null)
|
|
{
|
|
rayEnd -= Character.Submarine.SimPosition;
|
|
}
|
|
DoRayCast(rayStart, rayEnd);
|
|
}
|
|
if (AIParams.WallTargetingMethod.HasFlag(WallTargetingMethod.Heading))
|
|
{
|
|
Vector2 rayStart = refPos;
|
|
Vector2 rayEnd = rayStart + VectorExtensions.Forward(Character.AnimController.Collider.Rotation + MathHelper.PiOver2, avoidLookAheadDistance * 5);
|
|
if (SelectedAiTarget.Entity.Submarine != null && Character.Submarine == null)
|
|
{
|
|
rayStart -= SelectedAiTarget.Entity.Submarine.SimPosition;
|
|
rayEnd -= SelectedAiTarget.Entity.Submarine.SimPosition;
|
|
}
|
|
else if (SelectedAiTarget.Entity.Submarine == null && Character.Submarine != null)
|
|
{
|
|
rayStart -= Character.Submarine.SimPosition;
|
|
rayEnd -= Character.Submarine.SimPosition;
|
|
}
|
|
DoRayCast(rayStart, rayEnd);
|
|
}
|
|
if (AIParams.WallTargetingMethod.HasFlag(WallTargetingMethod.Steering))
|
|
{
|
|
Vector2 rayStart = refPos;
|
|
Vector2 rayEnd = rayStart + Steering * 5;
|
|
if (SelectedAiTarget.Entity.Submarine != null && Character.Submarine == null)
|
|
{
|
|
rayStart -= SelectedAiTarget.Entity.Submarine.SimPosition;
|
|
rayEnd -= SelectedAiTarget.Entity.Submarine.SimPosition;
|
|
}
|
|
else if (SelectedAiTarget.Entity.Submarine == null && Character.Submarine != null)
|
|
{
|
|
rayStart -= Character.Submarine.SimPosition;
|
|
rayEnd -= Character.Submarine.SimPosition;
|
|
}
|
|
DoRayCast(rayStart, rayEnd);
|
|
}
|
|
if (wallHits.Any())
|
|
{
|
|
Vector2 targetdiff = ConvertUnits.ToSimUnits(SelectedAiTarget.WorldPosition - (AttackLimb != null ? AttackLimb.WorldPosition : WorldPosition));
|
|
float targetDistance = targetdiff.LengthSquared();
|
|
Body closestBody = null;
|
|
float closestDistance = 0;
|
|
int sectionIndex = -1;
|
|
Vector2 sectionPos = Vector2.Zero;
|
|
foreach ((Body body, int index, Vector2 sectionPosition) in wallHits)
|
|
{
|
|
Structure structure = body.UserData as Structure;
|
|
float distance = Vector2.DistanceSquared(
|
|
refPos,
|
|
Submarine.GetRelativeSimPosition(ConvertUnits.ToSimUnits(sectionPosition), Character.Submarine, structure.Submarine));
|
|
//if the wall is further than the target (e.g. at the other side of the sub?), we shouldn't be targeting it
|
|
if (distance > targetDistance) { continue; }
|
|
if (closestBody == null || closestDistance == 0 || distance < closestDistance)
|
|
{
|
|
closestBody = body;
|
|
closestDistance = distance;
|
|
wall = structure;
|
|
sectionPos = sectionPosition;
|
|
sectionIndex = index;
|
|
}
|
|
}
|
|
if (closestBody == null || sectionIndex == -1) { return; }
|
|
Vector2 attachTargetNormal;
|
|
if (wall.IsHorizontal)
|
|
{
|
|
attachTargetNormal = new Vector2(0.0f, Math.Sign(WorldPosition.Y - wall.WorldPosition.Y));
|
|
sectionPos.Y += (wall.BodyHeight <= 0.0f ? wall.Rect.Height : wall.BodyHeight) / 2 * attachTargetNormal.Y;
|
|
}
|
|
else
|
|
{
|
|
attachTargetNormal = new Vector2(Math.Sign(WorldPosition.X - wall.WorldPosition.X), 0.0f);
|
|
sectionPos.X += (wall.BodyWidth <= 0.0f ? wall.Rect.Width : wall.BodyWidth) / 2 * attachTargetNormal.X;
|
|
}
|
|
LatchOntoAI?.SetAttachTarget(wall, ConvertUnits.ToSimUnits(sectionPos), attachTargetNormal);
|
|
if (Character.AnimController.CanEnterSubmarine == CanEnterSubmarine.True ||
|
|
!wall.SectionBodyDisabled(sectionIndex) && !IsWallDisabled(wall))
|
|
{
|
|
if (wall.NoAITarget && Character.AnimController.CanEnterSubmarine == CanEnterSubmarine.True)
|
|
{
|
|
bool isTargetingDoor = SelectedAiTarget.Entity is Item i && i.GetComponent<Door>() != null;
|
|
// Blocked by a wall that shouldn't be targeted. The main intention here is to prevent monsters from entering the the tail and the nose pieces.
|
|
if (!isTargetingDoor)
|
|
{
|
|
//TODO: this might cause problems: many wall pieces (like smaller shuttle pieces
|
|
//and small decorative wall structures are currently marked as having no AI target,
|
|
//which can mean a monster very frequently ignores targets inside because they're blocked by those structures
|
|
IgnoreTarget(SelectedAiTarget);
|
|
ResetAITarget();
|
|
}
|
|
}
|
|
else
|
|
{
|
|
wallTarget = new WallTarget(sectionPos, wall, sectionIndex);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// Blocked by a disabled wall.
|
|
IgnoreTarget(SelectedAiTarget);
|
|
ResetAITarget();
|
|
}
|
|
}
|
|
|
|
void DoRayCast(Vector2 rayStart, Vector2 rayEnd)
|
|
{
|
|
Body hitTarget = Submarine.CheckVisibility(rayStart, rayEnd, ignoreSubs: true,
|
|
ignoreSensors: CanEnterSubmarine != CanEnterSubmarine.False,
|
|
ignoreDisabledWalls: CanEnterSubmarine != CanEnterSubmarine.False);
|
|
if (hitTarget != null && IsValid(hitTarget, out wall))
|
|
{
|
|
int sectionIndex = wall.FindSectionIndex(ConvertUnits.ToDisplayUnits(Submarine.LastPickedPosition));
|
|
if (sectionIndex >= 0)
|
|
{
|
|
wallHits.Add((hitTarget, sectionIndex, GetSectionPosition(wall, sectionIndex)));
|
|
}
|
|
}
|
|
}
|
|
|
|
Vector2 GetSectionPosition(Structure wall, int sectionIndex)
|
|
{
|
|
float sectionDamage = wall.SectionDamage(sectionIndex);
|
|
for (int i = sectionIndex - 2; i <= sectionIndex + 2; i++)
|
|
{
|
|
if (wall.SectionBodyDisabled(i))
|
|
{
|
|
if (Character.AnimController.CanEnterSubmarine != CanEnterSubmarine.False &&
|
|
CanPassThroughHole(wall, i, requiredHoleCount))
|
|
{
|
|
sectionIndex = i;
|
|
break;
|
|
}
|
|
else
|
|
{
|
|
// Ignore and keep breaking other sections
|
|
continue;
|
|
}
|
|
}
|
|
if (wall.SectionDamage(i) > sectionDamage)
|
|
{
|
|
sectionIndex = i;
|
|
}
|
|
}
|
|
return wall.SectionPosition(sectionIndex, world: false);
|
|
}
|
|
|
|
bool IsValid(Body hit, out Structure wall)
|
|
{
|
|
wall = null;
|
|
if (Submarine.LastPickedFraction == 1.0f) { return false; }
|
|
if (hit.UserData is not Structure w) { return false; }
|
|
if (w.Submarine == null) { return false; }
|
|
if (w.Submarine != SelectedAiTarget.Entity.Submarine) { return false; }
|
|
if (Character.Submarine == null)
|
|
{
|
|
if (w.Prefab.Tags.Contains("inner"))
|
|
{
|
|
if (Character.AnimController.CanEnterSubmarine == CanEnterSubmarine.False) { return false; }
|
|
}
|
|
else if (!AIParams.TargetOuterWalls)
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
wall = w;
|
|
return true;
|
|
}
|
|
}
|
|
|
|
private bool TrySteerThroughGaps(float deltaTime)
|
|
{
|
|
if (wallTarget != null && wallTarget.SectionIndex > -1 && CanPassThroughHole(wallTarget.Structure, wallTarget.SectionIndex, requiredHoleCount))
|
|
{
|
|
WallSection section = wallTarget.Structure.GetSection(wallTarget.SectionIndex);
|
|
Vector2 targetPos = wallTarget.Structure.SectionPosition(wallTarget.SectionIndex, world: true);
|
|
return section?.gap != null && SteerThroughGap(wallTarget.Structure, section, targetPos, deltaTime);
|
|
}
|
|
else if (SelectedAiTarget != null)
|
|
{
|
|
if (SelectedAiTarget.Entity is Structure wall)
|
|
{
|
|
for (int i = 0; i < wall.Sections.Length; i++)
|
|
{
|
|
WallSection section = wall.Sections[i];
|
|
if (CanPassThroughHole(wall, i, requiredHoleCount) && section?.gap != null)
|
|
{
|
|
return SteerThroughGap(wall, section, wall.SectionPosition(i, true), deltaTime);
|
|
}
|
|
}
|
|
}
|
|
else if (SelectedAiTarget.Entity is Item i)
|
|
{
|
|
var door = i.GetComponent<Door>();
|
|
// Don't try to enter dry hulls if cannot walk or if the gap is too narrow
|
|
if (door?.LinkedGap?.FlowTargetHull != null && !door.LinkedGap.IsRoomToRoom && door.CanBeTraversed)
|
|
{
|
|
if (Character.AnimController.CanWalk || door.LinkedGap.FlowTargetHull.WaterPercentage > 25)
|
|
{
|
|
if (door.LinkedGap.Size > ConvertUnits.ToDisplayUnits(colliderWidth))
|
|
{
|
|
float maxDistance = Math.Max(ConvertUnits.ToDisplayUnits(colliderLength), 100);
|
|
return SteerThroughGap(door.LinkedGap, door.LinkedGap.FlowTargetHull.WorldPosition, deltaTime, maxDistance: maxDistance);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private AITargetMemory GetTargetMemory(AITarget target, bool addIfNotFound = false, bool keepAlive = false)
|
|
{
|
|
if (!targetMemories.TryGetValue(target, out AITargetMemory memory))
|
|
{
|
|
if (addIfNotFound)
|
|
{
|
|
memory = new AITargetMemory(target, minPriority);
|
|
targetMemories.Add(target, memory);
|
|
}
|
|
}
|
|
if (keepAlive)
|
|
{
|
|
memory.Priority = Math.Max(memory.Priority, minPriority);
|
|
}
|
|
return memory;
|
|
}
|
|
|
|
private void UpdateCurrentMemoryLocation()
|
|
{
|
|
if (_selectedAiTarget != null)
|
|
{
|
|
if (_selectedAiTarget.Entity == null || _selectedAiTarget.Entity.Removed)
|
|
{
|
|
_selectedAiTarget = null;
|
|
}
|
|
else if (CanPerceive(_selectedAiTarget, checkVisibility: false))
|
|
{
|
|
var memory = GetTargetMemory(_selectedAiTarget);
|
|
if (memory != null)
|
|
{
|
|
memory.Location = _selectedAiTarget.WorldPosition;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private readonly List<AITarget> removals = new List<AITarget>();
|
|
private void FadeMemories(float deltaTime)
|
|
{
|
|
removals.Clear();
|
|
foreach (var kvp in targetMemories)
|
|
{
|
|
var target = kvp.Key;
|
|
var memory = kvp.Value;
|
|
// Slowly decrease all memories
|
|
float fadeTime = memoryFadeTime;
|
|
if (target == SelectedAiTarget)
|
|
{
|
|
// Don't decrease the current memory
|
|
fadeTime = 0;
|
|
}
|
|
else if (target == _lastAiTarget)
|
|
{
|
|
// Halve the latest memory fading.
|
|
fadeTime /= 2;
|
|
}
|
|
memory.Priority -= fadeTime * deltaTime;
|
|
// Remove targets that have no priority or have been removed
|
|
if (memory.Priority <= 1 || target.Entity == null || target.Entity.Removed || !AITarget.List.Contains(target))
|
|
{
|
|
removals.Add(target);
|
|
}
|
|
}
|
|
removals.ForEach(r => targetMemories.Remove(r));
|
|
}
|
|
|
|
private readonly float targetIgnoreTime = 10;
|
|
private float targetIgnoreTimer;
|
|
private readonly HashSet<AITarget> ignoredTargets = new HashSet<AITarget>();
|
|
public void IgnoreTarget(AITarget target)
|
|
{
|
|
if (target == null) { return; }
|
|
ignoredTargets.Add(target);
|
|
targetIgnoreTimer = targetIgnoreTime * Rand.Range(0.75f, 1.25f);
|
|
}
|
|
#endregion
|
|
|
|
#region State switching
|
|
/// <summary>
|
|
/// How long do we hold on to the current state after losing a target before we reset back to the original state.
|
|
/// In other words, how long do we have to idle before the original state is restored.
|
|
/// </summary>
|
|
private readonly float stateResetCooldown = 10;
|
|
private float stateResetTimer;
|
|
private bool isStateChanged;
|
|
private readonly Dictionary<StatusEffect.AITrigger, CharacterParams.TargetParams> activeTriggers = new Dictionary<StatusEffect.AITrigger, CharacterParams.TargetParams>();
|
|
private readonly HashSet<StatusEffect.AITrigger> inactiveTriggers = new HashSet<StatusEffect.AITrigger>();
|
|
|
|
public void LaunchTrigger(StatusEffect.AITrigger trigger)
|
|
{
|
|
if (trigger.IsTriggered) { return; }
|
|
if (activeTriggers.ContainsKey(trigger)) { return; }
|
|
if (activeTriggers.ContainsValue(currentTargetingParams))
|
|
{
|
|
if (!trigger.AllowToOverride) { return; }
|
|
var existingTrigger = activeTriggers.FirstOrDefault(kvp => kvp.Value == currentTargetingParams && kvp.Key.AllowToBeOverridden);
|
|
if (existingTrigger.Key == null) { return; }
|
|
activeTriggers.Remove(existingTrigger.Key);
|
|
}
|
|
trigger.Launch();
|
|
activeTriggers.Add(trigger, currentTargetingParams);
|
|
ChangeParams(currentTargetingParams, trigger.State);
|
|
}
|
|
|
|
private void UpdateTriggers(float deltaTime)
|
|
{
|
|
foreach (var triggerObject in activeTriggers)
|
|
{
|
|
StatusEffect.AITrigger trigger = triggerObject.Key;
|
|
if (trigger.IsPermanent) { continue; }
|
|
trigger.UpdateTimer(deltaTime);
|
|
if (!trigger.IsActive)
|
|
{
|
|
trigger.Reset();
|
|
ResetParams(triggerObject.Value);
|
|
inactiveTriggers.Add(trigger);
|
|
}
|
|
}
|
|
foreach (StatusEffect.AITrigger trigger in inactiveTriggers)
|
|
{
|
|
activeTriggers.Remove(trigger);
|
|
}
|
|
inactiveTriggers.Clear();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resets the target's state to the original value defined in the xml.
|
|
/// </summary>
|
|
private bool TryResetOriginalState(Identifier tag)
|
|
{
|
|
if (!modifiedParams.ContainsKey(tag)) { return false; }
|
|
if (AIParams.TryGetTargets(tag, out IEnumerable<CharacterParams.TargetParams> matchingParams))
|
|
{
|
|
foreach (var targetParams in matchingParams)
|
|
{
|
|
modifiedParams.Remove(tag);
|
|
if (tempParams.ContainsKey(tag))
|
|
{
|
|
tempParams.Values.ForEach(t => AIParams.RemoveTarget(t));
|
|
tempParams.Remove(tag);
|
|
}
|
|
ResetParams(targetParams);
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parameters originally defined in the AI params and modified temporarily.
|
|
/// </summary>
|
|
private readonly Dictionary<Identifier, IEnumerable<CharacterParams.TargetParams>> modifiedParams = new Dictionary<Identifier, IEnumerable<CharacterParams.TargetParams>>();
|
|
/// <summary>
|
|
/// Parameters created temporarily. Not originally defined in the AI params at all.
|
|
/// </summary>
|
|
private readonly Dictionary<Identifier, CharacterParams.TargetParams> tempParams = new Dictionary<Identifier, CharacterParams.TargetParams>();
|
|
private readonly List<CharacterParams.TargetParams> tempParamsList = new List<CharacterParams.TargetParams>();
|
|
|
|
private void ChangeParams(CharacterParams.TargetParams targetParams, AIState state, float? priority = null)
|
|
{
|
|
if (targetParams == null) { return; }
|
|
if (priority.HasValue)
|
|
{
|
|
targetParams.Priority = priority.Value;
|
|
}
|
|
targetParams.State = state;
|
|
}
|
|
|
|
private void ResetParams(CharacterParams.TargetParams targetParams)
|
|
{
|
|
targetParams?.Reset();
|
|
if (currentTargetingParams == targetParams || State is AIState.Idle or AIState.Patrol)
|
|
{
|
|
ResetAITarget();
|
|
State = AIState.Idle;
|
|
PreviousState = AIState.Idle;
|
|
}
|
|
}
|
|
|
|
private void ChangeParams(Identifier tag, AIState state, float? priority = null, bool onlyExisting = false, bool ignoreAttacksIfNotInSameSub = false)
|
|
{
|
|
var existingTargetParams = GetTargetParams(tag);
|
|
if (existingTargetParams.None())
|
|
{
|
|
if (!onlyExisting && !tempParams.ContainsKey(tag))
|
|
{
|
|
if (AIParams.TryAddNewTarget(tag, state, priority ?? minPriority, out CharacterParams.TargetParams targetParams))
|
|
{
|
|
tempParams.Add(tag, targetParams);
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
foreach (var targetParams in existingTargetParams)
|
|
{
|
|
if (priority.HasValue)
|
|
{
|
|
targetParams.Priority = Math.Max(targetParams.Priority, priority.Value);
|
|
}
|
|
targetParams.State = state;
|
|
if (state == AIState.Attack)
|
|
{
|
|
targetParams.IgnoreIfNotInSameSub = ignoreAttacksIfNotInSameSub;
|
|
targetParams.IgnoreInside = false;
|
|
targetParams.IgnoreOutside = false;
|
|
targetParams.IgnoreTargetInside = false;
|
|
targetParams.IgnoreTargetOutside = false;
|
|
targetParams.IgnoreIncapacitated = false;
|
|
}
|
|
}
|
|
modifiedParams.TryAdd(tag, existingTargetParams);
|
|
}
|
|
}
|
|
|
|
private void ChangeTargetState(Identifier tag, AIState state, float? priority = null)
|
|
{
|
|
isStateChanged = true;
|
|
SetStateResetTimer();
|
|
ChangeParams(tag, state, priority);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Temporarily changes the predefined state for a target. Eg. Idle -> Attack.
|
|
/// Note: does not change the current AIState!
|
|
/// </summary>
|
|
private void ChangeTargetState(Character target, AIState state, float? priority = null)
|
|
{
|
|
isStateChanged = true;
|
|
SetStateResetTimer();
|
|
if (!Character.IsPet || !target.IsHuman)
|
|
{
|
|
//don't turn pets hostile to all humans when attacked by one
|
|
ChangeParams(target.SpeciesName, state, priority, ignoreAttacksIfNotInSameSub: !target.IsHuman);
|
|
}
|
|
if (target.IsHuman)
|
|
{
|
|
if (AIParams.TryGetHighestPriorityTarget(Tags.Human, out CharacterParams.TargetParams targetParams))
|
|
{
|
|
priority = targetParams.Priority;
|
|
}
|
|
// Target also items, because if we are blind and the target doesn't move, we can only perceive the target when it uses items
|
|
if (state is AIState.Attack or AIState.Escape)
|
|
{
|
|
ChangeParams(Tags.Weapon, state, priority);
|
|
ChangeParams(Tags.ToolItem, state, priority);
|
|
}
|
|
if (state == AIState.Attack)
|
|
{
|
|
// If the target is shooting from the submarine, we might not perceive it because it doesn't move.
|
|
// --> Target the submarine too.
|
|
if (target.Submarine != null && Character.Submarine == null && (canAttackDoors || canAttackWalls))
|
|
{
|
|
ChangeParams(Tags.Room, state, priority / 2);
|
|
if (canAttackWalls)
|
|
{
|
|
ChangeParams(Tags.Wall, state, priority / 2);
|
|
}
|
|
if (canAttackDoors && IsAggressiveBoarder)
|
|
{
|
|
ChangeParams(Tags.Door, state, priority / 2);
|
|
}
|
|
}
|
|
ChangeParams(Tags.Provocative, state, priority, onlyExisting: true);
|
|
}
|
|
}
|
|
}
|
|
|
|
private void ResetOriginalState()
|
|
{
|
|
isStateChanged = false;
|
|
modifiedParams.Keys.ForEachMod(tag => TryResetOriginalState(tag));
|
|
}
|
|
#endregion
|
|
|
|
protected override void OnTargetChanged(AITarget previousTarget, AITarget newTarget)
|
|
{
|
|
base.OnTargetChanged(previousTarget, newTarget);
|
|
if ((newTarget != null || wallTarget != null) && IsLatchedOnSub)
|
|
{
|
|
if (newTarget?.Entity is not Structure wall)
|
|
{
|
|
wall = wallTarget?.Structure;
|
|
}
|
|
// The target is not a wall or it's not the same as we are attached to -> release
|
|
bool releaseTarget = wall?.Bodies == null || (!wall.Bodies.Contains(LatchOntoAI.AttachJoints[0].BodyB) && wall.Submarine?.PhysicsBody?.FarseerBody != LatchOntoAI.AttachJoints[0].BodyB);
|
|
if (!releaseTarget)
|
|
{
|
|
for (int i = 0; i < wall.Sections.Length; i++)
|
|
{
|
|
if (CanPassThroughHole(wall, i))
|
|
{
|
|
releaseTarget = true;
|
|
}
|
|
}
|
|
}
|
|
if (releaseTarget)
|
|
{
|
|
wallTarget = null;
|
|
LatchOntoAI.DeattachFromBody(reset: true, cooldown: 1);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
wallTarget = null;
|
|
}
|
|
if (newTarget == null) { return; }
|
|
if (currentTargetingParams != null)
|
|
{
|
|
observeTimer = currentTargetingParams.Timer * Rand.Range(0.75f, 1.25f);
|
|
}
|
|
reachTimer = 0;
|
|
sinTime = 0;
|
|
if (breakCircling && strikeTimer <= 0 && CirclePhase != CirclePhase.CloseIn)
|
|
{
|
|
CirclePhase = CirclePhase.Start;
|
|
}
|
|
}
|
|
|
|
protected override void OnStateChanged(AIState from, AIState to)
|
|
{
|
|
LatchOntoAI?.DeattachFromBody(reset: true);
|
|
if (disableTailCoroutine != null)
|
|
{
|
|
bool isInTransition = from is AIState.HideTo or AIState.Hiding && to is AIState.HideTo or AIState.Hiding;
|
|
if (!isInTransition)
|
|
{
|
|
CoroutineManager.StopCoroutines(disableTailCoroutine);
|
|
Character.AnimController.RestoreTemporarilyDisabled();
|
|
disableTailCoroutine = null;
|
|
}
|
|
}
|
|
if (to is AIState.Hiding)
|
|
{
|
|
ReleaseDragTargets();
|
|
}
|
|
Character.AnimController.ReleaseStuckLimbs();
|
|
AttackLimb = null;
|
|
movementMargin = 0;
|
|
ResetEscape();
|
|
if (isStateChanged && to == AIState.Idle && from != to)
|
|
{
|
|
SetStateResetTimer();
|
|
}
|
|
blockCheckTimer = 0;
|
|
reachTimer = 0;
|
|
sinTime = 0;
|
|
if (breakCircling && strikeTimer <= 0 && CirclePhase != CirclePhase.CloseIn)
|
|
{
|
|
CirclePhase = CirclePhase.Start;
|
|
}
|
|
if (to != AIState.Idle)
|
|
{
|
|
playDeadTimer = PlayDeadCoolDown;
|
|
}
|
|
#if CLIENT
|
|
if (to == AIState.Attack)
|
|
{
|
|
Character.PlaySound(CharacterSound.SoundType.Attack, maxInterval: 3);
|
|
}
|
|
#endif
|
|
}
|
|
|
|
private void SetStateResetTimer() => stateResetTimer = stateResetCooldown * Rand.Range(0.75f, 1.25f);
|
|
|
|
private float GetPerceivingRange(AITarget target)
|
|
{
|
|
float maxSightOrSoundRange = Math.Max(target.SightRange * Sight, target.SoundRange * Hearing);
|
|
if (AIParams.MaxPerceptionDistance >= 0 && maxSightOrSoundRange > AIParams.MaxPerceptionDistance) { return AIParams.MaxPerceptionDistance; }
|
|
return maxSightOrSoundRange;
|
|
}
|
|
|
|
private bool CanPerceive(AITarget target, float dist = -1, float distSquared = -1, bool checkVisibility = false)
|
|
{
|
|
if (target?.Entity == null) { return false; }
|
|
bool insideSightRange;
|
|
bool insideSoundRange;
|
|
if (checkVisibility)
|
|
{
|
|
Submarine mySub = Character.Submarine;
|
|
Submarine targetSub = target.Entity.Submarine;
|
|
// We only want to check the visibility when the target is in ruins/wreck/similiar place where sneaking should be possible.
|
|
// When the monsters attack the player sub, they wall hack so that they can be more aggressive.
|
|
// Pets should always check the visibility, unless the pet and the target are both outside the submarine -> shouldn't target when they can't perceive (= no wall hack)
|
|
checkVisibility =
|
|
(Character.IsPet && (mySub != null || targetSub != null)) ||
|
|
(mySub != null && (targetSub == null || (targetSub == mySub && !targetSub.Info.IsPlayer)));
|
|
}
|
|
if (dist > 0)
|
|
{
|
|
if (AIParams.MaxPerceptionDistance >= 0 && dist > AIParams.MaxPerceptionDistance) { return false; }
|
|
insideSightRange = IsInRange(dist, target.SightRange, Sight);
|
|
if (!checkVisibility && insideSightRange) { return true; }
|
|
insideSoundRange = IsInRange(dist, target.SoundRange, Hearing);
|
|
}
|
|
else
|
|
{
|
|
if (distSquared < 0)
|
|
{
|
|
distSquared = Vector2.DistanceSquared(Character.WorldPosition, target.WorldPosition);
|
|
}
|
|
if (AIParams.MaxPerceptionDistance >= 0 && distSquared > AIParams.MaxPerceptionDistance * AIParams.MaxPerceptionDistance) { return false; }
|
|
insideSightRange = IsInRangeSqr(distSquared, target.SightRange, Sight);
|
|
if (!checkVisibility && insideSightRange) { return true; }
|
|
insideSoundRange = IsInRangeSqr(distSquared, target.SoundRange, Hearing);
|
|
}
|
|
if (!checkVisibility)
|
|
{
|
|
return insideSightRange || insideSoundRange;
|
|
}
|
|
else
|
|
{
|
|
if (!insideSightRange && !insideSoundRange) { return false; }
|
|
// Inside the same submarine -> check whether the target is behind a wall
|
|
if (target.Entity is Character c && VisibleHulls.Contains(c.CurrentHull) || target.Entity is Item i && VisibleHulls.Contains(i.CurrentHull))
|
|
{
|
|
return insideSightRange || insideSoundRange;
|
|
}
|
|
else
|
|
{
|
|
// No line of sight to the target -> Ignore sight and use only half of the sound range
|
|
if (dist > 0)
|
|
{
|
|
return IsInRange(dist, target.SoundRange, Hearing / 2);
|
|
}
|
|
else
|
|
{
|
|
if (distSquared < 0)
|
|
{
|
|
distSquared = Vector2.DistanceSquared(Character.WorldPosition, target.WorldPosition);
|
|
}
|
|
return IsInRangeSqr(distSquared, target.SoundRange, Hearing / 2);
|
|
}
|
|
}
|
|
}
|
|
|
|
bool IsInRange(float dist, float range, float perception) => dist <= range * perception;
|
|
bool IsInRangeSqr(float distSquared, float range, float perception) => distSquared <= MathUtils.Pow2(range * perception);
|
|
}
|
|
|
|
public void ReevaluateAttacks()
|
|
{
|
|
canAttackWalls = LatchOntoAI is { AttachToSub: true };
|
|
canAttackDoors = false;
|
|
canAttackCharacters = false;
|
|
canAttackItems = false;
|
|
foreach (var limb in Character.AnimController.Limbs)
|
|
{
|
|
if (limb.IsSevered) { continue; }
|
|
if (limb.Disabled) { continue; }
|
|
if (limb.attack == null) { continue; }
|
|
if (!canAttackWalls)
|
|
{
|
|
canAttackWalls = (limb.attack.StructureDamage > 0 || limb.attack.Ranged && limb.attack.IsValidTarget(AttackTarget.Structure));
|
|
}
|
|
if (!canAttackDoors)
|
|
{
|
|
// Doors are technically items, but intentionally treated as structures here.
|
|
canAttackDoors = (limb.attack.ItemDamage > 0 || limb.attack.Ranged) && limb.attack.IsValidTarget(AttackTarget.Structure);
|
|
}
|
|
if (!canAttackItems)
|
|
{
|
|
// AttackTarget.Structure is also accepted for backwards support.
|
|
canAttackItems = canAttackDoors || (limb.attack.ItemDamage > 0 || limb.attack.Ranged) && limb.attack.IsValidTarget(AttackTarget.Structure | AttackTarget.Item);
|
|
}
|
|
if (!canAttackCharacters)
|
|
{
|
|
canAttackCharacters = limb.attack.IsValidTarget(AttackTarget.Character);
|
|
}
|
|
}
|
|
if (PathSteering != null)
|
|
{
|
|
PathSteering.CanBreakDoors = canAttackDoors;
|
|
}
|
|
}
|
|
|
|
private bool IsPositionInsideAllowedZone(Vector2 pos, out Vector2 targetDir)
|
|
{
|
|
targetDir = Vector2.Zero;
|
|
if (Level.Loaded == null) { return true; }
|
|
if (Level.Loaded.LevelData.Biome.IsEndBiome) { return true; }
|
|
if (AIParams.AvoidAbyss)
|
|
{
|
|
if (pos.Y < Level.Loaded.AbyssStart)
|
|
{
|
|
// Too far down
|
|
targetDir = Vector2.UnitY;
|
|
}
|
|
}
|
|
else if (AIParams.StayInAbyss)
|
|
{
|
|
if (pos.Y > Level.Loaded.AbyssStart)
|
|
{
|
|
// Too far up
|
|
targetDir = -Vector2.UnitY;
|
|
}
|
|
else if (pos.Y < Level.Loaded.AbyssEnd)
|
|
{
|
|
// Too far down
|
|
targetDir = Vector2.UnitY;
|
|
}
|
|
}
|
|
float margin = Level.OutsideBoundsCurrentMargin;
|
|
if (pos.X < -margin)
|
|
{
|
|
// Too far left
|
|
targetDir = Vector2.UnitX;
|
|
}
|
|
else if (pos.X > Level.Loaded.Size.X + margin)
|
|
{
|
|
// Too far right
|
|
targetDir = -Vector2.UnitX;
|
|
}
|
|
return targetDir == Vector2.Zero;
|
|
}
|
|
|
|
private Vector2 returnDir;
|
|
private float returnTimer;
|
|
private void SteerInsideLevel(float deltaTime)
|
|
{
|
|
if (SteeringManager is IndoorsSteeringManager) { return; }
|
|
if (Level.Loaded == null) { return; }
|
|
if (State == AIState.Attack && returnTimer <= 0) { return; }
|
|
float returnTime = 5;
|
|
if (!IsPositionInsideAllowedZone(WorldPosition, out Vector2 targetDir))
|
|
{
|
|
returnDir = targetDir;
|
|
returnTimer = returnTime * Rand.Range(0.75f, 1.25f);
|
|
}
|
|
if (returnTimer > 0)
|
|
{
|
|
returnTimer -= deltaTime;
|
|
SteeringManager.Reset();
|
|
SteeringManager.SteeringManual(deltaTime, returnDir * 10);
|
|
SteeringManager.SteeringAvoid(deltaTime, avoidLookAheadDistance, 15);
|
|
}
|
|
}
|
|
|
|
public override bool SteerThroughGap(Structure wall, WallSection section, Vector2 targetWorldPos, float deltaTime)
|
|
{
|
|
IsTryingToSteerThroughGap = true;
|
|
wallTarget = null;
|
|
LatchOntoAI?.DeattachFromBody(reset: true, cooldown: 2);
|
|
Character.AnimController.ReleaseStuckLimbs();
|
|
bool success = base.SteerThroughGap(wall, section, targetWorldPos, deltaTime);
|
|
if (success)
|
|
{
|
|
// If already inside, target the hull, else target the wall.
|
|
SelectedAiTarget = Character.CurrentHull != null ? section.gap.AiTarget : wall.AiTarget;
|
|
SteeringManager.SteeringAvoid(deltaTime, avoidLookAheadDistance, weight: 1);
|
|
}
|
|
IsSteeringThroughGap = success;
|
|
return success;
|
|
}
|
|
|
|
public override bool SteerThroughGap(Gap gap, Vector2 targetWorldPos, float deltaTime, float maxDistance = -1)
|
|
{
|
|
bool success = base.SteerThroughGap(gap, targetWorldPos, deltaTime, maxDistance);
|
|
if (success)
|
|
{
|
|
wallTarget = null;
|
|
LatchOntoAI?.DeattachFromBody(reset: true, cooldown: 2);
|
|
Character.AnimController.ReleaseStuckLimbs();
|
|
SteeringManager.SteeringAvoid(deltaTime, avoidLookAheadDistance, weight: 1);
|
|
}
|
|
IsSteeringThroughGap = success;
|
|
return success;
|
|
}
|
|
|
|
public bool CanPassThroughHole(Structure wall, int sectionIndex) => CanPassThroughHole(wall, sectionIndex, requiredHoleCount);
|
|
|
|
public override bool Escape(float deltaTime)
|
|
{
|
|
if (SelectedAiTarget != null && (SelectedAiTarget.Entity == null || SelectedAiTarget.Entity.Removed))
|
|
{
|
|
State = AIState.Idle;
|
|
return false;
|
|
}
|
|
else if (CurrentTargetMemory is AITargetMemory targetMemory && SelectedAiTarget?.Entity is Character)
|
|
{
|
|
targetMemory.Priority += deltaTime * PriorityFearIncrement;
|
|
}
|
|
bool isSteeringThroughGap = UpdateEscape(deltaTime, canAttackDoors);
|
|
if (!isSteeringThroughGap)
|
|
{
|
|
if (SelectedAiTarget?.Entity is Character targetCharacter && targetCharacter.CurrentHull == Character.CurrentHull)
|
|
{
|
|
SteerAwayFromTheEnemy();
|
|
}
|
|
else if (canAttackDoors && HasValidPath())
|
|
{
|
|
var door = PathSteering.CurrentPath.CurrentNode?.ConnectedDoor ?? PathSteering.CurrentPath.NextNode?.ConnectedDoor;
|
|
if (door is { CanBeTraversed: false } && !door.HasAccess(Character) && door.Item.AiTarget is { } doorAiTarget)
|
|
{
|
|
if (SelectedAiTarget != doorAiTarget || State != AIState.Attack)
|
|
{
|
|
SelectTarget(doorAiTarget, CurrentTargetMemory.Priority);
|
|
State = AIState.Attack;
|
|
AttackLimb = null;
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (EscapeTarget == null)
|
|
{
|
|
if (SelectedAiTarget?.Entity is Character)
|
|
{
|
|
SteerAwayFromTheEnemy();
|
|
}
|
|
else
|
|
{
|
|
SteeringManager.SteeringWander(avoidWanderingOutsideLevel: Character.CurrentHull == null);
|
|
if (Character.CurrentHull == null)
|
|
{
|
|
SteeringManager.SteeringAvoid(deltaTime, lookAheadDistance: avoidLookAheadDistance, weight: 5);
|
|
}
|
|
}
|
|
}
|
|
return isSteeringThroughGap;
|
|
|
|
void SteerAwayFromTheEnemy()
|
|
{
|
|
if (SelectedAiTarget == null) { return; }
|
|
Vector2 escapeDir = Vector2.Normalize(WorldPosition - SelectedAiTarget.WorldPosition);
|
|
if (!MathUtils.IsValid(escapeDir))
|
|
{
|
|
escapeDir = Vector2.UnitY;
|
|
}
|
|
if (Character.CurrentHull != null && !Character.AnimController.InWater)
|
|
{
|
|
// Inside
|
|
escapeDir = new Vector2(Math.Sign(escapeDir.X), 0);
|
|
}
|
|
SteeringManager.Reset();
|
|
SteeringManager.SteeringManual(deltaTime, escapeDir);
|
|
}
|
|
}
|
|
|
|
private readonly List<Limb> targetLimbs = new List<Limb>();
|
|
public Limb GetTargetLimb(Limb attackLimb, Character target, LimbType targetLimbType = LimbType.None)
|
|
{
|
|
targetLimbs.Clear();
|
|
foreach (var limb in target.AnimController.Limbs)
|
|
{
|
|
if (limb.type == targetLimbType || targetLimbType == LimbType.None)
|
|
{
|
|
targetLimbs.Add(limb);
|
|
}
|
|
}
|
|
if (targetLimbs.None())
|
|
{
|
|
// If no limbs of given type was found, accept any limb.
|
|
targetLimbs.AddRange(target.AnimController.Limbs);
|
|
}
|
|
float closestDist = float.MaxValue;
|
|
Limb targetLimb = null;
|
|
foreach (Limb limb in targetLimbs)
|
|
{
|
|
if (limb.IsSevered) { continue; }
|
|
if (limb.Hidden) { continue; }
|
|
float dist = Vector2.DistanceSquared(limb.WorldPosition, attackLimb.WorldPosition) / Math.Max(limb.AttackPriority, 0.1f);
|
|
if (dist < closestDist)
|
|
{
|
|
closestDist = dist;
|
|
targetLimb = limb;
|
|
}
|
|
}
|
|
return targetLimb;
|
|
}
|
|
|
|
private static Character GetOwner(Item item)
|
|
{
|
|
var pickable = item.GetComponent<Pickable>();
|
|
if (pickable != null)
|
|
{
|
|
Character owner = pickable.Picker ?? item.FindParentInventory(i => i.Owner is Character)?.Owner as Character;
|
|
if (owner != null)
|
|
{
|
|
var target = owner.AiTarget;
|
|
if (target?.Entity != null && !target.Entity.Removed)
|
|
{
|
|
return owner;
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
|
|
//the "memory" of the Character
|
|
//keeps track of how preferable it is to attack a specific target
|
|
//(if the Character can't inflict much damage the target, the priority decreases
|
|
//and if the target attacks the Character, the priority increases)
|
|
class AITargetMemory
|
|
{
|
|
public readonly AITarget Target;
|
|
public Vector2 Location { get; set; }
|
|
|
|
private float priority;
|
|
|
|
public float Priority
|
|
{
|
|
get { return priority; }
|
|
set { priority = MathHelper.Clamp(value, 1.0f, 100.0f); }
|
|
}
|
|
|
|
public AITargetMemory(AITarget target, float priority)
|
|
{
|
|
Target = target;
|
|
Location = target.WorldPosition;
|
|
this.priority = priority;
|
|
}
|
|
}
|
|
}
|