This repository has been archived on 2026-10-31. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files

268 lines
13 KiB
C#
Raw Permalink Normal View History

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}");
}
}
}