#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.Globalization; using System.Linq; using OpenRA.Graphics; using OpenRA.Mods.Common.Lint; using OpenRA.Mods.Common.Orders; using OpenRA.Mods.Common.Traits; using OpenRA.Mods.Common.Traits.Render; using OpenRA.Network; using OpenRA.Primitives; using OpenRA.Widgets; namespace OpenRA.Mods.Common.Widgets { public class ProductionIcon { public ActorInfo Actor; public string Name; public HotkeyReference Hotkey; public Sprite Sprite; public PaletteReference Palette; public PaletteReference IconClockPalette; public PaletteReference IconDarkenPalette; public float2 Pos; public List Queued; public ProductionQueue ProductionQueue; } public class ProductionPaletteWidget : Widget { public enum ReadyTextStyleOptions { Solid, AlternatingColor, Blinking } public readonly ReadyTextStyleOptions ReadyTextStyle = ReadyTextStyleOptions.AlternatingColor; public readonly Color TextColor = Color.White; public readonly Color ReadyTextAltColor = Color.Gold; public readonly int Columns = 3; public readonly int2 IconSize = new(64, 48); public readonly int2 IconMargin = int2.Zero; public readonly int2 IconSpriteOffset = int2.Zero; public readonly float2 QueuedOffset = new(4, 2); public readonly TextAlign QueuedTextAlign = TextAlign.Left; public readonly string ClickSound = ChromeMetrics.Get("ClickSound"); public readonly string ClickDisabledSound = ChromeMetrics.Get("ClickDisabledSound"); public readonly string TooltipContainer; public readonly string TooltipTemplate = "PRODUCTION_TOOLTIP"; // Note: LinterHotkeyNames assumes that these are disabled by default public readonly string HotkeyPrefix = null; public readonly int HotkeyCount = 0; public readonly HotkeyReference SelectProductionBuildingHotkey = new(); public readonly string ClockAnimation = "clock"; public readonly string ClockSequence = "idle"; public readonly string ClockPalette = "chrome"; public readonly string NotBuildableAnimation = "clock"; public readonly string NotBuildableSequence = "idle"; public readonly string NotBuildablePalette = "chrome"; public readonly string OverlayFont = "TinyBold"; public readonly string SymbolsFont = "Symbols"; public readonly bool DrawTime = true; [FluentReference] public string ReadyText = ""; [FluentReference] public string HoldText = ""; public readonly string InfiniteSymbol = "\u221E"; public int DisplayedIconCount { get; private set; } public int TotalIconCount { get; private set; } public event Action OnIconCountChanged = (a, b) => { }; public ProductionIcon TooltipIcon { get; private set; } public Func GetTooltipIcon; public readonly World World; readonly ModData modData; readonly OrderManager orderManager; public int MinimumRows = 4; public int MaximumRows = int.MaxValue; public int IconRowOffset = 0; public int MaxIconRowOffset = int.MaxValue; readonly Lazy tooltipContainer; ProductionQueue currentQueue; HotkeyReference[] hotkeys; public ProductionQueue CurrentQueue { get => currentQueue; set { currentQueue = value; if (currentQueue != null) UpdateCachedProductionIconOverlays(); RefreshIcons(); } } public override Rectangle EventBounds => eventBounds; Dictionary icons = new(); Animation cantBuild; Animation clock; Rectangle eventBounds = Rectangle.Empty; readonly WorldRenderer worldRenderer; SpriteFont overlayFont, symbolFont; float2 iconOffset, holdOffset, readyOffset, timeOffset, infiniteOffset; Player cachedQueueOwner; IProductionIconOverlay[] pios; [CustomLintableHotkeyNames] public static IEnumerable LinterHotkeyNames(MiniYamlNode widgetNode, Action emitError) { var prefix = ""; var prefixNode = widgetNode.Value.NodeWithKeyOrDefault("HotkeyPrefix"); if (prefixNode != null) prefix = prefixNode.Value.Value; var count = 0; var countNode = widgetNode.Value.NodeWithKeyOrDefault("HotkeyCount"); if (countNode != null) count = FieldLoader.GetValue("HotkeyCount", countNode.Value.Value); if (count == 0) return Array.Empty(); if (string.IsNullOrEmpty(prefix)) emitError($"{widgetNode.Location} must define HotkeyPrefix if HotkeyCount > 0."); return Exts.MakeArray(count, i => prefix + (i + 1).ToStringInvariant("D2")); } [ObjectCreator.UseCtor] public ProductionPaletteWidget(ModData modData, OrderManager orderManager, World world, WorldRenderer worldRenderer) { this.modData = modData; this.orderManager = orderManager; World = world; this.worldRenderer = worldRenderer; GetTooltipIcon = () => TooltipIcon; tooltipContainer = Exts.Lazy(() => Ui.Root.Get(TooltipContainer)); } public override void Initialize(WidgetArgs args) { base.Initialize(args); clock = new Animation(World, ClockAnimation); cantBuild = new Animation(World, NotBuildableAnimation); cantBuild.PlayFetchIndex(NotBuildableSequence, () => 0); hotkeys = Exts.MakeArray(HotkeyCount, i => modData.Hotkeys[HotkeyPrefix + (i + 1).ToStringInvariant("D2")]); overlayFont = Game.Renderer.Fonts[OverlayFont]; Game.Renderer.Fonts.TryGetValue(SymbolsFont, out symbolFont); iconOffset = 0.5f * IconSize.ToFloat2() + IconSpriteOffset; HoldText = FluentProvider.GetMessage(HoldText); holdOffset = iconOffset - overlayFont.Measure(HoldText) / 2; ReadyText = FluentProvider.GetMessage(ReadyText); readyOffset = iconOffset - overlayFont.Measure(ReadyText) / 2; if (ChromeMetrics.TryGet("InfiniteOffset", out infiniteOffset)) infiniteOffset += QueuedOffset; else infiniteOffset = QueuedOffset; } public void ScrollDown() { if (CanScrollDown) IconRowOffset++; } public bool CanScrollDown { get { var totalRows = (TotalIconCount + Columns - 1) / Columns; return IconRowOffset < totalRows - MaxIconRowOffset; } } public void ScrollUp() { if (CanScrollUp) IconRowOffset--; } public bool CanScrollUp => IconRowOffset > 0; public void ScrollToTop() { IconRowOffset = 0; } public IEnumerable AllBuildables { get { if (CurrentQueue == null) return Enumerable.Empty(); return CurrentQueue.AllItems().OrderBy(a => a.TraitInfo().BuildPaletteOrder); } } public override void Tick() { TotalIconCount = AllBuildables.Count(); if (CurrentQueue != null && !CurrentQueue.Actor.IsInWorld) CurrentQueue = null; if (CurrentQueue != null) { if (CurrentQueue.Actor.Owner != cachedQueueOwner) UpdateCachedProductionIconOverlays(); RefreshIcons(); } } public override void MouseEntered() { if (TooltipContainer != null) tooltipContainer.Value.SetTooltip(TooltipTemplate, new WidgetArgs() { { "player", World.LocalPlayer }, { "getTooltipIcon", GetTooltipIcon }, { "world", World } }); } public override void MouseExited() { if (TooltipContainer != null) tooltipContainer.Value.RemoveTooltip(); } public override bool HandleMouseInput(MouseInput mi) { var icon = icons.Where(i => i.Key.Contains(mi.Location)) .Select(i => i.Value).FirstOrDefault(); if (mi.Event == MouseInputEvent.Move) TooltipIcon = icon; if (mi.Event == MouseInputEvent.Scroll) { if (mi.Delta.Y < 0 && CanScrollDown) { ScrollDown(); Ui.ResetTooltips(); Game.Sound.PlayNotification(World.Map.Rules, World.LocalPlayer, "Sounds", ClickSound, null); } else if (mi.Delta.Y > 0 && CanScrollUp) { ScrollUp(); Ui.ResetTooltips(); Game.Sound.PlayNotification(World.Map.Rules, World.LocalPlayer, "Sounds", ClickSound, null); } } if (icon == null) return false; // Eat mouse-up events if (mi.Event != MouseInputEvent.Down) return true; return HandleEvent(icon, mi.Button, mi.Modifiers); } protected bool PickUpCompletedBuildingIcon(ProductionItem item) { if (item == null) return false; var actor = World.Map.Rules.Actors[item.Item]; if (item.Done && actor.HasTraitInfo()) { World.OrderGenerator = new PlaceBuildingOrderGenerator(CurrentQueue, item.Item, worldRenderer); return true; } return false; } public void PickUpCompletedBuilding() { PickUpCompletedBuildingIcon(CurrentQueue.CurrentItem()); } bool HandleLeftClick(ProductionItem item, ProductionIcon icon, int handleCount, Modifiers modifiers) { if (PickUpCompletedBuildingIcon(item)) { Game.Sound.PlayNotification(World.Map.Rules, World.LocalPlayer, "Sounds", ClickSound, null); return true; } if (item != null && item.Paused) { // Resume a paused item Game.Sound.PlayNotification(World.Map.Rules, World.LocalPlayer, "Sounds", ClickSound, null); Game.Sound.PlayNotification(World.Map.Rules, World.LocalPlayer, "Speech", CurrentQueue.Info.QueuedAudio, World.LocalPlayer.Faction.InternalName); TextNotificationsManager.AddTransientLine(World.LocalPlayer, CurrentQueue.Info.QueuedTextNotification); World.IssueOrder(Order.PauseProduction(CurrentQueue.Actor, icon.Name, false)); return true; } var buildable = CurrentQueue.BuildableItems().FirstOrDefault(a => a.Name == icon.Name); if (buildable != null) { if (CurrentQueue.Info.PayUpFront && currentQueue.GetProductionCost(buildable) > CurrentQueue.Actor.Owner.PlayerActor.Trait().GetCashAndResources()) return false; Game.Sound.PlayNotification(World.Map.Rules, World.LocalPlayer, "Sounds", ClickSound, null); // Queue a new item var canQueue = CurrentQueue.CanQueue(buildable, out var notification, out var textNotification); if (!CurrentQueue.AllQueued().Any()) { Game.Sound.PlayNotification(World.Map.Rules, World.LocalPlayer, "Speech", notification, World.LocalPlayer.Faction.InternalName); TextNotificationsManager.AddTransientLine(World.LocalPlayer, textNotification); } if (canQueue) { var queued = !modifiers.HasModifier(Modifiers.Ctrl); World.IssueOrder(Order.StartProduction(CurrentQueue.Actor, icon.Name, handleCount, queued)); return true; } } return false; } bool HandleRightClick(ProductionItem item, ProductionIcon icon, int handleCount) { if (item == null) return false; Game.Sound.PlayNotification(World.Map.Rules, World.LocalPlayer, "Sounds", ClickSound, null); if (CurrentQueue.Info.DisallowPaused || item.Paused || item.Done || item.TotalCost == item.RemainingCost || !item.Started) { // Instantly cancel items that haven't started, have finished, or if the queue doesn't support pausing 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)); } else { // Pause an existing item Game.Sound.PlayNotification(World.Map.Rules, World.LocalPlayer, "Speech", CurrentQueue.Info.OnHoldAudio, World.LocalPlayer.Faction.InternalName); TextNotificationsManager.AddTransientLine(World.LocalPlayer, CurrentQueue.Info.OnHoldTextNotification); World.IssueOrder(Order.PauseProduction(CurrentQueue.Actor, icon.Name, true)); } return true; } bool HandleMiddleClick(ProductionItem item, ProductionIcon icon, int handleCount) { if (item == null) return false; // 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; } bool HandleEvent(ProductionIcon icon, MouseButton btn, Modifiers modifiers) { var startCount = modifiers.HasModifier(Modifiers.Shift) ? 5 : 1; // PERF: avoid an unnecessary enumeration by casting back to its known type var cancelCount = modifiers.HasModifier(Modifiers.Ctrl) ? ((List)CurrentQueue.AllQueued()).Count : startCount; var item = icon.Queued.FirstOrDefault(); var handled = btn == MouseButton.Left ? HandleLeftClick(item, icon, startCount, modifiers) : btn == MouseButton.Right ? HandleRightClick(item, icon, cancelCount) : btn == MouseButton.Middle && HandleMiddleClick(item, icon, cancelCount); if (!handled) Game.Sound.PlayNotification(World.Map.Rules, World.LocalPlayer, "Sounds", ClickDisabledSound, null); return true; } public override bool HandleKeyPress(KeyInput e) { if (e.Event == KeyInputEvent.Up || CurrentQueue == null) return false; if (SelectProductionBuildingHotkey.IsActivatedBy(e)) return SelectProductionBuilding(); var batchModifiers = e.Modifiers.HasModifier(Modifiers.Shift) ? Modifiers.Shift : Modifiers.None; // HACK: enable production if the shift key is pressed e.Modifiers &= ~Modifiers.Shift; var toBuild = icons.Values.FirstOrDefault(i => i.Hotkey != null && i.Hotkey.IsActivatedBy(e)); return toBuild != null && HandleEvent(toBuild, MouseButton.Left, batchModifiers); } bool SelectProductionBuilding() { var viewport = worldRenderer.Viewport; var selection = World.Selection; if (CurrentQueue == null) return true; var facility = CurrentQueue.MostLikelyProducer().Actor; if (facility == null || facility.OccupiesSpace == null) return true; if (selection.Actors.Count == 1 && selection.Contains(facility)) viewport.Center(selection.Actors); else selection.Combine(World, new[] { facility }, false, true); Game.Sound.PlayNotification(World.Map.Rules, null, "Sounds", ClickSound, null); return true; } void UpdateCachedProductionIconOverlays() { cachedQueueOwner = CurrentQueue.Actor.Owner; pios = cachedQueueOwner.PlayerActor.TraitsImplementing().ToArray(); } public void RefreshIcons() { icons = new Dictionary(); var producer = CurrentQueue != null ? CurrentQueue.MostLikelyProducer() : default; if (CurrentQueue == null || producer.Trait == null) { if (DisplayedIconCount != 0) { OnIconCountChanged(DisplayedIconCount, 0); DisplayedIconCount = 0; } return; } var oldIconCount = DisplayedIconCount; DisplayedIconCount = 0; var rb = RenderBounds; var faction = producer.Trait.Faction; foreach (var item in AllBuildables.Skip(IconRowOffset * Columns).Take(MaxIconRowOffset * Columns)) { var x = DisplayedIconCount % Columns; var y = DisplayedIconCount / Columns; var rect = new Rectangle(rb.X + x * (IconSize.X + IconMargin.X), rb.Y + y * (IconSize.Y + IconMargin.Y), IconSize.X, IconSize.Y); var rsi = item.TraitInfo(); var icon = new Animation(World, rsi.GetImage(item, faction)); var bi = item.TraitInfo(); icon.Play(bi.Icon); var palette = bi.IconPaletteIsPlayerPalette ? bi.IconPalette + producer.Actor.Owner.InternalName : bi.IconPalette; var pi = new ProductionIcon() { Actor = item, Name = item.Name, Hotkey = DisplayedIconCount < HotkeyCount ? hotkeys[DisplayedIconCount] : null, Sprite = icon.Image, Palette = worldRenderer.Palette(palette), IconClockPalette = worldRenderer.Palette(ClockPalette), IconDarkenPalette = worldRenderer.Palette(NotBuildablePalette), Pos = new float2(rect.Location), Queued = currentQueue.AllQueued().Where(a => a.Item == item.Name).ToList(), ProductionQueue = currentQueue }; icons.Add(rect, pi); DisplayedIconCount++; } eventBounds = icons.Keys.Union(); if (oldIconCount != DisplayedIconCount) OnIconCountChanged(oldIconCount, DisplayedIconCount); } public override void Draw() { timeOffset = iconOffset - overlayFont.Measure(WidgetUtils.FormatTime(0, World.Timestep)) / 2; if (CurrentQueue == null) return; var buildableItems = CurrentQueue.BuildableItems(); // Icons Game.Renderer.EnableAntialiasingFilter(); foreach (var icon in icons.Values) { WidgetUtils.DrawSpriteCentered(icon.Sprite, icon.Palette, icon.Pos + iconOffset); // Draw the ProductionIconOverlay's sprites foreach (var pio in pios.Where(p => p.IsOverlayActive(icon.Actor))) WidgetUtils.DrawSpriteCentered(pio.Sprite, worldRenderer.Palette(pio.Palette), icon.Pos + iconOffset + pio.Offset(IconSize)); // Build progress if (icon.Queued.Count > 0) { var first = icon.Queued[0]; clock.PlayFetchIndex(ClockSequence, () => (first.TotalTime - first.RemainingTime) * (clock.CurrentSequence.Length - 1) / first.TotalTime); clock.Tick(); WidgetUtils.DrawSpriteCentered(clock.Image, icon.IconClockPalette, icon.Pos + iconOffset); } else if (!buildableItems.Any(a => a.Name == icon.Name)) WidgetUtils.DrawSpriteCentered(cantBuild.Image, icon.IconDarkenPalette, icon.Pos + iconOffset); } Game.Renderer.DisableAntialiasingFilter(); // Overlays foreach (var icon in icons.Values) { var total = icon.Queued.Count; if (total > 0) { var first = icon.Queued[0]; 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); } else if (first.Paused) overlayFont.DrawTextWithContrast(HoldText, icon.Pos + holdOffset, TextColor, Color.Black, 1); else if (!waiting && DrawTime) overlayFont.DrawTextWithContrast(WidgetUtils.FormatTime(first.Queue.RemainingTimeActual(first), World.Timestep), icon.Pos + timeOffset, TextColor, Color.Black, 1); if (first.Infinite && symbolFont != null) symbolFont.DrawTextWithContrast(InfiniteSymbol, icon.Pos + infiniteOffset, TextColor, Color.Black, 1); else if (total > 1 || waiting) { var pos = QueuedOffset; if (QueuedTextAlign != TextAlign.Left) { var size = overlayFont.Measure(total.ToString(NumberFormatInfo.CurrentInfo)); pos = QueuedTextAlign == TextAlign.Center ? new float2(QueuedOffset.X - size.X / 2, QueuedOffset.Y) : new float2(QueuedOffset.X - size.X, QueuedOffset.Y); } overlayFont.DrawTextWithContrast(total.ToString(NumberFormatInfo.CurrentInfo), icon.Pos + pos, TextColor, Color.Black, 1); } } } } public override string GetCursor(int2 pos) { var icon = icons.Where(i => i.Key.Contains(pos)) .Select(i => i.Value).FirstOrDefault(); return icon != null ? base.GetCursor(pos) : null; } } }