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 } public enum AttackPattern { Straight, Sweep, Circle } public enum CirclePhase { Start, CloseIn, FallBack, Advance, Strike } public enum WallTargetingMethod { Target = 0x1, Heading = 0x2, Steering = 0x4 } partial class EnemyAIController : AIController { public static bool DisableEnemyAI; private AIState _state; public AIState State { get { return _state; } set { if (_state == value) { return; } PreviousState = _state; OnStateChanged(_state, value); _state = value; if (_state == AIState.Attack) { #if CLIENT Character.PlaySound(CharacterSound.SoundType.Attack, maxInterval: 3); #endif } } } public AIState PreviousState { get; private set; } /// /// Enable the character to attack the outposts and the characters inside them. Disabled by default in normal levels, enabled in outpost levels. /// 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 => AIParams.Sight; private float Hearing => AIParams.Hearing; 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 && _previousAttackLimb.attack.SnapRopeOnNewAttack) { _previousAttackLimb.AttachedRope?.Snap(); } } else if (_attackLimb != null && _attackLimb.attack.CoolDownTimer <= 0) { if (_attackLimb != null && _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 SelectedTargetMemory => selectedTargetMemory; private AITargetMemory selectedTargetMemory; private float targetValue; private CharacterParams.TargetParams selectedTargetingParams; private Dictionary targetMemories; private readonly int requiredHoleCount; private bool canAttackWalls; public bool CanAttackDoors => canAttackDoors; private bool canAttackDoors; 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 CoroutineHandle disableTailCoroutine; private readonly List myBodies; public LatchOntoAI LatchOntoAI { get; private set; } public SwarmBehavior SwarmBehavior { get; private set; } public PetBehavior PetBehavior { get; private set; } public CharacterParams.TargetParams SelectedTargetingParams { get { return selectedTargetingParams; } } public bool AttackHumans { get { var target = GetTargetParams(CharacterPrefab.HumanSpeciesName); return target != null && target.Priority > 0.0f && (target.State == AIState.Attack || target.State == AIState.Aggressive); } } public bool AttackRooms { get { var target = GetTargetParams("room"); return target != null && target.Priority > 0.0f && (target.State == AIState.Attack || target.State == 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))); } } /// /// The monster won't try to damage these submarines /// public HashSet UnattackableSubmarines { get; private set; } = new HashSet(); public static bool IsTargetBeingChasedBy(Character target, Character character) => character?.AIController is EnemyAIController enemyAI && enemyAI.SelectedAiTarget?.Entity == target && (enemyAI.State == AIState.Attack || enemyAI.State == 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 targetCharacter && targetCharacter.IsOnPlayerTeam; 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(); steeringManager = outsideSteering; //allow targeting outposts and outpost NPCs in outpost levels TargetOutposts = Level.Loaded != null && Level.Loaded.Type == LevelData.LevelType.Outpost; List aiElements = new List(); List aiCommonness = new List(); 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); CreatureMetrics.UnlockInEditor(Character.SpeciesName); } private CharacterParams.AIParams _aiParams; /// /// Shorthand for with null checking. /// /// or an empty params. Does not return nulls. 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 CharacterParams.TargetParams GetTargetParams(string targetTag) => GetTargetParams(targetTag.ToIdentifier()); private CharacterParams.TargetParams GetTargetParams(Identifier targetTag) => AIParams.GetTarget(targetTag, false); private CharacterParams.TargetParams GetTargetParams(AITarget aiTarget) => GetTargetParams(GetTargetingTag(aiTarget)); private Identifier GetTargetingTag(AITarget aiTarget) { if (aiTarget?.Entity == null) { return Identifier.Empty; } Identifier targetingTag = Identifier.Empty; if (aiTarget.Entity is Character targetCharacter) { if (targetCharacter.IsDead) { targetingTag = "dead".ToIdentifier(); } else if (AIParams.TryGetTarget(targetCharacter.CharacterHealth.GetActiveAfflictionTags(), out CharacterParams.TargetParams tp) && tp.Threshold >= Character.GetDamageDoneByAttacker(targetCharacter)) { targetingTag = tp.Tag; } else if (PetBehavior != null && aiTarget.Entity == PetBehavior.Owner) { targetingTag = "owner".ToIdentifier(); } else if (PetBehavior != null && (!Character.IsOnFriendlyTeam(targetCharacter) || IsAttackingOwner(targetCharacter))) { targetingTag = "hostile".ToIdentifier(); } else if (AIParams.TryGetTarget(targetCharacter, out CharacterParams.TargetParams tP)) { targetingTag = tP.Tag; } else if (targetCharacter.AIController is EnemyAIController enemy) { if (enemy.PetBehavior != null && (PetBehavior != null || AIParams.HasTag("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. targetingTag = "pet".ToIdentifier(); } else if (targetCharacter.IsHusk && AIParams.HasTag("husk")) { targetingTag = "husk".ToIdentifier(); } else if (!Character.IsSameSpeciesOrGroup(targetCharacter)) { if (enemy.CombatStrength > CombatStrength) { targetingTag = "stronger".ToIdentifier(); } else if (enemy.CombatStrength < CombatStrength) { targetingTag = "weaker".ToIdentifier(); } else { targetingTag = "equal".ToIdentifier(); } } } } else if (aiTarget.Entity is Item targetItem) { foreach (var prio in AIParams.Targets) { if (targetItem.HasTag(prio.Tag)) { targetingTag = prio.Tag; break; } } if (targetingTag.IsEmpty) { if (targetItem.GetComponent() != null) { targetingTag = "sonar".ToIdentifier(); } if (targetItem.GetComponent() != null) { targetingTag = "door".ToIdentifier(); } } } else if (aiTarget.Entity is Structure) { targetingTag = "wall".ToIdentifier(); } else if (aiTarget.Entity is Hull) { targetingTag = "room".ToIdentifier(); } return targetingTag; } public override void SelectTarget(AITarget target) => SelectTarget(target, 100); public void SelectTarget(AITarget target, float priority) { SelectedAiTarget = target; selectedTargetMemory = GetTargetMemory(target, addIfNotFound: true); selectedTargetMemory.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()?.GetRope()?.Snap()); } } public override void Update(float deltaTime) { if (DisableEnemyAI) { return; } base.Update(deltaTime); UpdateTriggers(deltaTime); Character.ClearInputs(); IsTryingToSteerThroughGap = false; Reverse = false; 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 != null && currPath.CurrentNode != null) { if (currPath.CurrentNode.SimPosition.Y < Character.AnimController.GetColliderBottom().Y) { // Don't allow to jump from too high. float allowedJumpHeight = Character.AnimController.ImpactTolerance / 2; float height = Math.Abs(currPath.CurrentNode.SimPosition.Y - Character.SimPosition.Y); ignorePlatforms = height < allowedJumpHeight; } } if (Character.IsClimbing && PathSteering.IsNextLadderSameAsCurrent) { Character.AnimController.TargetMovement = new Vector2(0.0f, Math.Sign(Character.AnimController.TargetMovement.Y)); } } Character.AnimController.IgnorePlatforms = ignorePlatforms; 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 (updateTargetsTimer > 0) { updateTargetsTimer -= deltaTime; } else if (avoidTimer <= 0 || activeTriggers.Any() && returnTimer <= 0) { UpdateTargets(out CharacterParams.TargetParams targetingParams); updateTargetsTimer = updateTargetsInterval * Rand.Range(0.75f, 1.25f); if (SelectedAiTarget == null) { State = AIState.Idle; } else if (targetingParams != null) { selectedTargetingParams = targetingParams; State = targetingParams.State; } if ((LatchOntoAI == null || !LatchOntoAI.IsAttached || wallTarget != null) && (State == AIState.Attack || State == AIState.Aggressive || State == AIState.PassiveAggressive)) { UpdateWallTarget(requiredHoleCount); } } } 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.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 == null || 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 && selectedTargetingParams != null && selectedTargetingParams.ReactDistance > 0 ? selectedTargetingParams.ReactDistance : GetPerceivingRange(SelectedAiTarget); if (squaredDistance <= Math.Pow(reactDistance, 2)) { float halfReactDistance = reactDistance / 2; float attackDistance = selectedTargetingParams != null && selectedTargetingParams.AttackDistance > 0 ? selectedTargetingParams.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: if (SelectedAiTarget == null || 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 >= selectedTargetingParams.Threshold; } return true; } Character attacker = targetCharacter.LastAttackers.LastOrDefault(ShouldRetaliate)?.Character; if (attacker?.AiTarget != null) { ChangeTargetState(attacker, AIState.Attack, selectedTargetingParams.Priority * 2); SelectTarget(attacker.AiTarget); State = AIState.Attack; UpdateWallTarget(requiredHoleCount); return; } } } float sqrDist = Vector2.DistanceSquared(WorldPosition, SelectedAiTarget.WorldPosition); float reactDist = GetPerceivingRange(SelectedAiTarget); Vector2 offset = Vector2.Zero; if (selectedTargetingParams != null) { if (selectedTargetingParams.ReactDistance > 0) { reactDist = selectedTargetingParams.ReactDistance; } offset = selectedTargetingParams.Offset; } if (offset != Vector2.Zero) { reactDist += offset.Length(); } if (sqrDist > MathUtils.Pow2(reactDist + movementMargin)) { movementMargin = State == AIState.FleeTo ? 0 : reactDist; run = true; UpdateFollow(deltaTime); } else { movementMargin = MathHelper.Clamp(movementMargin -= deltaTime, 0, reactDist); if (State == AIState.FleeTo) { 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 != null && !Character.Removed) { 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 == null || SelectedAiTarget.Entity == null || SelectedAiTarget.Entity.Removed) { State = AIState.Idle; return; } run = false; sqrDist = Vector2.DistanceSquared(WorldPosition, SelectedAiTarget.WorldPosition); reactDist = selectedTargetingParams != null && selectedTargetingParams.ReactDistance > 0 ? selectedTargetingParams.ReactDistance : GetPerceivingRange(SelectedAiTarget); float halfReactDist = reactDist / 2; float attackDist = selectedTargetingParams != null && selectedTargetingParams.AttackDistance > 0 ? selectedTargetingParams.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); } } #region Idle private void UpdateIdle(float deltaTime, bool followLastTarget = true) { 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 != null && !target.Entity.Removed && 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 (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 targetHulls = new List(); private readonly List hullWeights = new List(); 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 != null && !pathSteering.CurrentPath.Finished && !pathSteering.CurrentPath.Unreachable) { PathSteering.SteeringSeek(Character.GetRelativeSimPosition(patrolTarget), weight: 1, minGapWidth: minGapSize * 1.5f, nodeFilter: n => PatrolNodeFilter(n)); 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 == null || SelectedAiTarget.Entity == null || SelectedAiTarget.Entity.Removed) { State = AIState.Idle; 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 = true; 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: 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 == 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() != 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 (wallTarget != null && Character.Submarine == 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 e) { if (e.Submarine != null) { 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(); canAttack = distance < AttackLimb.attack.Range; if (canAttack) { reachTimer = 0; } else if (selectedTargetingParams.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 (Math.Abs(toTarget.Y) > AttackLimb.attack.Range / 2 && Math.Abs(toTarget.X) <= AttackLimb.attack.Range) { humanoidAnimController.Crouching = true; } } 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; } } } } } 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; } var pathSteering = SteeringManager as IndoorsSteeringManager; if (AttackLimb != null && AttackLimb.attack.Retreat) { UpdateFallBack(attackWorldPos, deltaTime, followThrough: false); } 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; } if (pathSteering != null) { if (pathSteering.CurrentPath != null) { // Attack doors if (canAttackDoors) { // 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 != null && !door.CanBeTraversed && !door.HasAccess(Character)) { if (door.Item.AiTarget != null && SelectedAiTarget != door.Item.AiTarget) { SelectTarget(door.Item.AiTarget, selectedTargetMemory.Priority); State = AIState.Attack; 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) { // 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)) { Vector2 myPos = Character.AnimController.SimplePhysicsEnabled ? Character.SimPosition : steeringLimb.SimPosition; SteeringManager.SteeringManual(deltaTime, Vector2.Normalize(steerPos - myPos)); } else { pathSteering.SteeringSeek(steerPos, weight: 2, minGapWidth: minGapSize, startNodeFilter: n => (n.Waypoint.CurrentHull == null) == (Character.CurrentHull == null), checkVisiblity: true); if (!pathSteering.IsPathDirty && 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 { pathSteering.SteeringSeek(steerPos, weight: 5, minGapWidth: minGapSize); } } else { // Sweeping and circling doesn't work well inside if (Character.CurrentHull == null) { switch (selectedTargetingParams.AttackPattern) { case AttackPattern.Sweep: if (selectedTargetingParams.SweepDistance > 0) { if (distance <= 0) { distance = (attackWorldPos - WorldPosition).Length(); } float amplitude = MathHelper.Lerp(0, selectedTargetingParams.SweepStrength, MathUtils.InverseLerp(selectedTargetingParams.SweepDistance, 0, distance)); if (amplitude > 0) { sweepTimer += deltaTime * selectedTargetingParams.SweepSpeed; float sin = (float)Math.Sin(sweepTimer) * amplitude; steerPos = MathUtils.RotatePointAroundTarget(attackSimPos, SimPosition, sin); } else { sweepTimer = Rand.Range(-1000f, 1000f) * selectedTargetingParams.SweepSpeed; } } break; case AttackPattern.Circle: if (IsCoolDownRunning) { break; } if (IsAttackRunning && CirclePhase != CirclePhase.Strike) { break; } if (selectedTargetingParams == null) { break; } var targetSub = SelectedAiTarget.Entity?.Submarine; ISpatialEntity spatialTarget = targetSub ?? SelectedAiTarget.Entity; float targetSize = 0; if (!selectedTargetingParams.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 = selectedTargetingParams.CircleStartDistance * 0.5f; float maxFallBackDistance = selectedTargetingParams.CircleStartDistance; float maxRandomOffset = selectedTargetingParams.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 * selectedTargetingParams.CircleRotationSpeed; float maxRotationSpeed = 0.5f * selectedTargetingParams.CircleRotationSpeed; circleRotationSpeed = MathHelper.Lerp(minRotationSpeed, maxRotationSpeed, intensity); circleFallbackDistance = MathHelper.Lerp(maxFallBackDistance, minFallBackDistance, intensity); circleOffset = Rand.Vector(MathHelper.Lerp(maxRandomOffset, 0, intensity)); } else { circleRotationSpeed = selectedTargetingParams.CircleRotationSpeed; circleFallbackDistance = maxFallBackDistance; circleOffset = Rand.Vector(maxRandomOffset); } circleRotationSpeed *= Rand.Range(1 - selectedTargetingParams.CircleRandomRotationFactor, 1 + selectedTargetingParams.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 + selectedTargetingParams.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 = selectedTargetingParams.IgnoreTargetSize ? selectedTargetingParams.CircleStartDistance * 0.9f: targetSize + selectedTargetingParams.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 + selectedTargetingParams.CircleStartDistance * 1.2f)) { if (selectedTargetingParams.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 (selectedTargetingParams.CircleStrikeDistanceMultiplier < 1) { return 0; } float requiredDistMultiplier = 2; bool isHeading = Vector2.Dot(Vector2.Normalize(attackWorldPos - WorldPosition), Vector2.Normalize(Steering)) > 0.9f; if (isHeading) { requiredDistMultiplier = selectedTargetingParams.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(selectedTargetingParams.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 (selectedTargetingParams.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 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 (target is ISerializableEntity se && target is 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; } } return true; } private readonly List attackLimbs = new List(); private readonly List weights = new List(); 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 dist = Vector2.Distance(limb.WorldPosition, attackPos); float distanceFactor = 1; if (limb.attack.Ranged) { float min = 100; distanceFactor = MathHelper.Lerp(1, 0, MathUtils.InverseLerp(min, Math.Max(limb.attack.Range / 2, min), dist)); } else { // The limb is ignored if the target is not close. Prevents character going in reverse if very far away from it. // We also need a max value that is more than the actual range. distanceFactor = MathHelper.Lerp(1, 0, MathUtils.InverseLerp(0, limb.attack.Range * 3, dist)); } 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 == null || attacker.AiTarget == null || attacker.Removed || attacker.IsDead) { return; } 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 (AIParams.AttackWhenProvoked && canAttack && !ignoredTargets.Contains(attacker.AiTarget)) { if (attacker.IsHusk) { ChangeTargetState("husk", AIState.Attack, 100); } else { ChangeTargetState(attacker, AIState.Attack, 100); } } else if (!AIParams.HasTag(attacker.SpeciesName)) { if (attacker.IsHusk) { ChangeTargetState("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.TryGetTarget(attacker, out CharacterParams.TargetParams targetingParams)) { if (targetingParams.State == AIState.Aggressive || targetingParams.State == AIState.PassiveAggressive) { ChangeTargetState(attacker, AIState.Attack, 100); } } } AITargetMemory targetMemory = GetTargetMemory(attacker.AiTarget, addIfNotFound: true, keepAlive: true); targetMemory.Priority += GetRelativeDamage(attackResult.Damage, Character.Vitality) * AIParams.AggressionHurt; // 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; 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; } } if (damageTarget != null) { Character.SetInput(item.IsShootable ? InputType.Shoot : InputType.Use, false, true); item.Use(deltaTime, user: Character); } } } if (damageTarget == null) { return true; } //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; } selectedTargetMemory.Priority += GetRelativeDamage(attackResult.Damage, damageTarget.Health) * greed; } else { selectedTargetMemory.Priority -= Math.Max(selectedTargetMemory.Priority / 2, 1); return selectedTargetMemory.Priority > 1; } } } return true; } private float aimTimer; private float visibilityCheckTimer; private bool canSeeTarget; 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; } visibilityCheckTimer -= deltaTime; if (visibilityCheckTimer <= 0.0f) { canSeeTarget = Character.CanSeeTarget(target); visibilityCheckTimer = 0.2f; } if (!canSeeTarget) { 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; 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; } } } 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 == null || SelectedAiTarget.Entity == null || SelectedAiTarget.Entity.Removed) { State = AIState.Idle; if (Character.SelectedCharacter != null) { Character.DeselectCharacter(); } return; } if (SelectedAiTarget.Entity is Character || SelectedAiTarget.Entity is Item) { Limb mouthLimb = Character.AnimController.GetLimb(LimbType.Head); if (mouthLimb == null) { DebugConsole.ThrowError("Character \"" + Character.SpeciesName + "\" failed to eat a target (No head limb defined)", contentPackage: Character.Prefab.ContentPackage); State = AIState.Idle; return; } Vector2 mouthPos = Character.AnimController.SimplePhysicsEnabled ? SimPosition : Character.AnimController.GetMouthPosition().Value; 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); Character.AnimController.Collider.ApplyForce(limbDiff * mouthLimb.Mass * 50.0f, mouthPos); } } else { IgnoreTarget(SelectedAiTarget); State = AIState.Idle; ResetAITarget(); } } #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 AITarget UpdateTargets(out CharacterParams.TargetParams targetingParams) { AITarget newTarget = null; targetValue = 0; selectedTargetMemory = null; targetingParams = null; bool isAnyTargetClose = false; bool isBeingChased = IsBeingChased; float maxModifier = 5; foreach (AITarget aiTarget in AITarget.List) { if (aiTarget.ShouldBeIgnored()) { continue; } if (ignoredTargets.Contains(aiTarget)) { continue; } if (aiTarget.Type == AITarget.TargetType.HumanOnly) { continue; } if (!TargetOutposts) { 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; } float valueModifier = 1; Identifier targetingTag = GetTargetingTag(aiTarget); 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; } if (targetCharacter.AIController is EnemyAIController enemy) { if (targetingTag == "stronger" && (State == AIState.Avoid || State == AIState.Escape || State == AIState.Flee)) { if (SelectedAiTarget == aiTarget) { // Freightened -> 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 { // 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; } } Door door = null; if (aiTarget.Entity is Item item) { door = item.GetComponent(); bool targetingFromOutsideToInside = item.CurrentHull != null && Character.CurrentHull == null; 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 (targetingTag == "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 && targetingTag == "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 isCharacterInside = Character.CurrentHull != null; bool isInnerWall = s.Prefab.Tags.Contains("inner"); if (isInnerWall && !isCharacterInside) { // Ignore inner walls when outside (walltargets still work) continue; } bool attemptToGetInside = 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); if (!attemptToGetInside && IsWallDisabled(s)) { continue; } // 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 (attemptToGetInside) { if (!isCharacterInside) { if (CanPassThroughHole(s, i)) { valueModifier *= leadsInside ? (IsAggressiveBoarder ? maxModifier : 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, maxModifier); } } if (door != null) { if (door.Item.Submarine == null) { continue; } bool isOutdoor = door.LinkedGap?.FlowTargetHull != null && !door.LinkedGap.IsRoomToRoom; // 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; } 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 = maxModifier; } else if (door.LinkedGap != null) { valueModifier = 1 + door.LinkedGap.Open * (maxModifier - 1); } } else { // Inside -> ignore open doors and outer doors valueModifier = isOpen || isOutdoor ? 0 : 1; } } } else if (aiTarget.Entity is IDamageable targetDamageable && targetDamageable.Health <= 0.0f) { continue; } } if (targetingTag == null) { continue; } var targetParams = GetTargetParams(targetingTag); if (targetParams == null) { continue; } if (targetParams.IgnoreInside && Character.CurrentHull != null) { continue; } if (targetParams.IgnoreOutside && Character.CurrentHull == null) { continue; } if (targetParams.IgnoreIncapacitated && targetCharacter != null && targetCharacter.IsIncapacitated) { 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 == AIState.Observe || targetParams.State == 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; } if (targetParams.State == AIState.FleeTo) { float target = targetParams.Threshold; if (targetParams.ThresholdMin > 0 && targetParams.ThresholdMax > 0) { target = selectedTargetingParams == targetParams && State == AIState.FleeTo ? targetParams.ThresholdMax : targetParams.ThresholdMin; } if (Character.HealthPercentage > target) { 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 (targetParams.State == AIState.Eat && Character.Params.Health.HealthRegenerationWhenEating > 0 && !Character.IsPet) { valueModifier *= MathHelper.Lerp(1f, 0.1f, Character.HealthPercentage / 100f); } valueModifier *= targetParams.Priority; if (valueModifier == 0.0f) { continue; } if (targetingTag != "decoy") { if (SwarmBehavior != null && SwarmBehavior.Members.Any()) { // Halve the priority for each swarm mate targeting the same target -> reduces stacking foreach (Character 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; } } } 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 (targetParams.PerceptionDistanceMultiplier > 0.0f) { dist /= targetParams.PerceptionDistanceMultiplier; } if (targetParams.MaxPerceptionDistance > 0.0f && dist * dist > targetParams.MaxPerceptionDistance * targetParams.MaxPerceptionDistance) { continue; } if (!CanPerceive(aiTarget, dist, checkVisibility: SelectedAiTarget != aiTarget)) { continue; } if (SelectedAiTarget == aiTarget) { if (Character.Submarine == null && aiTarget.Entity is ISpatialEntity spatialEntity && spatialEntity.Submarine != null) { if (targetingTag == "door" || targetingTag == "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 != null && closestBody.UserData is 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 / 2; float subHalfHeight = spatialEntity.Submarine.Borders.Height / 2; 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 (targetParams.State == AIState.Avoid || targetParams.State == AIState.PassiveAggressive || targetParams.State == AIState.Aggressive) { float reactDistance = targetParams.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); AITargetMemory 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 (targetParams.PrioritizeSubCenter || targetParams.AttackPattern == AttackPattern.Circle || targetParams.AttackPattern == 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 (targetParams.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 (targetParams.State) { case AIState.Escape: case AIState.Avoid: break; default: if (targetParams.State == AIState.Attack) { // In the attack state allow going into non-allowed zone only when chasing a target. if (State == targetParams.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; } var characterTargetingTag = GetTargetingTag(owner.AiTarget); if (!characterTargetingTag.IsEmpty) { // if the enemy is configured to ignore the target character, ignore the provocative item they're holding/wearing too var characterTargetingParams = GetTargetParams(characterTargetingTag); if (characterTargetingParams?.State == AIState.Idle) { continue; } } } } if (targetCharacter != null) { if (Character.CurrentHull != null && targetCharacter.CurrentHull != Character.CurrentHull) { if (targetParams.State == AIState.Follow || targetParams.State == AIState.Protect || targetParams.State == AIState.Observe || targetParams.State == AIState.Eat) { // Ignore targets that cannot be seen if (!VisibleHulls.Contains(targetCharacter.CurrentHull)) { continue; } } } if (targetCharacter.Submarine != Character.Submarine || (targetCharacter.CurrentHull == null) != (Character.CurrentHull == null)) { if (targetCharacter.Submarine != null) { // Target is inside -> reduce the priority valueModifier *= 0.5f; if (Character.Submarine != null) { // Both inside different submarines -> can ignore safely continue; } } 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; selectedTargetMemory = targetMemory; targetValue = valueModifier; targetingParams = targetParams; if (!isAnyTargetClose) { isAnyTargetClose = ConvertUnits.ToDisplayUnits(colliderLength) > nonModifiedDist; } } } SelectedAiTarget = newTarget; if (SelectedAiTarget != _previousAiTarget) { if ((SelectedAiTarget != null || wallTarget != null) && IsLatchedOnSub) { if (SelectedAiTarget?.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; } } return SelectedAiTarget; } 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() != 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(); // 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 removals = new List(); 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 ignoredTargets = new HashSet(); public void IgnoreTarget(AITarget target) { if (target == null) { return; } ignoredTargets.Add(target); targetIgnoreTimer = targetIgnoreTime * Rand.Range(0.75f, 1.25f); } #endregion #region State switching /// /// 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. /// private readonly float stateResetCooldown = 10; private float stateResetTimer; private bool isStateChanged; private readonly Dictionary activeTriggers = new Dictionary(); private readonly HashSet inactiveTriggers = new HashSet(); public void LaunchTrigger(StatusEffect.AITrigger trigger) { if (trigger.IsTriggered) { return; } if (activeTriggers.ContainsKey(trigger)) { return; } if (activeTriggers.ContainsValue(selectedTargetingParams)) { if (!trigger.AllowToOverride) { return; } var existingTrigger = activeTriggers.FirstOrDefault(kvp => kvp.Value == selectedTargetingParams && kvp.Key.AllowToBeOverridden); if (existingTrigger.Key == null) { return; } activeTriggers.Remove(existingTrigger.Key); } trigger.Launch(); activeTriggers.Add(trigger, selectedTargetingParams); ChangeParams(selectedTargetingParams, 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(); } private bool TryResetOriginalState(string tag) => TryResetOriginalState(tag.ToIdentifier()); /// /// Resets the target's state to the original value defined in the xml. /// private bool TryResetOriginalState(Identifier tag) { if (!modifiedParams.ContainsKey(tag)) { return false; } if (AIParams.TryGetTarget(tag, out CharacterParams.TargetParams targetParams)) { modifiedParams.Remove(tag); if (tempParams.ContainsKey(tag)) { tempParams.Values.ForEach(t => AIParams.RemoveTarget(t)); tempParams.Remove(tag); } ResetParams(targetParams); return true; } else { return false; } } private readonly Dictionary modifiedParams = new Dictionary(); private readonly Dictionary tempParams = new Dictionary(); 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 (selectedTargetingParams == targetParams || State == AIState.Idle || State == AIState.Patrol) { ResetAITarget(); State = AIState.Idle; PreviousState = AIState.Idle; } } private void ChangeParams(string tag, AIState state, float? priority = null, bool onlyExisting = false) => ChangeParams(tag.ToIdentifier(), state, priority, onlyExisting); private void ChangeParams(Identifier tag, AIState state, float? priority = null, bool onlyExisting = false, bool ignoreAttacksIfNotInSameSub = false) { if (!AIParams.TryGetTarget(tag, out CharacterParams.TargetParams targetParams)) { if (!onlyExisting && !tempParams.ContainsKey(tag)) { if (AIParams.TryAddNewTarget(tag, state, priority ?? minPriority, out targetParams)) { if (state == AIState.Attack) { // Only applies to new temp target params. Shouldn't affect any existing definitions (handled below). targetParams.IgnoreIfNotInSameSub = ignoreAttacksIfNotInSameSub; } tempParams.Add(tag, targetParams); } } } if (targetParams != null) { if (priority.HasValue) { targetParams.Priority = Math.Max(targetParams.Priority, priority.Value); } targetParams.State = state; if (!modifiedParams.ContainsKey(tag)) { modifiedParams.Add(tag, targetParams); } } } private void ChangeTargetState(string tag, AIState state, float? priority = null) { isStateChanged = true; SetStateResetTimer(); ChangeParams(tag, state, priority); } /// /// Temporarily changes the predefined state for a target. Eg. Idle -> Attack. /// Note: does not change the current AIState! /// 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) { priority = GetTargetParams("human")?.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 == AIState.Attack || state == AIState.Escape) { ChangeParams("weapon", state, priority); ChangeParams("tool", 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("room", state, priority / 2); if (canAttackWalls) { ChangeParams("wall", state, priority / 2); } if (canAttackDoors && IsAggressiveBoarder) { ChangeParams("door", state, priority / 2); } } ChangeParams("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) { return; } var targetParams = GetTargetParams(newTarget); if (targetParams != null) { observeTimer = targetParams.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) { CoroutineManager.StopCoroutines(disableTailCoroutine); Character.AnimController.RestoreTemporarilyDisabled(); disableTailCoroutine = null; } 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; } } 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) { // 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 && (Character.Submarine != null || target.Entity.Submarine != null) || target.Entity.Submarine != null && target.Entity.Submarine == Character.Submarine && target.Entity.Submarine.TeamID == CharacterTeamType.None; } 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 != null && LatchOntoAI.AttachToSub; canAttackDoors = false; canAttackCharacters = 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.IsValidTarget(AttackTarget.Structure) && (limb.attack.StructureDamage > 0 || limb.attack.Ranged); } if (!canAttackDoors) { canAttackDoors = limb.attack.IsValidTarget(AttackTarget.Structure) && (limb.attack.ItemDamage > 0 || limb.attack.Ranged); } 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 (SelectedTargetMemory 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 != null && !door.CanBeTraversed && !door.HasAccess(Character)) { if (SelectedAiTarget != door.Item.AiTarget || State != AIState.Attack) { SelectTarget(door.Item.AiTarget, SelectedTargetMemory.Priority); State = AIState.Attack; 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 targetLimbs = new List(); 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(); 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; } } }