Files

354 lines
16 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
}