Files

353 lines
16 KiB
C#
Raw Permalink Normal View History

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