diff --git a/OpenRA.Mods.Common/Activities/Air/DeliverBulkOrder.cs b/OpenRA.Mods.Common/Activities/Air/DeliverBulkOrder.cs new file mode 100644 index 0000000000..b7d3f5f421 --- /dev/null +++ b/OpenRA.Mods.Common/Activities/Air/DeliverBulkOrder.cs @@ -0,0 +1,118 @@ +#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.Collections.Generic; +using System.Linq; +using OpenRA.Activities; +using OpenRA.Mods.Common.Traits; +using OpenRA.Primitives; +using OpenRA.Traits; + +namespace OpenRA.Mods.Common.Activities +{ + public class DeliverBulkOrder : Activity + { + readonly Actor producer; + readonly List<(ActorInfo Actor, int Resources, int Cash)> orderedActors; + readonly string productionType; + readonly BulkProductionQueue queue; + readonly Cargo cargo; + int delayBetweenUnloads = 0; + + public DeliverBulkOrder(Actor transport, Actor producer, List<(ActorInfo Actor, int Resources, int Cash)> orderedActors, + string productionType, BulkProductionQueue queue) + { + this.producer = producer; + this.orderedActors = orderedActors; + this.productionType = productionType; + this.queue = queue; + cargo = transport.Trait(); + } + + protected override void OnFirstRun(Actor self) + { + var landingOffset = producer.Info.TraitInfo().LandOffset; + QueueChild(new Land(self, Target.FromActor(producer), WDist.FromCells(0), landingOffset)); + if (cargo.Info.BeforeUnloadDelay > 0) + QueueChild(new Wait(cargo.Info.BeforeUnloadDelay)); + } + + protected override void OnLastRun(Actor self) + { + if (!producer.IsDead || producer.IsInWorld) + foreach (var cargo in producer.TraitsImplementing()) + cargo.Delivered(producer); + + if (cargo.Info.AfterUnloadDelay > 0) + Queue(new Wait(cargo.Info.AfterUnloadDelay)); + Queue(new FlyOffMap(self, Target.FromCell(self.World, self.World.Map.ChooseClosestEdgeCell(self.Location)))); + Queue(new RemoveSelf()); + } + + protected override void OnActorDispose(Actor self) + { + queue.DeliverFinished(); + } + + public override bool Tick(Actor self) + { + if (!producer.IsInWorld || producer.IsDead) + { + // Try to find another ProductionBulkAirDrop + var newProducer = self.World.ActorsHavingTrait() + .Where(a => a.Owner == self.Owner) + .ClosestToIgnoringPath(self); + if (newProducer != null) + { + Cancel(self); + Queue(new DeliverBulkOrder(self, newProducer, orderedActors, productionType, queue)); + return true; + } + else + { + queue.DeliverFinished(); + return true; + } + } + + if (orderedActors == null || orderedActors.Count == 0) + { + queue.DeliverFinished(); + return true; + } + + var actor = orderedActors[^1]; + var productionTrait = producer.Trait(); + var exit = productionTrait.PublicExit(producer, actor.Actor, productionType); + if (exit == null) + return false; + + if (delayBetweenUnloads > 0) + { + delayBetweenUnloads--; + return false; + } + + delayBetweenUnloads = cargo.Info.BetweenUnloadDelay; + producer.World.AddFrameEndTask(ww => + { + var inits = new TypeDictionary + { + new OwnerInit(self.Owner), + new FactionInit(BuildableInfo.GetInitialFaction(actor.Actor, producer.Trait().Faction)) + }; + productionTrait.DoProduction(producer, actor.Actor, exit?.Info, productionType, inits); + orderedActors.Remove(actor); + }); + return false; + } + } +} diff --git a/OpenRA.Mods.Common/Activities/UnloadCargo.cs b/OpenRA.Mods.Common/Activities/UnloadCargo.cs index 64ba288d6f..235445d7b5 100644 --- a/OpenRA.Mods.Common/Activities/UnloadCargo.cs +++ b/OpenRA.Mods.Common/Activities/UnloadCargo.cs @@ -27,6 +27,7 @@ namespace OpenRA.Mods.Common.Activities readonly Mobile mobile; readonly bool assignTargetOnFirstRun; readonly WDist unloadRange; + int delayBetweenUnloads = 0; Target destination; bool takeOffAfterUnload; @@ -96,6 +97,13 @@ namespace OpenRA.Mods.Common.Activities if (cargo.CanUnload()) { + if (delayBetweenUnloads > 0) + { + delayBetweenUnloads--; + return false; + } + + delayBetweenUnloads = cargo.Info.BetweenUnloadDelay; foreach (var inu in notifiers) inu.Unloading(self); diff --git a/OpenRA.Mods.Common/Traits/Cargo.cs b/OpenRA.Mods.Common/Traits/Cargo.cs index f237e550e6..bb48cfda71 100644 --- a/OpenRA.Mods.Common/Traits/Cargo.cs +++ b/OpenRA.Mods.Common/Traits/Cargo.cs @@ -59,6 +59,9 @@ namespace OpenRA.Mods.Common.Traits [Desc("Delay (in ticks) before continuing after unloading a passenger.")] public readonly int AfterUnloadDelay = 25; + [Desc("Delay (in ticks) before each passenger is unloaded.")] + public readonly int BetweenUnloadDelay = 0; + [CursorReference] [Desc("Cursor to display when able to unload the passengers.")] public readonly string UnloadCursor = "deploy"; diff --git a/OpenRA.Mods.Common/Traits/Player/BulkProductionQueue.cs b/OpenRA.Mods.Common/Traits/Player/BulkProductionQueue.cs new file mode 100644 index 0000000000..8a5aa8fad4 --- /dev/null +++ b/OpenRA.Mods.Common/Traits/Player/BulkProductionQueue.cs @@ -0,0 +1,331 @@ +#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 System.Linq; +using OpenRA.Traits; + +namespace OpenRA.Mods.Common.Traits +{ + [TraitLocation(SystemActors.Player)] + [Desc("Attach this to the player actor (not a building!) to define a new shared build queue.", + "Allows you to build multiple actors before delivery", + "Will work together with the `ProductionBulkAirDrop` trait on the actor that actually does the production.", + "You will also want to add `PrimaryBuilding` to let the user choose where new units should exit.")] + public class BulkProductionQueueInfo : ProductionQueueInfo, Requires, Requires + { + [Desc("Maximum order capacity.")] + public readonly int MaxCapacity = 6; + + [Desc("Delivery delay in ticks.")] + public readonly int DeliveryDelay = 1500; + + [NotificationReference("Speech")] + [Desc("Notification played when the delivery started.")] + public readonly string StartDeliveryNotification = null; + + [Desc("Return funds if the delivery fails.")] + public readonly bool RefundUndeliveredActors = true; + + [FluentReference(optional: true)] + [Desc("Notification displayed when the delivery started.")] + public readonly string StartDeliveryTextNotification = null; + + [NotificationReference("Speech")] + [Desc("Notifications to play when the delivery actor is on its way. Last notification is played when the actors to deliver are spawned on the map.")] + public readonly string[] DeliveryProgressNotifications = []; + + public override object Create(ActorInitializer init) { return new BulkProductionQueue(init, this); } + } + + public class BulkProductionQueue : ProductionQueue + { + static readonly ActorInfo[] NoItems = []; + + readonly Actor self; + readonly BulkProductionQueueInfo info; + + protected readonly List<(ActorInfo Actor, int Resources, int Cash)> ActorsReadyForDelivery = []; + public int DeliveryDelay { get; private set; } = 0; + protected int notificationInterval = 0; + protected int notificationIndex = 0; + + protected bool deliveryProcessStarted = false; + + public BulkProductionQueue(ActorInitializer init, BulkProductionQueueInfo info) + : base(init, info) + { + self = init.Self; + this.info = info; + if (info.DeliveryProgressNotifications.Length != 0) + notificationInterval = info.DeliveryDelay / info.DeliveryProgressNotifications.Length; + } + + protected override void Tick(Actor self) + { + // PERF: Avoid LINQ. + Enabled = false; + var isActive = false; + foreach (var x in self.World.ActorsWithTrait()) + { + if (x.Trait.IsTraitDisabled) + continue; + + if (x.Actor.Owner != self.Owner || !x.Trait.Info.Produces.Contains(Info.Type)) + continue; + + Enabled |= IsValidFaction; + isActive |= !x.Trait.IsTraitPaused; + } + + if (!Enabled) + { + DeliverFinished(); + ClearQueue(); + } + else + { + if (HasDeliveryStarted() && DeliveryDelay > 0) + { + DeliveryDelay--; + PlayDeliveryProgressNotifications(); + } + else if (HasDeliveryStarted() && DeliveryDelay == 0) + { + PlayDeliveryProgressNotifications(); + DeliveryHasArrived(); + DeliveryDelay--; + } + } + + TickInner(self, !isActive); + } + + public override IEnumerable AllItems() + { + return Enabled ? base.AllItems() : NoItems; + } + + public override IEnumerable BuildableItems() + { + return Enabled && ActorsReadyForDelivery.Count != info.MaxCapacity && !deliveryProcessStarted ? base.BuildableItems() : NoItems; + } + + public override TraitPair MostLikelyProducer() + { + var productionActor = self.World.ActorsWithTrait() + .Where(x => x.Actor.Owner == self.Owner + && !x.Trait.IsTraitDisabled && x.Trait.Info.Produces.Contains(Info.Type)) + .OrderBy(x => x.Trait.IsTraitPaused) + .ThenByDescending(x => x.Actor.Trait().IsPrimary) + .ThenByDescending(x => x.Actor.ActorID) + .FirstOrDefault(); + + return productionActor; + } + + protected override bool BuildUnit(ActorInfo unit) + { + // Find a production structure to build this actor + var bi = unit.TraitInfo(); + + // Some units may request a specific production type, which is ignored if the AllTech cheat is enabled + var type = developerMode.AllTech ? Info.Type : (bi.BuildAtProductionType ?? Info.Type); + + var producers = self.World.ActorsWithTrait() + .Where(x => x.Actor.Owner == self.Owner + && !x.Trait.IsTraitDisabled + && x.Trait.Info.Produces.Contains(type)) + .OrderByDescending(x => x.Actor.Trait().IsPrimary) + .OrderByDescending(x => x.Actor.Trait().IsPrimary) + .ThenByDescending(x => x.Actor.ActorID); + var anyProducers = false; + foreach (var p in producers) + { + anyProducers = true; + if (p.Trait.IsTraitPaused) + continue; + if (ActorsReadyForDelivery.Count <= info.MaxCapacity) + { + var item = Queue.First(i => i.Done && i.Item == unit.Name); + ActorsReadyForDelivery.Add((unit, item.ResourcesPaid, item.TotalCost - item.ResourcesPaid)); + EndProduction(item); + return true; + } + } + + if (!anyProducers) + CancelProduction(unit.Name, 1); + + return false; + } + + public override void ResolveOrder(Actor self, Order order) + { + if (!Enabled) + return; + + var rules = self.World.Map.Rules; + switch (order.OrderString) + { + case "StartProduction": + var unit = rules.Actors[order.TargetString]; + var bi = unit.TraitInfo(); + + // Not built by this queue + if (!bi.Queue.Contains(Info.Type)) + return; + + // You can't build that + if (BuildableItems().All(b => b.Name != order.TargetString)) + return; + + // Check if the player is trying to build more units that they are allowed + var fromLimit = int.MaxValue; + if (!developerMode.AllTech) + { + if (Info.QueueLimit > 0) + fromLimit = Info.QueueLimit - Queue.Count; + + if (Info.ItemLimit > 0) + fromLimit = Math.Min(fromLimit, Info.ItemLimit - Queue.Count(i => i.Item == order.TargetString)); + + if (bi.BuildLimit > 0) + { + var inQueue = Queue.Count(pi => pi.Item == order.TargetString); + var owned = self.Owner.World.ActorsHavingTrait().Count(a => a.Info.Name == order.TargetString && a.Owner == self.Owner); + fromLimit = Math.Min(fromLimit, bi.BuildLimit - (inQueue + owned)); + } + + if (fromLimit <= 0) + return; + } + + var cost = GetProductionCost(unit); + var time = GetBuildTime(unit, bi); + var amountToBuild = Math.Min(fromLimit, order.ExtraData); + for (var n = 0; n < amountToBuild; n++) + { + if (Info.PayUpFront && cost > playerResources.GetCashAndResources()) + return; + BeginProduction(new ProductionItem(this, order.TargetString, cost, playerPower, () => self.World.AddFrameEndTask(_ => + { + // Make sure the item hasn't been invalidated between the ProductionItem ticking and this FrameEndTask running + if (!Queue.Any(i => i.Done && i.Item == unit.Name)) + return; + BuildUnit(unit); + })), !order.Queued); + } + + break; + case "PauseProduction": + PauseProduction(order.TargetString, order.ExtraData != 0); + break; + case "CancelProduction": + CancelProduction(order.TargetString, order.ExtraData); + break; + case "ReturnOrder": + ReturnOrder(order.TargetString, order.ExtraData); + break; + case "PurchaseOrder": + if (!deliveryProcessStarted && order.TargetString == info.Type) + StartDeliveryProcess(); + break; + } + } + + public void DeliverFinished() + { + if (info.RefundUndeliveredActors || !deliveryProcessStarted) + ReturnOrder(); + ActorsReadyForDelivery.Clear(); + deliveryProcessStarted = false; + } + + public bool HasDeliveryStarted() + { + return deliveryProcessStarted; + } + + public List<(ActorInfo Actor, int Resources, int Cash)> GetActorsReadyForDelivery() + { + return ActorsReadyForDelivery; + } + + protected void StartDeliveryProcess() + { + ClearQueue(); + deliveryProcessStarted = true; + DeliveryDelay = info.DeliveryDelay; + var rules = self.World.Map.Rules; + Game.Sound.PlayNotification(rules, self.Owner, "Speech", info.StartDeliveryNotification, self.Owner.Faction.InternalName); + if (info.StartDeliveryTextNotification != null) + TextNotificationsManager.AddTransientLine(self.Owner, info.StartDeliveryTextNotification); + } + + protected void DeliveryHasArrived() + { + var producers = self.World.ActorsWithTrait() + .Where(x => x.Actor.Owner == self.Owner + && !x.Trait.IsTraitDisabled + && !x.Trait.IsTraitPaused + && x.Trait.Info.Produces.Contains(Info.Type)) + .OrderByDescending(x => x.Actor.Trait().IsPrimary) + .ThenByDescending(x => x.Actor.ActorID); + var p = producers.FirstOrDefault(); + p.Trait?.DeliverOrder(p.Actor, ActorsReadyForDelivery, Info.Type, this); + } + + public void ReturnOrder(string itemName = null, uint numberToCancel = 1) + { + if (itemName == null) + { + foreach (var actorData in ActorsReadyForDelivery) + { + playerResources.GiveResources(actorData.Resources); + playerResources.GiveCash(actorData.Cash); + } + + ActorsReadyForDelivery.Clear(); + return; + } + + for (var i = 0; i < numberToCancel; i++) + { + var actor = ActorsReadyForDelivery.LastOrDefault(actor => actor.Actor.Name == itemName); + if (actor.Actor == null) + break; + playerResources.GiveResources(actor.Resources); + playerResources.GiveCash(actor.Cash); + ActorsReadyForDelivery.Remove(actor); + } + } + + protected void PlayDeliveryProgressNotifications() + { + if (notificationInterval == 0) + { + Game.Sound.PlayNotification(self.World.Map.Rules, self.Owner, "Speech", + info.DeliveryProgressNotifications[notificationIndex], self.Owner.Faction.InternalName); + notificationInterval = info.DeliveryDelay / info.DeliveryProgressNotifications.Length; + notificationIndex++; + if (info.DeliveryProgressNotifications.Length == notificationIndex) + { + notificationIndex = 0; + return; + } + } + + notificationInterval--; + } + } +} diff --git a/OpenRA.Mods.Common/Traits/Player/ProductionQueue.cs b/OpenRA.Mods.Common/Traits/Player/ProductionQueue.cs index 2ad507ca9f..d96fca30e9 100644 --- a/OpenRA.Mods.Common/Traits/Player/ProductionQueue.cs +++ b/OpenRA.Mods.Common/Traits/Player/ProductionQueue.cs @@ -145,7 +145,7 @@ namespace OpenRA.Mods.Common.Traits protected Production[] productionTraits; // Will change if the owner changes - PowerManager playerPower; + protected PowerManager playerPower; protected PlayerResources playerResources; protected DeveloperMode developerMode; protected TechTree techTree; @@ -435,7 +435,7 @@ namespace OpenRA.Mods.Common.Traits return true; } - public void ResolveOrder(Actor self, Order order) + public virtual void ResolveOrder(Actor self, Order order) { if (!Enabled) return; diff --git a/OpenRA.Mods.Common/Traits/ProductionBulkAirDrop.cs b/OpenRA.Mods.Common/Traits/ProductionBulkAirDrop.cs new file mode 100644 index 0000000000..54655037db --- /dev/null +++ b/OpenRA.Mods.Common/Traits/ProductionBulkAirDrop.cs @@ -0,0 +1,141 @@ +#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.Collections.Generic; +using System.Linq; +using OpenRA.Mods.Common.Activities; +using OpenRA.Traits; + +namespace OpenRA.Mods.Common.Traits +{ + [Desc("Deliver multiple units via skylift. Works with BulkProductionQueue")] + public class ProductionBulkAirdropInfo : ProductionInfo + { + [NotificationReference("Speech")] + [Desc("Speech notification to play when a unit is delivered.")] + public readonly string ReadyNotification = "Reinforce"; + + [FluentReference(optional: true)] + [Desc("Text notification to display when a unit is delivered.")] + public readonly string ReadyTextNotification = null; + + [FieldLoader.Require] + [ActorReference(typeof(AircraftInfo))] + [Desc("Cargo aircraft used for delivery. Must have the `" + nameof(Aircraft) + "` trait.")] + public readonly string ActorType = null; + + [Desc("Direction the aircraft should face to land.")] + public readonly WAngle Facing = new(256); + + [Desc("Offset the aircraft used for landing.")] + public readonly WVec LandOffset = WVec.Zero; + + public override object Create(ActorInitializer init) { return new ProductionBulkAirdrop(init, this); } + } + + sealed class ProductionBulkAirdrop : Production + { + RallyPoint rp; + + public ProductionBulkAirdrop(ActorInitializer init, ProductionBulkAirdropInfo info) + : base(init, info) { } + + protected override void Created(Actor self) + { + base.Created(self); + + rp = self.TraitOrDefault(); + } + + public bool DeliverOrder(Actor producer, List<(ActorInfo Actor, int Resources, int Cash)> orderedActors, string productionType, BulkProductionQueue queue) + { + if (IsTraitDisabled || IsTraitPaused) + return false; + var info = (ProductionBulkAirdropInfo)Info; + var owner = producer.Owner; + var map = owner.World.Map; + var startPos = producer.World.Map.ChooseClosestEdgeCell(producer.Location); + var spawnFacing = producer.World.Map.FacingBetween(startPos, producer.Location, WAngle.Zero); + + foreach (var tower in producer.TraitsImplementing()) + tower.IncomingDelivery(producer); + + owner.World.AddFrameEndTask(w => + { + if (!producer.IsInWorld || producer.IsDead) + { + // Try to find another producer + var anotherProducer = queue.MostLikelyProducer(); + if (anotherProducer.Trait != null && anotherProducer.Trait is ProductionBulkAirdrop anotherStarport) + anotherStarport.DeliverOrder(anotherProducer.Actor, orderedActors, productionType, queue); + else + queue.DeliverFinished(); + return; + } + + var waitTickbeforeSpawn = 15; + var aircraftInfo = producer.World.Map.Rules.Actors[info.ActorType].TraitInfo(); + var exit = producer.RandomExitOrDefault(producer.World, productionType); + var exitCell = producer.Location; + if (exit != null) + exitCell = producer.Location + exit.Info.ExitCell; + + var destinations = rp != null && rp.Path.Count > 0 ? rp.Path : [exitCell]; + + // Aircraft fly in under their own power. + foreach (var orderedAircraft in orderedActors.Where(actor => actor.Actor.HasTraitInfo())) + { + var altitude = orderedAircraft.Actor.TraitInfo().CruiseAltitude; + var aircraft = w.CreateActor(orderedAircraft.Actor.Name, + [ + new CenterPositionInit(w.Map.CenterOfCell(startPos) + new WVec(WDist.Zero, WDist.Zero, altitude)), + new OwnerInit(owner), + new FacingInit(spawnFacing) + ]); + var move = aircraft.TraitOrDefault(); + if (move != null) + { + aircraft.QueueActivity(new Wait(waitTickbeforeSpawn)); + waitTickbeforeSpawn += 15; + + // first move must be to the Producer location + aircraft.QueueActivity(move.MoveTo(exitCell, 2, evaluateNearestMovableCell: true)); + foreach (var cell in destinations) + aircraft.QueueActivity(move.MoveTo(cell, 2, evaluateNearestMovableCell: true)); + } + } + + orderedActors.RemoveAll(actor => actor.Actor.HasTraitInfo()); + if (orderedActors.Count == 0) + { + queue.DeliverFinished(); + return; + } + + var transport = w.CreateActor(info.ActorType, + [ + new CenterPositionInit(w.Map.CenterOfCell(startPos) + new WVec(WDist.Zero, WDist.Zero, aircraftInfo.CruiseAltitude)), + new OwnerInit(owner), + new FacingInit(spawnFacing) + ]); + + transport.QueueActivity(new DeliverBulkOrder(transport, producer, orderedActors, productionType, queue)); + }); + + return true; + } + + public Exit PublicExit(Actor self, ActorInfo producee, string productionType) + { + return SelectExit(self, producee, productionType); + } + } +} diff --git a/OpenRA.Mods.Common/Widgets/ProductionPaletteWidget.cs b/OpenRA.Mods.Common/Widgets/ProductionPaletteWidget.cs index c58b822125..05243db258 100644 --- a/OpenRA.Mods.Common/Widgets/ProductionPaletteWidget.cs +++ b/OpenRA.Mods.Common/Widgets/ProductionPaletteWidget.cs @@ -50,6 +50,8 @@ namespace OpenRA.Mods.Common.Widgets public readonly int2 IconSpriteOffset = int2.Zero; public readonly float2 QueuedOffset = new(4, 2); + + public readonly float2 BulkOffset = new(4, 2); public readonly TextAlign QueuedTextAlign = TextAlign.Left; public readonly string ClickSound = ChromeMetrics.Get("ClickSound"); @@ -363,6 +365,24 @@ namespace OpenRA.Mods.Common.Widgets bool HandleRightClick(ProductionItem item, ProductionIcon icon, int handleCount) { + if (CurrentQueue is BulkProductionQueue bulkProductionQueue && !bulkProductionQueue.HasDeliveryStarted()) + { + var readyActors = bulkProductionQueue.GetActorsReadyForDelivery(); + if (readyActors.Any(a => a.Actor.Name == icon.Name)) + { + World.IssueOrder( + new Order("ReturnOrder", CurrentQueue.Actor, false) + { + ExtraData = (uint)handleCount, + TargetString = icon.Name + }); + Game.Sound.PlayNotification(World.Map.Rules, World.LocalPlayer, "Sounds", ClickSound, null); + return true; + } + else + return false; + } + if (item == null) return false; @@ -390,17 +410,18 @@ namespace OpenRA.Mods.Common.Widgets bool HandleMiddleClick(ProductionItem item, ProductionIcon icon, int handleCount) { - if (item == null) - return false; + if (item != null) + { + // Directly cancel, skipping "on-hold" + Game.Sound.PlayNotification(World.Map.Rules, World.LocalPlayer, "Sounds", ClickSound, null); + Game.Sound.PlayNotification(World.Map.Rules, World.LocalPlayer, "Speech", CurrentQueue.Info.CancelledAudio, World.LocalPlayer.Faction.InternalName); + TextNotificationsManager.AddTransientLine(World.LocalPlayer, CurrentQueue.Info.CancelledTextNotification); - // Directly cancel, skipping "on-hold" - Game.Sound.PlayNotification(World.Map.Rules, World.LocalPlayer, "Sounds", ClickSound, null); - Game.Sound.PlayNotification(World.Map.Rules, World.LocalPlayer, "Speech", CurrentQueue.Info.CancelledAudio, World.LocalPlayer.Faction.InternalName); - TextNotificationsManager.AddTransientLine(World.LocalPlayer, CurrentQueue.Info.CancelledTextNotification); + World.IssueOrder(Order.CancelProduction(CurrentQueue.Actor, icon.Name, handleCount)); + return true; + } - World.IssueOrder(Order.CancelProduction(CurrentQueue.Actor, icon.Name, handleCount)); - - return true; + return false; } bool HandleEvent(ProductionIcon icon, MouseButton btn, Modifiers modifiers) @@ -568,10 +589,13 @@ namespace OpenRA.Mods.Common.Widgets var waiting = !CurrentQueue.IsProducing(first) && !first.Done; if (first.Done) { - if (ReadyTextStyle == ReadyTextStyleOptions.Solid || orderManager.LocalFrameNumber * worldRenderer.World.Timestep / 360 % 2 == 0) - overlayFont.DrawTextWithContrast(ReadyText, icon.Pos + readyOffset, TextColor, Color.Black, 1); - else if (ReadyTextStyle == ReadyTextStyleOptions.AlternatingColor) - overlayFont.DrawTextWithContrast(ReadyText, icon.Pos + readyOffset, ReadyTextAltColor, Color.Black, 1); + if (CurrentQueue is not BulkProductionQueue) + { + if (ReadyTextStyle == ReadyTextStyleOptions.Solid || orderManager.LocalFrameNumber * worldRenderer.World.Timestep / 360 % 2 == 0) + overlayFont.DrawTextWithContrast(ReadyText, icon.Pos + readyOffset, TextColor, Color.Black, 1); + else if (ReadyTextStyle == ReadyTextStyleOptions.AlternatingColor) + overlayFont.DrawTextWithContrast(ReadyText, icon.Pos + readyOffset, ReadyTextAltColor, Color.Black, 1); + } } else if (first.Paused) overlayFont.DrawTextWithContrast(HoldText, @@ -603,6 +627,14 @@ namespace OpenRA.Mods.Common.Widgets TextColor, Color.Black, 1); } } + + if (CurrentQueue is BulkProductionQueue bulkProductionQueue) + { + var readyActors = bulkProductionQueue.GetActorsReadyForDelivery(). + Count(a => a.Actor.Name == icon.Name); + overlayFont.DrawTextWithContrast(readyActors.ToString(NumberFormatInfo.CurrentInfo), + icon.Pos + BulkOffset, TextColor, Color.Black, 1); + } } } diff --git a/OpenRA.Mods.D2k/Widgets/Logic/PurchaseWidgetLogic.cs b/OpenRA.Mods.D2k/Widgets/Logic/PurchaseWidgetLogic.cs new file mode 100644 index 0000000000..48c66a7451 --- /dev/null +++ b/OpenRA.Mods.D2k/Widgets/Logic/PurchaseWidgetLogic.cs @@ -0,0 +1,84 @@ +#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 OpenRA.Mods.Common.Traits; +using OpenRA.Mods.Common.Widgets; +using OpenRA.Primitives; +using OpenRA.Widgets; + +namespace OpenRA.Mods.D2k.Widgets.Logic +{ + public class PurchaseWidgetLogic : ChromeLogic + { + [FluentReference("time")] + const string DeliveryIn = "label-deliver-in-timer"; + string time = ""; + readonly World world; + readonly Lazy paletteWidget; + readonly ButtonWidget purchaseButton; + readonly LabelWidget deliveryLabel; + readonly Color textColor; + + [ObjectCreator.UseCtor] + public PurchaseWidgetLogic(Widget widget, World world) + { + this.world = world; + purchaseButton = widget.Get("PURCHASE_BUTTON"); + deliveryLabel = widget.Get("DELIVERY_IN"); + textColor = deliveryLabel.TextColor; + var textCache = new CachedTransform(s => FluentProvider.GetMessage(DeliveryIn, "time", time)); + deliveryLabel.GetText = () => textCache.Update(time); + paletteWidget = Exts.Lazy(() => Ui.Root.Get("PRODUCTION_PALETTE") as ProductionPaletteWidget); + purchaseButton.IsVisible = () => false; + purchaseButton.OnClick = ResolveOrder; + } + + public override void Tick() + { + if (world.LocalPlayer == null) + return; + if (paletteWidget.Value.CurrentQueue is BulkProductionQueue bulkProduction) + { + purchaseButton.IsVisible = () => bulkProduction.GetActorsReadyForDelivery(). + Count != 0 && !bulkProduction.HasDeliveryStarted(); + } + else + { + purchaseButton.IsVisible = () => false; + } + + foreach (var bulkQueue in world.LocalPlayer.PlayerActor.TraitsImplementing()) + { + if (bulkQueue.HasDeliveryStarted()) + { + time = WidgetUtils.FormatTime(bulkQueue.DeliveryDelay, world.Timestep); + deliveryLabel.Visible = true; + if (bulkQueue.DeliveryDelay <= 0 && Game.LocalTick % 25 < 15) + deliveryLabel.TextColor = Color.White; + else + deliveryLabel.TextColor = textColor; + } + else + deliveryLabel.Visible = false; + } + } + + void ResolveOrder() + { + world.IssueOrder( + new Order("PurchaseOrder", paletteWidget.Value.CurrentQueue.Actor, false) + { + TargetString = paletteWidget.Value.CurrentQueue.Info.Type + }); + } + } +} diff --git a/mods/d2k/chrome.yaml b/mods/d2k/chrome.yaml index a6c3368174..1d37444123 100644 --- a/mods/d2k/chrome.yaml +++ b/mods/d2k/chrome.yaml @@ -17,7 +17,9 @@ sidebar: Regions: background-top: 0, 0, 226, 295 background-iconrow: 0, 295, 226, 48 - background-bottom: 0, 343, 226, 13 + background-bottom: 0, 343, 226, 28 + purchase-button: 226, 281, 125, 29 + sidebar-button: Inherits: ^Chrome @@ -115,40 +117,31 @@ production-icons: order-icons: Inherits: ^Chrome Regions: - debug: 10, 357, 34, 35 - debug-disabled: 10, 393, 34, 35 - debug-active: 10, 430, 34, 35 - guard: 49, 357, 34, 35 - guard-disabled: 49, 393, 34, 35 - guard-active: 49, 430, 34, 35 - repair: 88, 357, 34, 35 - repair-disabled: 88, 393, 34, 35 - repair-active: 88, 430, 34, 35 - sell: 127, 357, 34, 35 - sell-disabled: 127, 393, 34, 35 - sell-active: 127, 430, 34, 35 - options: 165, 357, 40, 38 - options-disabled: 165, 393, 40, 38 - options-active: 165, 430, 40, 38 - beacon: 209, 357, 36, 35 - beacon-disabled: 209, 393, 36, 35 - beacon-active: 209, 430, 36, 35 - power: 248, 357, 34, 35 - power-disabled: 248, 393, 36, 35 - power-active: 248, 430, 36, 35 - diplomacy: 287, 357, 36, 35 - diplomacy-disabled: 287, 393, 36, 35 - diplomacy-active: 287, 430, 36, 35 + repair: 238, 357, 34, 35 + repair-disabled: 238, 393, 34, 35 + repair-active: 238, 430, 34, 35 + sell: 277, 357, 34, 35 + sell-disabled: 277, 393, 34, 35 + sell-active: 277, 430, 34, 35 + options: 315, 357, 40, 38 + options-disabled: 315, 393, 40, 38 + options-active: 315, 430, 40, 38 + beacon: 359, 357, 36, 35 + beacon-disabled: 359, 393, 36, 35 + beacon-active: 359, 430, 36, 35 + power: 398, 357, 34, 35 + power-disabled: 398, 393, 36, 35 + power-active: 398, 430, 36, 35 command-button: Inherits: ^Chrome - PanelRegion: 331, 427, 0, 0, 34, 41, 0, 0 + PanelRegion: 232, 312, 0, 0, 34, 41, 0, 0 PanelSides: Center command-button-hover: Inherits: command-button command-button-pressed: Inherits: ^Chrome - PanelRegion: 401, 427, 0, 0, 34, 41, 0, 0 + PanelRegion: 301, 312, 0, 0, 34, 41, 0, 0 PanelSides: Center command-button-highlighted: Inherits: command-button-pressed @@ -158,7 +151,7 @@ command-button-highlighted-pressed: Inherits: command-button-pressed command-button-disabled: Inherits: ^Chrome - PanelRegion: 366, 427, 0, 0, 34, 41, 0, 0 + PanelRegion: 266, 312, 0, 0, 34, 41, 0, 0 PanelSides: Center command-button-highlighted-disabled: Inherits: command-button-disabled @@ -304,6 +297,33 @@ progressbar-bg: progressbar-thumb: Inherits: button +button-purchase: + Inherits: ^Chrome + PanelRegion: 226, 281, 0, 0, 125, 29,0 ,0 + +button-purchase-disabled: + Inherits: button-purchase + +button-purchase-hover: + Inherits: ^Chrome + PanelRegion: 226, 252, 0, 0, 125, 29,0 ,0 + +button-purchase-highlighted: + Inherits: button-purchase-hover + +button-purchase-highlighted-hover: + Inherits: button-purchase-hover + +button-purchase-pressed: + Inherits: ^Chrome + PanelRegion: 226, 223, 0, 0, 125, 29,0 ,0 + +button-purchase-highlighted-pressed: + Inherits: button-purchase-pressed + +button-purchase-highlighted-disabled: + Inherits: button-purchase + button: Inherits: ^Dialog PanelRegion: 513, 1, 2, 2, 122, 122, 2, 2 diff --git a/mods/d2k/chrome/ingame-player.yaml b/mods/d2k/chrome/ingame-player.yaml index 07174ee5df..9b6c0d7378 100644 --- a/mods/d2k/chrome/ingame-player.yaml +++ b/mods/d2k/chrome/ingame-player.yaml @@ -486,6 +486,28 @@ Container@PLAYER_WIDGETS: ClickThrough: false ImageCollection: sidebar ImageName: background-bottom + Children: + Container@PURCHASE_PANEL: + Logic: PurchaseWidgetLogic + Children: + Button@PURCHASE_BUTTON: + X: PARENT_LEFT + 65 + Y: PARENT_TOP + 1 + Width: 125 + Height: 29 + Font: Bold + Background: button-purchase + TooltipText: purchase-panel-button-tooltip + TooltipContainer: TOOLTIP_CONTAINER + Label@DELIVERY_IN: + X: PARENT_TOP + 80 + Y: PARENT_LEFT + 3 + TextColor: FFD091 + Width: 100 + Height: 22 + Align: Center + Font: Bold + Text: purchase-panel-label-delivery LogicTicker@PRODUCTION_TICKER: ProductionPalette@PRODUCTION_PALETTE: X: 39 diff --git a/mods/d2k/fluent/chrome.ftl b/mods/d2k/fluent/chrome.ftl index d380eadf67..e66779a81a 100644 --- a/mods/d2k/fluent/chrome.ftl +++ b/mods/d2k/fluent/chrome.ftl @@ -57,6 +57,7 @@ label-combat-stats-buildings-killed-header = B. Killed label-combat-stats-buildings-dead-header = B. Lost label-combat-stats-army-value-header = Army Value label-combat-stats-vision-header = Vision +label-deliver-in-timer = DELIVERY IN: { $time } ## ingame-observer.yaml, ingame-player.yaml label-mute-indicator = Audio Muted @@ -179,6 +180,9 @@ productionpalette-sidebar-production-palette = .ready = READY .hold = ON HOLD +purchase-panel-button-tooltip = Order selected units +purchase-panel-label-delivery = DELIVERY IN: + button-production-types-building-tooltip = Buildings button-production-types-infantry-tooltip = Infantry button-production-types-vehicle-tooltip = Light Vehicles diff --git a/mods/d2k/maps/atreides-05/rules.yaml b/mods/d2k/maps/atreides-05/rules.yaml index d359a59b5c..4e8383c451 100644 --- a/mods/d2k/maps/atreides-05/rules.yaml +++ b/mods/d2k/maps/atreides-05/rules.yaml @@ -24,9 +24,6 @@ carryall.reinforce: Cargo: MaxWeight: 10 -frigate: - Aircraft: - LandableTerrainTypes: Sand, Rock, Transition, Spice, SpiceSand, Dune, Concrete barracks.harkonnen: Inherits: barracks diff --git a/mods/d2k/rules/aircraft.yaml b/mods/d2k/rules/aircraft.yaml index e871bd55c3..a6fecf2db4 100644 --- a/mods/d2k/rules/aircraft.yaml +++ b/mods/d2k/rules/aircraft.yaml @@ -86,16 +86,17 @@ frigate: Aircraft: IdleBehavior: LeaveMap Speed: 189 - TurnSpeed: 4 + TurnSpeed: 16 Repulsable: False MaximumPitch: 20 CruiseAltitude: 2048 VTOL: true + TakeOffOnCreation: false CanHover: true - CanSlide: true -AppearsOnRadar: Cargo: MaxWeight: 20 + BetweenUnloadDelay: 15 RejectsOrders: ornithopter: diff --git a/mods/d2k/rules/player.yaml b/mods/d2k/rules/player.yaml index 1ad3d01800..71c6cbd781 100644 --- a/mods/d2k/rules/player.yaml +++ b/mods/d2k/rules/player.yaml @@ -63,13 +63,15 @@ Player: CancelledAudio: Cancelled SpeedUp: true BuildTimeSpeedReduction: 100, 66, 50 - ClassicProductionQueue@Starport: + BulkProductionQueue@Starport: Type: Starport DisplayOrder: 4 - BuildDurationModifier: 212 + QueueLimit: 6 + PayUpFront: true + StartDeliveryNotification: OrderPlaced + DeliveryProgressNotifications: TMinusFive, TMinusFour, TMinusThree, TMinusTwo, TMinusOne BlockedAudio: NoRoom BlockedTextNotification: notification-no-room-for-new-unit - QueuedAudio: OrderPlaced OnHoldAudio: OnHold CancelledAudio: Cancelled ClassicProductionQueue@Aircraft: diff --git a/mods/d2k/rules/starport.yaml b/mods/d2k/rules/starport.yaml index 6ea4efa3a8..2e5ac3e486 100644 --- a/mods/d2k/rules/starport.yaml +++ b/mods/d2k/rules/starport.yaml @@ -3,6 +3,7 @@ mcv.starport: Buildable: Prerequisites: starport Queue: Starport + BuildDuration: 0 Valued: Cost: 2500 -MapEditorData: @@ -16,6 +17,7 @@ harvester.starport: Buildable: Prerequisites: starport Queue: Starport + BuildDuration: 0 Valued: Cost: 1500 -MapEditorData: @@ -29,6 +31,7 @@ trike.starport: Buildable: Prerequisites: starport Queue: Starport + BuildDuration: 0 Valued: Cost: 315 -MapEditorData: @@ -42,6 +45,7 @@ quad.starport: Buildable: Prerequisites: starport Queue: Starport + BuildDuration: 0 Valued: Cost: 500 -MapEditorData: @@ -55,6 +59,7 @@ siege_tank.starport: Buildable: Prerequisites: starport Queue: Starport + BuildDuration: 0 Valued: Cost: 1075 -MapEditorData: @@ -68,6 +73,7 @@ missile_tank.starport: Buildable: Prerequisites: starport Queue: Starport + BuildDuration: 0 Valued: Cost: 1250 -MapEditorData: @@ -81,6 +87,7 @@ combat_tank_a.starport: Buildable: Prerequisites: ~starport.atreides_combat Queue: Starport + BuildDuration: 0 Valued: Cost: 875 -MapEditorData: @@ -94,6 +101,7 @@ combat_tank_h.starport: Buildable: Prerequisites: ~starport.harkonnen_combat Queue: Starport + BuildDuration: 0 Valued: Cost: 875 -MapEditorData: @@ -107,6 +115,7 @@ combat_tank_o.starport: Buildable: Prerequisites: ~starport.ordos_combat Queue: Starport + BuildDuration: 0 Valued: Cost: 875 -MapEditorData: @@ -120,6 +129,7 @@ carryall.starport: Buildable: Prerequisites: starport Queue: Starport + BuildDuration: 0 Valued: Cost: 1500 -MapEditorData: diff --git a/mods/d2k/rules/structures.yaml b/mods/d2k/rules/structures.yaml index 445b9c1bbe..7d788f7ac8 100644 --- a/mods/d2k/rules/structures.yaml +++ b/mods/d2k/rules/structures.yaml @@ -657,9 +657,7 @@ starport: Exit@2: SpawnOffset: 0,-480,0 ExitCell: 0,2 - ProductionAirdrop: - WaitTickBeforeProduce: 10 - WaitTickAfterProduce: 15 + ProductionBulkAirdrop: LandOffset: 0, -256, 0 Produces: Starport ActorType: frigate diff --git a/mods/d2k/uibits/chrome.png b/mods/d2k/uibits/chrome.png index d54fbc5743..ccaa71cc01 100644 Binary files a/mods/d2k/uibits/chrome.png and b/mods/d2k/uibits/chrome.png differ