Files
OpenRA/OpenRA.Mods.Common/Traits/Air/Aircraft.cs
Paul Chote eb09870a10 Fix landing altitude checks.
Both GetPosition and LandPosition were trying to solve the problem
of dynamic altitude offsets for carryalls with different-sized cargo.

However, because they both apply the offsets, when the two are used
together the two sets of offsets cancel to 0, producing incorrect behaviour.

GetPosition is removed in favor of using CenterPosition with the dynamic
LandAltitude. Fly.Tick and Carryall ignore the z coordinate so their
behaviour is unchanged. Land and Aircraft.AtLandAltitude now return
the correct result.
2025-12-29 16:19:57 +02:00

1382 lines
44 KiB
C#

#region Copyright & License Information
/*
* Copyright (c) The OpenRA Developers and Contributors
* This file is part of OpenRA, which is free software. It is made
* available to you under the terms of the GNU General Public License
* as published by the Free Software Foundation, either version 3 of
* the License, or (at your option) any later version. For more
* information, see COPYING.
*/
#endregion
using System;
using System.Collections.Frozen;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using OpenRA.Activities;
using OpenRA.Mods.Common.Activities;
using OpenRA.Mods.Common.Orders;
using OpenRA.Primitives;
using OpenRA.Support;
using OpenRA.Traits;
namespace OpenRA.Mods.Common.Traits
{
public enum IdleBehaviorType
{
None,
Land,
ReturnToBase,
LeaveMap,
LeaveMapAtClosestEdge
}
public class AircraftInfo : PausableConditionalTraitInfo, IPositionableInfo, IFacingInfo, IMoveInfo, ICruiseAltitudeInfo,
IActorPreviewInitInfo, IEditorActorOptions
{
[Desc("Behavior when aircraft becomes idle. Options are Land, ReturnToBase, LeaveMap, and None.",
"'Land' will behave like 'None' (hover or circle) if a suitable landing site is not available.")]
public readonly IdleBehaviorType IdleBehavior = IdleBehaviorType.None;
public readonly WDist CruiseAltitude = new(1280);
[Desc("Whether the aircraft can be repulsed.")]
public readonly bool Repulsable = true;
[Desc("The distance it tries to maintain from other aircraft if repulsable.")]
public readonly WDist IdealSeparation = new(1706);
[Desc("The speed at which the aircraft is repulsed from other aircraft. Specify -1 for normal movement speed.")]
public readonly int RepulsionSpeed = -1;
public readonly WAngle InitialFacing = WAngle.Zero;
[Desc("Speed at which the actor turns.")]
public readonly WAngle TurnSpeed = new(512);
[Desc("Turn speed to apply when aircraft flies in circles while idle. Defaults to TurnSpeed if undefined.")]
public readonly WAngle? IdleTurnSpeed = null;
[Desc("When flying if the difference between current facing and desired facing is less than this value, don't turn. This prevents visual jitter.")]
public readonly WAngle TurnDeadzone = new(2);
[Desc("Maximum flight speed when cruising.")]
public readonly int Speed = 1;
[Desc("If non-negative, force the aircraft to move in circles at this speed when idle (a speed of 0 means don't move), ignoring CanHover.")]
public readonly int IdleSpeed = -1;
[Desc("Body pitch when flying forwards. Only relevant for voxel aircraft.")]
public readonly WAngle Pitch = WAngle.Zero;
[Desc("Pitch steps to apply each tick when starting/stopping.")]
public readonly WAngle PitchSpeed = WAngle.Zero;
[Desc("Body roll when turning. Only relevant for voxel aircraft.")]
public readonly WAngle Roll = WAngle.Zero;
[Desc("Body roll to apply when aircraft flies in circles while idle. Defaults to Roll if undefined. Only relevant for voxel aircraft.")]
public readonly WAngle? IdleRoll = null;
[Desc("Roll steps to apply each tick when turning.")]
public readonly WAngle RollSpeed = WAngle.Zero;
[Desc("Minimum altitude where this aircraft is considered airborne.")]
public readonly int MinAirborneAltitude = 1;
public readonly FrozenSet<string> LandableTerrainTypes = FrozenSet<string>.Empty;
[Desc("Can the actor be ordered to move in to shroud?")]
public readonly bool MoveIntoShroud = true;
[Desc("e.g. crate, wall, infantry")]
public readonly BitSet<CrushClass> Crushes = default;
[Desc("Types of damage that are caused while crushing. Leave empty for no damage types.")]
public readonly BitSet<DamageType> CrushDamageTypes = default;
[VoiceReference]
public readonly string Voice = "Action";
[Desc("Color to use for the target line for regular move orders.")]
public readonly Color TargetLineColor = Color.Green;
[GrantedConditionReference]
[Desc("The condition to grant to self while airborne.")]
public readonly string AirborneCondition = null;
[GrantedConditionReference]
[Desc("The condition to grant to self while at cruise altitude.")]
public readonly string CruisingCondition = null;
[Desc("Can the actor hover in place mid-air? If not, then the actor will have to remain in motion (circle around).")]
public readonly bool CanHover = false;
[Desc("Can the actor immediately change direction without turning first (doesn't need to fly in a curve)?")]
public readonly bool CanSlide = false;
[Desc("Does the actor land and take off vertically?")]
public readonly bool VTOL = false;
[Desc("Does this VTOL actor need to turn before landing (on terrain)?")]
public readonly bool TurnToLand = false;
[Desc("Does this actor automatically take off after resupplying?")]
public readonly bool TakeOffOnResupply = false;
[Desc("Does this actor automatically take off after creation?")]
public readonly bool TakeOffOnCreation = true;
[Desc("Can this actor be given an explicit land order using the force-move modifier?")]
public readonly bool CanForceLand = true;
[Desc("Altitude at which the aircraft considers itself landed.")]
public readonly WDist LandAltitude = WDist.Zero;
[Desc("Range to search for an alternative landing location if the ordered cell is blocked.")]
public readonly WDist LandRange = WDist.FromCells(5);
[Desc("How fast this actor ascends or descends during horizontal movement.")]
public readonly WAngle MaximumPitch = WAngle.FromDegrees(10);
[Desc("How fast this actor ascends or descends when moving vertically only (vertical take off/landing or hovering towards CruiseAltitude).")]
public readonly WDist AltitudeVelocity = new(43);
[Desc("Sounds to play when the actor is taking off.")]
public readonly ImmutableArray<string> TakeoffSounds = [];
[Desc("Sounds to play when the actor is landing.")]
public readonly ImmutableArray<string> LandingSounds = [];
[Desc("The distance of the resupply base that the aircraft will wait for its turn.")]
public readonly WDist WaitDistanceFromResupplyBase = new(3072);
[Desc("The number of ticks that a airplane will wait to make a new search for an available airport.")]
public readonly int NumberOfTicksToVerifyAvailableAirport = 150;
[Desc("Facing to use for actor previews (map editor, color picker, etc)")]
public readonly WAngle PreviewFacing = new(384);
[Desc("Display order for the facing slider in the map editor")]
public readonly int EditorFacingDisplayOrder = 3;
[ConsumedConditionReference]
[Desc("Boolean expression defining the condition under which the regular (non-force) move cursor is disabled.")]
public readonly BooleanExpression RequireForceMoveCondition = null;
[CursorReference]
[Desc("Cursor to display when a move order can be issued at target location.")]
public readonly string Cursor = "move";
[CursorReference]
[Desc("Cursor to display when a move order cannot be issued at target location.")]
public readonly string BlockedCursor = "move-blocked";
[CursorReference]
[Desc("Cursor to display when able to land at target building.")]
public readonly string EnterCursor = "enter";
[CursorReference]
[Desc("Cursor to display when unable to land at target building.")]
public readonly string EnterBlockedCursor = "enter-blocked";
public WAngle GetInitialFacing() { return InitialFacing; }
public WDist GetCruiseAltitude() { return CruiseAltitude; }
public Color GetTargetLineColor() { return TargetLineColor; }
public override object Create(ActorInitializer init) { return new Aircraft(init, this); }
IEnumerable<ActorInit> IActorPreviewInitInfo.ActorPreviewInits(ActorInfo ai, ActorPreviewType type)
{
yield return new FacingInit(PreviewFacing);
}
public IReadOnlyDictionary<CPos, SubCell> OccupiedCells(ActorInfo info, CPos location, SubCell subCell = SubCell.Any) =>
new Dictionary<CPos, SubCell>();
bool IOccupySpaceInfo.SharesCell => false;
// Used to determine if an aircraft can spawn landed
public bool CanEnterCell(World world, Actor self, CPos cell,
SubCell subCell = SubCell.FullCell, Actor ignoreActor = null, BlockedByActor check = BlockedByActor.All)
{
if (!world.Map.Contains(cell))
return false;
var type = world.Map.GetTerrainInfo(cell).Type;
if (!LandableTerrainTypes.Contains(type))
return false;
if (check == BlockedByActor.None)
return true;
// Since aircraft don't share cells, we don't pass the subCell parameter
return !world.ActorMap.GetActorsAt(cell).Any(x => x != ignoreActor);
}
IEnumerable<EditorActorOption> IEditorActorOptions.ActorOptions(ActorInfo ai, World world)
{
yield return new EditorActorSlider("Facing", EditorFacingDisplayOrder, 0, 1023, 8,
actor =>
{
var init = actor.GetInitOrDefault<FacingInit>(this);
return (init != null ? init.Value : InitialFacing).Angle;
},
(actor, value) => actor.ReplaceInit(new FacingInit(new WAngle((int)value))));
}
}
public class Aircraft : PausableConditionalTrait<AircraftInfo>, ITick, ISync, IFacing, IPositionable, IMove,
INotifyAddedToWorld, INotifyRemovedFromWorld, INotifyActorDisposing, INotifyBecomingIdle, ICreationActivity,
IActorPreviewInitModifier, IDeathActorInitModifier, IIssueDeployOrder, IIssueOrder, IResolveOrder, IOrderVoice
{
readonly Actor self;
Repairable repairable;
Rearmable rearmable;
IAircraftCenterPositionOffset[] positionOffsets;
IDisposable reservation;
IEnumerable<int> speedModifiers;
INotifyMoving[] notifyMoving;
INotifyCenterPositionChanged[] notifyCenterPositionChanged;
IOverrideAircraftLanding overrideAircraftLanding;
[VerifySync]
public WAngle Facing
{
get => Orientation.Yaw;
set => Orientation = Orientation.WithYaw(value);
}
public WAngle Pitch
{
get => Orientation.Pitch;
set => Orientation = Orientation.WithPitch(value);
}
public WAngle Roll
{
get => Orientation.Roll;
set => Orientation = Orientation.WithRoll(value);
}
public WRot Orientation { get; private set; }
[VerifySync]
public WPos CenterPosition { get; private set; }
public CPos TopLeft => self.World.Map.CellContaining(CenterPosition);
public WAngle TurnSpeed => IsTraitDisabled || IsTraitPaused ? WAngle.Zero : Info.TurnSpeed;
public WAngle? IdleTurnSpeed => IsTraitDisabled || IsTraitPaused ? null : Info.IdleTurnSpeed;
public WAngle GetTurnSpeed(bool isIdleTurn)
{
// A MovementSpeed of zero indicates either a speed modifier of zero percent or that the trait is paused or disabled.
// Bail early in that case.
if ((isIdleTurn && IdleMovementSpeed == 0) || MovementSpeed == 0)
return WAngle.Zero;
var turnSpeed = isIdleTurn ? IdleTurnSpeed ?? TurnSpeed : TurnSpeed;
return new WAngle(Util.ApplyPercentageModifiers(turnSpeed.Angle, speedModifiers).Clamp(1, 1024));
}
public Actor ReservedActor { get; private set; }
public bool MayYieldReservation { get; private set; }
public bool ForceLanding { get; private set; }
(CPos, SubCell)[] landingCells = [];
public bool RequireForceMove;
readonly int creationActivityDelay;
readonly bool creationByMap;
readonly CPos[] creationRallyPoint;
bool notify = true;
public static WPos GroundPosition(Actor self)
{
return self.CenterPosition - new WVec(WDist.Zero, WDist.Zero, self.World.Map.DistanceAboveTerrain(self.CenterPosition));
}
public bool AtLandAltitude => self.World.Map.DistanceAboveTerrain(self.CenterPosition) == LandAltitude;
bool airborne;
bool cruising;
int airborneToken = Actor.InvalidConditionToken;
int cruisingToken = Actor.InvalidConditionToken;
MovementType movementTypes;
WPos cachedPosition;
WAngle cachedFacing;
public Aircraft(ActorInitializer init, AircraftInfo info)
: base(info)
{
self = init.Self;
var locationInit = init.GetOrDefault<LocationInit>();
var centerPositionInit = init.GetOrDefault<CenterPositionInit>();
if (locationInit != null || centerPositionInit != null)
{
var pos = centerPositionInit?.Value ?? self.World.Map.CenterOfCell(locationInit.Value);
creationByMap = init.Contains<SpawnedByMapInit>();
SetPosition(self, pos);
}
Facing = init.GetValue<FacingInit, WAngle>(Info.InitialFacing);
creationActivityDelay = init.GetValue<CreationActivityDelayInit, int>(0);
creationRallyPoint = init.GetOrDefault<RallyPointInit>()?.Value;
}
public WDist LandAltitude
{
get
{
var alt = Info.LandAltitude;
foreach (var offset in positionOffsets)
alt -= new WDist(offset.PositionOffset.Z);
return alt;
}
}
public override IEnumerable<VariableObserver> GetVariableObservers()
{
foreach (var observer in base.GetVariableObservers())
yield return observer;
if (Info.RequireForceMoveCondition != null)
yield return new VariableObserver(RequireForceMoveConditionChanged, Info.RequireForceMoveCondition.Variables);
}
void RequireForceMoveConditionChanged(Actor self, IReadOnlyDictionary<string, int> conditions)
{
RequireForceMove = Info.RequireForceMoveCondition.Evaluate(conditions);
}
protected override void Created(Actor self)
{
repairable = self.TraitOrDefault<Repairable>();
rearmable = self.TraitOrDefault<Rearmable>();
speedModifiers = self.TraitsImplementing<ISpeedModifier>().ToArray().Select(sm => sm.GetSpeedModifier());
cachedPosition = self.CenterPosition;
notifyMoving = self.TraitsImplementing<INotifyMoving>().ToArray();
positionOffsets = self.TraitsImplementing<IAircraftCenterPositionOffset>().ToArray();
overrideAircraftLanding = self.TraitOrDefault<IOverrideAircraftLanding>();
notifyCenterPositionChanged = self.TraitsImplementing<INotifyCenterPositionChanged>().ToArray();
base.Created(self);
}
void INotifyAddedToWorld.AddedToWorld(Actor self)
{
AddedToWorld(self);
}
protected virtual void AddedToWorld(Actor self)
{
self.World.AddToMaps(self, this);
var altitude = self.World.Map.DistanceAboveTerrain(CenterPosition);
if (altitude.Length >= Info.MinAirborneAltitude)
OnAirborneAltitudeReached();
if (altitude == Info.CruiseAltitude)
OnCruisingAltitudeReached();
}
void INotifyRemovedFromWorld.RemovedFromWorld(Actor self)
{
RemovedFromWorld(self);
}
protected virtual void RemovedFromWorld(Actor self)
{
UnReserve();
self.World.RemoveFromMaps(self, this);
OnCruisingAltitudeLeft();
OnAirborneAltitudeLeft();
}
void ITick.Tick(Actor self)
{
Tick(self);
}
protected virtual void Tick(Actor self)
{
// Add land activity if Aircraft trait is paused and the actor can land at the current location.
if (!ForceLanding && IsTraitPaused && airborne && CanLand(self.Location)
&& !((self.CurrentActivity is Land) || self.CurrentActivity is Turn))
{
self.QueueActivity(false, new Land(self));
ForceLanding = true;
}
// Add takeoff activity if Aircraft trait is not paused and the actor should not land when idle.
if (ForceLanding && !IsTraitPaused && !cruising && self.CurrentActivity is not TakeOff)
{
ForceLanding = false;
if (Info.IdleBehavior != IdleBehaviorType.Land)
self.QueueActivity(false, new TakeOff(self));
}
var oldCachedFacing = cachedFacing;
cachedFacing = Facing;
var oldCachedPosition = cachedPosition;
cachedPosition = self.CenterPosition;
var newMovementTypes = MovementType.None;
if (oldCachedFacing != Facing)
newMovementTypes |= MovementType.Turn;
if ((oldCachedPosition - cachedPosition).HorizontalLengthSquared != 0)
newMovementTypes |= MovementType.Horizontal;
if ((oldCachedPosition - cachedPosition).VerticalLengthSquared != 0)
newMovementTypes |= MovementType.Vertical;
CurrentMovementTypes = newMovementTypes;
if (!CurrentMovementTypes.HasMovementType(MovementType.Horizontal))
{
if (Info.Roll != WAngle.Zero && Roll != WAngle.Zero)
Roll = Util.TickFacing(Roll, WAngle.Zero, Info.RollSpeed);
if (Info.Pitch != WAngle.Zero && Pitch != WAngle.Zero)
Pitch = Util.TickFacing(Pitch, WAngle.Zero, Info.PitchSpeed);
}
Repulse();
}
public void Repulse()
{
var repulsionForce = GetRepulsionForce();
if (repulsionForce == WVec.Zero)
return;
var speed = Info.RepulsionSpeed != -1 ? Info.RepulsionSpeed : MovementSpeed;
// HACK: Prevent updating visibility twice per tick. We really shouldn't be
// moving twice in a tick in the first place.
notify = false;
SetPosition(self, CenterPosition + FlyStep(speed, repulsionForce.Yaw));
notify = true;
}
public virtual WVec GetRepulsionForce()
{
if (!Info.Repulsable)
return WVec.Zero;
if (reservation != null)
{
var distanceFromReservationActor = (ReservedActor.CenterPosition - self.CenterPosition).HorizontalLength;
if (distanceFromReservationActor < Info.WaitDistanceFromResupplyBase.Length)
return WVec.Zero;
}
// Repulsion only applies when we're flying at CruiseAltitude!
if (!cruising)
return WVec.Zero;
// PERF: Avoid LINQ.
var repulsionForce = WVec.Zero;
foreach (var actor in self.World.FindActorsInCircle(self.CenterPosition, Info.IdealSeparation))
{
if (actor.IsDead)
continue;
var ai = actor.Info.TraitInfoOrDefault<AircraftInfo>();
if (ai == null || !ai.Repulsable || ai.CruiseAltitude != Info.CruiseAltitude)
continue;
repulsionForce += GetRepulsionForce(actor);
}
// Actors outside the map bounds receive an extra nudge towards the center of the map
if (!self.World.Map.Contains(self.Location))
{
// The map bounds are in projected coordinates, which is technically wrong for this,
// but we avoid the issues in practice by guessing the middle of the map instead of the edge
var center = WPos.Lerp(self.World.Map.ProjectedTopLeft, self.World.Map.ProjectedBottomRight, 1, 2);
repulsionForce += new WVec(0, 1024, 0).Rotate(WRot.FromYaw((self.CenterPosition - center).Yaw));
}
if (Info.CanSlide)
return repulsionForce;
// Non-hovering actors mush always keep moving forward, so they need extra calculations.
var currentDir = FlyStep(Facing);
var length = currentDir.HorizontalLength * repulsionForce.HorizontalLength;
if (length == 0)
return WVec.Zero;
var dot = WVec.Dot(currentDir, repulsionForce) / length;
// avoid stalling the plane
return dot >= 0 ? repulsionForce : WVec.Zero;
}
public WVec GetRepulsionForce(Actor other)
{
if (self == other || other.CenterPosition.Z < self.CenterPosition.Z)
return WVec.Zero;
var d = self.CenterPosition - other.CenterPosition;
var distSq = d.HorizontalLengthSquared;
if (distSq > Info.IdealSeparation.LengthSquared)
return WVec.Zero;
if (distSq < 1)
{
var yaw = self.World.SharedRandom.Next(0, 1023);
var rot = new WRot(WAngle.Zero, WAngle.Zero, new WAngle(yaw));
return new WVec(1024, 0, 0).Rotate(rot);
}
return d * 1024 * 8 / (int)distSq;
}
public Actor GetActorBelow()
{
// Map.DistanceAboveTerrain(WPos pos) is called directly because Aircraft is an IPositionable trait
// and all calls occur in Tick methods.
if (self.World.Map.DistanceAboveTerrain(CenterPosition) != LandAltitude)
return null; // Not on the resupplier.
return self.World.ActorMap.GetActorsAt(self.Location)
.FirstOrDefault(a => a.Info.HasTraitInfo<ReservableInfo>());
}
public void MakeReservation(Actor target)
{
UnReserve();
var reservable = target.TraitOrDefault<Reservable>();
if (reservable != null)
{
reservation = reservable.Reserve(target, self, this);
ReservedActor = target;
}
}
public void AllowYieldingReservation()
{
if (reservation == null)
return;
MayYieldReservation = true;
}
public void UnReserve()
{
if (reservation == null)
return;
reservation.Dispose();
reservation = null;
ReservedActor = null;
MayYieldReservation = false;
}
bool AircraftCanEnter(Actor a, TargetModifiers modifiers)
{
if (RequireForceMove && !modifiers.HasModifier(TargetModifiers.ForceMove))
return false;
return AircraftCanEnter(a);
}
bool AircraftCanEnter(Actor a)
{
if (self.AppearsHostileTo(a))
return false;
var canRearmAtActor = rearmable != null && rearmable.Info.RearmActors.Contains(a.Info.Name);
var canRepairAtActor = repairable != null && repairable.Info.RepairActors.Contains(a.Info.Name);
return canRearmAtActor || canRepairAtActor;
}
bool AircraftCanResupplyAt(Actor a, bool allowedToForceEnter = false)
{
if (self.AppearsHostileTo(a))
return false;
var canRearmAtActor = rearmable != null && rearmable.Info.RearmActors.Contains(a.Info.Name);
var canRepairAtActor = repairable != null && repairable.Info.RepairActors.Contains(a.Info.Name);
var allowedToEnterRearmer = canRearmAtActor && (allowedToForceEnter || rearmable.RearmableAmmoPools.Any(p => !p.HasFullAmmo));
var allowedToEnterRepairer = canRepairAtActor && (allowedToForceEnter || self.GetDamageState() != DamageState.Undamaged);
return allowedToEnterRearmer || allowedToEnterRepairer;
}
public int MovementSpeed => !IsTraitDisabled && !IsTraitPaused ? Util.ApplyPercentageModifiers(Info.Speed, speedModifiers) : 0;
public int IdleMovementSpeed => Info.IdleSpeed < 0 ? MovementSpeed :
!IsTraitDisabled && !IsTraitPaused ? Util.ApplyPercentageModifiers(Info.IdleSpeed, speedModifiers) : 0;
public (CPos Cell, SubCell SubCell)[] OccupiedCells()
{
return landingCells;
}
public WVec FlyStep(WAngle facing)
{
return FlyStep(MovementSpeed, facing);
}
public WVec FlyStep(int speed, WAngle facing)
{
var dir = new WVec(0, -1024, 0).Rotate(WRot.FromYaw(facing));
return speed * dir / 1024;
}
public CPos? FindLandingLocation(CPos targetCell, WDist maxSearchDistance)
{
// The easy case
if (CanLand(targetCell, blockedByMobile: false))
return targetCell;
var cellRange = (maxSearchDistance.Length + 1023) / 1024;
var centerPosition = self.World.Map.CenterOfCell(targetCell);
foreach (var c in self.World.Map.FindTilesInCircle(targetCell, cellRange))
{
if (!CanLand(c, blockedByMobile: false))
continue;
var delta = self.World.Map.CenterOfCell(c) - centerPosition;
if (delta.LengthSquared < maxSearchDistance.LengthSquared)
return c;
}
return null;
}
public bool CanLand(IEnumerable<CPos> cells, Actor dockingActor = null, bool blockedByMobile = true)
{
foreach (var c in cells)
if (!CanLand(c, dockingActor, blockedByMobile))
return false;
return true;
}
public bool CanLand(CPos cell, Actor dockingActor = null, bool blockedByMobile = true)
{
if (!self.World.Map.Contains(cell))
return false;
foreach (var otherActor in self.World.ActorMap.GetActorsAt(cell))
if (IsBlockedBy(self, otherActor, dockingActor, blockedByMobile))
return false;
// Terrain type is ignored when docking with an actor
if (dockingActor != null)
return true;
var landableTerrain = overrideAircraftLanding != null ? overrideAircraftLanding.LandableTerrainTypes : Info.LandableTerrainTypes;
return landableTerrain.Contains(self.World.Map.GetTerrainInfo(cell).Type);
}
bool IsBlockedBy(Actor self, Actor otherActor, Actor ignoreActor, bool blockedByMobile = true)
{
// We are not blocked by the actor we are ignoring.
if (otherActor == self || otherActor == ignoreActor)
return false;
// We are not blocked by actors we can nudge out of the way
// TODO: Generalize blocker checks and handling here and in Locomotor
if (!blockedByMobile && self.Owner.RelationshipWith(otherActor.Owner) == PlayerRelationship.Ally &&
otherActor.TraitOrDefault<Mobile>() != null && otherActor.CurrentActivity == null)
return false;
// PERF: Only perform ITemporaryBlocker trait look-up if mod/map rules contain any actors that are temporary blockers
if (self.World.RulesContainTemporaryBlocker)
{
// If there is a temporary blocker in our path, but we can remove it, we are not blocked.
var temporaryBlocker = otherActor.TraitOrDefault<ITemporaryBlocker>();
if (temporaryBlocker != null && temporaryBlocker.CanRemoveBlockage(otherActor, self))
return false;
}
// If we cannot crush the other actor in our way, we are blocked.
if (Info.Crushes.IsEmpty)
return true;
// If the other actor in our way cannot be crushed, we are blocked.
// PERF: Avoid LINQ.
foreach (var crushable in otherActor.Crushables)
if (crushable.CrushableBy(otherActor, self, Info.Crushes))
return false;
return true;
}
public bool CanRearmAt(Actor host)
{
return rearmable != null && rearmable.Info.RearmActors.Contains(host.Info.Name) && rearmable.RearmableAmmoPools.Any(p => !p.HasFullAmmo);
}
public bool CanRepairAt(Actor host)
{
return repairable != null && repairable.Info.RepairActors.Contains(host.Info.Name) && self.GetDamageState() != DamageState.Undamaged;
}
public void ModifyDeathActorInit(Actor self, TypeDictionary init)
{
init.Add(new FacingInit(Facing));
}
void INotifyBecomingIdle.OnBecomingIdle(Actor self)
{
OnBecomingIdle(self);
}
protected virtual void OnBecomingIdle(Actor self)
{
if (Info.IdleBehavior == IdleBehaviorType.LeaveMap)
{
self.QueueActivity(new FlyOffMap(self));
self.QueueActivity(new RemoveSelf());
}
else if (Info.IdleBehavior == IdleBehaviorType.LeaveMapAtClosestEdge)
{
var edgeCell = self.World.Map.ChooseClosestEdgeCell(self.Location);
self.QueueActivity(new FlyOffMap(self, Target.FromCell(self.World, edgeCell)));
self.QueueActivity(new RemoveSelf());
}
else if (Info.IdleBehavior == IdleBehaviorType.ReturnToBase && GetActorBelow() == null)
self.QueueActivity(new ReturnToBase(self, null, !Info.TakeOffOnResupply));
else
{
var dat = self.World.Map.DistanceAboveTerrain(CenterPosition);
if (dat == LandAltitude)
{
if (!CanLand(self.Location) && ReservedActor == null)
self.QueueActivity(new TakeOff(self));
// All remaining idle behaviors rely on not being at LandAltitude, so unconditionally return
return;
}
if (Info.IdleBehavior == IdleBehaviorType.Land && Info.LandableTerrainTypes.Count > 0)
self.QueueActivity(new Land(self));
else
self.QueueActivity(new FlyIdle(self));
}
}
#region Implement IPositionable
public bool CanExistInCell(CPos cell) { return true; }
public bool IsLeavingCell(CPos location, SubCell subCell = SubCell.Any) { return false; } // TODO: Handle landing
public bool CanEnterCell(CPos cell, Actor ignoreActor = null, BlockedByActor check = BlockedByActor.All) { return true; }
public SubCell GetValidSubCell(SubCell preferred) { return SubCell.Invalid; }
public SubCell GetAvailableSubCell(CPos a, SubCell preferredSubCell = SubCell.Any, Actor ignoreActor = null, BlockedByActor check = BlockedByActor.All)
{
// Does not use any subcell
return SubCell.Invalid;
}
public void SetCenterPosition(Actor self, WPos pos) { SetPosition(self, pos); }
// Changes position, but not altitude
public void SetPosition(Actor self, CPos cell, SubCell subCell = SubCell.Any)
{
SetPosition(self, self.World.Map.CenterOfCell(cell) + new WVec(0, 0, CenterPosition.Z));
}
public void SetPosition(Actor self, WPos pos)
{
CenterPosition = pos;
if (!self.IsInWorld)
return;
var altitude = self.World.Map.DistanceAboveTerrain(CenterPosition);
// LandingCells define OccupiedCells, so we need to keep current position with LandindCells in sync.
// Though we don't want to update LandingCells when the unit is airborne, as when non-VTOL units reserve
// their landing position it is expected for their landing cell to not match their current position.
if (HasInfluence() && altitude.Length <= Info.MinAirborneAltitude)
{
var currentPos = new[] { (TopLeft, SubCell.FullCell) };
if (landingCells.SequenceEqual(currentPos))
{
self.World.ActorMap.RemoveInfluence(self, this);
landingCells = currentPos;
self.World.ActorMap.AddInfluence(self, this);
}
}
self.World.UpdateMaps(self, this);
var isAirborne = altitude.Length >= Info.MinAirborneAltitude;
if (isAirborne && !airborne)
OnAirborneAltitudeReached();
else if (!isAirborne && airborne)
OnAirborneAltitudeLeft();
var isCruising = altitude == Info.CruiseAltitude;
if (isCruising && !cruising)
OnCruisingAltitudeReached();
else if (!isCruising && cruising)
OnCruisingAltitudeLeft();
// NB: This can be called from the constructor before notifyCenterPositionChanged is assigned.
if (notify && notifyCenterPositionChanged != null)
foreach (var n in notifyCenterPositionChanged)
n.CenterPositionChanged(self, 0, 0);
FinishedMoving(self);
}
public void FinishedMoving(Actor self)
{
// Only crush actors on having landed
if (!self.IsAtGroundLevel())
return;
CrushAction(self, (notifyCrushed) => notifyCrushed.OnCrush);
}
public void EnteringCell(Actor self)
{
CrushAction(self, (notifyCrushed) => notifyCrushed.WarnCrush);
}
void CrushAction(Actor self, Func<INotifyCrushed, Action<Actor, Actor, BitSet<CrushClass>>> action)
{
var crushables = self.World.ActorMap.GetActorsAt(TopLeft).Where(a => a != self)
.SelectMany(a => a.Crushables.Select(t => new TraitPair<ICrushable>(a, t)));
// Only crush actors that are on the ground level
foreach (var crushable in crushables)
if (crushable.Trait.CrushableBy(crushable.Actor, self, Info.Crushes) && crushable.Actor.IsAtGroundLevel())
foreach (var notifyCrushed in crushable.Actor.TraitsImplementing<INotifyCrushed>())
action(notifyCrushed)(crushable.Actor, self, Info.Crushes);
}
public void AddInfluence((CPos, SubCell)[] landingCells)
{
if (HasInfluence())
throw new InvalidOperationException(
$"Cannot {nameof(AddInfluence)} until previous influence is removed with {nameof(RemoveInfluence)}");
this.landingCells = landingCells;
if (self.IsInWorld)
self.World.ActorMap.AddInfluence(self, this);
}
public void AddInfluence(CPos landingCell)
{
AddInfluence([(landingCell, SubCell.FullCell)]);
}
public void RemoveInfluence()
{
if (self.IsInWorld)
self.World.ActorMap.RemoveInfluence(self, this);
landingCells = [];
}
public bool HasInfluence()
{
return landingCells.Length > 0;
}
#endregion
#region Implement IMove
public Activity MoveTo(CPos cell, int nearEnough = 0, Actor ignoreActor = null,
bool evaluateNearestMovableCell = false, Color? targetLineColor = null)
{
return new Fly(self, Target.FromCell(self.World, cell), WDist.FromCells(nearEnough), targetLineColor: targetLineColor);
}
public Activity MoveWithinRange(in Target target, WDist range,
WPos? initialTargetPosition = null, Color? targetLineColor = null)
{
return new Fly(self, target, WDist.Zero, range, initialTargetPosition, targetLineColor);
}
public Activity MoveWithinRange(in Target target, WDist minRange, WDist maxRange,
WPos? initialTargetPosition = null, Color? targetLineColor = null)
{
return new Fly(self, target, minRange, maxRange,
initialTargetPosition, targetLineColor);
}
public Activity MoveFollow(Actor self, in Target target, WDist minRange, WDist maxRange,
WPos? initialTargetPosition = null, Color? targetLineColor = null)
{
return new FlyFollow(self, target, minRange, maxRange,
initialTargetPosition, targetLineColor);
}
public Activity ReturnToCell(Actor self) { return null; }
public Activity MoveToTarget(Actor self, in Target target,
WPos? initialTargetPosition = null, Color? targetLineColor = null)
{
return new Fly(self, target, initialTargetPosition, targetLineColor);
}
public Activity MoveIntoTarget(Actor self, in Target target)
{
return new Land(self, target);
}
public Activity MoveOntoTarget(Actor self, in Target target, in WVec offset, WAngle? facing, Color? targetLineColor = null)
{
return new Land(self, target, offset, facing, targetLineColor);
}
public Activity LocalMove(Actor self, WPos fromPos, WPos toPos)
{
// TODO: Ignore repulsion when moving
var activities = new CallFunc(() => SetCenterPosition(self, fromPos));
activities.Queue(new Fly(self, Target.FromPos(toPos)));
return activities;
}
public int EstimatedMoveDuration(Actor self, WPos fromPos, WPos toPos)
{
var speed = MovementSpeed;
return speed > 0 ? (toPos - fromPos).Length / speed : 0;
}
public CPos NearestMoveableCell(CPos cell) { return cell; }
public MovementType CurrentMovementTypes
{
get => movementTypes;
set
{
var oldValue = movementTypes;
movementTypes = value;
if (value != oldValue)
foreach (var n in notifyMoving)
n.MovementTypeChanged(self, value);
}
}
public bool CanEnterTargetNow(Actor self, in Target target)
{
// Lambdas can't use 'in' variables, so capture a copy for later
var targetActor = target;
if (target.Positions.Any(p => self.World.ActorMap.GetActorsAt(self.World.Map.CellContaining(p)).Any(a => a != self && a != targetActor.Actor)))
return false;
MakeReservation(target.Actor);
return true;
}
#endregion
#region Implement order interfaces
public IEnumerable<IOrderTargeter> Orders
{
get
{
yield return new EnterAlliedActorTargeter<BuildingInfo>(
"ForceEnter",
6,
Info.EnterCursor,
Info.EnterBlockedCursor,
(target, modifiers) => Info.CanForceLand && modifiers.HasModifier(TargetModifiers.ForceMove) && AircraftCanEnter(target),
target => Reservable.IsAvailableFor(target, self) && AircraftCanResupplyAt(target, true));
yield return new EnterAlliedActorTargeter<BuildingInfo>(
"Enter",
5,
Info.EnterCursor,
Info.EnterBlockedCursor,
AircraftCanEnter,
target => Reservable.IsAvailableFor(target, self) && AircraftCanResupplyAt(target, !Info.TakeOffOnResupply));
yield return new AircraftMoveOrderTargeter(this);
}
}
public Order IssueOrder(Actor self, IOrderTargeter order, in Target target, bool queued)
{
if (!IsTraitDisabled &&
(order.OrderID == "Enter" || order.OrderID == "Move" || order.OrderID == "Land" || order.OrderID == "ForceEnter"))
return new Order(order.OrderID, self, target, queued);
return null;
}
Order IIssueDeployOrder.IssueDeployOrder(Actor self, bool queued)
{
if (IsTraitDisabled || rearmable == null || rearmable.Info.RearmActors.Count == 0)
return null;
return new Order("ReturnToBase", self, queued);
}
bool IIssueDeployOrder.CanIssueDeployOrder(Actor self, bool queued) { return rearmable != null && rearmable.Info.RearmActors.Count > 0; }
public string VoicePhraseForOrder(Actor self, Order order)
{
if (IsTraitDisabled)
return null;
switch (order.OrderString)
{
case "Land":
case "Move":
if (!Info.MoveIntoShroud && order.Target.Type != TargetType.Invalid)
{
var cell = self.World.Map.CellContaining(order.Target.CenterPosition);
if (!self.Owner.Shroud.IsExplored(cell))
return null;
}
return Info.Voice;
case "Enter":
case "ForceEnter":
case "Stop":
case "Scatter":
return Info.Voice;
case "ReturnToBase":
return rearmable != null && rearmable.Info.RearmActors.Count > 0 ? Info.Voice : null;
default: return null;
}
}
public void ResolveOrder(Actor self, Order order)
{
if (IsTraitDisabled)
return;
var orderString = order.OrderString;
if (orderString == "Move")
{
if (!order.Target.IsValidFor(self))
return;
var cell = self.World.Map.Clamp(self.World.Map.CellContaining(order.Target.CenterPosition));
if (!Info.MoveIntoShroud && !self.Owner.Shroud.IsExplored(cell))
return;
if (!order.Queued)
UnReserve();
var target = Target.FromCell(self.World, cell);
// TODO: this should scale with unit selection group size.
self.QueueActivity(order.Queued, new Fly(self, target, WDist.FromCells(8), targetLineColor: Info.TargetLineColor));
self.ShowTargetLines();
}
else if (orderString == "Land")
{
if (!order.Target.IsValidFor(self))
return;
var cell = self.World.Map.Clamp(self.World.Map.CellContaining(order.Target.CenterPosition));
if (!Info.MoveIntoShroud && !self.Owner.Shroud.IsExplored(cell))
return;
if (!order.Queued)
UnReserve();
var target = Target.FromCell(self.World, cell);
self.QueueActivity(order.Queued, new Land(self, target, targetLineColor: Info.TargetLineColor));
self.ShowTargetLines();
}
else if (orderString == "Enter" || orderString == "ForceEnter" || orderString == "Repair")
{
// Enter, ForceEnter and Repair orders are only valid for own/allied actors,
// which are guaranteed to never be frozen.
if (order.Target.Type != TargetType.Actor)
return;
var targetActor = order.Target.Actor;
var isForceEnter = orderString == "ForceEnter";
var canResupplyAt = AircraftCanResupplyAt(targetActor, isForceEnter || !Info.TakeOffOnResupply);
// This is what the order targeter checks to display the correct cursor, so we need to make sure
// the behavior matches the cursor if the player clicks despite a "blocked" cursor.
if (!canResupplyAt || !Reservable.IsAvailableFor(targetActor, self))
return;
if (!order.Queued)
UnReserve();
// Aircraft with TakeOffOnResupply would immediately take off again, so there's no point in automatically forcing
// them to land on a resupplier. For aircraft without it, it makes more sense to land than to idle above a
// free resupplier.
var forceLand = isForceEnter || !Info.TakeOffOnResupply;
self.QueueActivity(order.Queued, new ReturnToBase(self, targetActor, forceLand));
self.ShowTargetLines();
}
else if (orderString == "Stop")
{
// We don't want the Stop order to cancel a running Resupply activity.
// Resupply is always either the main activity or a child of ReturnToBase.
if (self.CurrentActivity is Resupply ||
(self.CurrentActivity is ReturnToBase && GetActorBelow() != null))
return;
self.CancelActivity();
UnReserve();
}
else if (orderString == "ReturnToBase")
{
// Do nothing if not rearmable and don't restart activity every time deploy hotkey is triggered
if (rearmable == null || rearmable.Info.RearmActors.Count == 0 || self.CurrentActivity is ReturnToBase || GetActorBelow() != null)
return;
if (!order.Queued)
UnReserve();
// Aircraft with TakeOffOnResupply would immediately take off again, so there's no point in forcing them to land
// on a resupplier. For aircraft without it, it makes more sense to land than to idle above a free resupplier.
self.QueueActivity(order.Queued, new ReturnToBase(self, null, !Info.TakeOffOnResupply));
self.ShowTargetLines();
}
else if (orderString == "Scatter")
{
self.QueueActivity(order.Queued, new Nudge(self));
self.ShowTargetLines();
}
}
#endregion
#region Airborne conditions
void OnAirborneAltitudeReached()
{
if (airborne)
return;
airborne = true;
if (airborneToken == Actor.InvalidConditionToken)
airborneToken = self.GrantCondition(Info.AirborneCondition);
}
void OnAirborneAltitudeLeft()
{
if (!airborne)
return;
airborne = false;
if (airborneToken != Actor.InvalidConditionToken)
airborneToken = self.RevokeCondition(airborneToken);
}
#endregion
#region Cruising conditions
void OnCruisingAltitudeReached()
{
if (cruising)
return;
cruising = true;
if (cruisingToken == Actor.InvalidConditionToken)
cruisingToken = self.GrantCondition(Info.CruisingCondition);
}
void OnCruisingAltitudeLeft()
{
if (!cruising)
return;
cruising = false;
if (cruisingToken != Actor.InvalidConditionToken)
cruisingToken = self.RevokeCondition(cruisingToken);
}
#endregion
void INotifyActorDisposing.Disposing(Actor self)
{
UnReserve();
}
void IActorPreviewInitModifier.ModifyActorPreviewInit(Actor self, TypeDictionary inits)
{
if (!inits.Contains<DynamicFacingInit>() && !inits.Contains<FacingInit>())
inits.Add(new DynamicFacingInit(() => Facing));
}
Activity ICreationActivity.GetCreationActivity()
{
if (creationRallyPoint != null || creationActivityDelay > 0 || creationByMap)
return new AssociateWithAirfieldActivity(this, creationActivityDelay, creationRallyPoint, creationByMap);
return null;
}
sealed class AssociateWithAirfieldActivity : Activity
{
readonly Aircraft aircraft;
readonly int delay;
readonly CPos[] rallyPoint;
readonly bool creationByMap;
public AssociateWithAirfieldActivity(Aircraft self, int delay, CPos[] rallyPoint, bool creationByMap)
{
aircraft = self;
this.delay = delay;
this.rallyPoint = rallyPoint;
this.creationByMap = creationByMap;
}
protected override void OnFirstRun(Actor self)
{
var cpos = self.Location;
var pos = self.CenterPosition;
bool TryDock()
{
var host = aircraft.GetActorBelow();
if (host != null)
{
// Center the actor on the resupplier.
var exit = host.NearestExitOrDefault(pos);
pos = host.CenterPosition;
pos = new WPos(pos.X, pos.Y, pos.Z - self.World.Map.DistanceAboveTerrain(pos).Length);
if (exit != null)
{
pos += exit.Info.SpawnOffset;
if (exit.Info.Facing != null)
aircraft.Facing = exit.Info.Facing.Value;
}
aircraft.AddInfluence(cpos);
aircraft.SetPosition(self, pos);
aircraft.MakeReservation(host);
// Freshly created aircraft shouldn't block the exit, so we allow them to yield their reservation.
aircraft.AllowYieldingReservation();
return true;
}
return false;
}
if (creationByMap)
{
if (TryDock())
return;
pos = new WPos(pos.X, pos.Y, pos.Z - self.World.Map.DistanceAboveTerrain(pos).Length);
if (!aircraft.Info.TakeOffOnCreation && aircraft.CanLand(cpos))
{
aircraft.AddInfluence(cpos);
aircraft.SetPosition(self, pos);
}
else
aircraft.SetPosition(self, new WPos(pos.X, pos.Y, pos.Z + aircraft.Info.CruiseAltitude.Length));
}
else
{
TryDock();
if (delay > 0)
QueueChild(new Wait(delay));
}
}
public override bool Tick(Actor self)
{
if (!aircraft.Info.TakeOffOnCreation || IsCanceling)
return true;
if (rallyPoint != null && rallyPoint.Length > 0)
foreach (var cell in rallyPoint)
QueueChild(new AttackMoveActivity(self, () => aircraft.MoveTo(cell, 1, evaluateNearestMovableCell: true, targetLineColor: Color.OrangeRed)));
if (!creationByMap)
aircraft.UnReserve();
return true;
}
public override IEnumerable<Target> GetTargets(Actor self)
{
if (ChildActivity != null)
return ChildActivity.GetTargets(self);
return Target.None;
}
public override IEnumerable<TargetLineNode> TargetLineNodes(Actor self)
{
var a = ChildActivity;
for (; a != null; a = a.NextActivity)
if (!a.IsCanceling)
foreach (var n in a.TargetLineNodes(self))
yield return n;
}
}
public class AircraftMoveOrderTargeter : IOrderTargeter
{
readonly Aircraft aircraft;
public string OrderID { get; protected set; }
public int OrderPriority => 4;
public bool IsQueued { get; protected set; }
public AircraftMoveOrderTargeter(Aircraft aircraft)
{
this.aircraft = aircraft;
OrderID = "Move";
}
public bool TargetOverridesSelection(Actor self, in Target target, List<Actor> actorsAt, CPos xy, TargetModifiers modifiers)
{
// Always prioritise orders over selecting other peoples actors or own actors that are already selected
if (target.Type == TargetType.Actor && (target.Actor.Owner != self.Owner || self.World.Selection.Contains(target.Actor)))
return true;
return modifiers.HasModifier(TargetModifiers.ForceMove);
}
public virtual bool CanTarget(Actor self, in Target target, ref TargetModifiers modifiers, ref string cursor)
{
if (target.Type != TargetType.Terrain || (aircraft.RequireForceMove && !modifiers.HasModifier(TargetModifiers.ForceMove)))
return false;
var location = self.World.Map.CellContaining(target.CenterPosition);
// Aircraft can be force-landed by issuing a force-move order on a clear terrain cell
// Cells that contain a blocking building are treated as regular force move orders, overriding
// selection for left-mouse orders
if (modifiers.HasModifier(TargetModifiers.ForceMove) && aircraft.Info.CanForceLand)
{
var buildingAtLocation = self.World.ActorMap.GetActorsAt(location)
.Any(a => a.TraitOrDefault<Building>() != null && a.TraitOrDefault<Selectable>() != null);
if (!buildingAtLocation || aircraft.CanLand(location, blockedByMobile: false))
OrderID = "Land";
}
IsQueued = modifiers.HasModifier(TargetModifiers.ForceQueue);
var explored = self.Owner.Shroud.IsExplored(location);
cursor = !aircraft.IsTraitPaused && (explored || aircraft.Info.MoveIntoShroud) && self.World.Map.Contains(location) ?
aircraft.Info.Cursor : aircraft.Info.BlockedCursor;
return true;
}
}
}
}