#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; } } }