新增 BotTakeoverCameraFollower(Player trait):托管开启时通过 ViewportCenterProvider 自动跟随热点事件,默认偏防守(基地/矿区受击优先,建造落点次之),并使用热点衰减 + 最短驻留时间 + 切换阈值避免多处战斗时镜头抖动。 同时: - mods/ra/rules/player.yaml 接入该 trait(仅 takeover 激活时生效) - 修复 ingame-player.yaml 的 TooltipDesc/FTL 校验警告 - 小幅代码风格优化(不改行为)
502 lines
12 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|