using Ananta.SDK.Network; using Ananta.SDK.Rpc; using Ananta.SDK.Serialization; using Ananta.Server.ClientData.Client4229938; using Ananta.Server.Configuration; using Ananta.Server.Protocol.Client4229938; using Ananta.Server.RpcTypes.Client4229938; using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene; namespace Ananta.Server.Handlers.Game; /// /// 街上正常行走的行人(AetherCity crowd)。 /// /// ★ 这条线是**客户端自驱**的:AetherPedManager 用 ContextSteering + ZoneGraph Wander 自己走, /// 服务端**没有任何移动包可发**(MethodId 表里没有 AskUpdateMoveDatas 这类 RPC)。 /// 服务端只负责三件事: /// ① SyncAetherAIInitDatas 打开 HasZoneGraph —— 已有 EnsureAetherVehicleInit() 负责 /// ② SyncSetCrowdDensity(68996045) —— 解锁客户端 ECS 人群生成器 /// ③ SyncCrowdSpecifiedDensityRefresh(68195167) —— 在玩家周围的盒子里按密度补人 /// 玩家走远(默认 35m)后重发 ③,否则走出盒子就没人了。 /// /// 契约权威 = dump.cs `IGameSceneToClient` 接口签名,见 CrowdMethods4229938.cs 文件头。 /// internal sealed partial class GameRouter { private static readonly object CrowdLock = new(); /// 密度重复推送的合并窗口(毫秒)。客户端会在等人群刷新期间按帧重发 GM 命令, /// 每来一次就回一对包会把主线程饿死(donor 实测 133 包/秒)。同密度在此窗口内只答一次。 private const int CrowdRepeatGuardMs = 1500; private static long _crowdPushedTicks; private static float _crowdPushedDensity = float.NaN; /// 上一次推送所属的 LandPlotType,用于判断是否值得重发全局密度包。 private static string _crowdPushedPlotType = string.Empty; /// 上一次推送时 CrowdEnabled 的取值。运行中把开关从「开」拨到「关」时靠它补一次清零推送。 private static bool _crowdLastEnabled = true; /// /// 关掉人群时的「清零」推送:把全局密度压到 0,并在玩家脚下补一个同样为 0 的刷新盒。 /// /// ⛑️ 为什么不是「什么都不发」:客户端的 AetherCity 人群是**自驱**的 —— HasZoneGraph 一打开 /// (SyncAetherAIInitDatas)它就按场景自带数据自己生成人,我们的 SyncSetCrowdDensity 只是 /// override。所以「不发密度包」= 回到客户端默认密度 = 街上照样有人。要没人只能主动压 0。 /// ⛑️ 不要发 RevertToNormal=true —— 那是「恢复默认密度」,效果正好相反。 /// private static async Task PushCrowdDisabledAsync(TcpSession session, Vec3 pos, string reason) { var cfg = CrowdCfg(); try { await session.NotifyAsync(MethodId.SyncSetCrowdDensity, UxSerializer.Serialize(new SceneMethods.SyncSetCrowdDensity4229938 { RevertToNormal = false, OverrideDensity = 0f, }), CancellationToken.None); await session.NotifyAsync(MethodId.SyncCrowdSpecifiedDensityRefresh, UxSerializer.Serialize(new SceneMethods.SyncCrowdSpecifiedDensityRefresh4229938 { Position = new SceneMethods.UxVector3(pos.X, pos.Y, pos.Z), Extends = new SceneMethods.UxVector3(cfg.CrowdExtendsMeters, cfg.CrowdExtendsHeight, cfg.CrowdExtendsMeters), DensityFactor = 0f, }), CancellationToken.None); session.Log.Info($"[CROWD] disabled ({reason}) at ({pos.X:F1},{pos.Y:F1},{pos.Z:F1}) density=0 " + $"extends={cfg.CrowdExtendsMeters:F0}x{cfg.CrowdExtendsHeight:F0}x{cfg.CrowdExtendsMeters:F0}"); return true; } catch (Exception ex) { session.Log.Warn($"[CROWD] disable push failed ({reason}): {ex.Message}"); return false; } } private static bool ShouldPushCrowd(float density) { var now = Environment.TickCount64; lock (CrowdLock) { // NaN 永不相等 ⇒ 启动后第一次一定通过。 if (MathF.Abs(_crowdPushedDensity - density) < 0.01f && now - _crowdPushedTicks < CrowdRepeatGuardMs) return false; _crowdPushedTicks = now; _crowdPushedDensity = density; return true; } } private static AetherSettings CrowdCfg() => PrivateServerConfigStore.Current.Gameplay.Aether; private static Vec3 CrowdAnchor(WorldEntryState state) => state.HasLastReportedPlayerTransform ? state.LastReportedPlayerPosition : state.WorldEntryCreateHeroPosition; /// /// T0-8:把玩家脚下的地块类型与该地块的时段人流曲线合成实际密度。 /// /// density = 全局 CrowdDensity × 地块类型系数 × max(HourlyFlow[hour], CrowdHourFloor) /// /// 为什么不用 UrbanDiversityConfig 的 PopulationDensity / DegreeOfBusyness: /// 实测 681 个地块里这两个字段几乎恒为 0(只有沙滩区有值),没有信息量。 /// LandPlotType + HourlyFlow 是仅有的两个可用维度,HourlyFlow 已按各自峰值归一化到 1.0。 /// private static (float Density, string PlotName, string LandPlotType, double HourFactor) ResolveCrowdDensity( WorldEntryState state, Vec3 pos) { var cfg = CrowdCfg(); var baseDensity = cfg.CrowdDensity; if (!cfg.CrowdUseLandPlots) return (baseDensity, "(按地块已关)", "", 1.0); var hits = CivilianDistrictCatalog4229938.WithinRadius(pos.X, pos.Z, cfg.CrowdPlotSearchMeters); if (hits.Count == 0) return (baseDensity * cfg.CrowdPlotMissFactor, "(地块外)", "", 1.0); var plot = hits[0].District; // 面板设过时间就以它为准,否则跟随本地时钟的小时(服务端自己不推进时间)。 int hour; lock (state.SyncRoot) hour = state.HasExplicitTime ? (int)state.TimeHour : DateTime.Now.Hour; var hourFactor = Math.Clamp(plot.FlowAt(hour), 0.0, 1.0); if (hourFactor < cfg.CrowdHourFloor) hourFactor = cfg.CrowdHourFloor; var typeFactor = cfg.CrowdTypeFactors.TryGetValue(plot.LandPlotType, out var factor) ? factor : 1.0f; return (baseDensity * typeFactor * (float)hourFactor, plot.Name, plot.LandPlotType, hourFactor); } /// /// 世界进入(OnLoadingFinished)时激活客户端原生人群。 /// 必须在 EnsureAetherVehicleInit 之后 —— 没有 HasZoneGraph=true 客户端根本不建人群层。 /// internal static async Task PublishAmbientCrowdAsync(RpcContext ctx) { var cfg = CrowdCfg(); var state = GetStateIfExists(ctx.Session); if (state is null) return; // 关掉 ⇒ 不是「什么都不发」,而是主动把密度压到 0(客户端人群自驱,见 PushCrowdDisabledAsync 说明)。 if (!cfg.CrowdEnabled) { _crowdLastEnabled = false; await PushCrowdDisabledAsync(ctx.Session, CrowdAnchor(state), "world enter"); return; } _crowdLastEnabled = true; var pos = CrowdAnchor(state); var (density, plotName, plotType, hourFactor) = ResolveCrowdDensity(state, pos); try { await ctx.NotifyAsync(MethodId.SyncSetCrowdDensity, new SceneMethods.SyncSetCrowdDensity4229938 { RevertToNormal = false, OverrideDensity = density, }); await ctx.NotifyAsync(MethodId.SyncCrowdSpecifiedDensityRefresh, new SceneMethods.SyncCrowdSpecifiedDensityRefresh4229938 { Position = new SceneMethods.UxVector3(pos.X, pos.Y, pos.Z), Extends = new SceneMethods.UxVector3(cfg.CrowdExtendsMeters, cfg.CrowdExtendsHeight, cfg.CrowdExtendsMeters), DensityFactor = density, }); lock (state.SyncRoot) { state.LastCrowdCenter = pos; state.HasCrowdCenter = true; } ctx.Session.Log.Info($"[CROWD] activated at ({pos.X:F1},{pos.Y:F1},{pos.Z:F1}) " + $"density={density:F3} plot={plotName}({plotType}) hour={hourFactor:F2} " + $"extends={cfg.CrowdExtendsMeters:F0}x{cfg.CrowdExtendsHeight:F0}x{cfg.CrowdExtendsMeters:F0}"); } catch (Exception ex) { ctx.Session.Log.Warn($"[CROWD] activation failed: {ex.Message}"); } } /// /// 玩家移动时流式补人:离开上次中心超过阈值就重发一次密度刷新盒。 /// 由 GameRouter.Movement.cs 的位移报告处 fire-and-forget 调用。 /// internal static async Task CheckAndRefreshCrowdOnPlayerMoveAsync(TcpSession session, Vec3 currentPos) { var cfg = CrowdCfg(); var state = GetStateIfExists(session); if (state is null || !state.Ready) return; // 关掉 ⇒ 只在「刚刚从开拨到关」时补一次清零推送,之后彻底安静(否则每走一步都发一对包)。 if (!cfg.CrowdEnabled) { if (_crowdLastEnabled) { _crowdLastEnabled = false; await PushCrowdDisabledAsync(session, currentPos, "toggle off"); } return; } _crowdLastEnabled = true; // 客户端挂起期间不重发人群盒子 —— 见 GameRouter.IsBackgroundPushPaused 的说明。 if (IsBackgroundPushPaused(session)) return; bool shouldRefresh; lock (state.SyncRoot) { if (!state.HasCrowdCenter) { state.LastCrowdCenter = currentPos; state.HasCrowdCenter = true; shouldRefresh = true; } else { var dx = currentPos.X - state.LastCrowdCenter.X; var dz = currentPos.Z - state.LastCrowdCenter.Z; var threshold = cfg.CrowdRefreshMoveMeters; shouldRefresh = (dx * dx + dz * dz) >= threshold * threshold; if (shouldRefresh) state.LastCrowdCenter = currentPos; } } if (!shouldRefresh) return; var (density, plotName, plotType, hourFactor) = ResolveCrowdDensity(state, currentPos); try { // 地块类型换了一类就把全局密度包 ② 也重发一次:客户端可能仍拿着上一类地块解锁的密度, // 只发局部盒子 ③ 未必会让它重新计算。只在跨类型时发,不是每步都发。 if (!string.Equals(plotType, _crowdPushedPlotType, StringComparison.Ordinal)) { await session.NotifyAsync(MethodId.SyncSetCrowdDensity, UxSerializer.Serialize(new SceneMethods.SyncSetCrowdDensity4229938 { RevertToNormal = false, OverrideDensity = density, }), CancellationToken.None); _crowdPushedPlotType = plotType; } await session.NotifyAsync(MethodId.SyncCrowdSpecifiedDensityRefresh, UxSerializer.Serialize(new SceneMethods.SyncCrowdSpecifiedDensityRefresh4229938 { Position = new SceneMethods.UxVector3(currentPos.X, currentPos.Y, currentPos.Z), Extends = new SceneMethods.UxVector3(cfg.CrowdExtendsMeters, cfg.CrowdExtendsHeight, cfg.CrowdExtendsMeters), DensityFactor = density, }), CancellationToken.None); session.Log.Info($"[CROWD] refresh on move ({currentPos.X:F1},{currentPos.Y:F1},{currentPos.Z:F1}) " + $"density={density:F3} plot={plotName}({plotType}) hour={hourFactor:F2}"); } catch (Exception ex) { session.Log.Warn($"[CROWD] move refresh failed: {ex.Message}"); } } /// 面板 / GM 显式刷新。返回是否真的发了(被合并掉返回 false)。 internal static async Task RefreshAmbientCrowdAsync(TcpSession session, float? overrideDensity = null) { var state = GetStateIfExists(session); if (state is null || !state.Ready) return false; var cfg = CrowdCfg(); // 开关关着时这个按钮的语义变成「立刻清场」:压 0 + 回收服务端行人。 if (!cfg.CrowdEnabled) { _crowdLastEnabled = false; _crowdPushedDensity = 0f; _crowdPushedTicks = Environment.TickCount64; await RetireAmbientPedsAsync(session); await PushCrowdDisabledAsync(session, CrowdAnchor(state), "panel refresh"); return true; } _crowdLastEnabled = true; // 显式指定了密度就照用;否则按脚下地块 × 时段算。 var pos0 = CrowdAnchor(state); var (plotDensity, plotName, plotType, hourFactor) = ResolveCrowdDensity(state, pos0); var density = overrideDensity ?? plotDensity; if (!ShouldPushCrowd(density)) { session.Log.Info($"[CROWD] refresh coalesced (density={density:F2})"); return false; } var pos = pos0; try { await session.NotifyAsync(MethodId.SyncSetCrowdDensity, UxSerializer.Serialize(new SceneMethods.SyncSetCrowdDensity4229938 { RevertToNormal = false, OverrideDensity = density }), CancellationToken.None); await session.NotifyAsync(MethodId.SyncCrowdSpecifiedDensityRefresh, UxSerializer.Serialize(new SceneMethods.SyncCrowdSpecifiedDensityRefresh4229938 { Position = new SceneMethods.UxVector3(pos.X, pos.Y, pos.Z), Extends = new SceneMethods.UxVector3(cfg.CrowdExtendsMeters, cfg.CrowdExtendsHeight, cfg.CrowdExtendsMeters), DensityFactor = density, }), CancellationToken.None); lock (state.SyncRoot) { state.LastCrowdCenter = pos; state.HasCrowdCenter = true; } session.Log.Info($"[CROWD] explicit refresh at ({pos.X:F1},{pos.Y:F1},{pos.Z:F1}) " + $"density={density:F3} plot={plotName}({plotType}) hour={hourFactor:F2}" + (overrideDensity.HasValue ? " (密度由调用方指定)" : "")); return true; } catch (Exception ex) { session.Log.Warn($"[CROWD] explicit refresh failed: {ex.Message}"); return false; } } /// /// 只算不推:给 GM 面板回报「玩家脚下现在会算出什么密度」。 /// 面板改完参数不动也能看到这个值变化,不用真的走 35 米才知道。 /// internal static (bool Ok, float Density, string PlotName, string LandPlotType, double HourFactor, float X, float Z) ProbeCrowdDensity(TcpSession session) { var state = GetStateIfExists(session); if (state is null) return (false, 0f, "", "", 0.0, 0f, 0f); var pos = CrowdAnchor(state); var (density, plotName, landPlotType, hourFactor) = ResolveCrowdDensity(state, pos); return (true, density, plotName, landPlotType, hourFactor, pos.X, pos.Z); } }