1137 lines
52 KiB
C#
1137 lines
52 KiB
C#
using System.Globalization;
|
||
using Ananta.SDK.Network;
|
||
using Ananta.SDK.Serialization;
|
||
using Ananta.Server.ClientData.Client4229938;
|
||
using Ananta.Server.Configuration;
|
||
using Ananta.Server.Protocol.Client4229938;
|
||
using Ananta.Server.RpcTypes.Client4229938;
|
||
using GameMethods = Ananta.Server.RpcTypes.Client4229938.Methods.Game;
|
||
using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene;
|
||
|
||
namespace Ananta.Server.Handlers.Game;
|
||
|
||
/// <summary>
|
||
/// 自动氛围行人(ambient pedestrians)—— 玩家一进世界,街上就有会走的人。
|
||
///
|
||
/// ★★★★★ 2026-09-20 终版配方(移植 donor1「PrivateServer修改」实机验证的路人网络):
|
||
/// 1. `SyncAetherAICrowdAdd`(crowd 通道)建人:ActionId=**400**(行为动作组,非 POI 动作)、
|
||
/// TargetLocationReason=1(Wander)、speed=1.6。
|
||
/// 2. `SyncManagedLogicAgent`(68771737)(LogicHandoff)—— **必发**。没有它 ⇒ T-pose;
|
||
/// 而且它还是**客户端来要路径(AskClientNpcFindPath)的前提**(donor1 第 81 轮三重证据:
|
||
/// 成功会话 12 条 vs 失败 2 条 / FindPath 310 次 vs 0 次)。
|
||
/// 3. `SyncLogicAgentEnter`(68719890) —— 把 id 注册进 logic-agent 管理器(任务从它派发)。
|
||
/// 4. `SyncAgentForceGo`(68698324) —— 启动行为树。
|
||
/// 5. **移动任务** `SyncLogicAgentCommand`(68050574) + `TaskWayPointMoveCommandData`
|
||
/// (wire tag 39):自带速度/步态(SpecificMethod=Walk)/动画组,客户端的
|
||
/// LogicBehaviorSchedulerModule 负责寻路+避障+步态。**这是唯一能产生走路步态的通道**。
|
||
/// 6. 沿真实人行道路网(PedLaneGraph,88361 条车道)每 30m 一段、预计走完+5s 后发下一段。
|
||
///
|
||
/// ⛑️ 已证伪的两条路(别回头):
|
||
/// · **位置流** `SyncLogicAgentSyncData`(68838628):不带任务 ⇒ 客户端产生不了步态。
|
||
/// 我们 10Hz 推 6280 条、字节布局逐字节对齐客户端上报,人纹丝不动;
|
||
/// donor1 第 16~23 轮的「服务端硬推位置」同一条死路。
|
||
/// · **`SyncMoveWandering`**(68745518):走 `WanderingActionModule`(BaseUnit 管线),
|
||
/// 而 crowd NPC 是 AgentEntity(ECS 管线)⇒ 静默失败。
|
||
///
|
||
/// 多态命令线格式(donor1 第 40 轮反汇编钉死,见 LogicAgentCommandMethods.cs):
|
||
/// tag 字节 = 类型名字母序(39=TaskWayPointMove);基类尾部 MoveId(1B)+DataSource(2B,
|
||
/// Other=255);Lua 表偏 1 不可信。
|
||
/// </summary>
|
||
internal sealed partial class GameRouter
|
||
{
|
||
private const string AmbientKey = "client4229938-ambient-peds";
|
||
|
||
/// <summary>批量退役的分块大小(T1-24)。一次连发上千个 Remove 会打爆客户端 socket。</summary>
|
||
private const int RemoveBatchSize = 48;
|
||
private const int RemoveBatchDelayMs = 12;
|
||
|
||
/// <summary>移动任务的相关 id(客户端会把它回执回来;donor1 同款语义)。</summary>
|
||
private static int s_ambientCommandSeq;
|
||
|
||
/// <summary>模板池过滤结果只打一次日志(见 <see cref="PedFormworkPool"/>)。</summary>
|
||
private static int s_formworkPoolLogged;
|
||
|
||
internal sealed class AmbientPed
|
||
{
|
||
internal ulong Id;
|
||
internal float X;
|
||
internal float Y;
|
||
internal float Z;
|
||
internal long WanderArmedTicks;
|
||
|
||
/// <summary>服务端驱动行走的路网状态(<see cref="PedLaneGraph"/>)。Walking=false 就是原地不动。</summary>
|
||
internal bool Walking;
|
||
internal PedLaneGraph.WalkState Walk;
|
||
internal float Speed;
|
||
|
||
/// <summary>上一个移动任务的下发时间(TickCount64)—— 任务步进的续接时钟。</summary>
|
||
internal long TargetSetTicks;
|
||
|
||
/// <summary>上一次实际下发的路程(米)。<=0 表示还没量过,按 stepMetres 兜底。
|
||
/// 用它算续接时钟才准:末段常常短于 stepMetres,按满步长等就会"多站一会儿"。</summary>
|
||
internal float LastStepMetres;
|
||
|
||
/// <summary>续接时钟的随机相位(毫秒)。同一批生成的人如果节拍完全一致,
|
||
/// 就会"一起起步、一起掉头" —— 各人偏移一点就错开了。</summary>
|
||
internal int StepJitterMs;
|
||
|
||
/// <summary>目标导向行程的剩余折线(服务端规划,客户端沿它走)。null = 退回随机游走。</summary>
|
||
internal (float X, float Y, float Z)[]? Route;
|
||
|
||
/// <summary>下一个要走的折线下标。</summary>
|
||
internal int RouteIdx;
|
||
|
||
/// <summary>
|
||
/// 连续派单失败次数(成功即清零)。达到 `AmbientPedsStepFailLimit` 就回收重生。
|
||
///
|
||
/// ⛑️ 这是**服务端自己就能发现**的一路卡死:路网状态坏了(车道没了 / 规划不出来 /
|
||
/// 采样推进失败)时,这个行人会永久停在"派不出任务"的状态 —— 客户端看不到任何异常,
|
||
/// 表现出来就是一个人杵着不动。donor round 84 的判据是"连续 5 次失败 ⇒ 判定客户端已不再走"。
|
||
/// </summary>
|
||
internal int StepFailCount;
|
||
|
||
/// <summary>
|
||
/// 位置回馈校正次数,计在 `AmbientPedsStallWindowMs` 这个滑动窗口里。
|
||
/// 达到 `AmbientPedsStallResyncLimit` 说明这个单位**反复**跟服务端账本脱节(贴墙 / 卡地形),
|
||
/// 校正已经救不回来 ⇒ 回收重生到别处,而不是让它一直杵在那儿。
|
||
/// </summary>
|
||
internal int ResyncCount;
|
||
|
||
/// <summary><see cref="ResyncCount"/> 的计数窗口起点(TickCount64)。</summary>
|
||
internal long ResyncWindowTicks;
|
||
|
||
/// <summary>
|
||
/// 客户端回执「命令已装好」的次数(<c>AskReportLogicAgentCommandSuccess2</c> 67057559)。
|
||
///
|
||
/// 2026-09-24 自捐赠端 DremkaPS 移植。他们那句注释是移植的理由:
|
||
/// <c>SyncLogicAgentCommand</c> 对服务端是 fire-and-forget —— 客户端装了这条命令、还是拿到
|
||
/// 就丢了,在出站日志里长得<b>一模一样</b>。有了这对计数,「行人还是不走」才能分成
|
||
/// 「收了 12 条一条没跑」和「每一条都被拒了」两种完全不同的故障。
|
||
/// </summary>
|
||
internal int CommandAcks;
|
||
|
||
/// <summary>客户端回执「命令失败」的次数(<c>AskReportLogicAgentCommandFail</c> 67193144)。</summary>
|
||
internal int CommandFails;
|
||
|
||
/// <summary>最近一条回执带回的 commandId(与 <c>s_ambientCommandSeq</c> 同源)。</summary>
|
||
internal int LastAckCommandId;
|
||
}
|
||
|
||
internal sealed class AmbientSessionState
|
||
{
|
||
internal object SyncRoot { get; } = new();
|
||
internal CancellationTokenSource? Cts;
|
||
internal CancellationTokenSource? MoveCts;
|
||
|
||
/// <summary>最近一次已知的玩家位置 —— 目标导向行程以它为中心取终点,
|
||
/// 保证行人不会一路走出 <c>ambientPedsRecycleMeters</c> 被回收。</summary>
|
||
internal float HomeX;
|
||
internal float HomeZ;
|
||
internal Task? Loop;
|
||
internal Task? MoveLoop;
|
||
internal List<AmbientPed> Peds { get; } = [];
|
||
}
|
||
|
||
private static AmbientSessionState AmbientState(TcpSession session)
|
||
{
|
||
if (session.Items.TryGetValue(AmbientKey, out var existing) && existing is AmbientSessionState typed)
|
||
return typed;
|
||
|
||
var created = new AmbientSessionState();
|
||
session.Items[AmbientKey] = created;
|
||
return created;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 用客户端上报的真实位置校正行人的服务端账本。
|
||
///
|
||
/// ⛑️ 这是**唯一**的客户端位置回馈通道:散人是客户端自驱的(硬规则 19),服务端推完
|
||
/// `TaskWayPointMove` 就不知道人到底走到哪了,只在自己的账本里把 <c>ped.X/Z</c> 推到
|
||
/// 任务终点。一旦客户端实际被卡住(贴墙、被玩家推开、插值中断),两边就永久脱节 ⇒
|
||
/// 之后每一段任务都从幻想位置出发,行人看起来"站在原地不动"。
|
||
/// 2026-09-23 用户实测:"有 1 个卡在原地不动,打他几下让他位移之后又正常走了" ——
|
||
/// 正是因为击退让客户端位置重新贴合了服务端账本。
|
||
///
|
||
/// 触发源:<c>AskClientNpcFindPath</c>(67974714) 请求里的 <c>From</c>,就是客户端手里那个
|
||
/// 单位的真实坐标(同一 session 34/40 个行人 id 都能对上,确认是我们的单位)。
|
||
///
|
||
/// ⛑️ 双阈值是必须的:小于 min 是正常误差(车道采样点有间距),大于 max 说明这不是
|
||
/// 我们的单位或者数据异常 —— 两种情况都不能写回,否则行人会被拖着乱跳。
|
||
/// </summary>
|
||
internal static bool TryResyncAmbientPed(TcpSession session, ulong pedId, float realX, float realZ,
|
||
out float drift, out bool hardReset)
|
||
{
|
||
drift = 0f;
|
||
hardReset = false;
|
||
var cfg = PrivateServerConfigStore.Current.Gameplay.Aether;
|
||
if (!session.Items.TryGetValue(AmbientKey, out var raw) || raw is not AmbientSessionState owner)
|
||
return false;
|
||
|
||
lock (owner.SyncRoot)
|
||
{
|
||
AmbientPed? ped = null;
|
||
foreach (var p in owner.Peds)
|
||
{
|
||
if (p.Id != pedId)
|
||
continue;
|
||
ped = p;
|
||
break;
|
||
}
|
||
if (ped is null)
|
||
return false;
|
||
|
||
var dx = realX - ped.X;
|
||
var dz = realZ - ped.Z;
|
||
drift = MathF.Sqrt(dx * dx + dz * dz);
|
||
if (!float.IsFinite(drift) || drift < MathF.Max(0.5f, cfg.AmbientPedsResyncMinMetres))
|
||
return false;
|
||
if (drift > MathF.Max(1f, cfg.AmbientPedsResyncMaxMetres))
|
||
return false;
|
||
|
||
ped.X = realX;
|
||
ped.Z = realZ;
|
||
|
||
// ⛑️⛑️ 2026-09-23 改:**问路不等于卡住**。
|
||
// 实测每人每 ~15 s 问一次路,drift 中位 38 m —— 因为账本在下发任务时就被推到了路线末端,
|
||
// 客户端多半只是"还没走完"。以前只要 drift 落在 [3,60] 就 Route=null + 立刻重派
|
||
// ⇒ 行人每 15 秒被掐断一次行程,表现就是走走停停 / 杵着不动。
|
||
// 现在只有"真实位置已经不在当前路线上"才算真脱节,才清路线重规划。
|
||
if (ped.Route is { Length: > 0 } route &&
|
||
RouteDistance(route, realX, realZ, out var nearestIdx) <=
|
||
MathF.Max(3f, cfg.AmbientPedsResyncRouteToleranceMetres))
|
||
{
|
||
// 软校正:账本归位 + 跳过已经走过的折线点,剩下的行程继续走完。
|
||
var next = Math.Min(nearestIdx + 1, route.Length - 1);
|
||
if (next > ped.RouteIdx)
|
||
ped.RouteIdx = next;
|
||
var walk0 = ped.Walk;
|
||
PedLaneGraph.SnapTo(realX, realZ, ref walk0);
|
||
ped.Walk = walk0;
|
||
return true;
|
||
}
|
||
|
||
// 硬校正:确实偏离了路线(被击退 / 自驱跑偏 / 卡地形)⇒ 从真实位置重新规划。
|
||
hardReset = true;
|
||
ped.Route = null;
|
||
ped.RouteIdx = 0;
|
||
var walk = ped.Walk;
|
||
PedLaneGraph.SnapTo(realX, realZ, ref walk);
|
||
ped.Walk = walk;
|
||
ped.TargetSetTicks = 0; // 立刻派下一段,不再干等余量
|
||
|
||
// 滑动窗口计数:窗口过期就重新开一局。反复脱节(而不是偶发一次)才算"顽固卡住"。
|
||
var nowTick = Environment.TickCount64;
|
||
if (nowTick - ped.ResyncWindowTicks > Math.Max(1000, cfg.AmbientPedsStallWindowMs))
|
||
{
|
||
ped.ResyncWindowTicks = nowTick;
|
||
ped.ResyncCount = 0;
|
||
}
|
||
ped.ResyncCount++;
|
||
return true;
|
||
}
|
||
}
|
||
|
||
/// <summary>点到折线的最短距离,并给出最近点的下标。</summary>
|
||
private static float RouteDistance((float X, float Y, float Z)[] route, float x, float z, out int nearestIdx)
|
||
{
|
||
nearestIdx = 0;
|
||
var best = float.MaxValue;
|
||
for (var i = 0; i < route.Length; i++)
|
||
{
|
||
var d = MathF.Sqrt((route[i].X - x) * (route[i].X - x) + (route[i].Z - z) * (route[i].Z - z));
|
||
if (d < best)
|
||
{
|
||
best = d;
|
||
nearestIdx = i;
|
||
}
|
||
}
|
||
// 折线可能稀疏(采样间距 6 m),点到点的距离不够,还要算点到线段的距离。
|
||
for (var i = 0; i + 1 < route.Length; i++)
|
||
{
|
||
var d = SegmentDistance(route[i].X, route[i].Z, route[i + 1].X, route[i + 1].Z, x, z);
|
||
if (d < best)
|
||
{
|
||
best = d;
|
||
nearestIdx = i;
|
||
}
|
||
}
|
||
return best;
|
||
}
|
||
|
||
private static float SegmentDistance(float ax, float az, float bx, float bz, float px, float pz)
|
||
{
|
||
var vx = bx - ax;
|
||
var vz = bz - az;
|
||
var wx = px - ax;
|
||
var wz = pz - az;
|
||
var len2 = vx * vx + vz * vz;
|
||
var t = len2 <= 0f ? 0f : Math.Clamp((wx * vx + wz * vz) / len2, 0f, 1f);
|
||
var cx = ax + vx * t;
|
||
var cz = az + vz * t;
|
||
return MathF.Sqrt((px - cx) * (px - cx) + (pz - cz) * (pz - cz));
|
||
}
|
||
|
||
/// <summary>世界就绪后调用一次;幂等。配置关掉就直接返回。</summary>
|
||
internal static void EnsureAmbientPeds(TcpSession session)
|
||
{
|
||
var cfg = PrivateServerConfigStore.Current.Gameplay.Aether;
|
||
// ⛑️ crowdEnabled 是人群总开关:它关着时一个服务端行人都不发,
|
||
// 否则用户关了「启用人群」街上照样有 40 个行人(2026-09-24 实测)。
|
||
if (!cfg.AmbientPedsEnabled || !cfg.CrowdEnabled)
|
||
return;
|
||
|
||
var owner = AmbientState(session);
|
||
lock (owner.SyncRoot)
|
||
{
|
||
if (owner.Loop is { IsCompleted: false })
|
||
return;
|
||
owner.Cts?.Dispose();
|
||
owner.Cts = new CancellationTokenSource();
|
||
var token = owner.Cts.Token;
|
||
owner.Loop = Task.Run(() => RunAmbientLoopAsync(session, owner, token));
|
||
|
||
// 服务端驱动行走:独立的较高频循环(默认 100ms)只负责推进 + 下发位置。
|
||
if (cfg.AmbientPedsDriven && PedLaneGraph.Available)
|
||
{
|
||
owner.MoveCts?.Dispose();
|
||
owner.MoveCts = new CancellationTokenSource();
|
||
owner.MoveLoop = Task.Run(() => RunAmbientMoveLoopAsync(session, owner, owner.MoveCts.Token));
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 把场上所有服务端行人**真回收**:逐个发 SyncAetherAINpcRemove 再停循环。
|
||
///
|
||
/// ⛑️ 为什么不能直接 StopAmbientPeds:那个只停循环、本地链表一清了事,
|
||
/// 客户端上的人还站着 —— 面板关「启用人群」后用户看到的"还是有人"多半就是这批。
|
||
/// 分块(RemoveBatchSize + RemoveBatchDelayMs)沿用回收路径的老规矩,一次性连发会打爆 socket。
|
||
/// </summary>
|
||
internal static async Task RetireAmbientPedsAsync(TcpSession session)
|
||
{
|
||
if (!session.Items.TryGetValue(AmbientKey, out var existing) || existing is not AmbientSessionState owner)
|
||
return;
|
||
|
||
List<AmbientPed> all;
|
||
lock (owner.SyncRoot)
|
||
{
|
||
all = [.. owner.Peds];
|
||
owner.Peds.Clear();
|
||
owner.Cts?.Cancel();
|
||
owner.MoveCts?.Cancel();
|
||
}
|
||
|
||
session.Log.Info($"[AMBIENT] retire all peds count={all.Count}");
|
||
if (all.Count == 0)
|
||
return;
|
||
|
||
for (var i = 0; i < all.Count; i += RemoveBatchSize)
|
||
{
|
||
if (i > 0)
|
||
await Task.Delay(RemoveBatchDelayMs, CancellationToken.None);
|
||
|
||
var end = Math.Min(all.Count, i + RemoveBatchSize);
|
||
for (var j = i; j < end; j++)
|
||
{
|
||
try
|
||
{
|
||
await session.NotifyAsync(MethodId.SyncAetherAINpcRemove,
|
||
UxSerializer.Serialize(new SceneMethods.SyncAetherAINpcRemove4229938 { pid = all[j].Id }),
|
||
CancellationToken.None);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
session.Log.Warn($"[AMBIENT] retire remove failed pid={all[j].Id}: {ex.Message}");
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>断开/切场景时停掉循环(不主动移除行人 —— 场景卸载客户端自己清)。</summary>
|
||
internal static void StopAmbientPeds(TcpSession session)
|
||
{
|
||
if (!session.Items.TryGetValue(AmbientKey, out var existing) || existing is not AmbientSessionState owner)
|
||
return;
|
||
lock (owner.SyncRoot)
|
||
{
|
||
owner.Cts?.Cancel();
|
||
owner.Cts?.Dispose();
|
||
owner.Cts = null;
|
||
owner.MoveCts?.Cancel();
|
||
owner.MoveCts?.Dispose();
|
||
owner.MoveCts = null;
|
||
owner.Peds.Clear();
|
||
}
|
||
}
|
||
|
||
private static async Task RunAmbientLoopAsync(TcpSession session, AmbientSessionState owner, CancellationToken token)
|
||
{
|
||
var a = PrivateServerConfigStore.Current.Gameplay.Aether;
|
||
var laneStats = PedLaneGraph.Stats();
|
||
var channel = IsCrowdChannel(a) ? "crowd" : "static";
|
||
session.Log.Info($"[AMBIENT] pedestrian loop started channel={channel} " +
|
||
$"actionId={a.AmbientPedsCrowdActionId} " +
|
||
$"targetReason={a.AmbientPedsTargetLocationReason} " +
|
||
$"poiAction={a.AmbientPedsPoiActionId} speed={a.AmbientPedsDesiredSpeed} " +
|
||
$"driven={a.AmbientPedsDriven && PedLaneGraph.Available} " +
|
||
$"task=TaskWayPointMove(tag39) lanes={laneStats.Lanes}/banned={laneStats.Banned}/width={laneStats.WithWidth} " +
|
||
$"step={a.AmbientPedsStepMetres:F0}m tickMs={a.AmbientPedsSyncMs} " +
|
||
$"logicEnter={a.AmbientPedsLogicEnter} handoff={a.AmbientPedsCrowdManagedLogicAgent}");
|
||
var tick = Math.Max(200, PrivateServerConfigStore.Current.Gameplay.Aether.AmbientPedsTickMs);
|
||
|
||
while (!token.IsCancellationRequested)
|
||
{
|
||
// 运行中被面板关掉人群 ⇒ 当场回收已下发的行人并退出(不用等重进场景)。
|
||
if (!PrivateServerConfigStore.Current.Gameplay.Aether.CrowdEnabled)
|
||
{
|
||
session.Log.Info("[AMBIENT] loop stopping: crowd disabled from panel");
|
||
await RetireAmbientPedsAsync(session);
|
||
break;
|
||
}
|
||
|
||
try
|
||
{
|
||
// 客户端挂起期间只睡不推 —— 见 GameRouter.IsBackgroundPushPaused 的说明。
|
||
if (!IsBackgroundPushPaused(session))
|
||
await TickAmbientAsync(session, owner, token);
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
break;
|
||
}
|
||
catch (ObjectDisposedException)
|
||
{
|
||
session.Log.Info("[AMBIENT] loop stopped: session disposed");
|
||
break;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
session.Log.Info($"[AMBIENT] tick failed: {ex.Message}");
|
||
}
|
||
|
||
try
|
||
{
|
||
await Task.Delay(tick, token);
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 服务端驱动行走循环(donor1 实机配方,2026-09-20 移植):**移动任务步进**,不是位置流。
|
||
///
|
||
/// 每个 Walking 的行人沿 PedLaneGraph 抽一段 30m 折线,发一条 `TaskWayPointMove` 命令
|
||
/// (`SyncLogicAgentCommand` 68050574 + wire tag 39,自带速度/步态/动画组);
|
||
/// 预计走完的时间(步长 ÷ 速度 + 5s 余量)过后再发下一段 —— 与 donor1 的
|
||
/// `CrowdLaneNeedsTarget` 同一条时钟。
|
||
///
|
||
/// ⛑️ 为什么不用位置流:`SyncLogicAgentSyncData`(68838628)不携带任何任务,
|
||
/// 客户端产生不了步态。donor1 第 39 轮实测 Servo 流 = 走两秒动画然后站住;
|
||
/// 我们 09-20 实测 10Hz 推 6280 条、字节布局与客户端上报逐字节对齐,人依然纹丝不动。
|
||
/// donor1 AI_HANDOFF 明确把「服务端硬推位置」标记为**已证伪路线**(路人第 16~23 轮的老路)。
|
||
/// </summary>
|
||
private static async Task RunAmbientMoveLoopAsync(
|
||
TcpSession session, AmbientSessionState owner, CancellationToken token)
|
||
{
|
||
var cfg = PrivateServerConfigStore.Current.Gameplay.Aether;
|
||
var intervalMs = Math.Clamp(cfg.AmbientPedsSyncMs, 200, 2000);
|
||
var stepMetres = MathF.Max(6f, cfg.AmbientPedsStepMetres);
|
||
var stepsSent = 0;
|
||
|
||
while (!token.IsCancellationRequested)
|
||
{
|
||
try
|
||
{
|
||
await Task.Delay(intervalMs, token);
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
break;
|
||
}
|
||
catch (ObjectDisposedException)
|
||
{
|
||
break;
|
||
}
|
||
|
||
// 客户端挂起期间不推进行走任务 —— 见 GameRouter.IsBackgroundPushPaused 的说明。
|
||
if (IsBackgroundPushPaused(session))
|
||
continue;
|
||
|
||
var now = Environment.TickCount64;
|
||
List<AmbientPed> walkers;
|
||
lock (owner.SyncRoot)
|
||
walkers = owner.Peds.Where(p => p.Walking).ToList();
|
||
if (walkers.Count == 0)
|
||
continue;
|
||
|
||
var sent = 0;
|
||
foreach (var ped in walkers)
|
||
{
|
||
// donor1 的续接时钟:expected + 5s 余量(Walk 期间客户端有到达判定,提前发会重启步态)。
|
||
// ⛑️ 这个余量就是"行人站着发呆"的那段时间:走完一段后要空等它过去才发下一段。
|
||
// 想让发呆变短就调 `ambientPedsStepSlackMs`,但调太小会在客户端还没走完时提前派活
|
||
// ⇒ 行人被拽回上一段终点(瞬移)。零瞬移的做法是加长 stepMetres、降低发呆频率。
|
||
// ⛑️ 按**实际下发**的路程算,不是按满步长:末段常常短于 stepMetres,
|
||
// 按 60m 去等就会多站一会儿(用户看到的"走走停停"有一部分来自这里)。
|
||
var metres = ped.LastStepMetres > 0f ? ped.LastStepMetres : stepMetres;
|
||
var expected = (long)(metres / MathF.Max(0.3f, ped.Speed) * 1000f)
|
||
+ Math.Max(0, cfg.AmbientPedsStepSlackMs)
|
||
+ ped.StepJitterMs;
|
||
if (ped.TargetSetTicks != 0 && now - ped.TargetSetTicks < expected)
|
||
continue;
|
||
|
||
if (await PushAmbientStepCommandAsync(session, owner, ped, stepMetres, token))
|
||
{
|
||
sent++;
|
||
ped.StepFailCount = 0;
|
||
}
|
||
else
|
||
{
|
||
// 派不出来(路网状态坏 / 规划不出来)不报错,只累加 —— 由 TickAmbientAsync
|
||
// 的看门狗在达到 `AmbientPedsStepFailLimit` 时回收重生。
|
||
// ⛑️ 不在这里直接回收:这个 Task 只负责推任务,remove/补点归 TickAmbientAsync。
|
||
ped.StepFailCount++;
|
||
}
|
||
}
|
||
|
||
if (sent > 0)
|
||
{
|
||
stepsSent += sent;
|
||
session.Log.Info($"[AMBIENT] step commands sent={sent} total={stepsSent} live={walkers.Count}");
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 给一个行人发下一段 `TaskWayPointMove` 移动任务:从当前位置沿路网推进
|
||
/// <paramref name="stepMetres"/> 米,沿途采样折线(起点 + 每 ~6m 一点 + 终点)。
|
||
/// 只有推进成功才写回路网状态 —— 失败时 <see cref="AmbientPed.Walk"/> 原样保留,下一拍重试。
|
||
/// </summary>
|
||
private static async Task<bool> PushAmbientStepCommandAsync(
|
||
TcpSession session, AmbientSessionState owner, AmbientPed ped, float stepMetres, CancellationToken token)
|
||
{
|
||
var cfg = PrivateServerConfigStore.Current.Gameplay.Aether;
|
||
// ⛑️ 采样间距固定(`ambientPedsSampleMetres`,默认 6m),不再随路程放大 ——
|
||
// 车道中位数才 3.58m,采样太稀会让行人切弯穿绿化。
|
||
var chunk = MathF.Max(2f, cfg.AmbientPedsSampleMetres);
|
||
var points = new List<SceneMethods.UxVector3>(24) { new(ped.X, ped.Y, ped.Z) };
|
||
|
||
// ---- ① 目标导向行程(默认):一次规划几十米开外的整条路径,行人穿过多个路口 ----
|
||
if (cfg.AmbientPedsJourneyEnabled && PedLaneGraph.Available)
|
||
{
|
||
if (ped.Route is null || ped.RouteIdx >= ped.Route.Length - 1)
|
||
{
|
||
// 传**比例**而不是米数:实际偏移 = 该点车道的 HalfWalk × 这个值,窄路自动不偏
|
||
// (2026-09-23 修:"窄路上行人卡在街边墙壁里" —— 固定 0.6m 跟路宽无关,照样把人推进墙)。
|
||
var planned = PedLaneGraph.TryPlanJourney(ped.X, ped.Z, owner.HomeX, owner.HomeZ,
|
||
cfg.AmbientPedsJourneyMinMetres, cfg.AmbientPedsJourneyMaxMetres,
|
||
ped.Walk.Offset, Random.Shared);
|
||
ped.Route = planned is { Count: >= 2 } ? planned.ToArray() : null;
|
||
ped.RouteIdx = 0;
|
||
}
|
||
|
||
var route = ped.Route;
|
||
if (route is { Length: >= 2 } && ped.RouteIdx < route.Length - 1)
|
||
{
|
||
var need = stepMetres;
|
||
var acc = 0f;
|
||
var cx = ped.X;
|
||
var cz = ped.Z;
|
||
while (need > 0f && ped.RouteIdx < route.Length - 1)
|
||
{
|
||
var p = route[++ped.RouteIdx];
|
||
var dx = p.X - cx;
|
||
var dz = p.Z - cz;
|
||
var d = MathF.Sqrt(dx * dx + dz * dz);
|
||
cx = p.X;
|
||
cz = p.Z;
|
||
need -= d;
|
||
acc += d;
|
||
if (acc >= chunk)
|
||
{
|
||
points.Add(new SceneMethods.UxVector3(p.X, p.Y, p.Z));
|
||
acc = 0f;
|
||
}
|
||
}
|
||
|
||
// 末点必须是真实落点,否则客户端走到倒数第二个点就"没路了",又会站住。
|
||
var tail = route[ped.RouteIdx];
|
||
var ex = tail.X - points[^1].X;
|
||
var ez = tail.Z - points[^1].Z;
|
||
if (ex * ex + ez * ez > 0.09f)
|
||
points.Add(new SceneMethods.UxVector3(tail.X, tail.Y, tail.Z));
|
||
|
||
return await SendAmbientStepAsync(session, ped, points, token);
|
||
}
|
||
}
|
||
|
||
// ---- ② 退回随机游走 ----
|
||
// ⛑️ 从目标导向退回来必须先吸附:Walk.Lane 还停在生成时那条老车道上,
|
||
// 直接 Advance 会从几十米外出点 ⇒ 行人瞬移。
|
||
var walk = ped.Walk; // struct 副本:只有整段推进成功才写回
|
||
PedLaneGraph.SnapTo(ped.X, ped.Z, ref walk);
|
||
|
||
var remaining = stepMetres;
|
||
while (remaining > 0f)
|
||
{
|
||
var d = MathF.Min(chunk, remaining);
|
||
if (!PedLaneGraph.Advance(ref walk, d, Random.Shared, out var x, out var y, out var z, out _))
|
||
return false;
|
||
points.Add(new SceneMethods.UxVector3(x, y, z));
|
||
remaining -= d;
|
||
}
|
||
|
||
ped.Walk = walk;
|
||
ped.Route = null; // 下次再试目标导向
|
||
return await SendAmbientStepAsync(session, ped, points, token);
|
||
}
|
||
|
||
/// <summary>把折线封装成一条 `TaskWayPointMove` 发出,并回写行人位置与实际路程。</summary>
|
||
private static async Task<bool> SendAmbientStepAsync(
|
||
TcpSession session, AmbientPed ped, List<SceneMethods.UxVector3> points, CancellationToken token)
|
||
{
|
||
if (points.Count < 2)
|
||
return false;
|
||
|
||
var metres = 0f;
|
||
for (var i = 1; i < points.Count; i++)
|
||
{
|
||
var dx = points[i].X - points[i - 1].X;
|
||
var dz = points[i].Z - points[i - 1].Z;
|
||
metres += MathF.Sqrt(dx * dx + dz * dz);
|
||
}
|
||
|
||
await session.NotifyAsync(MethodId.IGameSceneToClient_SyncLogicAgentCommand,
|
||
UxSerializer.Serialize(WorldCodec.LogicAgentCommandWayPoint(
|
||
ped.Id, Interlocked.Increment(ref s_ambientCommandSeq),
|
||
SceneMethods.AgentMovementMethods.Walk, ped.Speed,
|
||
moveActionId: 0u, moveActionGroup: 0u,
|
||
keepMovingAction: true, points, noRootMotion: true)), token);
|
||
|
||
ped.X = points[^1].X;
|
||
ped.Y = points[^1].Y;
|
||
ped.Z = points[^1].Z;
|
||
ped.LastStepMetres = metres;
|
||
ped.TargetSetTicks = Environment.TickCount64;
|
||
return true;
|
||
}
|
||
|
||
private static async Task TickAmbientAsync(TcpSession session, AmbientSessionState owner, CancellationToken token)
|
||
{
|
||
var cfg = PrivateServerConfigStore.Current.Gameplay.Aether;
|
||
var state = GetStateIfExists(session);
|
||
if (state is null)
|
||
return;
|
||
|
||
Vec3 player;
|
||
lock (state.SyncRoot)
|
||
{
|
||
if (!state.Ready || !state.HasLastReportedPlayerTransform || state.PendingTeleportId != 0)
|
||
return;
|
||
player = state.LastReportedPlayerPosition;
|
||
}
|
||
if (!float.IsFinite(player.X) || !float.IsFinite(player.Z))
|
||
return;
|
||
|
||
// 目标导向行程的中心锚点(驱动循环是另一个 Task,拿不到这里的 player)。
|
||
owner.HomeX = player.X;
|
||
owner.HomeZ = player.Z;
|
||
|
||
// ---- 1. 回收走远了的 ----
|
||
var recycle = MathF.Max(cfg.AmbientPedsRecycleMeters, cfg.AmbientPedsRadiusMeters + 50f);
|
||
List<AmbientPed> gone = [];
|
||
var stalled = 0;
|
||
var nowWatch = Environment.TickCount64;
|
||
var stallWindow = Math.Max(1000, cfg.AmbientPedsStallWindowMs);
|
||
var failLimit = Math.Max(2, cfg.AmbientPedsStepFailLimit);
|
||
var resyncLimit = Math.Max(2, cfg.AmbientPedsStallResyncLimit);
|
||
|
||
lock (owner.SyncRoot)
|
||
{
|
||
for (var i = owner.Peds.Count - 1; i >= 0; i--)
|
||
{
|
||
var ped = owner.Peds[i];
|
||
|
||
// ---- 看门狗:两路卡死判定,命中就回收重生(下面第 3 步会自动补点)----
|
||
// ① 服务端侧:连续派不出任务 —— 路网状态坏了,行人会永久杵着不动。
|
||
// ② 客户端侧:窗口内反复位置脱节 —— 贴墙/卡地形,校正也救不回来。
|
||
// ⛑️ 判据都用"连续/窗口内 N 次"而不是一次:偶发一次是正常抖动,
|
||
// 一次就回收会让行人在正常游走时凭空消失(比站着不动更难看)。
|
||
var stalledNow = ped.StepFailCount >= failLimit
|
||
|| (ped.ResyncCount >= resyncLimit
|
||
&& nowWatch - ped.ResyncWindowTicks <= stallWindow);
|
||
if (stalledNow)
|
||
{
|
||
stalled++;
|
||
gone.Add(ped);
|
||
owner.Peds.RemoveAt(i);
|
||
continue;
|
||
}
|
||
|
||
var dx = ped.X - player.X;
|
||
var dz = ped.Z - player.Z;
|
||
if (dx * dx + dz * dz <= recycle * recycle)
|
||
continue;
|
||
gone.Add(ped);
|
||
owner.Peds.RemoveAt(i);
|
||
}
|
||
}
|
||
// ⛑️ 一次性连发上千个 Remove 会把客户端 socket 直接打爆(donor 实测 1952~2204 个 ⇒
|
||
// "你的主机中的软件中止了一个已建立的连接")。分块 48 个/批 + 批间 12 ms 之后,
|
||
// 他们的进世界延迟 2500/4500 ms → 0。传送 / 切场景时 gone 会很大,这是我们最容易莫名掉线的地方之一。
|
||
for (var i = 0; i < gone.Count; i += RemoveBatchSize)
|
||
{
|
||
if (i > 0)
|
||
await Task.Delay(RemoveBatchDelayMs, token);
|
||
|
||
var end = Math.Min(gone.Count, i + RemoveBatchSize);
|
||
for (var j = i; j < end; j++)
|
||
await session.NotifyAsync(MethodId.SyncAetherAINpcRemove,
|
||
UxSerializer.Serialize(new SceneMethods.SyncAetherAINpcRemove4229938 { pid = gone[j].Id }),
|
||
token);
|
||
}
|
||
|
||
// ---- 2. 游荡续期(MaxTime 是总预算,走完就永久站住)----
|
||
// crowd 通道是客户端自驱游荡,没有总时长上限,不用重发。
|
||
List<AmbientPed> due = [];
|
||
if (!IsCrowdChannel(cfg))
|
||
{
|
||
var rearmMs = (long)(MathF.Max(5f, cfg.AmbientPedsWanderRearmSeconds) * 1000f);
|
||
var now = Environment.TickCount64;
|
||
lock (owner.SyncRoot)
|
||
due = owner.Peds.Where(p => now - p.WanderArmedTicks >= rearmMs).ToList();
|
||
|
||
foreach (var ped in due)
|
||
await PushAmbientWanderAsync(session, cfg, ped, token);
|
||
}
|
||
|
||
if (gone.Count > 0 || due.Count > 0)
|
||
session.Log.Info($"[AMBIENT] recycle={gone.Count} (stalled={stalled}) reWander={due.Count} live={owner.Peds.Count}");
|
||
|
||
// ---- 3. 补点 ----
|
||
int live;
|
||
lock (owner.SyncRoot)
|
||
live = owner.Peds.Count;
|
||
var deficit = cfg.AmbientPedsTarget - live;
|
||
if (deficit <= 0)
|
||
return;
|
||
|
||
var batch = Math.Min(deficit, cfg.AmbientPedsTopUpPerTick);
|
||
var spawned = 0;
|
||
var driven = cfg.AmbientPedsDriven && PedLaneGraph.Available;
|
||
for (var i = 0; i < batch; i++)
|
||
{
|
||
float sx, sy, sz;
|
||
var walking = false;
|
||
var walk = default(PedLaneGraph.WalkState);
|
||
|
||
if (driven && PedLaneGraph.TryPickSpawn(player.X, player.Z, cfg.AmbientPedsRadiusMeters,
|
||
Random.Shared, out sx, out sy, out sz, out walk))
|
||
{
|
||
walking = true;
|
||
}
|
||
else
|
||
{
|
||
var spot = PedSpawnTable.PickNear(player.X, player.Z, cfg.AmbientPedsRadiusMeters,
|
||
cfg.AmbientPedsMinGapMeters, owner.Peds);
|
||
if (spot is null)
|
||
break;
|
||
sx = spot.Value.X;
|
||
sy = spot.Value.Y;
|
||
sz = spot.Value.Z;
|
||
}
|
||
|
||
var ped = await SpawnAmbientPedAsync(session, owner, cfg, sx, sy, sz, walking, walk, token);
|
||
if (ped is null)
|
||
break;
|
||
lock (owner.SyncRoot)
|
||
owner.Peds.Add(ped);
|
||
spawned++;
|
||
}
|
||
|
||
if (spawned > 0)
|
||
session.Log.Info($"[AMBIENT] spawned {spawned} pedestrians near " +
|
||
$"({player.X:F0},{player.Z:F0}) live={live + spawned}/{cfg.AmbientPedsTarget}");
|
||
}
|
||
|
||
private static bool IsCrowdChannel(AetherSettings cfg)
|
||
=> string.Equals(cfg.AmbientPedsChannel, "crowd", StringComparison.OrdinalIgnoreCase);
|
||
|
||
private static async Task<AmbientPed?> SpawnAmbientPedAsync(
|
||
TcpSession session, AmbientSessionState owner, AetherSettings cfg, float sx, float sy, float sz,
|
||
bool walking, PedLaneGraph.WalkState walk, CancellationToken token)
|
||
{
|
||
var formwork = PedFormworkPool.Pick();
|
||
if (formwork is null)
|
||
return null;
|
||
|
||
// T0-28:模板池过滤结果只打一次 —— 判"是不是滤空了 / 滤掉了多少"全靠这一行。
|
||
if (Interlocked.Exchange(ref s_formworkPoolLogged, 1) == 0)
|
||
session.Log.Info($"[AMBIENT] formwork pool {PedFormworkPool.Count} usable " +
|
||
$"(raw={PedFormworkPool.RawCount}, filtered={PedFormworkPool.Filtered})");
|
||
|
||
var id = unchecked((ulong)IdCounterStore.Next(StaticNpcIdCounter, StaticNpcIdFloor));
|
||
// ⛑️ 2026-09-26:出生朝向必须跟着车道走向。原来是纯随机(-180~180),
|
||
// 而位移由 TaskWayPointMove 的路径点决定 ⇒ 身体朝 A、位移朝 B,看起来就是"倒退着走路"。
|
||
// 非路网出生(站位表)没有走向可言,才退回随机。
|
||
var facing = Random.Shared.NextSingle() * 360f - 180f;
|
||
if (walking && PedLaneGraph.TryFacing(walk, out var laneFacing))
|
||
facing = laneFacing;
|
||
var crowd = IsCrowdChannel(cfg);
|
||
var spot = new PedSpawnPoint(sx, sy, sz);
|
||
|
||
if (crowd)
|
||
await SendAmbientCrowdPedAsync(session, cfg, formwork.Value, id, spot, facing, token);
|
||
else
|
||
await SendAmbientStaticPedAsync(session, cfg, formwork.Value, id, spot, facing, token);
|
||
|
||
var ped = new AmbientPed
|
||
{
|
||
Id = id,
|
||
X = sx,
|
||
Y = sy,
|
||
Z = sz,
|
||
WanderArmedTicks = 0,
|
||
Walking = walking,
|
||
Walk = walk,
|
||
Speed = MathF.Max(0.1f, cfg.AmbientPedsDesiredSpeed) * (0.85f + Random.Shared.NextSingle() * 0.3f),
|
||
// 同一批生成的人如果节拍完全一致,就会"一起起步、一起掉头" —— 各人错开一点。
|
||
StepJitterMs = Random.Shared.Next(0, Math.Max(1, cfg.AmbientPedsStepSlackMs)),
|
||
};
|
||
|
||
// crowd 通道是客户端自驱游荡,服务端发游荡包反而多余;static 通道必须发,否则只播 idle。
|
||
if (!crowd)
|
||
await PushAmbientWanderAsync(session, cfg, ped, token);
|
||
|
||
// donor1 第 39 轮:移动任务在**创建时**就发第一条,不等驱动循环的首拍 ——
|
||
// 否则路人在第一拍之前是站着的。路网可用(walking)才发。
|
||
if (walking)
|
||
await PushAmbientStepCommandAsync(session, owner, ped,
|
||
MathF.Max(6f, cfg.AmbientPedsStepMetres), token);
|
||
return ped;
|
||
}
|
||
|
||
/// <summary>static 通道:静态 NPC + 行为树 + simulation owner(人一定出得来、有动画)。</summary>
|
||
private static async Task SendAmbientStaticPedAsync(
|
||
TcpSession session, AetherSettings cfg, uint formwork, ulong id,
|
||
PedSpawnPoint spot, float facing, CancellationToken token)
|
||
{
|
||
// ⛑️⛑️ POI 动作**不能留 0**。0 = 无动作 ⇒ 站着不动。
|
||
// 1 = UrbanDiversityPOIActionConfig.Walk(走路)。
|
||
await SendCivilianAsync(session, new CivilianSpawnItem(formwork, cfg.AmbientPedsPoiActionId), id,
|
||
spot.X, spot.Y, spot.Z, facing);
|
||
|
||
// 客户端是空 stub(无 override),默认关;留着给未来客户端版本/对照实验。
|
||
if (cfg.AmbientPedsSendDesiredSpeed)
|
||
{
|
||
await session.NotifyAsync(MethodId.SyncChangeNpcMoveDesiredSpeed,
|
||
UxSerializer.Serialize(new SceneMethods.SyncChangeNpcMoveDesiredSpeed4229938
|
||
{
|
||
EntityId = id,
|
||
DesiredSpeed = cfg.AmbientPedsDesiredSpeed,
|
||
}), token);
|
||
}
|
||
|
||
if (cfg.AmbientPedsForceGo)
|
||
{
|
||
await session.NotifyAsync(MethodId.IGameSceneToClient_SyncAgentForceGo,
|
||
UxSerializer.Serialize(new SceneMethods.SyncAgentForceGo4229938
|
||
{
|
||
id = id,
|
||
isForceGo = true,
|
||
}), token);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// crowd 通道:`SyncAetherAICrowdAdd`(68757757)(donor_lyco082 实机验证的市民通道)。
|
||
/// 客户端自驱游荡,服务端不用发移动包。⚠️ NpcFormworkId 必须是 AgentConfig id(4013xxxx)。
|
||
/// </summary>
|
||
private static async Task SendAmbientCrowdPedAsync(
|
||
TcpSession session, AetherSettings cfg, uint formwork, ulong id,
|
||
PedSpawnPoint spot, float facing, CancellationToken token)
|
||
{
|
||
await session.NotifyAsync(MethodId.SyncAetherAICrowdAdd,
|
||
UxSerializer.Serialize(new SceneMethods.SyncAetherAICrowdAdd4229938
|
||
{
|
||
crowd = new SceneMethods.ClientCrowdInitData4229938
|
||
{
|
||
NpcFormworkId = formwork,
|
||
AgentPersonaId = cfg.AmbientPedsCrowdPersonaId,
|
||
UrbanDiversityConfigId = 0,
|
||
DesiredSpeed = cfg.AmbientPedsDesiredSpeed,
|
||
// ★ 走起来靠 TargetLocationReason=1(Wander):之前填 0(None) = 告诉客户端
|
||
// "这个人没有目标位置意图" ⇒ 人出来了但不走。
|
||
// ⛑️ ActionId 是**动作组 400**(不是 POI 动作 id 1)—— 见下面 POI 处注释,别改回 1。
|
||
ActionId = cfg.AmbientPedsCrowdActionId,
|
||
TargetLocationReason = cfg.AmbientPedsTargetLocationReason,
|
||
FashionSuitId = 0,
|
||
Id = id,
|
||
Position = new SceneMethods.UxVector3(spot.X, spot.Y, spot.Z),
|
||
Facing = facing,
|
||
EulerAngles = new SceneMethods.UxVector3(0f, facing, 0f),
|
||
},
|
||
}), token);
|
||
|
||
// ⛑️⛑️ 没有 simulation owner ⇒ ECS→GO 交接后全模型状态机被关掉 ⇒ **T-pose**。
|
||
// ★ donor1 第 81 轮(三重证据):`SyncManagedLogicAgent` 也是**客户端来要路径
|
||
// (AskClientNpcFindPath)的前提** —— 状态机被关掉,客户端根本不来问,
|
||
// 服务端推多少命令都没人接。所以 driven 模式必须发。
|
||
if (cfg.AmbientPedsCrowdManagedLogicAgent)
|
||
{
|
||
await session.NotifyAsync(MethodId.SyncManagedLogicAgent,
|
||
UxSerializer.Serialize(WorldCodec.ManagedLogicAgent(id, Profile.PlayerPid, 0)), token);
|
||
}
|
||
|
||
// donor1 第 37 轮:Enter 把 id 注册进客户端的 logic-agent 同步管理器,
|
||
// 任务/同步都从它派发 —— 不发它,任务可能被静默丢弃。
|
||
if (cfg.AmbientPedsLogicEnter)
|
||
{
|
||
await session.NotifyAsync(MethodId.SyncLogicAgentEnter,
|
||
UxSerializer.Serialize(WorldCodec.LogicAgentEnter(id)), token);
|
||
}
|
||
|
||
// ⛑️⛑️ 2026-09-22 槽位归位(T0-9):
|
||
// `ClientCrowdInitData.ActionId` = **动作组**(400 = NPC 默认,donor 第 38 轮面板实测,上限 499)
|
||
// `SyncAgentPlayPOIAction.CPoiActionId` = **POI 动作 id**(POI 表:1=走路/9018、2=idle/1001、17=站姿闲置/1001)
|
||
// 这两个是**不同的表**。之前把动作组号 400 塞进 POI 槽位 ⇒ 每个行人都在播
|
||
// POI id 400「站姿_工人_记录(平板)」(还带 UsageId=81,需要平板道具,而 crowd 通道从不发 belongings)
|
||
// ⇒ 出场就是个拿平板记录的站姿,跟"走路"相反。位移靠 TaskWayPointMove,这里只负责动画状态 ⇒ 填 1。
|
||
if (cfg.AmbientPedsCrowdPlayPoiAction && cfg.AmbientPedsPoiActionId != 0)
|
||
{
|
||
await session.NotifyAsync(MethodId.IGameSceneToClient_SyncAgentPlayPOIAction,
|
||
UxSerializer.Serialize(new GameMethods.DonorClientNpcPoiActionData4229938
|
||
{
|
||
Id = id,
|
||
SPoiActionId = 0,
|
||
CPoiActionId = cfg.AmbientPedsPoiActionId,
|
||
IsActive = true,
|
||
PlayPoiSpeed = 1f,
|
||
}), token);
|
||
}
|
||
|
||
if (cfg.AmbientPedsForceGo)
|
||
{
|
||
await session.NotifyAsync(MethodId.IGameSceneToClient_SyncAgentForceGo,
|
||
UxSerializer.Serialize(new SceneMethods.SyncAgentForceGo4229938
|
||
{
|
||
id = id,
|
||
isForceGo = true,
|
||
}), token);
|
||
}
|
||
|
||
// T2-32 路人骇入:crowd 通道同样要标可骇(static 通道在 SendCivilianAsync 尾部标)。
|
||
// 行人一条 belongings 都没有 ⇒ 连手机都没有 ⇒ 按钮不会出,所以这里是 GivePhoneToCrowdPeds 的主战场。
|
||
await MarkNpcHackableOnSpawnAsync(session, id, HackNpcChannel.Crowd, token);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 下发/续期一个行人的游荡意图。MoveId 不是序号,是 BattleMoveActionType,
|
||
/// 填 0(Unknown) 等于"没有 locomotion 状态",所以填 2(WalkFront)。
|
||
/// ⛑️ PathTags / 角度窗口是最容易踩的两个静默失败点,见文件头。
|
||
/// </summary>
|
||
private static async Task PushAmbientWanderAsync(
|
||
TcpSession session, AetherSettings cfg, AmbientPed ped, CancellationToken token)
|
||
{
|
||
var maxDis = MathF.Max(cfg.AmbientPedsWanderMinDis + 1f, cfg.AmbientPedsWanderMaxDis);
|
||
await session.NotifyAsync(MethodId.SyncMoveWandering,
|
||
UxSerializer.Serialize(new SceneMethods.SyncMoveWandering4229938
|
||
{
|
||
Data = new SceneMethods.MoveWanderingData4229938
|
||
{
|
||
PathTags = cfg.AmbientPedsWanderPathTags,
|
||
CloseObstacleAvoidance = true,
|
||
MinDis = cfg.AmbientPedsWanderMinDis,
|
||
MaxDis = maxDis,
|
||
InRangeAngle = cfg.AmbientPedsWanderLeftAngle,
|
||
OutRangeAngle = cfg.AmbientPedsWanderRightAngle,
|
||
MaxTime = cfg.AmbientPedsWanderSeconds + 5f,
|
||
MaxOnceWanderTime = cfg.AmbientPedsWanderSeconds,
|
||
UnitId = ped.Id,
|
||
MoveType = 0,
|
||
MoveId = WorldCodec.MoveAction.WalkFront,
|
||
CloseIK = false,
|
||
InstanceId = 0,
|
||
BlockCloseCapsuleCollision = false,
|
||
},
|
||
}), token);
|
||
|
||
ped.WanderArmedTicks = Environment.TickCount64;
|
||
}
|
||
}
|
||
|
||
/// <summary>一个候选站位(真实行人车道端点,带地面高度)。</summary>
|
||
internal readonly record struct PedSpawnPoint(float X, float Y, float Z);
|
||
|
||
/// <summary>
|
||
/// 行人布点表:`Ananta.Server/Ananta.App/data/ped_spawn_points.csv`(x,y,z)。
|
||
/// 100 米网格索引,按玩家位置查半径内候选。
|
||
/// </summary>
|
||
internal static class PedSpawnTable
|
||
{
|
||
private const float CellSize = 100f;
|
||
|
||
private sealed class Table
|
||
{
|
||
internal PedSpawnPoint[] Points = [];
|
||
internal Dictionary<(int, int), int[]> Cells = [];
|
||
}
|
||
|
||
private static readonly Lazy<Table> Cache = new(Load);
|
||
|
||
internal static int Count => Cache.Value.Points.Length;
|
||
|
||
private static Table Load()
|
||
{
|
||
var path = PrivateServerConfigStore.ResolveProjectPath(
|
||
"Ananta.Server/Ananta.App/data/ped_spawn_points.csv");
|
||
var points = new List<PedSpawnPoint>();
|
||
if (File.Exists(path))
|
||
{
|
||
foreach (var line in File.ReadLines(path))
|
||
{
|
||
if (line.Length == 0 || line.StartsWith("x,", StringComparison.OrdinalIgnoreCase))
|
||
continue;
|
||
var parts = line.Split(',');
|
||
if (parts.Length < 3)
|
||
continue;
|
||
if (!float.TryParse(parts[0], NumberStyles.Float, CultureInfo.InvariantCulture, out var x)
|
||
|| !float.TryParse(parts[1], NumberStyles.Float, CultureInfo.InvariantCulture, out var y)
|
||
|| !float.TryParse(parts[2], NumberStyles.Float, CultureInfo.InvariantCulture, out var z))
|
||
continue;
|
||
points.Add(new PedSpawnPoint(x, y, z));
|
||
}
|
||
}
|
||
|
||
var cells = new Dictionary<(int, int), List<int>>();
|
||
for (var i = 0; i < points.Count; i++)
|
||
{
|
||
var key = (CellOf(points[i].X), CellOf(points[i].Z));
|
||
if (!cells.TryGetValue(key, out var bucket))
|
||
cells[key] = bucket = [];
|
||
bucket.Add(i);
|
||
}
|
||
|
||
return new Table
|
||
{
|
||
Points = [.. points],
|
||
Cells = cells.ToDictionary(kv => kv.Key, kv => kv.Value.ToArray()),
|
||
};
|
||
}
|
||
|
||
private static int CellOf(float v) => (int)MathF.Floor(v / CellSize);
|
||
|
||
/// <summary>
|
||
/// 在 (x,z) 半径内随机挑一个站位,且与已有行人保持 minGap 米。
|
||
/// 找不到就返回 null(比如玩家站在水域/荒地,路网上没点)。
|
||
/// </summary>
|
||
internal static PedSpawnPoint? PickNear(float x, float z, float radius, float minGap,
|
||
IReadOnlyList<GameRouter.AmbientPed> existing)
|
||
{
|
||
var table = Cache.Value;
|
||
if (table.Points.Length == 0)
|
||
return null;
|
||
|
||
var span = (int)MathF.Ceiling(radius / CellSize);
|
||
var cx = CellOf(x);
|
||
var cz = CellOf(z);
|
||
|
||
List<int> candidates = [];
|
||
for (var dx = -span; dx <= span; dx++)
|
||
for (var dz = -span; dz <= span; dz++)
|
||
{
|
||
if (!table.Cells.TryGetValue((cx + dx, cz + dz), out var bucket))
|
||
continue;
|
||
foreach (var idx in bucket)
|
||
{
|
||
var p = table.Points[idx];
|
||
var ox = p.X - x;
|
||
var oz = p.Z - z;
|
||
if (ox * ox + oz * oz <= radius * radius)
|
||
candidates.Add(idx);
|
||
}
|
||
}
|
||
if (candidates.Count == 0)
|
||
return null;
|
||
|
||
var gapSq = minGap * minGap;
|
||
for (var attempt = 0; attempt < 24; attempt++)
|
||
{
|
||
var pick = table.Points[candidates[Random.Shared.Next(candidates.Count)]];
|
||
var ok = true;
|
||
foreach (var ped in existing)
|
||
{
|
||
var ox = ped.X - pick.X;
|
||
var oz = ped.Z - pick.Z;
|
||
if (ox * ox + oz * oz < gapSq)
|
||
{
|
||
ok = false;
|
||
break;
|
||
}
|
||
}
|
||
if (ok)
|
||
return pick;
|
||
}
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 氛围行人的 NPC 模板池(= AgentConfig id)。直接复用已投影好的市民组目录,
|
||
/// 只取 NpcFormworkId —— POI 一律留 0,否则人会去播动作而不走路。
|
||
///
|
||
/// `CivilianGroups` 是为"给每个 POI 动作配一个体型匹配的人形"建的,
|
||
/// 它<b>不保证模板是路人</b> —— 池里原本混着店员、摊主、刷怪NPC、剧情角色,还有上百个
|
||
/// 共用同一具通用模特的"假人"。这里按 `GameRouter.IsCivilianFormwork` 的三道筛子收敛成纯市民。
|
||
///
|
||
/// ⛑️ **来源恒为 `CivilianGroups.json`(用户 2026-09-26 定)**:本池只做<b>减法</b>,
|
||
/// 绝不从 `NpcCatalog`(9345 行全表)往里补 id —— 那里有敌人、动物、机器人、淤泥怪。
|
||
/// 过滤掉一批之后剩下多少就是多少(现在 1985 个),不要为了凑数去别的表里捞。
|
||
/// </summary>
|
||
internal static class PedFormworkPool
|
||
{
|
||
private static readonly Lazy<uint[]> Cache = new(Load);
|
||
|
||
internal static int Count => Cache.Value.Length;
|
||
|
||
/// <summary>过滤前 / 过滤后的数量,给日志和面板看(判"是不是滤空了")。</summary>
|
||
internal static int RawCount { get; private set; }
|
||
internal static int Filtered { get; private set; }
|
||
|
||
private static uint[] Load()
|
||
{
|
||
var raw = new HashSet<uint>();
|
||
var kept = new List<uint>();
|
||
foreach (var group in GameRouter.CivilianGroups)
|
||
foreach (var item in group.Items)
|
||
{
|
||
var id = item.NpcFormworkId;
|
||
if (id == 0 || !raw.Add(id))
|
||
continue;
|
||
// 三道筛子(id 段白名单 / 通用临时模型黑名单 / 名字黑名单)全在 IsCivilianFormwork 里。
|
||
if (GameRouter.IsCivilianFormwork(id))
|
||
kept.Add(id);
|
||
}
|
||
|
||
RawCount = raw.Count;
|
||
Filtered = raw.Count - kept.Count;
|
||
|
||
// 两道筛子把池子滤空 ⇒ 退回全池(宁可混进店员,也不要一个路人都刷不出来)。
|
||
if (kept.Count == 0 && raw.Count > 0)
|
||
{
|
||
Filtered = 0;
|
||
return [.. raw];
|
||
}
|
||
return [.. kept];
|
||
}
|
||
|
||
internal static uint? Pick()
|
||
{
|
||
var pool = Cache.Value;
|
||
return pool.Length == 0 ? null : pool[Random.Shared.Next(pool.Length)];
|
||
}
|
||
}
|