Files
Ananta-ps/Ananta.Server/Ananta.Handlers/Game/GameRouter.EnemyWarning.cs
T

269 lines
13 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Threading;
using Ananta.SDK.Network;
using Ananta.SDK.Serialization;
using Ananta.Server.ClientData.Client4229938;
using Ananta.Server.Protocol.Client4229938;
using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game;
namespace Ananta.Server.Handlers.Game;
/// <summary>
/// 敌人警戒状态机 —— 敌人 HUD(血条 + 挂它上面的警戒条)<b>什么时候出现</b>。
///
/// 移植自捐赠端 7k-v0.5 的 <c>Ananta.Handlers/Game/GameRouter.EnemyWarning.cs</c>(2026-09-26)。
///
/// 为什么需要它:客户端的 <c>EnemyHUDCtrl</c> 把<b>潜行警戒条</b>绑在 <c>unitDataSet.detectionState</c> 上
/// (<c>:85</c> → <c>OnShowDetectionEffect</c>),而 detectionState 只由
/// <c>SyncEnemyDetectStatus</c>(68126766) 写。我们原来在 <c>EngageSpawnedEnemyAsync</c> 里
/// 一 engage 就发 <c>Fight(4)</c>,于是敌人<b>一生成警戒条就在那</b>;
/// 捐赠端是先注册 <c>Idle(1)</c>,靠距离推进到 <c>Alert(3)</c> / <c>Fight(4)</c>。
///
/// ⛑⛑⛑ <b>2026-09-26 02:1x 订正(重要):这套动不了血条。</b>
/// 血条的判据是 <c>EnemyHUDCtrl.lua:551 RefreshHpVisible</c>:
/// <c>canShowHp = cfg.ShowHPBar</c>(模板配置,服务端改不了),
/// <c>shouldShowHp = ... and not HasState(unit, UnitStateConfig.HideHp) and ...</c>。
/// <b>整条链上没有任何一处读 detectionState。</b>
/// ⇒ "血条默认不显示、打了才显示" 要走 <c>GameRouter.EnemyHpBar.cs</c>(HideHp 10266)。
/// 实测「改了状态机血条还是一直挂着」就是这个原因,不是状态机没生效。
///
/// 设计要点(照抄捐赠端,别自创):
/// · <b>状态不变不发包</b>:每 2 s 才允许重估一次,且状态相同就直接跳过。
/// · <b>注册后两拍必须重发</b>(Warmup=2):模型/occur 重建会把早期那份 HUD 快照丢掉,
/// 只发一次的话血条就永远绑不上。
/// · <b>单轮最多 8 个</b>:一次 tick 里不能给几十个敌人刷包。
/// · <b>stealthValue 跟 state 走</b>:Fight=100 / Alert=50 / 其余 0(捐赠端原值)。
/// · <b>死亡/清账必须 clear</b>:留在 Fight 上的尸体会一直挂着一根空血条。
///
/// ⛑⛑ 这套只改 <b>HUD 显示时机</b>,不碰战斗激活:engage 那五件套
/// (LockTarget / FightEdict / DisarmState / BattleAiS / SyncEnemyDetect)原样不动,
/// 敌人会不会打人跟这里无关。关掉开关就整体回到"一生成就有血条"。
/// </summary>
internal sealed partial class GameRouter
{
// ---- EnemyDetectState(UX.Game.EnemyDetectState)----
private const byte LocalEnemyDetectStateIdle = 1;
private const byte LocalEnemyDetectStateAlert = 3;
/// <summary>注册之后的重估间隔(ms)。状态在此期间内不变就不发包。</summary>
private const int LocalEnemyWarningRefreshMs = 2000;
/// <summary>注册后第一拍的重发延迟(ms)。</summary>
private const int LocalEnemyWarningWarmupMs = 1500;
/// <summary>注册后必须无条件重发的次数。见类注释第 2 条。</summary>
private const int LocalEnemyWarningWarmupCount = 2;
/// <summary>单轮最多刷几个敌人。</summary>
private const int LocalEnemyWarningMaxPerPass = 8;
/// <summary>
/// 距离平方。用账本里记的敌人坐标(<see cref="LocalEnemyRecord.Position"/>)对
/// 玩家最后上报的坐标;任一侧没有有效坐标时按"够远"处理(回落到 Idle)。
/// </summary>
private static float LocalEnemyWarningDistanceSquared(LocalEnemyRecord record, WorldEntryState state)
{
var dx = record.Position.X - state.LastReportedPlayerPosition.X;
var dy = record.Position.Y - state.LastReportedPlayerPosition.Y;
var dz = record.Position.Z - state.LastReportedPlayerPosition.Z;
return dx * dx + dy * dy + dz * dz;
}
/// <summary>
/// 当前该显示的 detectionState。0 = 不参与(死人/已被骇成友军/坐标没就绪)。
///
/// 2026-09-26 <b>Wave 4</b>:25 m 内按 <b>AttackRange</b> 分 Alert(3) / Fight(4),
/// 移植 7K <c>EnemyWantsCloseCombat</c>(GameRouter.EnemyAiActions.cs:23-25)的判据:
/// 有攻击技能且距离² ≤ AttackRange² ⇒ Fight,否则 Alert(stealthValue=50)。
/// AttackRange 随 Wave 2 的目录列进来(近战 2.8 / 远程 18)——「远程单位站 20 m 外
/// 开枪是 Alert 不是 Fight」现在能表达了。距离用 <see cref="LocalEnemyRecord.AiPosition"/>
/// 估算(追击中的敌人会贴近才升 Fight);未接敌时 = 出生点,与旧值一致。
/// </summary>
internal static byte SelectLocalEnemyWarningState(LocalEnemyRecord record, WorldEntryState state)
{
// 死人不再参与。捐赠端这里还判了 IsHackedAlly(被骇成友军),我方账本没有这个字段
// ——被骇的敌人走的是 EnemyHacking 那条路,不进这里的账本。
if (record.Dead) return LocalEnemyDetectStateNone;
// 玩家坐标都没上来 / 正在传送 / 正在切人 ⇒ 一律按"没发现"处理,别在这时候刷状态。
if (!state.Ready || state.ActiveSpiritUnitId == 0 || !state.HasLastReportedPlayerTransform
|| state.PendingTeleportId != 0 || state.PendingSwitchTemplateId != 0)
return LocalEnemyDetectStateNone;
var radius = Math.Max(1f, EnemyHit.WarningRadiusMeters);
var distSq = LocalEnemyWarningDistanceSquared(record, state);
if (distSq > radius * radius)
return LocalEnemyDetectStateIdle;
// Wave 4:7K EnemyWantsCloseCombat 同款 —— 攻击圈内 Fight(4),圈内→25m 之间 Alert(3)。
var profile = EnemyAiCatalog4229938.ResolveSpawnProfile(record.EnemyId);
var combatDistSq = record.AttackAiEnabled
? LocalEnemyWarningAiDistanceSquared(record, state)
: distSq;
return profile is { Attacks.Count: > 0 } && combatDistSq <= profile.AttackRange * profile.AttackRange
? LocalEnemyDetectStateFight
: LocalEnemyDetectStateAlert;
}
/// <summary>同 <see cref="LocalEnemyWarningDistanceSquared"/>,但用 AI 估算位置
/// (追击中的敌人 <see cref="LocalEnemyRecord.AiPosition"/> 会被 worker 逐步推向玩家)。</summary>
private static float LocalEnemyWarningAiDistanceSquared(LocalEnemyRecord record, WorldEntryState state)
{
var dx = record.AiPosition.X - state.LastReportedPlayerPosition.X;
var dy = record.AiPosition.Y - state.LastReportedPlayerPosition.Y;
var dz = record.AiPosition.Z - state.LastReportedPlayerPosition.Z;
return dx * dx + dy * dy + dz * dz;
}
private static Task SendLocalEnemyWarningStateAsync(TcpSession session, ulong id, byte warningState)
=> session.NotifyAsync(MethodId.SyncEnemyDetectStatus,
UxSerializer.Serialize(new GameMethods.SyncEnemyDetectStatusData4229938
{
Id = id,
// 捐赠端原值:Fight=100 / Alert=50 / 其余 0。
StealthValue = warningState == LocalEnemyDetectStateFight ? 100f
: warningState == LocalEnemyDetectStateAlert ? 50f : 0f,
State = warningState,
}), CancellationToken.None);
/// <summary>
/// 注册一个敌人进状态机(spawn 之后调一次)。发 <c>SyncEnemyDetect</c> + <c>Idle(1)</c>。
/// 幂等:已经注册过就直接返回。
/// </summary>
internal static async Task PrimeLocalEnemyWarningAsync(TcpSession session, ulong uid)
{
if (!EnemyHit.WarningStateMachine)
return;
var runtime = GetLocalEnemyRuntimeState(session);
LocalEnemyRecord record;
lock (runtime.SyncRoot)
{
if (!runtime.Enemies.TryGetValue(uid, out var existing) || existing is null) return;
// 只有 server-spawned 的走这套;client-spawned 是"第一次命中才进账本",
// 本来就等同于"击中才显示"。
if (!existing.ServerSpawned || existing.WarningPrimed || existing.Dead) return;
record = existing;
}
try
{
await session.NotifyAsync(MethodId.SyncEnemyDetect,
UxSerializer.Serialize(new GameMethods.EnemyDetectData4229938 { Id = uid, Stealth = 0 }),
CancellationToken.None);
await SendLocalEnemyWarningStateAsync(session, uid, LocalEnemyDetectStateIdle);
lock (runtime.SyncRoot)
{
record.WarningPrimed = true;
record.WarningState = LocalEnemyDetectStateIdle;
record.WarningWarmupRemaining = LocalEnemyWarningWarmupCount;
record.WarningRefreshAt = Environment.TickCount64 + LocalEnemyWarningWarmupMs;
}
session.Log.Info($"[ENEMY-WARNING] registered uid={uid} state=Idle(1) — HUD hidden until the player closes in");
}
catch (Exception ex)
{
session.Log.Warn($"[ENEMY-WARNING] prime failed uid={uid}: {ex.Message}");
}
}
/// <summary>
/// 周期重估。挂在 <c>RunEnemyAttackAiAsync</c> 的 tick 上(100 ms 一次,内部自己节流到 2 s)。
/// </summary>
internal static async Task RefreshLocalEnemyWarningsAsync(TcpSession session, WorldEntryState state, long now)
{
if (!EnemyHit.WarningStateMachine)
return;
var runtime = GetLocalEnemyRuntimeState(session);
ulong[] ids;
lock (runtime.SyncRoot)
ids = runtime.Enemies.Values
.Where(e => e.WarningPrimed && !e.Dead)
.OrderBy(e => e.WarningRefreshAt)
.Select(e => e.Uid)
.ToArray();
if (ids.Length == 0) return;
int sent = 0;
foreach (var id in ids)
{
byte next;
bool warmup;
LocalEnemyRecord record;
lock (runtime.SyncRoot)
{
if (!runtime.Enemies.TryGetValue(id, out var existing) || existing is null) continue;
if (!existing.WarningPrimed || existing.Dead) continue;
record = existing;
next = SelectLocalEnemyWarningState(existing, state);
warmup = next != LocalEnemyDetectStateNone
&& existing.WarningWarmupRemaining > 0
&& now >= existing.WarningRefreshAt;
// 状态没变、又不在 warmup 期 ⇒ 什么都不做(这是不刷包的关键)。
if (next == existing.WarningState && !warmup) continue;
}
try
{
// warmup 重发要连 SyncEnemyDetect 一起补,捐赠端就是这么做的。
if (warmup)
await session.NotifyAsync(MethodId.SyncEnemyDetect,
UxSerializer.Serialize(new GameMethods.EnemyDetectData4229938 { Id = id, Stealth = 0 }),
CancellationToken.None);
await SendLocalEnemyWarningStateAsync(session, id, next);
}
catch (Exception ex)
{
session.Log.Warn($"[ENEMY-WARNING] refresh failed uid={id}: {ex.Message}");
continue;
}
lock (runtime.SyncRoot)
{
record.WarningState = next;
if (warmup) record.WarningWarmupRemaining--;
if (next == LocalEnemyDetectStateNone)
{
record.WarningPrimed = false;
record.WarningWarmupRemaining = 0;
}
record.WarningRefreshAt = now + LocalEnemyWarningRefreshMs;
}
session.Log.Info($"[ENEMY-WARNING] uid={id} state={next} warmup={warmup}");
if (++sent >= LocalEnemyWarningMaxPerPass) break;
}
}
/// <summary>
/// 死亡 / 拆账本时清掉警戒状态:发 <c>None(0)</c>,血条收起。
/// 不清的话尸体上会一直挂一根空血条。
/// </summary>
internal static async Task ClearLocalEnemyWarningAsync(TcpSession session, ulong uid)
{
var runtime = GetLocalEnemyRuntimeState(session);
lock (runtime.SyncRoot)
{
if (runtime.Enemies.TryGetValue(uid, out var rec) && rec is not null)
{
rec.WarningPrimed = false;
rec.WarningState = LocalEnemyDetectStateNone;
rec.WarningWarmupRemaining = 0;
}
}
// 无条件发一次 None:从来没注册过的单位(client-spawned)本来就是 None,发一次幂等,
// 这样也完整保留了 2026-09-26 之前"死亡时发 None 收血条"的行为(那条路径不走状态机)。
try
{
await SendLocalEnemyWarningStateAsync(session, uid, LocalEnemyDetectStateNone);
session.Log.Info($"[ENEMY-WARNING] cleared uid={uid} state=None(0)");
}
catch (Exception ex)
{
session.Log.Warn($"[ENEMY-WARNING] clear failed uid={uid}: {ex.Message}");
}
}
}