353 lines
16 KiB
C#
353 lines
16 KiB
C#
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;
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 街上正常行走的行人(AetherCity crowd)。
|
|||
|
|
///
|
|||
|
|
/// ★ 这条线是**客户端自驱**的:AetherPedManager 用 ContextSteering + ZoneGraph Wander 自己走,
|
|||
|
|
/// 服务端**没有任何移动包可发**(MethodId 表里没有 AskUpdateMoveDatas 这类 RPC)。
|
|||
|
|
/// 服务端只负责三件事:
|
|||
|
|
/// ① SyncAetherAIInitDatas 打开 HasZoneGraph —— 已有 EnsureAetherVehicleInit() 负责
|
|||
|
|
/// ② SyncSetCrowdDensity(68996045) —— 解锁客户端 ECS 人群生成器
|
|||
|
|
/// ③ SyncCrowdSpecifiedDensityRefresh(68195167) —— 在玩家周围的盒子里按密度补人
|
|||
|
|
/// 玩家走远(默认 35m)后重发 ③,否则走出盒子就没人了。
|
|||
|
|
///
|
|||
|
|
/// 契约权威 = dump.cs `IGameSceneToClient` 接口签名,见 CrowdMethods4229938.cs 文件头。
|
|||
|
|
/// </summary>
|
|||
|
|
internal sealed partial class GameRouter
|
|||
|
|
{
|
|||
|
|
private static readonly object CrowdLock = new();
|
|||
|
|
|
|||
|
|
/// <summary>密度重复推送的合并窗口(毫秒)。客户端会在等人群刷新期间按帧重发 GM 命令,
|
|||
|
|
/// 每来一次就回一对包会把主线程饿死(donor 实测 133 包/秒)。同密度在此窗口内只答一次。</summary>
|
|||
|
|
private const int CrowdRepeatGuardMs = 1500;
|
|||
|
|
private static long _crowdPushedTicks;
|
|||
|
|
private static float _crowdPushedDensity = float.NaN;
|
|||
|
|
/// <summary>上一次推送所属的 LandPlotType,用于判断是否值得重发全局密度包。</summary>
|
|||
|
|
private static string _crowdPushedPlotType = string.Empty;
|
|||
|
|
/// <summary>上一次推送时 <c>CrowdEnabled</c> 的取值。运行中把开关从「开」拨到「关」时靠它补一次清零推送。</summary>
|
|||
|
|
private static bool _crowdLastEnabled = true;
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 关掉人群时的「清零」推送:把全局密度压到 0,并在玩家脚下补一个同样为 0 的刷新盒。
|
|||
|
|
///
|
|||
|
|
/// ⛑️ 为什么不是「什么都不发」:客户端的 AetherCity 人群是**自驱**的 —— HasZoneGraph 一打开
|
|||
|
|
/// (SyncAetherAIInitDatas)它就按场景自带数据自己生成人,我们的 SyncSetCrowdDensity 只是
|
|||
|
|
/// override。所以「不发密度包」= 回到客户端默认密度 = 街上照样有人。要没人只能主动压 0。
|
|||
|
|
/// ⛑️ 不要发 RevertToNormal=true —— 那是「恢复默认密度」,效果正好相反。
|
|||
|
|
/// </summary>
|
|||
|
|
private static async Task<bool> 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;
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// T0-8:把玩家脚下的地块类型与该地块的时段人流曲线合成实际密度。
|
|||
|
|
///
|
|||
|
|
/// density = 全局 CrowdDensity × 地块类型系数 × max(HourlyFlow[hour], CrowdHourFloor)
|
|||
|
|
///
|
|||
|
|
/// 为什么不用 UrbanDiversityConfig 的 PopulationDensity / DegreeOfBusyness:
|
|||
|
|
/// 实测 681 个地块里这两个字段几乎恒为 0(只有沙滩区有值),没有信息量。
|
|||
|
|
/// LandPlotType + HourlyFlow 是仅有的两个可用维度,HourlyFlow 已按各自峰值归一化到 1.0。
|
|||
|
|
/// </summary>
|
|||
|
|
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);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 世界进入(OnLoadingFinished)时激活客户端原生人群。
|
|||
|
|
/// 必须在 EnsureAetherVehicleInit 之后 —— 没有 HasZoneGraph=true 客户端根本不建人群层。
|
|||
|
|
/// </summary>
|
|||
|
|
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}");
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 玩家移动时流式补人:离开上次中心超过阈值就重发一次密度刷新盒。
|
|||
|
|
/// 由 GameRouter.Movement.cs 的位移报告处 fire-and-forget 调用。
|
|||
|
|
/// </summary>
|
|||
|
|
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}");
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>面板 / GM 显式刷新。返回是否真的发了(被合并掉返回 false)。</summary>
|
|||
|
|
internal static async Task<bool> 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;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// <summary>
|
|||
|
|
/// 只算不推:给 GM 面板回报「玩家脚下现在会算出什么密度」。
|
|||
|
|
/// 面板改完参数不动也能看到这个值变化,不用真的走 35 米才知道。
|
|||
|
|
/// </summary>
|
|||
|
|
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);
|
|||
|
|
}
|
|||
|
|
}
|