diff --git a/OpenRA.Mods.Common/Traits/Player/BotTakeoverCameraFollower.cs b/OpenRA.Mods.Common/Traits/Player/BotTakeoverCameraFollower.cs new file mode 100644 index 0000000..281f53a --- /dev/null +++ b/OpenRA.Mods.Common/Traits/Player/BotTakeoverCameraFollower.cs @@ -0,0 +1,501 @@ +#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.Graphics; +using OpenRA.Traits; + +namespace OpenRA.Mods.Common.Traits +{ + [TraitLocation(SystemActors.Player)] + [Desc("When the local human player is handed over to a classic bot, automatically follow important defensive events with the camera.")] + public sealed class BotTakeoverCameraFollowerInfo : TraitInfo + { + [Desc("Enable camera following behavior.")] + public readonly bool Enabled = true; + + [Desc("Only follow while the BotTakeoverManager is active.")] + public readonly bool RequireTakeoverActive = true; + + [Desc("How often (in ticks) to re-evaluate the best focus point.")] + public readonly int ReevaluateIntervalTicks = 5; + + [Desc("Minimum time (in milliseconds) to stay on a focus point before switching, unless a much higher priority event appears.")] + public readonly int MinFocusDurationMs = 2500; + + [Desc("Before MinFocusDurationMs expires, the new focus must have at least this multiple of the current focus score to switch.")] + public readonly float LockedSwitchScoreMultiplier = 1.35f; + + [Desc("Forget hotspots after this time (in milliseconds).")] + public readonly int HotspotLifetimeMs = 15000; + + [Desc("Merge nearby hotspots within this distance (in WPos units).")] + public readonly int HotspotMergeDistance = 2048; + + [Desc("Maximum number of hotspots to track.")] + public readonly int MaxHotspots = 12; + + [Desc("Camera move speed (in WPos units per second) when smoothly tracking within a focus point.")] + public readonly float CameraSpeed = 60000f; + + [Desc("Snap instantly when the focus point changes.")] + public readonly bool SnapOnFocusChange = true; + + [Desc("Weight when a base building (construction yard / MCV) is attacked.")] + public readonly float BaseBuildingAttackedWeight = 120f; + + [Desc("Weight when a harvester is attacked.")] + public readonly float HarvesterAttackedWeight = 110f; + + [Desc("Weight when a refinery is attacked.")] + public readonly float RefineryAttackedWeight = 105f; + + [Desc("Weight when another structure is attacked.")] + public readonly float StructureAttackedWeight = 80f; + + [Desc("Weight when a unit is attacked.")] + public readonly float UnitAttackedWeight = 45f; + + [Desc("Weight when a building is placed (used as a low-priority 'expansion' focus when not under attack).")] + public readonly float BuildingPlacedWeight = 22f; + + public override object Create(ActorInitializer init) { return new BotTakeoverCameraFollower(init.Self, this); } + } + + public sealed class BotTakeoverCameraFollower : ITick, IWorldLoaded, INotifyDamage, INotifyBuildingPlaced + { + enum HotspotKind { Damage, Build } + + sealed class Hotspot + { + public float2 Position; + public float Strength; + public long LastUpdateTime; + public HotspotKind Kind; + } + + readonly Actor playerActor; + readonly World world; + readonly BotTakeoverCameraFollowerInfo info; + + WorldRenderer worldRenderer; + Func previousCenterProvider; + bool centerProviderInstalled; + + readonly List hotspots = []; + + bool lastFollowActive; + int reevaluateCountdown; + + Hotspot currentFocus; + long currentFocusStartTime; + + float2 targetPos; + bool hasTarget; + bool snapToTarget; + + float2 cameraPos; + bool hasCameraPos; + long lastCameraUpdateTime; + + public BotTakeoverCameraFollower(Actor self, BotTakeoverCameraFollowerInfo info) + { + playerActor = self; + world = self.World; + this.info = info; + } + + void IWorldLoaded.WorldLoaded(World w, WorldRenderer wr) + { + worldRenderer = wr; + } + + void ITick.Tick(Actor self) + { + if (!info.Enabled || worldRenderer == null) + return; + + var followActive = IsFollowActive(); + if (followActive) + EnsureCenterProviderInstalled(); + else + EnsureCenterProviderUninstalled(); + + if (followActive != lastFollowActive) + { + lastFollowActive = followActive; + OnFollowActiveChanged(followActive); + } + + if (!followActive) + return; + + if (--reevaluateCountdown > 0) + return; + + reevaluateCountdown = Math.Max(1, info.ReevaluateIntervalTicks); + + PruneHotspots(); + UpdateTarget(); + } + + void INotifyDamage.Damaged(Actor damaged, AttackInfo e) + { + if (!info.Enabled || worldRenderer == null) + return; + + if (!IsLocalPlayablePlayer()) + return; + + if (damaged == null || damaged.IsDead || !damaged.IsInWorld) + return; + + if (e.Attacker == null) + return; + + if (e.Attacker.Owner == damaged.Owner) + return; + + if (e.Attacker == damaged.World.WorldActor) + return; + + // Don't track healing / resurrect. + if (e.Damage.Value <= 0) + return; + + AddHotspot(damaged.CenterPosition, GetDamageWeight(damaged), HotspotKind.Damage); + } + + void INotifyBuildingPlaced.BuildingPlaced(Actor self, Actor building) + { + if (!info.Enabled || worldRenderer == null) + return; + + if (!IsLocalPlayablePlayer()) + return; + + if (building == null || building.IsDead || !building.IsInWorld) + return; + + AddHotspot(building.CenterPosition, info.BuildingPlacedWeight, HotspotKind.Build); + } + + bool IsLocalPlayablePlayer() + { + var localPlayer = world.LocalPlayer; + if (localPlayer == null || localPlayer.Spectating) + return false; + + return playerActor.Owner == localPlayer; + } + + bool IsFollowActive() + { + if (!IsLocalPlayablePlayer()) + return false; + + if (!info.RequireTakeoverActive) + return true; + + var takeover = playerActor.TraitOrDefault(); + return takeover != null && takeover.IsActive; + } + + void EnsureCenterProviderInstalled() + { + if (centerProviderInstalled) + return; + + if (!IsLocalPlayablePlayer()) + return; + + previousCenterProvider = worldRenderer.Viewport.ViewportCenterProvider; + worldRenderer.Viewport.ViewportCenterProvider = ViewportCenterProvider; + centerProviderInstalled = true; + } + + void EnsureCenterProviderUninstalled() + { + if (!centerProviderInstalled) + return; + + worldRenderer.Viewport.ViewportCenterProvider = previousCenterProvider; + previousCenterProvider = null; + centerProviderInstalled = false; + } + + float2 ViewportCenterProvider() + { + if (worldRenderer == null) + return float2.Zero; + + var now = Game.RunTime; + var viewportCenter = new float2(worldRenderer.Viewport.CenterPosition.X, worldRenderer.Viewport.CenterPosition.Y); + + if (!IsFollowActive()) + { + lastCameraUpdateTime = now; + cameraPos = viewportCenter; + hasCameraPos = true; + return viewportCenter; + } + + if (!hasCameraPos) + { + lastCameraUpdateTime = now; + cameraPos = viewportCenter; + hasCameraPos = true; + } + + var dtMs = Math.Max(0, now - lastCameraUpdateTime); + lastCameraUpdateTime = now; + + if (!hasTarget) + return viewportCenter; + + if (snapToTarget) + { + snapToTarget = false; + cameraPos = targetPos; + return cameraPos; + } + + // Prevent huge jumps when the framerate is very low or the game is paused. + var dt = Math.Min(dtMs / 1000f, 0.25f); + var maxStep = info.CameraSpeed * dt; + + var delta = targetPos - cameraPos; + var dist = delta.Length; + if (dist <= 0.001f || dist <= maxStep) + cameraPos = targetPos; + else + cameraPos += delta * (maxStep / dist); + + return cameraPos; + } + + void OnFollowActiveChanged(bool active) + { + reevaluateCountdown = 0; + currentFocus = null; + currentFocusStartTime = 0; + hasTarget = false; + snapToTarget = false; + hasCameraPos = false; + lastCameraUpdateTime = 0; + + if (!active) + { + hotspots.Clear(); + return; + } + + hotspots.Clear(); + targetPos = GetBaseFocusOrViewport(); + hasTarget = true; + snapToTarget = info.SnapOnFocusChange; + } + + float2 GetBaseFocusOrViewport() + { + var baseActor = world.ActorsHavingTrait() + .FirstOrDefault(a => !a.IsDead && a.IsInWorld && a.Owner == playerActor.Owner); + + if (baseActor != null) + return new float2(baseActor.CenterPosition.X, baseActor.CenterPosition.Y); + + return new float2(worldRenderer.Viewport.CenterPosition.X, worldRenderer.Viewport.CenterPosition.Y); + } + + float GetDamageWeight(Actor damaged) + { + if (damaged.Info.HasTraitInfo()) + return info.BaseBuildingAttackedWeight; + + if (damaged.Info.HasTraitInfo()) + return info.HarvesterAttackedWeight; + + if (damaged.Info.HasTraitInfo()) + return info.RefineryAttackedWeight; + + if (damaged.Info.HasTraitInfo()) + return info.StructureAttackedWeight; + + return info.UnitAttackedWeight; + } + + void AddHotspot(WPos pos, float weight, HotspotKind kind) + { + if (weight <= 0) + return; + + var now = Game.RunTime; + var p = new float2(pos.X, pos.Y); + + var mergeDist2 = info.HotspotMergeDistance * (long)info.HotspotMergeDistance; + + Hotspot best = null; + var bestDist2 = long.MaxValue; + + for (var i = 0; i < hotspots.Count; i++) + { + var h = hotspots[i]; + var d = p - h.Position; + var dist2 = (long)(d.X * d.X + d.Y * d.Y); + if (dist2 <= mergeDist2 && dist2 < bestDist2) + { + best = h; + bestDist2 = dist2; + } + } + + if (best != null) + { + var total = best.Strength + weight; + if (total <= 0.001f) + best.Position = p; + else + best.Position = (best.Position * best.Strength + p * weight) / total; + + best.Strength = total; + best.LastUpdateTime = now; + if (kind == HotspotKind.Damage) + best.Kind = HotspotKind.Damage; + return; + } + + if (hotspots.Count >= info.MaxHotspots) + RemoveLowestScoreHotspot(now); + + hotspots.Add(new Hotspot + { + Position = p, + Strength = weight, + LastUpdateTime = now, + Kind = kind + }); + } + + void RemoveLowestScoreHotspot(long now) + { + var worstIndex = -1; + var worstScore = float.MaxValue; + + for (var i = 0; i < hotspots.Count; i++) + { + var score = GetHotspotScore(hotspots[i], now); + if (score < worstScore) + { + worstScore = score; + worstIndex = i; + } + } + + if (worstIndex >= 0) + hotspots.RemoveAt(worstIndex); + } + + void PruneHotspots() + { + var now = Game.RunTime; + + for (var i = hotspots.Count - 1; i >= 0; i--) + { + var age = now - hotspots[i].LastUpdateTime; + if (age > info.HotspotLifetimeMs) + { + if (ReferenceEquals(hotspots[i], currentFocus)) + currentFocus = null; + hotspots.RemoveAt(i); + } + } + } + + float GetHotspotScore(Hotspot h, long now) + { + var age = now - h.LastUpdateTime; + if (age <= 0) + return h.Strength; + + if (age >= info.HotspotLifetimeMs) + return 0; + + var decay = 1f - (float)age / info.HotspotLifetimeMs; + return h.Strength * decay; + } + + void UpdateTarget() + { + var now = Game.RunTime; + + Hotspot best = null; + var bestScore = 0f; + + for (var i = 0; i < hotspots.Count; i++) + { + var h = hotspots[i]; + var score = GetHotspotScore(h, now); + if (score <= 0) + continue; + + if (score > bestScore) + { + best = h; + bestScore = score; + } + } + + var focusScore = currentFocus != null ? GetHotspotScore(currentFocus, now) : 0f; + if (focusScore <= 0) + currentFocus = null; + + var want = best; + if (want == null || bestScore <= 0) + { + currentFocus = null; + targetPos = GetBaseFocusOrViewport(); + hasTarget = true; + return; + } + + if (currentFocus == null) + { + SwitchFocus(want, now); + return; + } + + if (!ReferenceEquals(currentFocus, want)) + { + var locked = now < currentFocusStartTime + info.MinFocusDurationMs; + if (!locked) + SwitchFocus(want, now); + else if (bestScore >= focusScore * info.LockedSwitchScoreMultiplier) + SwitchFocus(want, now); + } + + targetPos = currentFocus.Position; + hasTarget = true; + } + + void SwitchFocus(Hotspot newFocus, long now) + { + currentFocus = newFocus; + currentFocusStartTime = now; + targetPos = newFocus.Position; + hasTarget = true; + + if (info.SnapOnFocusChange) + snapToTarget = true; + } + } +} diff --git a/OpenRA.Mods.Common/Traits/Player/BotTakeoverManager.cs b/OpenRA.Mods.Common/Traits/Player/BotTakeoverManager.cs index 7ac2566..aba83e7 100644 --- a/OpenRA.Mods.Common/Traits/Player/BotTakeoverManager.cs +++ b/OpenRA.Mods.Common/Traits/Player/BotTakeoverManager.cs @@ -47,7 +47,7 @@ namespace OpenRA.Mods.Common.Traits readonly BotTakeoverManagerInfo info; IBot activeBot; - readonly List conditionTokens = new(); + readonly List conditionTokens = []; bool pendingAutoActivate; int autoActivateRemainingTicks; diff --git a/OpenRA.Mods.Common/Widgets/Logic/Ingame/LLMTakeoverLogic.cs b/OpenRA.Mods.Common/Widgets/Logic/Ingame/LLMTakeoverLogic.cs index b40d35b..5fb2434 100644 --- a/OpenRA.Mods.Common/Widgets/Logic/Ingame/LLMTakeoverLogic.cs +++ b/OpenRA.Mods.Common/Widgets/Logic/Ingame/LLMTakeoverLogic.cs @@ -26,7 +26,7 @@ namespace OpenRA.Mods.Common.Widgets var button = widget.Get("LLM_TAKEOVER"); button.GetText = () => IsTakeoverActive() ? "Cancel AI" : "AI"; - button.IsHighlighted = () => IsTakeoverActive(); + button.IsHighlighted = IsTakeoverActive; button.OnClick = OnTakeoverClick; button.IsDisabled = () => world.LocalPlayer == null || world.LocalPlayer.WinState != WinState.Undefined; @@ -50,8 +50,7 @@ namespace OpenRA.Mods.Common.Widgets // Prefer the classic AI takeover manager (uses ModularBot + original bot modules). var takeover = player.PlayerActor.TraitOrDefault(); - if (takeover != null) - takeover.Toggle(); + takeover?.Toggle(); } } } diff --git a/mods/ra/chrome/ingame-player.yaml b/mods/ra/chrome/ingame-player.yaml index 5667d52..614e9f2 100644 --- a/mods/ra/chrome/ingame-player.yaml +++ b/mods/ra/chrome/ingame-player.yaml @@ -296,8 +296,8 @@ Container@PLAYER_WIDGETS: Width: 80 Height: 26 Font: Bold - Text: LLM AI TooltipText: button-llm-takeover.tooltip + TooltipDesc: button-llm-takeover.tooltipdesc TooltipContainer: TOOLTIP_CONTAINER Container@MUTE_INDICATOR: Logic: MuteIndicatorLogic diff --git a/mods/ra/rules/player.yaml b/mods/ra/rules/player.yaml index cf30068..4ac09c3 100644 --- a/mods/ra/rules/player.yaml +++ b/mods/ra/rules/player.yaml @@ -190,3 +190,6 @@ Player: AutoActivateDelayTicks: 1 BotType: normal GrantConditions: enable-normal-ai + BotTakeoverCameraFollower: + Enabled: true + RequireTakeoverActive: true