diff --git a/OpenRA.Mods.OpenKrush/Mechanics/AttackNotifications/Traits/AdvancedAttackNotifier.cs b/OpenRA.Mods.OpenKrush/Mechanics/AttackNotifications/Traits/AdvancedAttackNotifier.cs index dab868e..576edc2 100644 --- a/OpenRA.Mods.OpenKrush/Mechanics/AttackNotifications/Traits/AdvancedAttackNotifier.cs +++ b/OpenRA.Mods.OpenKrush/Mechanics/AttackNotifications/Traits/AdvancedAttackNotifier.cs @@ -17,6 +17,7 @@ namespace OpenRA.Mods.OpenKrush.Mechanics.AttackNotifications.Traits; using JetBrains.Annotations; using OpenRA.Traits; using Primitives; +using Widgets.Ingame; [UsedImplicitly(ImplicitUseTargetFlags.WithMembers)] [Desc("Attack notifier which supports per actor notifications.")] @@ -30,6 +31,9 @@ public class AdvancedAttackNotifierInfo : TraitInfo [Desc("Length of time (in ticks) to display a location ping in the minimap.")] public readonly int RadarPingDuration = 10 * 25; + [Desc("Cooldown in seconds between on-screen attack tooltips.")] + public readonly int TooltipCooldown = 15; + public override object Create(ActorInitializer init) { return new AdvancedAttackNotifier(this); @@ -40,6 +44,7 @@ public class AdvancedAttackNotifier : INotifyDamage, INotifyCreated { private readonly AdvancedAttackNotifierInfo info; private readonly Dictionary lastAttackTimes = new(); + private int lastTooltipTick; private RadarPings? radarPings; public AdvancedAttackNotifier(AdvancedAttackNotifierInfo info) @@ -66,6 +71,12 @@ void INotifyDamage.Damaged(Actor self, AttackInfo attackInfo) if (attackInfo.Damage.Value == 0) return; + if (self.World.WorldTick - this.lastTooltipTick >= this.info.TooltipCooldown * 25) + { + this.lastTooltipTick = self.World.WorldTick; + TooltipWidget.Instance?.ShowCentered("Your units are under attack!"); + } + var attackNotification = self.TraitOrDefault(); if (attackNotification == null) diff --git a/OpenRA.Mods.OpenKrush/Mechanics/Ui/Traits/MovePathDisplay.cs b/OpenRA.Mods.OpenKrush/Mechanics/Ui/Traits/MovePathDisplay.cs new file mode 100644 index 0000000..87000a9 --- /dev/null +++ b/OpenRA.Mods.OpenKrush/Mechanics/Ui/Traits/MovePathDisplay.cs @@ -0,0 +1,150 @@ +#region Copyright & License Information + +/* + * Copyright 2007-2022 The OpenKrush Developers (see AUTHORS) + * This file is part of OpenKrush, 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 + +namespace OpenRA.Mods.OpenKrush.Mechanics.Ui.Traits; + +using System.Collections.Generic; +using Common.Traits; +using JetBrains.Annotations; +using OpenRA.Graphics; +using OpenRA.Network; +using OpenRA.Primitives; +using OpenRA.Traits; + +[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)] +[TraitLocation(SystemActors.World)] +[Desc("Draws the planned path of local player units for a short time after a Move order is issued.")] +public class MovePathDisplayInfo : TraitInfo +{ + [Desc("Duration in ticks for which the path is displayed.")] + public readonly int DisplayTime = 50; + + [Desc("Line color of the displayed path.")] + public readonly Color LineColor = Color.Yellow; + + [Desc("Line width in pixels.")] + public readonly int LineWidth = 3; + + public override object Create(ActorInitializer init) + { + return new MovePathDisplay(this); + } +} + +sealed class PathLine +{ + public readonly Actor Actor; + public readonly List Cells; + public readonly int ExpireTick; + + public PathLine(Actor actor, List cells, int expireTick) + { + Actor = actor; + Cells = cells; + ExpireTick = expireTick; + } + + public IEnumerable Waypoints(World world) + { + var pos = Actor.CenterPosition; + var start = 0; + var bestDist = long.MaxValue; + for (var i = 0; i < Cells.Count; i++) + { + var c = world.Map.CenterOfSubCell(Cells[i], SubCell.FullCell); + var dx = (long)c.X - pos.X; + var dy = (long)c.Y - pos.Y; + var d = dx * dx + dy * dy; + if (d < bestDist) + { + bestDist = d; + start = i; + } + } + + yield return pos; + for (var i = start; i < Cells.Count; i++) + yield return world.Map.CenterOfSubCell(Cells[i], SubCell.FullCell); + } +} + +public class MovePathDisplay : ITick, IValidateOrder, IRenderAnnotations +{ + readonly MovePathDisplayInfo info; + readonly List lines = new(); + + public MovePathDisplay(MovePathDisplayInfo info) + { + this.info = info; + } + + bool IValidateOrder.OrderValidation(OrderManager orderManager, World world, int clientId, Order order) + { + if (order.OrderString != "Move" || order.Subject == null || order.Subject.IsDead || order.Subject.Owner != world.LocalPlayer) + return true; + + var mobile = order.Subject.TraitOrDefault(); + if (mobile == null || mobile.IsTraitDisabled || order.Target.Type == TargetType.Invalid) + return true; + + var targetCell = world.Map.Clamp(world.Map.CellContaining(order.Target.CenterPosition)); + if (!mobile.Info.LocomotorInfo.MoveIntoShroud && !world.LocalPlayer.Shroud.IsExplored(targetCell)) + return true; + + var path = mobile.PathFinder.FindPathToTargetCell( + order.Subject, + new[] { mobile.ToCell }, + mobile.NearestMoveableCell(targetCell), + BlockedByActor.All, + laneBias: true); + + if (path.Count == 0) + return true; + + path.Reverse(); + + if (mobile.FromCell != mobile.ToCell) + path.Insert(0, mobile.FromCell); + + lines.RemoveAll(l => l.Actor == order.Subject); + lines.Add(new PathLine(order.Subject, path, world.WorldTick + info.DisplayTime)); + + return true; + } + + void ITick.Tick(Actor self) + { + lines.RemoveAll(l => l.ExpireTick <= self.World.WorldTick || !l.Actor.IsInWorld || l.Actor.IsDead); + } + + IEnumerable IRenderAnnotations.RenderAnnotations(Actor self, WorldRenderer wr) + { + if (lines.Count == 0) + yield break; + + var now = wr.World.WorldTick; + var fadeTicks = System.Math.Max(1, info.DisplayTime / 4); + foreach (var line in lines) + { + var remaining = line.ExpireTick - now; + if (remaining <= 0 || !line.Actor.IsInWorld || line.Actor.IsDead) + continue; + + var alpha = 255f * System.Math.Min(1f, remaining / (float)fadeTicks); + var color = Color.FromArgb((int)alpha, info.LineColor); + yield return new TargetLineRenderable(line.Waypoints(wr.World), color, info.LineWidth, 2); + } + } + + bool IRenderAnnotations.SpatiallyPartitionable => false; +} diff --git a/OpenRA.Mods.OpenKrush/Widgets/Ingame/Buttons/BomberButtonWidget.cs b/OpenRA.Mods.OpenKrush/Widgets/Ingame/Buttons/BomberButtonWidget.cs index 130a1f5..fd7cd20 100644 --- a/OpenRA.Mods.OpenKrush/Widgets/Ingame/Buttons/BomberButtonWidget.cs +++ b/OpenRA.Mods.OpenKrush/Widgets/Ingame/Buttons/BomberButtonWidget.cs @@ -47,7 +47,7 @@ public override bool HandleKeyPress(KeyInput e) for (var i = 0; i < lastItem; i++) { if (e.Key != Game.ModData.Hotkeys[$"Production{i + 1:00}"].GetValue().Key - || e.Modifiers != Game.ModData.Hotkeys[$"Production{i + 1}"].GetValue().Modifiers) + || e.Modifiers != Game.ModData.Hotkeys[$"Production{i + 1:00}"].GetValue().Modifiers) continue; ((ProductionItemButtonWidget)this.Children[i]).ClickedLeft?.Invoke( @@ -112,7 +112,8 @@ public override void Tick() } button.Visible = this.Active; - button.Bounds.X = (-1 - i) * SidebarButtonWidget.Size; + button.Bounds.X = SidebarWidget.BarWidth - SidebarWidget.GridWidth - this.Bounds.X; + button.Bounds.Y = -this.Bounds.Y + i * SidebarButtonWidget.Size; } if (this.Children.Count == 0) diff --git a/OpenRA.Mods.OpenKrush/Widgets/Ingame/Buttons/ProductionCategoryButtonWidget.cs b/OpenRA.Mods.OpenKrush/Widgets/Ingame/Buttons/ProductionCategoryButtonWidget.cs index cc2f2be..f9b72e3 100644 --- a/OpenRA.Mods.OpenKrush/Widgets/Ingame/Buttons/ProductionCategoryButtonWidget.cs +++ b/OpenRA.Mods.OpenKrush/Widgets/Ingame/Buttons/ProductionCategoryButtonWidget.cs @@ -25,7 +25,7 @@ public ProductionCategoryButtonWidget(SidebarWidget sidebar, int index, string[] : base(sidebar, "unit") { this.Categories = categories; - this.Bounds = new(0, index * SidebarButtonWidget.Size, SidebarButtonWidget.Size, SidebarButtonWidget.Size); + this.Bounds = new(index * SidebarButtonWidget.Size, SidebarWidget.GridHeight, SidebarButtonWidget.Size, SidebarButtonWidget.Size); this.TooltipTitle = label; this.hotkey = Game.ModData.Hotkeys[$"Production{label}"].GetValue(); } @@ -35,6 +35,9 @@ public override bool HandleKeyPress(KeyInput e) if (!this.IsUsable() || e.Key != this.hotkey.Key || e.IsRepeat || e.Event != KeyInputEvent.Down || e.Modifiers != this.hotkey.Modifiers) return false; + if (!this.Active && this.Sidebar.AnyCategoryOpen) + return false; + if (!this.Active) { this.Active = true; @@ -119,7 +122,8 @@ public override void Tick() for (var i = 0; i < this.Children.Count; i++) { var palette = (ProductionPaletteColumnWidget)this.Children[i]; - palette.Bounds.X = (i + 1) * SidebarButtonWidget.Size * -1; + palette.Bounds.X = SidebarWidget.BarWidth - SidebarWidget.GridWidth - this.Bounds.X; + palette.Bounds.Y = -this.Bounds.Y; if (palette.IsFocused) focused = i; @@ -130,7 +134,7 @@ public override void Tick() else if (focused == -1) ((ProductionPaletteColumnWidget)this.Children[0]).IsFocused = true; - this.Children.ForEach(c => c.Visible = this.Active); + this.Children.ForEach(c => c.Visible = this.Active && ((ProductionPaletteColumnWidget)c).IsFocused); this.Type = this.Children.Count > 0 ? "unit" : "button"; } @@ -147,5 +151,9 @@ public void SelectFactory(Actor factory) foreach (var productionPalette in productionPalettes) productionPalette.IsFocused = productionPalette.Queue == productionQueue; + + // Queues for buildings/towers/walls are global (on the player actor), so fall back to the first palette. + if (productionQueue == null && productionPalettes.Length > 0) + productionPalettes[0].IsFocused = true; } } diff --git a/OpenRA.Mods.OpenKrush/Widgets/Ingame/Buttons/RadarButtonWidget.cs b/OpenRA.Mods.OpenKrush/Widgets/Ingame/Buttons/RadarButtonWidget.cs index a193dbd..148774e 100644 --- a/OpenRA.Mods.OpenKrush/Widgets/Ingame/Buttons/RadarButtonWidget.cs +++ b/OpenRA.Mods.OpenKrush/Widgets/Ingame/Buttons/RadarButtonWidget.cs @@ -25,6 +25,7 @@ public RadarButtonWidget(SidebarWidget sidebar) : base(sidebar, "button") { this.TooltipTitle = "Radar"; + this.Active = true; } public override bool HandleKeyPress(KeyInput e) @@ -59,7 +60,7 @@ protected override bool IsUsable() public override void Tick() { - this.hasRadar = false; + this.hasRadar = true; var showStances = PlayerRelationship.None; foreach (var e in this.Sidebar.IngameUi.World.ActorsWithTrait() @@ -67,11 +68,6 @@ public override void Tick() { var researchable = e.Actor.TraitOrDefault(); - if (!researchable.IsResearched(ProvidesResearchableRadarInfo.Available)) - continue; - - this.hasRadar = true; - if (researchable.IsResearched(ProvidesResearchableRadarInfo.ShowAllies)) showStances |= PlayerRelationship.Ally; @@ -80,9 +76,6 @@ public override void Tick() } this.Sidebar.IngameUi.Radar.ShowStances = showStances; - - if (!this.hasRadar) - this.Sidebar.IngameUi.Radar.Visible = this.Active = false; } protected override void DrawContents() diff --git a/OpenRA.Mods.OpenKrush/Widgets/Ingame/Buttons/SidebarButtonWidget.cs b/OpenRA.Mods.OpenKrush/Widgets/Ingame/Buttons/SidebarButtonWidget.cs index fb822fe..b08d1ef 100644 --- a/OpenRA.Mods.OpenKrush/Widgets/Ingame/Buttons/SidebarButtonWidget.cs +++ b/OpenRA.Mods.OpenKrush/Widgets/Ingame/Buttons/SidebarButtonWidget.cs @@ -46,7 +46,12 @@ public override void MouseEntered() this.Sidebar.IngameUi.Tooltip.TooltipText = this.TooltipText; this.Sidebar.IngameUi.Tooltip.Bounds.X = this.RenderBounds.X; - this.Sidebar.IngameUi.Tooltip.Bounds.Y = this.RenderBounds.Y + SidebarButtonWidget.Size / 2; + + var tooltipY = this.RenderBounds.Y + SidebarButtonWidget.Size / 2; + if (this.RenderBounds.Y >= Game.Renderer.Resolution.Height - SidebarButtonWidget.Size) + tooltipY = this.RenderBounds.Y - SidebarButtonWidget.Size / 2; + + this.Sidebar.IngameUi.Tooltip.Bounds.Y = tooltipY; this.Sidebar.IngameUi.Tooltip.Visible = true; } diff --git a/OpenRA.Mods.OpenKrush/Widgets/Ingame/IngameUiWidget.cs b/OpenRA.Mods.OpenKrush/Widgets/Ingame/IngameUiWidget.cs index 0b14996..79f8922 100644 --- a/OpenRA.Mods.OpenKrush/Widgets/Ingame/IngameUiWidget.cs +++ b/OpenRA.Mods.OpenKrush/Widgets/Ingame/IngameUiWidget.cs @@ -51,6 +51,7 @@ public IngameUiWidget(World world, WorldRenderer worldRenderer) this.Palette = this.WorldRenderer.Palette($"player{this.World.LocalPlayer.InternalName}"); this.AddChild(new StatusWidget(this)); + this.AddChild(new SelectionHealthWidget(this)); this.AddChild(this.Radar = new(this)); this.AddChild(new SidebarWidget(this)); this.AddChild(this.Tooltip = new()); diff --git a/OpenRA.Mods.OpenKrush/Widgets/Ingame/ProductionPaletteColumnWidget.cs b/OpenRA.Mods.OpenKrush/Widgets/Ingame/ProductionPaletteColumnWidget.cs index 0b8e2a2..aa6b0c7 100644 --- a/OpenRA.Mods.OpenKrush/Widgets/Ingame/ProductionPaletteColumnWidget.cs +++ b/OpenRA.Mods.OpenKrush/Widgets/Ingame/ProductionPaletteColumnWidget.cs @@ -45,16 +45,15 @@ public override bool HandleKeyPress(KeyInput e) if (!this.IsFocused || e.IsRepeat || e.Event != KeyInputEvent.Down) return false; - var lastItem = Math.Min(12, this.Children.Count); - - for (var i = 0; i < lastItem; i++) + for (var i = 0; i < 15; i++) { if (e.Key != Game.ModData.Hotkeys[$"Production{i + 1:00}"].GetValue().Key) continue; - ((ProductionItemButtonWidget)this.Children[i]).ClickedLeft?.Invoke( - new(MouseInputEvent.Down, MouseButton.None, int2.Zero, int2.Zero, e.Modifiers, 0) - ); + if (i < this.Children.Count) + ((ProductionItemButtonWidget)this.Children[i]).ClickedLeft?.Invoke( + new(MouseInputEvent.Down, MouseButton.None, int2.Zero, int2.Zero, e.Modifiers, 0) + ); return true; } @@ -64,69 +63,17 @@ public override bool HandleKeyPress(KeyInput e) public override bool HandleMouseInput(MouseInput mi) { - // TODO this whole block can be removed when arrows are widgets! - if (!this.EventBounds.Contains(mi.Location)) - return false; - - switch (mi.Event) - { - case MouseInputEvent.Down when mi.Location.Y - this.EventBounds.Y < this.visibleIcons * SidebarButtonWidget.Size: - return true; - - case MouseInputEvent.Down: - { - var arrow = (mi.Location.X - this.EventBounds.X) / (SidebarButtonWidget.Size / 2); - - switch (arrow) - { - case 0 when this.scrollOffset > 0: - Game.Sound.PlayNotification(this.sidebar.IngameUi.World.Map.Rules, null, "Sounds", "ClickSound", null); - this.scrollOffset--; - - break; - - case 1 when this.scrollOffset + this.visibleIcons < this.buildableItems.Length: - Game.Sound.PlayNotification(this.sidebar.IngameUi.World.Map.Rules, null, "Sounds", "ClickSound", null); - this.scrollOffset++; - - break; - } - - break; - } - - case MouseInputEvent.Scroll: - this.scrollOffset = Math.Max(0, Math.Min(this.scrollOffset += mi.Delta.Y < 0 ? 1 : -1, this.buildableItems.Length - this.visibleIcons)); - - break; - - case MouseInputEvent.Move: - break; - - case MouseInputEvent.Up: - break; - - default: - throw new ArgumentOutOfRangeException(Enum.GetName(mi.Event)); - } - - return true; + return this.EventBounds.Contains(mi.Location); } public override void Tick() { this.buildableItems = this.Queue.BuildableItems().ToArray(); - this.visibleIcons = Math.Min((Game.Renderer.Resolution.Height - this.RenderBounds.Top) / SidebarButtonWidget.Size, this.buildableItems.Length); - this.Bounds.Height = this.visibleIcons * SidebarButtonWidget.Size; - - if (this.visibleIcons < this.buildableItems.Length) - { - this.visibleIcons = (Game.Renderer.Resolution.Height - this.RenderBounds.Top - SidebarButtonWidget.Size / 2) / SidebarButtonWidget.Size; - this.Bounds.Height = this.visibleIcons * SidebarButtonWidget.Size + SidebarButtonWidget.Size / 2; - } - - this.scrollOffset = Math.Max(0, Math.Min(this.scrollOffset, this.buildableItems.Length - this.visibleIcons)); + this.visibleIcons = Math.Min(15, this.buildableItems.Length); + this.Bounds.Width = SidebarWidget.GridWidth; + this.Bounds.Height = SidebarWidget.GridHeight; + this.scrollOffset = 0; var oldButtons = this.Children .Where(c => c is ProductionItemButtonWidget && this.buildableItems.All(b => b.Name != ((ProductionItemButtonWidget)c).Item)) @@ -226,56 +173,9 @@ bool IsActive() else { button.Visible = true; - button.Bounds.Y = (i - this.scrollOffset) * SidebarButtonWidget.Size; + button.Bounds.X = ((i - this.scrollOffset) % SidebarWidget.GridColumns) * SidebarButtonWidget.Size; + button.Bounds.Y = ((i - this.scrollOffset) / SidebarWidget.GridColumns) * SidebarButtonWidget.Size; } } } - - public override void Draw() - { - // TODO this whole block can be removed when arrows are widgets! - if (this.visibleIcons >= this.buildableItems.Length) - return; - - var position = new int2( - this.RenderBounds.X + SidebarButtonWidget.Size / 4, - this.RenderBounds.Y + SidebarButtonWidget.Size / 4 + this.visibleIcons * SidebarButtonWidget.Size - ); - - this.sidebar.Buttons.PlayFetchIndex("button-small", () => 0); - WidgetUtils.DrawSpriteCentered(this.sidebar.Buttons.Image, this.sidebar.IngameUi.Palette, position); - this.sidebar.Buttons.PlayFetchIndex("button-small-down", () => 0); - WidgetUtils.DrawSpriteCentered(this.sidebar.Buttons.Image, this.sidebar.IngameUi.Palette, position); - - if (this.scrollOffset == 0) - { - WidgetUtils.FillRectWithColor( - new( - this.RenderBounds.X, - this.RenderBounds.Y + this.visibleIcons * SidebarButtonWidget.Size, - SidebarButtonWidget.Size / 2, - SidebarButtonWidget.Size / 2 - ), - Color.FromArgb(128, 0, 0, 0) - ); - } - - this.sidebar.Buttons.PlayFetchIndex("button-small", () => 0); - WidgetUtils.DrawSpriteCentered(this.sidebar.Buttons.Image, this.sidebar.IngameUi.Palette, position + new int2(SidebarButtonWidget.Size / 2, 0)); - this.sidebar.Buttons.PlayFetchIndex("button-small-up", () => 0); - WidgetUtils.DrawSpriteCentered(this.sidebar.Buttons.Image, this.sidebar.IngameUi.Palette, position + new int2(SidebarButtonWidget.Size / 2, 0)); - - if (this.scrollOffset + this.visibleIcons == this.buildableItems.Length) - { - WidgetUtils.FillRectWithColor( - new( - this.RenderBounds.X + SidebarButtonWidget.Size / 2, - this.RenderBounds.Y + this.visibleIcons * SidebarButtonWidget.Size, - SidebarButtonWidget.Size / 2, - SidebarButtonWidget.Size / 2 - ), - Color.FromArgb(128, 0, 0, 0) - ); - } - } } diff --git a/OpenRA.Mods.OpenKrush/Widgets/Ingame/RadarWidget.cs b/OpenRA.Mods.OpenKrush/Widgets/Ingame/RadarWidget.cs index 314d4fe..d48a917 100644 --- a/OpenRA.Mods.OpenKrush/Widgets/Ingame/RadarWidget.cs +++ b/OpenRA.Mods.OpenKrush/Widgets/Ingame/RadarWidget.cs @@ -53,7 +53,7 @@ public RadarWidget(IngameUiWidget ingameUi) this.DrawTerrain(); this.Resize(); - this.Visible = false; + this.Visible = true; } public override bool HandleKeyPress(KeyInput e) @@ -69,8 +69,8 @@ public override bool HandleKeyPress(KeyInput e) private void Resize() { this.Bounds = new( - 0, - Game.Renderer.Resolution.Height - this.ingameUi.World.Map.MapSize.Y * RadarWidget.Scale, + 4, + Game.Renderer.Resolution.Height - this.ingameUi.World.Map.MapSize.Y * RadarWidget.Scale - 52, this.ingameUi.World.Map.MapSize.X * RadarWidget.Scale, this.ingameUi.World.Map.MapSize.Y * RadarWidget.Scale ); diff --git a/OpenRA.Mods.OpenKrush/Widgets/Ingame/SelectionHealthWidget.cs b/OpenRA.Mods.OpenKrush/Widgets/Ingame/SelectionHealthWidget.cs new file mode 100644 index 0000000..65bb3a7 --- /dev/null +++ b/OpenRA.Mods.OpenKrush/Widgets/Ingame/SelectionHealthWidget.cs @@ -0,0 +1,244 @@ +#region Copyright & License Information + +/* + * Copyright 2007-2022 The OpenKrush Developers (see AUTHORS) + * This file is part of OpenKrush, 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 + +namespace OpenRA.Mods.OpenKrush.Widgets.Ingame; + +using System; +using System.Collections.Generic; +using System.Linq; +using Common.Traits; +using Common.Traits.Render; +using Common.Widgets; +using OpenRA.Graphics; +using OpenRA.Traits; +using OpenRA.Widgets; +using Primitives; + +public sealed class SelectionHealthWidget : Widget +{ + private readonly IngameUiWidget ingameUi; + private readonly World world; + + private Actor[] selectedActors = Array.Empty(); + private int selectionHash; + + private readonly Dictionary iconCache = new(); + + private const int CellWidth = 48; + private const int Columns = 5; + private const int BarHeight = 6; + private const int BarMargin = 2; + private const int BorderWidth = 1; + + private int lastCols; + private int lastRows; + private int lastTotalWidth; + private int lastTotalHeight; + private int lastX; + private int lastY; + + public SelectionHealthWidget(IngameUiWidget ingameUi) + { + this.ingameUi = ingameUi; + this.world = ingameUi.World; + this.Bounds = new(0, 0, Game.Renderer.Resolution.Width, Game.Renderer.Resolution.Height); + } + + public override void Tick() + { + if (this.selectionHash == this.world.Selection.Hash) + return; + + this.selectedActors = this.world.Selection.Actors + .Where(a => a.Owner == this.world.LocalPlayer && a.IsInWorld && !a.IsDead) + .ToArray(); + + this.selectionHash = this.world.Selection.Hash; + + UpdateLayout(); + } + + private void UpdateLayout() + { + if (this.selectedActors.Length == 0) + { + this.lastTotalWidth = 0; + return; + } + + var visibleCount = System.Math.Min(this.selectedActors.Length, Columns * 2); + this.lastCols = System.Math.Min(Columns, visibleCount); + this.lastRows = (visibleCount + this.lastCols - 1) / this.lastCols; + this.lastTotalWidth = this.lastCols * CellWidth; + this.lastTotalHeight = this.lastRows * CellWidth; + this.lastX = (Game.Renderer.Resolution.Width - this.lastTotalWidth) / 2; + this.lastY = Game.Renderer.Resolution.Height - 56 - this.lastTotalHeight; + } + + public override bool HandleMouseInput(MouseInput mi) + { + if (this.selectedActors.Length == 0 || mi.Event != MouseInputEvent.Down) + return false; + + var pos = mi.Location; + if (pos.X < this.lastX - BorderWidth || pos.X > this.lastX + this.lastTotalWidth + BorderWidth + || pos.Y < this.lastY - BorderWidth || pos.Y > this.lastY + this.lastTotalHeight + BorderWidth) + return false; + + if (mi.Button != MouseButton.Left) + return false; + + for (var i = 0; i < this.lastCols * this.lastRows && i < this.selectedActors.Length; i++) + { + var col = i % this.lastCols; + var row = i / this.lastCols; + var cellX = this.lastX + col * CellWidth; + var cellY = this.lastY + row * CellWidth; + + if (pos.X >= cellX && pos.X < cellX + CellWidth && pos.Y >= cellY && pos.Y < cellY + CellWidth) + { + var actor = this.selectedActors[i]; + if (!actor.IsInWorld || actor.IsDead) + return false; + + var shift = Game.GetModifierKeys().HasModifier(Modifiers.Shift); + var ctrl = Game.GetModifierKeys().HasModifier(Modifiers.Ctrl); + + if (ctrl) + this.world.Selection.Remove(actor); + else if (shift) + this.world.Selection.Add(actor); + else + this.world.Selection.Combine(this.world, new[] { actor }, false, true); + + Game.Sound.PlayNotification(this.world.Map.Rules, null, "Sounds", "ClickSound", null); + return true; + } + } + + return false; + } + + public override void Draw() + { + var actors = this.selectedActors + .Where(a => a.IsInWorld && !a.IsDead) + .ToArray(); + + if (actors.Length == 0 || this.lastTotalWidth == 0) + return; + + var visibleCount = System.Math.Min(actors.Length, this.lastCols * this.lastRows); + var cols = this.lastCols; + var rows = this.lastRows; + var totalWidth = this.lastTotalWidth; + var totalHeight = this.lastTotalHeight; + var x = this.lastX; + var y = this.lastY; + + // Black backdrop with border + var bgRect = new Rectangle(x - BorderWidth, y - BorderWidth, totalWidth + BorderWidth * 2, totalHeight + BorderWidth * 2); + WidgetUtils.FillRectWithColor(bgRect, Color.FromArgb(200, 0, 0, 0)); + WidgetUtils.FillRectWithColor( + new Rectangle(bgRect.X, bgRect.Y, bgRect.Width, BorderWidth), + Color.FromArgb(128, 255, 255, 255)); + WidgetUtils.FillRectWithColor( + new Rectangle(bgRect.X, bgRect.Bottom - BorderWidth, bgRect.Width, BorderWidth), + Color.FromArgb(128, 255, 255, 255)); + WidgetUtils.FillRectWithColor( + new Rectangle(bgRect.X, bgRect.Y, BorderWidth, bgRect.Height), + Color.FromArgb(128, 255, 255, 255)); + WidgetUtils.FillRectWithColor( + new Rectangle(bgRect.Right - BorderWidth, bgRect.Y, BorderWidth, bgRect.Height), + Color.FromArgb(128, 255, 255, 255)); + + for (var i = 0; i < visibleCount; i++) + { + var actor = actors[i]; + var health = actor.TraitOrDefault(); + if (health == null) + continue; + + var col = i % cols; + var row = i / cols; + + var cellX = x + col * CellWidth; + var cellY = y + row * CellWidth; + + var hpFraction = (float)health.HP / health.MaxHP; + var hpColor = hpFraction > 0.6f ? Color.FromArgb(200, 0, 200, 0) : + hpFraction > 0.3f ? Color.FromArgb(200, 200, 200, 0) : + Color.FromArgb(200, 200, 0, 0); + + var barWidth = CellWidth - BarMargin * 2; + var barX = cellX + BarMargin; + var barY = cellY + BarMargin; + + WidgetUtils.FillRectWithColor( + new Rectangle(barX, barY, barWidth, BarHeight), + Color.FromArgb(160, 0, 0, 0)); + + var fillWidth = System.Math.Max(1, (int)(barWidth * hpFraction)); + WidgetUtils.FillRectWithColor( + new Rectangle(barX, barY, fillWidth, BarHeight), + hpColor); + + var icon = GetIcon(actor.Info); + if (icon != null) + WidgetUtils.DrawSpriteCentered( + icon, + this.ingameUi.Palette, + new int2(cellX + CellWidth / 2, cellY + CellWidth - 12)); + } + + if (actors.Length > visibleCount) + { + var font = Game.Renderer.Fonts["Tiny"]; + font.DrawTextWithContrast( + $"+{actors.Length - visibleCount}", + new int2(x + totalWidth / 2 - 15, y + totalHeight + 4), + Color.White, + Color.Black, + 1); + } + } + + private Sprite? GetIcon(ActorInfo info) + { + if (this.iconCache.TryGetValue(info.Name, out var cached)) + return cached; + + var imageName = info.TraitInfoOrDefault()?.Image ?? info.Name; + + try + { + var anim = new Animation(this.world, imageName); + if (anim.HasSequence("icon")) + { + anim.PlayFetchIndex("icon", () => 0); + var sprite = anim.Image; + if (sprite != null) + { + this.iconCache[info.Name] = sprite; + return sprite; + } + } + } + catch + { + } + + this.iconCache[info.Name] = null!; + return null; + } +} diff --git a/OpenRA.Mods.OpenKrush/Widgets/Ingame/SidebarWidget.cs b/OpenRA.Mods.OpenKrush/Widgets/Ingame/SidebarWidget.cs index 39c80c5..8cabf3c 100644 --- a/OpenRA.Mods.OpenKrush/Widgets/Ingame/SidebarWidget.cs +++ b/OpenRA.Mods.OpenKrush/Widgets/Ingame/SidebarWidget.cs @@ -13,15 +13,26 @@ namespace OpenRA.Mods.OpenKrush.Widgets.Ingame; +using System.Linq; using Buttons; using Common.Widgets; using Graphics; +using Mechanics.Researching.Orders; using OpenRA.Widgets; using Primitives; public sealed class SidebarWidget : Widget { public const string Identifier = "OPENKRUSH_SIDEBAR"; + public const int GridColumns = 5; + public const int GridRows = 3; + public const int BarHeight = SidebarButtonWidget.Size; + public const int ButtonCount = 11; + + public static int GridWidth => GridColumns * SidebarButtonWidget.Size; + public static int GridHeight => GridRows * SidebarButtonWidget.Size; + public static int BarWidth => ButtonCount * SidebarButtonWidget.Size; + public readonly IngameUiWidget IngameUi; public readonly Animation Buttons; @@ -45,29 +56,36 @@ public SidebarWidget(IngameUiWidget ingameUi) ChromeMetrics.TryGet($"ButtonArea-{this.IngameUi.World.LocalPlayer.Faction.InternalName}", out this.ButtonArea); - this.AddChild(new ProductionCategoryButtonWidget(this, 0, new[] { "infantry" }, "Infantry")); - this.AddChild(new ProductionCategoryButtonWidget(this, 1, new[] { "vehicle", "beast" }, "Vehicles")); - this.AddChild(new ProductionCategoryButtonWidget(this, 2, new[] { "building" }, "Buildings")); - this.AddChild(new ProductionCategoryButtonWidget(this, 3, new[] { "tower" }, "Towers")); - this.AddChild(new ProductionCategoryButtonWidget(this, 4, new[] { "wall" }, "Walls")); - this.AddChild(this.bomber = new(this)); - this.AddChild(this.sell = new(this)); this.AddChild(this.research = new(this)); this.AddChild(this.repair = new(this)); - this.AddChild(this.radar = new(this)); this.AddChild(this.options = new(this)); + this.AddChild(new ProductionCategoryButtonWidget(this, 0, new[] { "infantry" }, "Infantry")); + this.AddChild(new ProductionCategoryButtonWidget(this, 1, new[] { "vehicle", "beast" }, "Vehicles")); + this.AddChild(new ProductionCategoryButtonWidget(this, 2, new[] { "building" }, "Buildings")); + this.AddChild(new ProductionCategoryButtonWidget(this, 3, new[] { "tower" }, "Towers")); + this.AddChild(new ProductionCategoryButtonWidget(this, 4, new[] { "wall" }, "Walls")); + + this.AddChild(new UnitCommandWidget(this)); + this.Resize(); } private void Resize() { - this.Bounds = new(Game.Renderer.Resolution.Width - SidebarButtonWidget.Size, 0, SidebarButtonWidget.Size, Game.Renderer.Resolution.Height); + this.Bounds = new( + Game.Renderer.Resolution.Width - SidebarWidget.BarWidth, + Game.Renderer.Resolution.Height - SidebarWidget.GridHeight - SidebarWidget.BarHeight, + SidebarWidget.BarWidth, + SidebarWidget.GridHeight + SidebarWidget.BarHeight + ); } + public bool AnyCategoryOpen => this.Children.OfType().Any(w => w.Active); + public override bool HandleMouseInput(MouseInput mi) { return this.EventBounds.Contains(mi.Location); @@ -75,48 +93,61 @@ public override bool HandleMouseInput(MouseInput mi) public override void Tick() { - if (this.Bounds.Height < 14 * SidebarButtonWidget.Size) - { - this.bomber.Bounds.Y = 5 * SidebarButtonWidget.Size; - this.sell.Bounds.Y = 6 * SidebarButtonWidget.Size; - this.research.Bounds.Y = 7 * SidebarButtonWidget.Size; - this.repair.Bounds.Y = 8 * SidebarButtonWidget.Size; - this.radar.Bounds.Y = 9 * SidebarButtonWidget.Size; - this.options.Bounds.Y = 10 * SidebarButtonWidget.Size; - } - else - { - this.bomber.Bounds.Y = 6 * SidebarButtonWidget.Size; - this.sell.Bounds.Y = 8 * SidebarButtonWidget.Size; - this.research.Bounds.Y = 9 * SidebarButtonWidget.Size; - this.repair.Bounds.Y = 10 * SidebarButtonWidget.Size; - this.radar.Bounds.Y = 12 * SidebarButtonWidget.Size; - this.options.Bounds.Y = 13 * SidebarButtonWidget.Size; - } + this.bomber.Bounds = new(5 * SidebarButtonWidget.Size, SidebarWidget.GridHeight, SidebarButtonWidget.Size, SidebarButtonWidget.Size); + this.sell.Bounds = new(6 * SidebarButtonWidget.Size, SidebarWidget.GridHeight, SidebarButtonWidget.Size, SidebarButtonWidget.Size); + this.research.Bounds = new(7 * SidebarButtonWidget.Size, SidebarWidget.GridHeight, SidebarButtonWidget.Size, SidebarButtonWidget.Size); + this.repair.Bounds = new(8 * SidebarButtonWidget.Size, SidebarWidget.GridHeight, SidebarButtonWidget.Size, SidebarButtonWidget.Size); + this.radar.Bounds = new(9 * SidebarButtonWidget.Size, SidebarWidget.GridHeight, SidebarButtonWidget.Size, SidebarButtonWidget.Size); + this.options.Bounds = new(10 * SidebarButtonWidget.Size, SidebarWidget.GridHeight, SidebarButtonWidget.Size, SidebarButtonWidget.Size); + + // Research, Sell and Repair are now in the building command grid (UnitCommandWidget). + this.research.Visible = false; + this.sell.Visible = false; + this.repair.Visible = false; + + base.Tick(); } public override void Draw() { - for (var y = 0; y < this.Bounds.Height; y += SidebarButtonWidget.Size) + WidgetUtils.FillRectWithColor( + new(this.RenderBounds.X, this.RenderBounds.Y, SidebarWidget.BarWidth, SidebarWidget.GridHeight), + Color.FromArgb(96, 0, 0, 0) + ); + + for (var x = 0; x < SidebarWidget.BarWidth; x += SidebarButtonWidget.Size) { this.Buttons.PlayFetchIndex("button", () => 0); WidgetUtils.DrawSpriteCentered( this.Buttons.Image, this.IngameUi.Palette, - new(this.RenderBounds.X + SidebarButtonWidget.Size / 2, y + SidebarButtonWidget.Size / 2) + new(this.RenderBounds.X + x + SidebarButtonWidget.Size / 2, this.RenderBounds.Y + SidebarWidget.GridHeight + SidebarButtonWidget.Size / 2) ); } + + base.Draw(); } - public void CloseAllBut(SidebarButtonWidget keepOpen) + public void CloseAllBut(SidebarButtonWidget? keepOpen) { foreach (var widget in this.Children.Where(w => w != keepOpen && w is ProductionCategoryButtonWidget or BomberButtonWidget)) ((SidebarButtonWidget)widget).Active = false; + + if (keepOpen is not ResearchButtonWidget && this.IngameUi.World.OrderGenerator is ResearchOrderGenerator) + this.IngameUi.World.CancelInputMode(); } public void SelectFactory(Actor factory, string category) { + if (category == "research") + { + this.CloseAllBut(null); + this.IngameUi.World.OrderGenerator = new ResearchOrderGenerator(); + + return; + } + if (this.Children.FirstOrDefault(child => child is ProductionCategoryButtonWidget button && button.Categories.Contains(category)) is not ProductionCategoryButtonWidget categoryButton) return; diff --git a/OpenRA.Mods.OpenKrush/Widgets/Ingame/StatusWidget.cs b/OpenRA.Mods.OpenKrush/Widgets/Ingame/StatusWidget.cs index 1ef8162..59727c5 100644 --- a/OpenRA.Mods.OpenKrush/Widgets/Ingame/StatusWidget.cs +++ b/OpenRA.Mods.OpenKrush/Widgets/Ingame/StatusWidget.cs @@ -37,7 +37,11 @@ public StatusWidget(IngameUiWidget ingameUi) private void Resize() { - this.Bounds = new((Game.Renderer.Resolution.Width - 180) / 2, 0, 180, 28); + var radarWidth = this.ingameUi.World.Map.MapSize.X * 2; + var gap = Game.Renderer.Resolution.Width - radarWidth - SidebarWidget.BarWidth; + var x = radarWidth + gap / 2 - 90; + + this.Bounds = new(Math.Max(0, x), 0, 180, 28); } public override void Tick() @@ -45,6 +49,7 @@ public override void Tick() var numPowers = this.ingameUi.World.Players.Sum(player => player.PlayerActor.TraitOrDefault().Powers.Count(p => p.Value.Active)); this.Bounds.Height = 28 + (numPowers / 4 + (numPowers % 4 == 0 ? 0 : 1)) * this.powerHeight + (numPowers > 0 ? 5 : 0); + this.Bounds.Y = 0; } public override void Draw() diff --git a/OpenRA.Mods.OpenKrush/Widgets/Ingame/TooltipWidget.cs b/OpenRA.Mods.OpenKrush/Widgets/Ingame/TooltipWidget.cs index e1b3819..75a6d74 100644 --- a/OpenRA.Mods.OpenKrush/Widgets/Ingame/TooltipWidget.cs +++ b/OpenRA.Mods.OpenKrush/Widgets/Ingame/TooltipWidget.cs @@ -20,66 +20,75 @@ namespace OpenRA.Mods.OpenKrush.Widgets.Ingame; public class TooltipWidget : Widget { + public static TooltipWidget? Instance { get; private set; } + private readonly SpriteFont tooltipTitleFont; private readonly SpriteFont tooltipTextFont; public string? TooltipTitle = null; public string? TooltipText = null; + private int showTicks; + private bool wasVisible; + private const int AutoHideDelay = 60; + public TooltipWidget() { + Instance = this; this.tooltipTitleFont = Game.Renderer.Fonts["Regular"]; this.tooltipTextFont = Game.Renderer.Fonts["Tiny"]; - this.Visible = false; } + public void ShowCentered(string title, string? text = null) + { + this.TooltipTitle = title; + this.TooltipText = text; + this.showTicks = 0; + this.Visible = true; + } + + public override void Tick() + { + if (!this.Visible) + { + this.wasVisible = false; + return; + } + + if (!this.wasVisible) + { + this.wasVisible = true; + this.showTicks = 0; + } + + this.showTicks++; + + if (this.showTicks >= AutoHideDelay) + { + this.Visible = false; + this.wasVisible = false; + this.showTicks = 0; + } + } + public override void Draw() { var tooltipTitleMeasure = this.TooltipTitle == null ? int2.Zero : this.tooltipTitleFont.Measure(this.TooltipTitle); var tooltipTextMeasure = this.TooltipText == null ? int2.Zero : this.tooltipTextFont.Measure(this.TooltipText); - WidgetUtils.FillRectWithColor( - new( - this.RenderBounds.X - Math.Max(tooltipTitleMeasure.X, tooltipTextMeasure.X) - 12, - this.RenderBounds.Y - (tooltipTitleMeasure.Y + tooltipTextMeasure.Y) / 2 - 6, - Math.Max(tooltipTitleMeasure.X, tooltipTextMeasure.X) + 12, - tooltipTitleMeasure.Y + tooltipTextMeasure.Y + 12 - ), - Color.FromArgb(255, 255, 255, 255) - ); - - WidgetUtils.FillRectWithColor( - new( - this.RenderBounds.X - Math.Max(tooltipTitleMeasure.X, tooltipTextMeasure.X) - 11, - this.RenderBounds.Y - (tooltipTitleMeasure.Y + tooltipTextMeasure.Y) / 2 - 5, - Math.Max(tooltipTitleMeasure.X, tooltipTextMeasure.X) + 10, - tooltipTitleMeasure.Y + tooltipTextMeasure.Y + 10 - ), - Color.FromArgb(255, 0, 0, 0) - ); + var w = System.Math.Max(tooltipTitleMeasure.X, tooltipTextMeasure.X) + 12; + var h = tooltipTitleMeasure.Y + tooltipTextMeasure.Y + 12; + + var x = Game.Renderer.Resolution.Width / 2 - w / 2; + var y = Game.Renderer.Resolution.Height / 3; + + WidgetUtils.FillRectWithColor(new(x, y, w, h), Color.FromArgb(255, 255, 255, 255)); + WidgetUtils.FillRectWithColor(new(x + 1, y + 1, w - 2, h - 2), Color.FromArgb(255, 0, 0, 0)); if (this.TooltipTitle != null) - { - this.tooltipTitleFont.DrawText( - this.TooltipTitle, - new int2( - this.RenderBounds.X - Math.Max(tooltipTitleMeasure.X, tooltipTextMeasure.X) - 6, - this.RenderBounds.Y - (tooltipTitleMeasure.Y + tooltipTextMeasure.Y) / 2 - 5 - ), - Color.White - ); - } + this.tooltipTitleFont.DrawText(this.TooltipTitle, new int2(x + 6, y + 5), Color.White); if (this.TooltipText != null) - { - this.tooltipTextFont.DrawText( - this.TooltipText, - new int2( - this.RenderBounds.X - Math.Max(tooltipTitleMeasure.X, tooltipTextMeasure.X) - 6, - this.RenderBounds.Y - (tooltipTitleMeasure.Y + tooltipTextMeasure.Y) / 2 + tooltipTitleMeasure.Y - ), - Color.White - ); - } + this.tooltipTextFont.DrawText(this.TooltipText, new int2(x + 6, y + 6 + tooltipTitleMeasure.Y), Color.White); } } diff --git a/OpenRA.Mods.OpenKrush/Widgets/Ingame/UnitCommandWidget.cs b/OpenRA.Mods.OpenKrush/Widgets/Ingame/UnitCommandWidget.cs new file mode 100644 index 0000000..d00f614 --- /dev/null +++ b/OpenRA.Mods.OpenKrush/Widgets/Ingame/UnitCommandWidget.cs @@ -0,0 +1,574 @@ +#region Copyright & License Information + +/* + * Copyright 2007-2022 The OpenKrush Developers (see AUTHORS) + * This file is part of OpenKrush, 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 + +namespace OpenRA.Mods.OpenKrush.Widgets.Ingame; + +using System; +using System.Linq; +using Buttons; +using Common.Orders; +using Common.Orders; +using Common.Traits; +using Common.Widgets; +using Graphics; +using Mechanics.Construction.Orders; +using Mechanics.Construction.Traits; +using Mechanics.Researching; +using Mechanics.Researching.Orders; +using Mechanics.Researching.Traits; +using Mechanics.Technicians.Orders; +using Mechanics.Technicians.Traits; +using OpenRA.Traits; +using OpenRA.Widgets; +using Primitives; + +// SC2-like command card shown in the bottom-right grid when units or buildings are selected. +public sealed class UnitCommandWidget : Widget +{ + private sealed class CommandSlot + { + public readonly string Label; + public readonly string TooltipTitle; + public readonly string TooltipText; + public readonly Action OnClick; + public readonly Func IsDisabled; + public readonly Func IsHighlighted; + public readonly string? Icon; + + public CommandSlot(string label, string tooltipTitle, string tooltipText, Action onClick, Func isDisabled, string? icon = null, Func? isHighlighted = null) + { + this.Label = label; + this.TooltipTitle = tooltipTitle; + this.TooltipText = tooltipText; + this.OnClick = onClick; + this.IsDisabled = isDisabled; + this.IsHighlighted = isHighlighted ?? (() => false); + this.Icon = icon; + } + } + + private readonly SidebarWidget sidebar; + private readonly World world; + private readonly SpriteFont? labelFont; + private readonly SpriteFont? keyFont; + private readonly CommandSlot[] slots = new CommandSlot[15]; + + private Actor[] selectedActors = Array.Empty(); + private TraitPair[] selectedDeploys = Array.Empty>(); + private TraitPair[] actorStances = Array.Empty>(); + private int selectionHash; + + private bool attackMoveDisabled = true; + private bool guardDisabled = true; + private bool scatterDisabled = true; + private bool stopDisabled = true; + + private int deployHighlighted; + private int scatterHighlighted; + private int stopHighlighted; + + private bool hadSelection; + private bool isBuildingSelection; + private bool wasBuildingSelection; + + public UnitCommandWidget(SidebarWidget sidebar) + { + this.sidebar = sidebar; + this.world = sidebar.IngameUi.World; + + Game.Renderer.Fonts.TryGetValue("Tiny", out this.labelFont); + Game.Renderer.Fonts.TryGetValue("TinyBold", out this.keyFont); + + this.Bounds = new(SidebarWidget.BarWidth - SidebarWidget.GridWidth, 0, SidebarWidget.GridWidth, SidebarWidget.GridHeight); + } + + private void PopulateUnitSlots() + { + Array.Clear(this.slots, 0, this.slots.Length); + + this.slots[0] = new( + "Move", + "Move", + "Move to a location. Right-click on the map.", + () => this.world.CancelInputMode(), + () => false + ); + + this.slots[1] = new( + "Attack", + "Attack Move", + "Move while attacking enemies on the way.", + () => this.ToggleAttackMove(true), + () => { this.UpdateStateIfNecessary(); return this.attackMoveDisabled; }, + isHighlighted: () => this.world.OrderGenerator is AttackMoveOrderGenerator + ); + + this.slots[2] = new( + "Stop", + "Stop", + "Cancel all orders.", + () => + { + this.stopHighlighted = 2; + this.PerformKeyboardOrderOnSelection(a => new Order("Stop", a, false)); + }, + () => { this.UpdateStateIfNecessary(); return this.stopDisabled; }, + isHighlighted: () => this.stopHighlighted > 0 + ); + + this.slots[3] = new( + "Guard", + "Guard", + "Guard a location or unit.", + () => this.ToggleGuard(true), + () => { this.UpdateStateIfNecessary(); return this.guardDisabled; }, + isHighlighted: () => this.world.OrderGenerator is GuardOrderGenerator + ); + + this.slots[4] = new( + "Scatter", + "Scatter", + "Scatter the selected units.", + () => + { + this.scatterHighlighted = 2; + this.PerformKeyboardOrderOnSelection(a => new Order("Scatter", a, false)); + }, + () => { this.UpdateStateIfNecessary(); return this.scatterDisabled; }, + isHighlighted: () => this.scatterHighlighted > 0 + ); + + this.slots[5] = new( + "Attack All", + "Attack Anything", + "Attack everything hostile in range.", + () => this.SetSelectionStance(UnitStance.AttackAnything), + () => { this.UpdateStateIfNecessary(); return this.actorStances.Length == 0; }, + isHighlighted: () => this.GetStanceHighlighted(UnitStance.AttackAnything) + ); + + this.slots[6] = new( + "Defend", + "Defend", + "Attack enemies in range, but never hunt them.", + () => this.SetSelectionStance(UnitStance.Defend), + () => { this.UpdateStateIfNecessary(); return this.actorStances.Length == 0; }, + isHighlighted: () => this.GetStanceHighlighted(UnitStance.Defend) + ); + + this.slots[7] = new( + "Return", + "Return Fire", + "Only return fire when attacked.", + () => this.SetSelectionStance(UnitStance.ReturnFire), + () => { this.UpdateStateIfNecessary(); return this.actorStances.Length == 0; }, + isHighlighted: () => this.GetStanceHighlighted(UnitStance.ReturnFire) + ); + + this.slots[8] = new( + "Hold", + "Hold Fire", + "Never fire.", + () => this.SetSelectionStance(UnitStance.HoldFire), + () => { this.UpdateStateIfNecessary(); return this.actorStances.Length == 0; }, + isHighlighted: () => this.GetStanceHighlighted(UnitStance.HoldFire) + ); + + this.slots[9] = new( + "Deploy", + "Deploy", + "Deploy or undeploy the selected units.", + () => + { + this.deployHighlighted = 2; + this.PerformDeployOrderOnSelection(Game.GetModifierKeys().HasModifier(Modifiers.Shift)); + }, + () => + { + this.UpdateStateIfNecessary(); + var queued = Game.GetModifierKeys().HasModifier(Modifiers.Shift); + return !this.selectedDeploys.Any(pair => pair.Trait.CanIssueDeployOrder(pair.Actor, queued)); + }, + isHighlighted: () => this.deployHighlighted > 0 + ); + } + + private void PopulateBuildingSlots() + { + Array.Clear(this.slots, 0, this.slots.Length); + + this.slots[12] = new( + "Sell", + "Sell", + "Sell the selected building.", + () => + { + if (this.selectedActors.Length > 0) + this.world.IssueOrder(new("Sell", this.selectedActors[0], false)); + }, + () => !this.world.ActorsWithTrait().Any(e => e.Actor.Owner == this.world.LocalPlayer), + icon: "sell" + ); + + this.slots[13] = new( + "Repair", + "Repair", + "Send a technician to repair the building.", + () => + { + if (this.selectedActors.Length == 0) + return; + + var technician = this.world.ActorsWithTrait() + .FirstOrDefault(t => t.Actor.Owner == this.world.LocalPlayer && t.Actor.IsIdle); + + if (technician != null) + this.world.IssueOrder(new(TechnicianEnterOrderTargeter.Id, technician.Actor, Target.FromActor(this.selectedActors[0]), true)); + }, + () => !this.world.ActorsWithTrait().Any(e => e.Actor.Owner == this.world.LocalPlayer && e.Actor.IsIdle), + icon: "repair" + ); + + this.slots[14] = new( + "Research", + "Research", + "Research this building.", + () => + { + if (this.selectedActors.Length == 0) + return; + + var researchActors = this.world.ActorsWithTrait() + .Where(a => a.Actor.Owner == this.world.LocalPlayer && !a.Trait.IsTraitDisabled) + .ToArray(); + + if (researchActors.Length == 0) + { + TooltipWidget.Instance?.ShowCentered("No research building", "Build a research lab first."); + return; + } + + var free = researchActors.FirstOrDefault(a => a.Trait.GetState() != ResarchState.Researching); + if (free != null) + this.world.IssueOrder(new(ResearchOrderTargeter.Id, free.Actor, Target.FromActor(this.selectedActors[0]), false)); + else + TooltipWidget.Instance?.ShowCentered("Already researching", "Wait for the current research to finish."); + }, + () => false, + icon: "research" + ); + } + + public override void Tick() + { + this.UpdateStateIfNecessary(); + + var hasSelection = this.selectedActors.Length > 0; + + if (hasSelection && !this.hadSelection && this.sidebar.AnyCategoryOpen && !this.isBuildingSelection) + this.sidebar.CloseAllBut(null); + + // Close production panel when the last building is deselected. + if (!this.isBuildingSelection && this.wasBuildingSelection && this.sidebar.AnyCategoryOpen) + this.sidebar.CloseAllBut(null); + + this.hadSelection = hasSelection; + this.wasBuildingSelection = this.isBuildingSelection; + + if (this.deployHighlighted > 0) + this.deployHighlighted--; + + if (this.scatterHighlighted > 0) + this.scatterHighlighted--; + + if (this.stopHighlighted > 0) + this.stopHighlighted--; + + if (this.selectedActors.Length == 0 || !this.RenderBounds.Contains(Viewport.LastMousePos)) + this.sidebar.IngameUi.Tooltip.Visible = false; + + base.Tick(); + } + + public override bool HandleKeyPress(KeyInput e) + { + if (this.selectedActors.Length == 0 || e.IsRepeat || e.Event != KeyInputEvent.Down) + return false; + + for (var i = 0; i < this.slots.Length; i++) + { + if (this.slots[i] == null) + continue; + + var hotkey = Game.ModData.Hotkeys[$"Production{i + 1:00}"].GetValue(); + + if (e.Key != hotkey.Key || e.Modifiers != hotkey.Modifiers) + continue; + + this.slots[i].OnClick(); + + return true; + } + + return false; + } + + public override bool HandleMouseInput(MouseInput mi) + { + if (this.selectedActors.Length == 0) + return false; + + if (mi.Event == MouseInputEvent.Move) + this.UpdateTooltip(mi.Location); + else if (mi.Event == MouseInputEvent.Down && mi.Button == MouseButton.Left) + { + for (var i = 0; i < this.slots.Length; i++) + { + var slot = this.slots[i]; + + if (slot == null || slot.IsDisabled()) + continue; + + if (this.CellBounds(i).Contains(mi.Location)) + { + slot.OnClick(); + + return true; + } + } + } + + return false; + } + + public override void Draw() + { + if (this.selectedActors.Length == 0) + return; + + for (var i = 0; i < this.slots.Length; i++) + { + var slot = this.slots[i]; + + if (slot == null) + continue; + + var bounds = this.CellBounds(i); + var disabled = slot.IsDisabled(); + var highlighted = slot.IsHighlighted(); + + this.sidebar.Buttons.PlayFetchIndex(highlighted ? "button-down" : "button", () => 0); + WidgetUtils.DrawSpriteCentered( + this.sidebar.Buttons.Image, + this.sidebar.IngameUi.Palette, + new(bounds.X + SidebarButtonWidget.Size / 2, bounds.Y + SidebarButtonWidget.Size / 2) + ); + + if (highlighted) + WidgetUtils.FillRectWithColor(bounds, Color.FromArgb(64, 255, 255, 255)); + + if (disabled) + WidgetUtils.FillRectWithColor(bounds, Color.FromArgb(140, 0, 0, 0)); + + if (slot.Icon != null) + { + this.sidebar.Buttons.PlayFetchIndex(slot.Icon, () => 0); + WidgetUtils.DrawSpriteCentered( + this.sidebar.Buttons.Image, + this.sidebar.IngameUi.Palette, + new(bounds.X + SidebarButtonWidget.Size / 2, bounds.Y + SidebarButtonWidget.Size / 2 - 4)); + } + + var labelFont = this.labelFont; + if (labelFont != null) + { + var measure = labelFont.Measure(slot.Label); + var textY = slot.Icon != null + ? bounds.Bottom - measure.Y - 2 + : bounds.Y + (SidebarButtonWidget.Size - measure.Y) / 2; + + labelFont.DrawTextWithContrast( + slot.Label, + new int2(bounds.X + (SidebarButtonWidget.Size - measure.X) / 2, textY), + Color.White, + Color.Black, + 1); + } + + var keyFont = this.keyFont; + if (keyFont != null) + { + var key = Game.ModData.Hotkeys[$"Production{i + 1:00}"].GetValue().DisplayString(); + var keyMeasure = keyFont.Measure(key); + keyFont.DrawTextWithContrast( + key, + new int2(bounds.Right - 3 - keyMeasure.X, bounds.Bottom - 3 - keyMeasure.Y), + Color.FromArgb(255, 200, 200, 200), + Color.Black, + 1); + } + } + } + + private Rectangle CellBounds(int index) + { + var x = index % SidebarWidget.GridColumns * SidebarButtonWidget.Size; + var y = index / SidebarWidget.GridColumns * SidebarButtonWidget.Size; + + return new(this.RenderBounds.X + x, this.RenderBounds.Y + y, SidebarButtonWidget.Size, SidebarButtonWidget.Size); + } + + private void UpdateTooltip(int2 location) + { + for (var i = 0; i < this.slots.Length; i++) + { + var slot = this.slots[i]; + + if (slot == null || !this.CellBounds(i).Contains(location)) + continue; + + this.sidebar.IngameUi.Tooltip.TooltipTitle = slot.TooltipTitle; + this.sidebar.IngameUi.Tooltip.TooltipText = slot.TooltipText; + this.sidebar.IngameUi.Tooltip.Bounds = new(this.RenderBounds.X + this.Bounds.Width / 2, this.RenderBounds.Y + this.Bounds.Height / 2, 0, 0); + this.sidebar.IngameUi.Tooltip.Visible = true; + + return; + } + } + + private void UpdateStateIfNecessary() + { + if (this.selectionHash == this.world.Selection.Hash) + return; + + // Take all selected actors (units and buildings) and filter to just units for the unit commands. + var allActors = this.world.Selection.Actors + .Where(a => a.Owner == this.world.LocalPlayer && a.IsInWorld && !a.IsDead) + .ToArray(); + + var unitActors = allActors.Where(a => !a.Info.HasTraitInfo()).ToArray(); + var buildingActors = allActors.Where(a => a.Info.HasTraitInfo()).ToArray(); + + // Show building commands only when solely buildings are selected. + this.isBuildingSelection = buildingActors.Length > 0 && unitActors.Length == 0; + + if (this.isBuildingSelection) + { + this.selectedActors = buildingActors; + this.PopulateBuildingSlots(); + } + else + { + this.selectedActors = unitActors; + this.PopulateUnitSlots(); + + this.attackMoveDisabled = !this.selectedActors.Any(a => + a.Info.HasTraitInfo() && a.Info.HasTraitInfo() && a.Info.HasTraitInfo()); + this.guardDisabled = !this.selectedActors.Any(a => a.Info.HasTraitInfo() && a.Info.HasTraitInfo()); + this.scatterDisabled = !this.selectedActors.Any(a => a.Info.HasTraitInfo()); + + var cbbInfos = this.selectedActors.Select(a => a.Info.TraitInfoOrDefault()).ToArray(); + this.stopDisabled = !cbbInfos.Any(i => i == null || !i.DisableStop); + + this.selectedDeploys = this.selectedActors + .SelectMany(a => a.TraitsImplementing() + .Select(d => new TraitPair(a, d))) + .ToArray(); + + this.actorStances = this.selectedActors + .SelectMany(a => a.TraitsImplementing() + .Where(at => at.Info.EnableStances) + .Select(at => new TraitPair(a, at))) + .ToArray(); + } + + this.selectionHash = this.world.Selection.Hash; + } + + private bool GetStanceHighlighted(UnitStance stance) + { + this.UpdateStateIfNecessary(); + + var active = this.actorStances.Where(at => !at.Trait.IsTraitDisabled).ToArray(); + + return active.Length > 0 && active.All(at => at.Trait.PredictedStance == stance); + } + + private void ToggleAttackMove(bool allowCancel) + { + if (this.world.OrderGenerator is AttackMoveOrderGenerator) + { + if (allowCancel) + this.world.CancelInputMode(); + } + else + this.world.OrderGenerator = new AttackMoveOrderGenerator(this.selectedActors, Game.Settings.Game.MouseButtonPreference.Action); + } + + private void ToggleGuard(bool allowCancel) + { + if (this.world.OrderGenerator is GuardOrderGenerator) + { + if (allowCancel) + this.world.CancelInputMode(); + } + else + this.world.OrderGenerator = new GuardOrderGenerator( + this.selectedActors, + "Guard", + "guard", + Game.Settings.Game.MouseButtonPreference.Action + ); + } + + private void PerformKeyboardOrderOnSelection(Func f) + { + this.UpdateStateIfNecessary(); + + var orders = this.selectedActors.Select(f).ToArray(); + + foreach (var order in orders) + this.world.IssueOrder(order); + + orders.PlayVoiceForOrders(); + } + + private void PerformDeployOrderOnSelection(bool queued) + { + this.UpdateStateIfNecessary(); + + var orders = this.selectedDeploys + .Where(pair => pair.Trait.CanIssueDeployOrder(pair.Actor, queued)) + .Select(pair => pair.Trait.IssueDeployOrder(pair.Actor, queued)) + .Where(order => order != null) + .ToArray(); + + foreach (var order in orders) + this.world.IssueOrder(order); + + orders.PlayVoiceForOrders(); + } + + private void SetSelectionStance(UnitStance stance) + { + this.UpdateStateIfNecessary(); + + foreach (var pair in this.actorStances) + { + if (!pair.Trait.IsTraitDisabled) + pair.Trait.PredictedStance = stance; + + this.world.IssueOrder(new Order("SetUnitStance", pair.Actor, false) { ExtraData = (uint)stance }); + } + } +} diff --git a/mods/openkrush/chrome/ingame-player.yaml b/mods/openkrush/chrome/ingame-player.yaml index b145968..7811930 100644 --- a/mods/openkrush/chrome/ingame-player.yaml +++ b/mods/openkrush/chrome/ingame-player.yaml @@ -13,105 +13,6 @@ Container@PLAYER_WIDGETS: JumpToGroupKeyPrefix: ControlGroupJumpTo # TODO for what? LogicTicker@SIDEBAR_TICKER: - # TODO merge into our ui! - Container@COMMAND_BAR: - Logic: CommandBarLogic - Y: WINDOW_BOTTOM - Children: - LogicKeyListener@MODIFIER_OVERRIDES: - Button@ATTACK_MOVE: - Key: AttackMove - DisableKeySound: true - Children: - Image@ICON: - ImageCollection: command-icons - ImageName: attack-move - Button@FORCE_MOVE: - DisableKeySound: true - Children: - Image@ICON: - ImageCollection: command-icons - ImageName: force-move - Button@FORCE_ATTACK: - DisableKeySound: true - Children: - Image@ICON: - ImageCollection: command-icons - ImageName: force-attack - Button@GUARD: - Key: Guard - DisableKeySound: true - Children: - Image@ICON: - ImageCollection: command-icons - ImageName: guard - Button@DEPLOY: - Key: Deploy - DisableKeyRepeat: true - DisableKeySound: true - Children: - Image@ICON: - ImageCollection: command-icons - ImageName: deploy - Button@SCATTER: - Key: Scatter - DisableKeyRepeat: true - DisableKeySound: true - Children: - Image@ICON: - ImageCollection: command-icons - ImageName: scatter - Button@STOP: - Key: Stop - DisableKeyRepeat: true - DisableKeySound: true - Children: - Image@ICON: - ImageCollection: command-icons - ImageName: stop - Button@QUEUE_ORDERS: - DisableKeySound: true - Children: - Image@ICON: - ImageCollection: command-icons - ImageName: queue-orders - # TODO merge into our ui! - Container@STANCE_BAR: - Logic: StanceSelectorLogic - Y: WINDOW_BOTTOM - Children: - Button@STANCE_ATTACKANYTHING: - Key: StanceAttackAnything - DisableKeyRepeat: true - DisableKeySound: true - Children: - Image@ICON: - ImageCollection: stance-icons - ImageName: attack-anything - Button@STANCE_DEFEND: - Key: StanceDefend - DisableKeyRepeat: true - DisableKeySound: true - Children: - Image@ICON: - ImageCollection: stance-icons - ImageName: defend - Button@STANCE_RETURNFIRE: - Key: StanceReturnFire - DisableKeyRepeat: true - DisableKeySound: true - Children: - Image@ICON: - ImageCollection: stance-icons - ImageName: return-fire - Button@STANCE_HOLDFIRE: - Key: StanceHoldFire - DisableKeyRepeat: true - DisableKeySound: true - Children: - Image@ICON: - ImageCollection: stance-icons - ImageName: hold-fire Container@MUTE_INDICATOR: Logic: MuteIndicatorLogic X: WINDOW_RIGHT - WIDTH - 231 diff --git a/mods/openkrush/hotkeys/production.yaml b/mods/openkrush/hotkeys/production.yaml index d4a89d3..290d546 100644 --- a/mods/openkrush/hotkeys/production.yaml +++ b/mods/openkrush/hotkeys/production.yaml @@ -37,3 +37,78 @@ Superweapons: U Description: Superweapons Types: ProductionSlot Contexts: Player + +Production01: Q + Description: Slot 01 + Types: ProductionSlot + Contexts: Player + +Production02: W + Description: Slot 02 + Types: ProductionSlot + Contexts: Player + +Production03: E + Description: Slot 03 + Types: ProductionSlot + Contexts: Player + +Production04: R + Description: Slot 04 + Types: ProductionSlot + Contexts: Player + +Production05: T + Description: Slot 05 + Types: ProductionSlot + Contexts: Player + +Production06: A + Description: Slot 06 + Types: ProductionSlot + Contexts: Player + +Production07: S + Description: Slot 07 + Types: ProductionSlot + Contexts: Player + +Production08: D + Description: Slot 08 + Types: ProductionSlot + Contexts: Player + +Production09: F + Description: Slot 09 + Types: ProductionSlot + Contexts: Player + +Production10: G + Description: Slot 10 + Types: ProductionSlot + Contexts: Player + +Production11: Y + Description: Slot 11 + Types: ProductionSlot + Contexts: Player + +Production12: X + Description: Slot 12 + Types: ProductionSlot + Contexts: Player + +Production13: C + Description: Slot 13 + Types: ProductionSlot + Contexts: Player + +Production14: V + Description: Slot 14 + Types: ProductionSlot + Contexts: Player + +Production15: B + Description: Slot 15 + Types: ProductionSlot + Contexts: Player diff --git a/mods/openkrush/rules/core.yaml b/mods/openkrush/rules/core.yaml index 5335e5c..66e7a22 100644 --- a/mods/openkrush/rules/core.yaml +++ b/mods/openkrush/rules/core.yaml @@ -350,7 +350,9 @@ Grass: 100 Sand: 100 Path: 100 + PathingCost: 75 Street: 100 + PathingCost: 60 # Base for all vehicles ^CoreVehicle: @@ -394,7 +396,9 @@ Grass: 100 Sand: 100 Path: 100 + PathingCost: 75 Street: 100 + PathingCost: 60 # Base for all aircrafts ^CoreAircraft: @@ -441,7 +445,9 @@ Grass: 100 Sand: 100 Path: 100 + PathingCost: 75 Street: 100 + PathingCost: 60 # Special heavy vehicles ^CoreHeavyVehicle: @@ -469,8 +475,14 @@ Voice: Attack # Auto target enemies nearby. AutoTarget: - AutoTargetPriority: + # Attack units first... + AutoTargetPriority@Units: InvalidTargets: Structure + Priority: 10 + # ... then buildings. + AutoTargetPriority@Buildings: + ValidTargets: Structure + Priority: 1 # Grouped traits for standard non-turreted behavior. ^CoreArmedSelf: @@ -484,5 +496,11 @@ SequenceAim: aim # Auto target enemies nearby. AutoTarget: - AutoTargetPriority: + # Attack units first... + AutoTargetPriority@Units: InvalidTargets: Structure + Priority: 10 + # ... then buildings. + AutoTargetPriority@Buildings: + ValidTargets: Structure + Priority: 1 diff --git a/mods/openkrush/rules/misc.yaml b/mods/openkrush/rules/misc.yaml index 58858f8..5ffe722 100644 --- a/mods/openkrush/rules/misc.yaml +++ b/mods/openkrush/rules/misc.yaml @@ -96,6 +96,8 @@ World: BuildingInfluence: # Pathfinding requirement. PathFinder: + # Shows the planned path of units for a short time after a move order. + MovePathDisplay: # Support for prespawned actors. SpawnMapActors: # Default game settings: gamespeed, techlevel, shortgame. @@ -177,3 +179,9 @@ EditorWorld: BuildableTerrainOverlay: AllowedTerrainTypes: clear Palette: openkrush + +# Required for campaign/mission spawn points. +mpspawn: + AlwaysVisible: + Immobile: + OccupiesSpace: false diff --git a/mods/openkrush_gen1/core/rules/mechanics/produces_buildings.yaml b/mods/openkrush_gen1/core/rules/mechanics/produces_buildings.yaml index 13618c4..c28a01c 100644 --- a/mods/openkrush_gen1/core/rules/mechanics/produces_buildings.yaml +++ b/mods/openkrush_gen1/core/rules/mechanics/produces_buildings.yaml @@ -6,6 +6,9 @@ RequiresCondition: !selfconstructing && !deconstructing Researchable: RequiresCondition: !selfconstructing && !deconstructing + FocusInUi: + Category: building + RequiresCondition: !selfconstructing && !deconstructing BaseBuilding: TooltipDescription: Description: Produces buildings diff --git a/mods/openkrush_gen1/core/rules/mechanics/researches_buildings.yaml b/mods/openkrush_gen1/core/rules/mechanics/researches_buildings.yaml index 70bac2e..10ddae2 100644 --- a/mods/openkrush_gen1/core/rules/mechanics/researches_buildings.yaml +++ b/mods/openkrush_gen1/core/rules/mechanics/researches_buildings.yaml @@ -1,5 +1,8 @@ ^researches_buildings: Researches: RequiresCondition: !selfconstructing && !deconstructing + FocusInUi: + Category: research + RequiresCondition: !selfconstructing && !deconstructing TooltipDescription: Description: Researches buildings diff --git a/mods/openkrush_gen1/mod.yaml b/mods/openkrush_gen1/mod.yaml index 539ee33..f71076e 100644 --- a/mods/openkrush_gen1/mod.yaml +++ b/mods/openkrush_gen1/mod.yaml @@ -25,6 +25,8 @@ ModCredits: MapFolders: openkrush_gen1|maps/classic: System openkrush_gen1|maps/multiplayer: System + openkrush_gen1|maps/campaign/survivors: System + openkrush_gen1|maps/campaign/evolved: System ~^SupportDir|maps/openkrush_gen1/{DEV_VERSION}: User Rules: @@ -184,6 +186,7 @@ Fonts: Size: 32 Missions: + openkrush_gen1|missions.yaml MapGrid: TileSize: 32,32