ra: 托管时镜头自动跟随防守热点
新增 BotTakeoverCameraFollower(Player trait):托管开启时通过 ViewportCenterProvider 自动跟随热点事件,默认偏防守(基地/矿区受击优先,建造落点次之),并使用热点衰减 + 最短驻留时间 + 切换阈值避免多处战斗时镜头抖动。 同时: - mods/ra/rules/player.yaml 接入该 trait(仅 takeover 激活时生效) - 修复 ingame-player.yaml 的 TooltipDesc/FTL 校验警告 - 小幅代码风格优化(不改行为)
This commit is contained in:
501
OpenRA.Mods.Common/Traits/Player/BotTakeoverCameraFollower.cs
Normal file
501
OpenRA.Mods.Common/Traits/Player/BotTakeoverCameraFollower.cs
Normal file
@@ -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<float2> previousCenterProvider;
|
||||
bool centerProviderInstalled;
|
||||
|
||||
readonly List<Hotspot> 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<BotTakeoverManager>();
|
||||
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<BaseBuilding>()
|
||||
.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<BaseBuildingInfo>())
|
||||
return info.BaseBuildingAttackedWeight;
|
||||
|
||||
if (damaged.Info.HasTraitInfo<HarvesterInfo>())
|
||||
return info.HarvesterAttackedWeight;
|
||||
|
||||
if (damaged.Info.HasTraitInfo<RefineryInfo>())
|
||||
return info.RefineryAttackedWeight;
|
||||
|
||||
if (damaged.Info.HasTraitInfo<BuildingInfo>())
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -47,7 +47,7 @@ namespace OpenRA.Mods.Common.Traits
|
||||
readonly BotTakeoverManagerInfo info;
|
||||
|
||||
IBot activeBot;
|
||||
readonly List<int> conditionTokens = new();
|
||||
readonly List<int> conditionTokens = [];
|
||||
|
||||
bool pendingAutoActivate;
|
||||
int autoActivateRemainingTicks;
|
||||
|
||||
@@ -26,7 +26,7 @@ namespace OpenRA.Mods.Common.Widgets
|
||||
|
||||
var button = widget.Get<ButtonWidget>("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<BotTakeoverManager>();
|
||||
if (takeover != null)
|
||||
takeover.Toggle();
|
||||
takeover?.Toggle();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user