269 lines
13 KiB
C#
269 lines
13 KiB
C#
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}");
|
||
}
|
||
}
|
||
}
|