Files
OpenRA/OpenRA.Mods.Common/Traits/Player/BotTakeoverCameraFollower.cs
let5sne.win10 9d80847a9a
Some checks failed
Continuous Integration / Linux (.NET 8.0) (push) Has been cancelled
Continuous Integration / Windows (.NET 8.0) (push) Has been cancelled
ra: 托管时镜头自动跟随防守热点
新增 BotTakeoverCameraFollower(Player trait):托管开启时通过 ViewportCenterProvider 自动跟随热点事件,默认偏防守(基地/矿区受击优先,建造落点次之),并使用热点衰减 + 最短驻留时间 + 切换阈值避免多处战斗时镜头抖动。

同时:

- mods/ra/rules/player.yaml 接入该 trait(仅 takeover 激活时生效)

- 修复 ingame-player.yaml 的 TooltipDesc/FTL 校验警告

- 小幅代码风格优化(不改行为)
2026-01-11 23:27:24 +08:00

502 lines
12 KiB
C#

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