#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.Generic; using OpenRA.Activities; using OpenRA.Mods.Common.Traits; using OpenRA.Primitives; using OpenRA.Traits; namespace OpenRA.Mods.Common.Activities { public class Fly : Activity { readonly Aircraft aircraft; readonly WDist maxRange; readonly WDist minRange; readonly Color? targetLineColor; readonly WDist nearEnough; Target target; Target lastVisibleTarget; bool useLastVisibleTarget; readonly RingBuffer previousPositions = new(5); public Fly(Actor self, in Target t, WDist nearEnough, WPos? initialTargetPosition = null, Color? targetLineColor = null) : this(self, t, initialTargetPosition, targetLineColor) { this.nearEnough = nearEnough; } public Fly(Actor self, in Target t, WPos? initialTargetPosition = null, Color? targetLineColor = null) { aircraft = self.Trait(); target = t; this.targetLineColor = targetLineColor; // The target may become hidden between the initial order request and the first tick (e.g. if queued) // Moving to any position (even if quite stale) is still better than immediately giving up if ((target.Type == TargetType.Actor && target.Actor.CanBeViewedByPlayer(self.Owner)) || target.Type == TargetType.FrozenActor || target.Type == TargetType.Terrain) lastVisibleTarget = Target.FromPos(target.CenterPosition); else if (initialTargetPosition.HasValue) lastVisibleTarget = Target.FromPos(initialTargetPosition.Value); } public Fly(Actor self, in Target t, WDist minRange, WDist maxRange, WPos? initialTargetPosition = null, Color? targetLineColor = null) : this(self, t, initialTargetPosition, targetLineColor) { this.maxRange = maxRange; this.minRange = minRange; } public static void FlyTick(Actor self, Aircraft aircraft, WAngle desiredFacing, WDist desiredAltitude, in WVec moveOverride, bool idleTurn = false) { var dat = self.World.Map.DistanceAboveTerrain(aircraft.CenterPosition); var move = moveOverride != WVec.Zero ? moveOverride : (aircraft.Info.CanSlide ? aircraft.FlyStep(desiredFacing) : aircraft.FlyStep(aircraft.Facing)); var oldFacing = aircraft.Facing; aircraft.Facing = Util.TickFacing(aircraft.Facing, desiredFacing, aircraft.GetTurnSpeed(idleTurn)); var roll = idleTurn ? aircraft.Info.IdleRoll ?? aircraft.Info.Roll : aircraft.Info.Roll; if (roll != WAngle.Zero) { var desiredRoll = aircraft.Facing == desiredFacing ? WAngle.Zero : new WAngle(roll.Angle * Util.GetTurnDirection(aircraft.Facing, oldFacing)); aircraft.Roll = Util.TickFacing(aircraft.Roll, desiredRoll, aircraft.Info.RollSpeed); } if (aircraft.Info.Pitch != WAngle.Zero) aircraft.Pitch = Util.TickFacing(aircraft.Pitch, aircraft.Info.Pitch, aircraft.Info.PitchSpeed); // Note: we assume that if move.Z is not zero, it's intentional and we want to move in that vertical direction instead of towards desiredAltitude. // If that is not desired, the place that calls this should make sure moveOverride.Z is zero. if (dat != desiredAltitude || move.Z != 0) { var maxDelta = move.HorizontalLength * aircraft.Info.MaximumPitch.Tan() / 1024; var moveZ = move.Z != 0 ? move.Z : (desiredAltitude.Length - dat.Length); var deltaZ = moveZ.Clamp(-maxDelta, maxDelta); move = new WVec(move.X, move.Y, deltaZ); } aircraft.SetPosition(self, aircraft.CenterPosition + move); } public static void FlyTick(Actor self, Aircraft aircraft, WAngle desiredFacing, WDist desiredAltitude, bool idleTurn = false) { FlyTick(self, aircraft, desiredFacing, desiredAltitude, WVec.Zero, idleTurn); } // Should only be used for vertical-only movement, usually VTOL take-off or land. Terrain-induced altitude changes should always be handled by FlyTick. public static bool VerticalTakeOffOrLandTick(Actor self, Aircraft aircraft, WAngle desiredFacing, WDist desiredAltitude, bool idleTurn = false) { var turnSpeed = idleTurn ? aircraft.IdleTurnSpeed ?? aircraft.TurnSpeed : aircraft.TurnSpeed; aircraft.Facing = Util.TickFacing(aircraft.Facing, desiredFacing, turnSpeed); var dat = self.World.Map.DistanceAboveTerrain(aircraft.CenterPosition); if (dat == desiredAltitude) return false; var maxDelta = aircraft.Info.AltitudeVelocity.Length; var deltaZ = (desiredAltitude.Length - dat.Length).Clamp(-maxDelta, maxDelta); aircraft.SetPosition(self, aircraft.CenterPosition + new WVec(0, 0, deltaZ)); return true; } public override bool Tick(Actor self) { // Refuse to take off if it would land immediately again. if (aircraft.ForceLanding) Cancel(self); var dat = self.World.Map.DistanceAboveTerrain(aircraft.CenterPosition); var isLanded = dat <= aircraft.LandAltitude; // HACK: Prevent paused (for example, EMP'd) aircraft from taking off. // This is necessary until the TODOs in the IsCanceling block below are addressed. if (isLanded && aircraft.IsTraitPaused) return false; if (IsCanceling) { // We must return the actor to a sensible height before continuing. // If the aircraft is on the ground we queue TakeOff to manage the influence reservation and takeoff sounds etc. // TODO: It would be better to not take off at all, but we lack the plumbing to detect current airborne/landed state. // If the aircraft lands when idle and is idle, we let the default idle handler manage this. // TODO: Remove this after fixing all activities to work properly with arbitrary starting altitudes. var landWhenIdle = aircraft.Info.IdleBehavior == IdleBehaviorType.Land; var skipHeightAdjustment = landWhenIdle && self.CurrentActivity.IsCanceling && self.CurrentActivity.NextActivity == null; if (aircraft.Info.CanHover && !skipHeightAdjustment && dat != aircraft.Info.CruiseAltitude) { if (isLanded) QueueChild(new TakeOff(self)); else VerticalTakeOffOrLandTick(self, aircraft, aircraft.Facing, aircraft.Info.CruiseAltitude); return false; } return true; } else if (isLanded) { QueueChild(new TakeOff(self)); return false; } target = target.Recalculate(self.Owner, out var targetIsHiddenActor); if (!targetIsHiddenActor && target.Type == TargetType.Actor) lastVisibleTarget = Target.FromTargetPositions(target); useLastVisibleTarget = targetIsHiddenActor || !target.IsValidFor(self); // Target is hidden or dead, and we don't have a fallback position to move towards if (useLastVisibleTarget && !lastVisibleTarget.IsValidFor(self)) return true; var checkTarget = useLastVisibleTarget ? lastVisibleTarget : target; var pos = aircraft.GetPosition(); var delta = checkTarget.CenterPosition - pos; // Inside the target annulus, so we're done var insideMaxRange = maxRange.Length > 0 && checkTarget.IsInRange(pos, maxRange); var insideMinRange = minRange.Length > 0 && checkTarget.IsInRange(pos, minRange); if (insideMaxRange && !insideMinRange) return true; var isSlider = aircraft.Info.CanSlide; var desiredFacing = aircraft.Facing; if (delta.HorizontalLengthSquared != 0) { var facing = delta.Yaw; // Prevent jittering. var diff = Math.Abs(facing.Angle - desiredFacing.Angle); var deadzone = aircraft.Info.TurnDeadzone.Angle; if (diff > deadzone && diff < 1024 - deadzone) desiredFacing = facing; } var move = isSlider ? aircraft.FlyStep(desiredFacing) : aircraft.FlyStep(aircraft.Facing); // Inside the minimum range, so reverse if we CanSlide, otherwise face away from the target. if (insideMinRange) { if (isSlider) FlyTick(self, aircraft, desiredFacing, aircraft.Info.CruiseAltitude, -move); else FlyTick(self, aircraft, desiredFacing + new WAngle(512), aircraft.Info.CruiseAltitude, move); return false; } // HACK: Consider ourselves blocked if we have moved by less than 64 WDist in the last five ticks // Stop if we are blocked and close enough if (previousPositions.Count == previousPositions.Capacity && (previousPositions.First() - previousPositions.Last()).LengthSquared < 4096 && delta.HorizontalLengthSquared <= nearEnough.LengthSquared) return true; // The next move would overshoot, so consider it close enough or set final position if we CanSlide if (delta.HorizontalLengthSquared < move.HorizontalLengthSquared) { // For VTOL landing to succeed, it must reach the exact target position, // so for the final move it needs to behave as if it had CanSlide. if (isSlider || aircraft.Info.VTOL) { // Set final (horizontal) position if (delta.HorizontalLengthSquared != 0) { // Ensure we don't include a non-zero vertical component here that would move us away from CruiseAltitude var deltaMove = new WVec(delta.X, delta.Y, 0); FlyTick(self, aircraft, desiredFacing, dat, deltaMove); } // Move to CruiseAltitude, if not already there if (dat != aircraft.Info.CruiseAltitude) { VerticalTakeOffOrLandTick(self, aircraft, aircraft.Facing, aircraft.Info.CruiseAltitude); return false; } } return true; } if (!isSlider) { // Using the turn rate, compute a hypothetical circle traced by a continuous turn. // If it contains the destination point, it's unreachable without more complex maneuvering. var turnRadius = CalculateTurnRadius(aircraft.MovementSpeed, aircraft.TurnSpeed); // The current facing is a tangent of the minimal turn circle. // Make a perpendicular vector, and use it to locate the turn's center. var turnCenterFacing = aircraft.Facing + new WAngle(Util.GetTurnDirection(aircraft.Facing, desiredFacing) * 256); var turnCenterDir = new WVec(0, -1024, 0).Rotate(WRot.FromYaw(turnCenterFacing)); turnCenterDir *= turnRadius; turnCenterDir /= 1024; // Compare with the target point, and keep flying away if it's inside the circle. var turnCenter = aircraft.CenterPosition + turnCenterDir; if ((checkTarget.CenterPosition - turnCenter).HorizontalLengthSquared < turnRadius * turnRadius) desiredFacing = aircraft.Facing; } previousPositions.Add(self.CenterPosition); FlyTick(self, aircraft, desiredFacing, aircraft.Info.CruiseAltitude, WVec.Zero); return false; } public override IEnumerable GetTargets(Actor self) { yield return target; } public override IEnumerable TargetLineNodes(Actor self) { if (targetLineColor.HasValue) yield return new TargetLineNode(useLastVisibleTarget ? lastVisibleTarget : target, targetLineColor.Value); } public static int CalculateTurnRadius(int speed, WAngle turnSpeed) { // turnSpeed -> divide into 256 to get the number of ticks per complete rotation // speed -> multiply to get distance travelled per rotation (circumference) // 180 -> divide by 2*pi to get the turn radius: 180==1024/(2*pi), with some extra leeway return turnSpeed.Angle > 0 ? 180 * speed / turnSpeed.Angle : 0; } } }