Files
Ananta-ps/Ananta.Server/Ananta.Handlers/Game/GameRouter.PartyNpcRoam.cs

378 lines
18 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.Serialization;
using Ananta.Server.Configuration;
using Ananta.Server.Protocol.Client4229938;
using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene;
namespace Ananta.Server.Handlers.Game;
/// <summary>
/// 派对 NPC「驻留行为」—— 让被邀请进派对的角色**自己会动**(2026-09-26 第 46 轮)。
///
/// WHY THIS FILE EXISTS
/// --------------------
/// 用户原话:「最主要的还是邀请进派对角色的行为问题,**不互动一个也要让角色们自由行动,
/// 自由做动作,不然还是死气沉沉**」。
///
/// 现场事实(第 45/46 轮实测):被邀角色**能被正确认出来(cardId 已修)、会播 POI 预制动作,
/// 但一动不动地站在原地做完那一套动作**。原因是 `SendCivilianAsync` 只发到「POI 动作」为止,
/// 少了让客户端接管 locomotion 的那几步。
///
/// ★★★★★ 官方口径(全部来自配表/客户端契约,不是猜)
/// · `SyncMoveWandering`(68745518) 的契约注释原文(CrowdMethods4229938.cs:204-207):
/// 「**让一个已投放的 NPC/行人"自己走起来"的唯一官方包。**
/// 它**不带目的地**——只有半径、角度窗口和时间预算,路径由客户端
/// ZoneGraph/AetherAI 自己挑。⇒ 服务端根本不需要路网来做"行走",
/// 只需要周期续期(MaxTime 是总时长,走完就永久站住)。」
/// · 项目自己的 static 路人配方(GameRouter.Ambient.cs:804-834 / 792-794):
/// `SendCivilianAsync`(建人)+ **`SyncAgentForceGo`(启动行为树)**… 且
/// 「crowd 通道是客户端自驱游荡,服务端发游荡包反而多余;**static 通道必须发,
/// 否则只播 idle**」—— 派对 NPC 正好走 static 通道。
/// · `GameRouter.Ambient.cs:16-27` 的终版配方把 `SyncLogicAgentEnter` 列为第 3 步:
/// 「把 id 注册进 logic-agent 管理器(**任务从它派发**)—— 不发它,任务可能被静默丢弃」。
///
/// ★ 为什么**不能**改用 crowd 通道(`SyncAetherAICrowdAdd` + `TargetLocationReason=1` 自驱游荡):
/// `ClientCrowdInitData4229938` 的字段只有
/// `NpcFormworkId / AgentPersonaId / UrbanDiversityConfigId / DesiredSpeed / ActionId /
/// TargetLocationReason / FashionSuitId` + 基类 `Id/Position/Facing/EulerAngles`
/// —— **没有 `AIAgentInfo`** ⇒ 带不了 `FightSpiritId` ⇒ `ClientData.cardId` 恒 0
/// ⇒ 第 45 轮刚修好的「烤肉邀请面板」会**立刻退回空列表**。所以通道必须留在 static。
///
/// ★★ 另一条被排除的路(配表级硬判据,别再回头):
/// 24 个可邀角色的 `AgentConfig.AiSettingId` 指向的行为集
/// (`AgentAIBehaviorSetsBattleAISBehaviorConfig`)**全部没有游荡行为**:
/// 40213395 Companion_Taffy / 40215027 Companion_zhuchang_Lin / 40213369 Companion_Battle /
/// 40215025 Bot_Common_SMG / 40213004 Behavior_EmptyBehavior(无战斗AI)…
/// 它们的 `Behavior_Wander` 与 `Behavior_StandBy` **都是空字符串**。
/// 对照:会走路的市民档是 40213001/40213002(`Main_Common_NPC`,
/// `Behavior_Wander = Behavior_Common_Wander`、`Behavior_StandBy = Behavior_Common_Patrol`)。
/// ⇒ 这些角色在官方场景里是「**跟着玩家的同伴**」,本来就不游荡;拿它们当驻场 NPC
/// 就必须由服务端/客户端额外下发行为意图。这正是本文件的职责。
///
/// ⚠ 已知边界(诚实标注)
/// 1. `SyncMoveWandering` 对 **crowd 通道(ECS/AgentEntity 管线)** 已被证伪
/// (Ambient.cs:33-34:走 WanderingActionModule = BaseUnit 管线 ⇒ 静默失败)。
/// 本文件只用在 **static 通道** 上(Ambient.cs:792-794 明确说 static 必须发)。
/// 若实测仍不动 ⇒ 把 `PartyNpcRoamMode` 换成 `"waypoint"`(路点巡游,同一份契约里
/// 被称作「唯一能产生走路步态的通道」),无需改代码。
/// 2. 游荡半径取小值(默认 2~10 m)——营地里有家具与碰撞体,绕远会走丢或穿模。
/// 3. 走完 `MaxTime` 就永久站住 ⇒ 必须**周期续期**,见 `PartyNpcRoamRearmSeconds`。
///
/// ★★★ 第 47 轮定案(用户现场):「**停走**」才是正解。
/// 用户实测 waypoint 档 = 「**可以走了,不过是乱走**」⇒ 走路通道本身已跑通
/// (`SyncLogicAgentCommand`(68050574) + `TaskWayPointMove`(tag 39) 有效,
/// `SyncMoveWandering` 无效),但马上要求「把角色**围成一圈**、**坐在地面**」。
/// ⇒ 默认 `PartyNpcRoamMode = "none"`;`SyncLogicAgentEnter` / `SyncAgentForceGo`
/// 已**上移到模式门之前**(`none` 档也要发 —— 行为树没起来,连 POI 坐姿都保不住)。
/// ⛔ 位置与朝向**本来就是对的**(`SpawnPartyNpcsAsync` 圆周排布 + `angle + π` 朝圆心),
/// 是 walk 任务把人走散、并用行走姿态顶掉了坐姿 ⇒ 不需要改坐标,也不需要另写编排。
/// </summary>
internal sealed partial class GameRouter
{
/// <summary>本 session 的驻留行为账本(随派对的 NPC 名单一起被重置)。</summary>
private const string PartyNpcRoamKey = "party.npc.roam";
/// <summary>巡游命令序号(只需单调递增;客户端把它回执回来)。</summary>
private static int s_partyRoamSeq;
internal sealed class PartyNpcRoamEntry
{
internal ulong EntityId;
internal float HomeX;
internal float HomeY;
internal float HomeZ;
/// <summary>上一次「下发/续期」的时刻(<see cref="Environment.TickCount64"/>)。</summary>
internal long ArmedTicks;
/// <summary>waypoint 模式:这一腿预计走完的时刻。</summary>
internal long EtaTicks;
/// <summary>waypoint 模式:下一腿的起点(= 上一腿的终点,服务端自己记账,不依赖客户端上报)。</summary>
internal float CursorX;
internal float CursorY;
internal float CursorZ;
}
internal sealed class PartyNpcRoamState
{
internal readonly List<PartyNpcRoamEntry> Entries = [];
internal readonly object SyncRoot = new();
internal CancellationTokenSource? Cts;
internal Task? Loop;
/// <summary>★ 第 49 轮:停走档那条日志<b>每批只打一次</b>(原先每人一条 ⇒ 10 条一样的话
/// 把 <c>[PARTYNPC] 生成汇总</c> 淹掉,而汇总才是真正要看的证据)。</summary>
internal bool StopWalkLogged;
}
private static PartyNpcRoamState RoamStateOf(TcpSession session)
{
if (!session.Items.TryGetValue(PartyNpcRoamKey, out var raw) || raw is not PartyNpcRoamState state)
{
state = new PartyNpcRoamState();
session.Items[PartyNpcRoamKey] = state;
}
return state;
}
/// <summary>重开派对(撤掉上一批人)时调用:停掉旧循环、清空账本。</summary>
internal static void ResetPartyNpcRoam(TcpSession session)
{
if (!session.Items.TryGetValue(PartyNpcRoamKey, out var raw) || raw is not PartyNpcRoamState state)
return;
state.Cts?.Cancel();
state.Cts?.Dispose();
state.Cts = null;
state.Loop = null;
lock (state.SyncRoot)
state.Entries.Clear();
state.StopWalkLogged = false; // ★ 第 49 轮:下一批重新打一次停走档日志
}
private static bool PartyRoamActive()
{
var mode = PrivateServerConfigStore.Current.Gameplay.PartyNpcRoamMode;
return !string.IsNullOrWhiteSpace(mode) && !mode.Equals("none", StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// 把一个人登记进驻留行为,并起后台续期循环。
/// ⛑ 在 <c>SendCivilianAsync</c> **之后**调用:单位必须先存在,任务才有人接。
/// </summary>
internal static async Task ArmPartyNpcRoamAsync(
TcpSession session, ulong entityId, float x, float y, float z, CancellationToken token)
{
var cfg = PrivateServerConfigStore.Current.Gameplay;
var state = RoamStateOf(session);
// ① `SyncLogicAgentEnter` —— 把 id 注册进客户端的 logic-agent 管理器。
// Ambient.cs 原文:「任务/同步都从它派发 —— 不发它,任务可能被静默丢弃」。
if (cfg.PartyNpcLogicEnter)
{
await session.NotifyAsync(MethodId.SyncLogicAgentEnter,
UxSerializer.Serialize(WorldCodec.LogicAgentEnter(entityId)), token);
}
// ② 独立 `SyncAgentForceGo` —— 启动行为树。
// static 路人配方里它是单独一个包(add 数据里的 ForceGo 字段不足以替代,
// 见 Ambient.cs:825-833:SendCivilianAsync 之后还要再发一次)。
if (cfg.PartyNpcForceGo)
{
await session.NotifyAsync(MethodId.IGameSceneToClient_SyncAgentForceGo,
UxSerializer.Serialize(new SceneMethods.SyncAgentForceGo4229938
{
id = entityId,
isForceGo = true,
}), token);
}
// ★★★ 第 47 轮:模式门从函数开头**搬到这里**(① Enter / ② ForceGo 之后)。
// 见 PrivateServerConfig.PartyNpcRoamMode 的 `"none"` 说明 —— 用户要的
// 「围成一圈、屁股坐在地面」就是停走档,而停走档**必须仍然**发 Enter/ForceGo
// (行为树没起来 ⇒ 连 POI 坐姿都保不住)⇒ 这两步不能跟着被一起跳过。
if (!PartyRoamActive())
{
// ★★★ 第 49 轮纠正:这条文案原写死「圆周站位 / 朝向圆心」= **露营专属措辞**,
// 而停走档对舞会同样成立(舞会 = 舞池点横排 + 全员统一朝向,见 PartyNpc.cs 的
// `unifiedFacingDeg` 分档)⇒ 在舞会里打这句话等于**把露营摆法说成舞会的**。
// 用户指令:「舞会有舞会的摆法,不要参考露营」⇒ 改成摆放中立的措辞,
// 站位/朝向一律以本批 `[PARTYNPC] 生成汇总` 那条为准。
if (!state.StopWalkLogged)
{
state.StopWalkLogged = true;
session.Log.Info(
$"[PARTYNPC] 驻留行为=停走(mode={cfg.PartyNpcRoamMode}):只发 Enter/ForceGo," +
"不发任何 locomotion ⇒ 角色留在各自站位上保持坐姿(站位与朝向见本批 " +
"[PARTYNPC] 生成汇总:露营=圆周+朝圆心,舞会=舞池横排+全员统一朝向)。" +
"本条每批只打一次。");
}
return;
}
var entry = new PartyNpcRoamEntry
{
EntityId = entityId,
HomeX = x,
HomeY = y,
HomeZ = z,
CursorX = x,
CursorY = y,
CursorZ = z,
};
lock (state.SyncRoot)
{
state.Entries.RemoveAll(e => e.EntityId == entityId);
state.Entries.Add(entry);
}
// ③ 行为意图本体。
await PushPartyRoamIntentAsync(session, entry, cfg, token);
EnsurePartyNpcRoamLoop(session, state);
}
/// <summary>按当前模式下发一次行为意图(并回写 <see cref="PartyNpcRoamEntry.ArmedTicks"/>)。</summary>
private static async Task PushPartyRoamIntentAsync(
TcpSession session, PartyNpcRoamEntry entry, GameplaySettings cfg, CancellationToken token)
{
if (cfg.PartyNpcRoamMode.Equals("waypoint", StringComparison.OrdinalIgnoreCase))
await PushPartyWaypointAsync(session, entry, cfg, token);
else
await PushPartyWanderAsync(session, entry, cfg, token);
}
/// <summary>
/// `SyncMoveWandering`(68745518) —— 客户端自驱游荡意图。
/// 逐字段照抄 `GameRouter.Ambient.PushAmbientWanderAsync`(同一条已验证的 static 配方),
/// 只有半径/时长换成派对用的保守值。
/// ⛑ `MoveId` 不是序号,是 `BattleMoveActionType`;填 0(Unknown) 等于「没有 locomotion 状态」。
/// </summary>
private static async Task PushPartyWanderAsync(
TcpSession session, PartyNpcRoamEntry entry, GameplaySettings cfg, CancellationToken token)
{
var minDis = MathF.Max(0.5f, cfg.PartyNpcRoamMinDis);
var maxDis = MathF.Max(minDis + 0.5f, cfg.PartyNpcRoamMaxDis);
await session.NotifyAsync(MethodId.SyncMoveWandering,
UxSerializer.Serialize(new SceneMethods.SyncMoveWandering4229938
{
Data = new SceneMethods.MoveWanderingData4229938
{
PathTags = cfg.PartyNpcRoamPathTags,
CloseObstacleAvoidance = true,
MinDis = minDis,
MaxDis = maxDis,
InRangeAngle = -180f,
OutRangeAngle = 180f,
MaxTime = cfg.PartyNpcRoamSeconds + 5f,
MaxOnceWanderTime = cfg.PartyNpcRoamSeconds,
UnitId = entry.EntityId,
MoveType = 0,
MoveId = WorldCodec.MoveAction.WalkFront,
CloseIK = false,
InstanceId = 0,
BlockCloseCapsuleCollision = false,
},
}), token);
entry.ArmedTicks = Environment.TickCount64;
}
/// <summary>
/// 对照/退路档:用 `SyncLogicAgentCommand`+`TaskWayPointMove`(tag 39) 让 NPC 沿一条
/// **绕着营地的小多边形**巡游。`Ambient.cs` 文件头称这条通道是
/// 「**唯一能产生走路步态的通道**」。
/// 服务端自己记 `Cursor*`(= 上一腿终点)⇒ 不需要客户端上报位置。
/// </summary>
private static async Task PushPartyWaypointAsync(
TcpSession session, PartyNpcRoamEntry entry, GameplaySettings cfg, CancellationToken token)
{
var radius = MathF.Max(0.5f, cfg.PartyNpcRoamMaxDis);
var angle = Random.Shared.NextSingle() * 2f * MathF.PI;
var tx = entry.HomeX + MathF.Sin(angle) * radius;
var tz = entry.HomeZ + MathF.Cos(angle) * radius;
// 落点保持与出发地同高(营地是平地;不要凭空造 Y 偏移 —— 铁律 17)。
var points = new List<SceneMethods.UxVector3>(2)
{
new(entry.CursorX, entry.CursorY, entry.CursorZ),
new(tx, entry.HomeY, tz),
};
var speed = MathF.Max(0.2f, cfg.PartyNpcRoamSpeed);
var dist = MathF.Sqrt((tx - entry.CursorX) * (tx - entry.CursorX) + (tz - entry.CursorZ) * (tz - entry.CursorZ));
await session.NotifyAsync(MethodId.IGameSceneToClient_SyncLogicAgentCommand,
UxSerializer.Serialize(WorldCodec.LogicAgentCommandWayPoint(
entry.EntityId, Interlocked.Increment(ref s_partyRoamSeq),
SceneMethods.AgentMovementMethods.Walk, speed,
moveActionId: 0u, moveActionGroup: 0u,
keepMovingAction: true, points, noRootMotion: true)), token);
entry.CursorX = tx;
entry.CursorY = entry.HomeY;
entry.CursorZ = tz;
entry.ArmedTicks = Environment.TickCount64;
entry.EtaTicks = entry.ArmedTicks + (long)(dist / speed * 1000f) + Math.Max(0, cfg.PartyNpcRoamLegSlackMs);
}
/// <summary>
/// 续期循环。`MaxTime` 走完客户端会**永久站住**,所以必须周期性补发。
/// 与 `EnsureLeftBehindLoop` 同款:session 断开后写 socket 会抛异常,循环自己退出,
/// 不需要额外的停止钩子。
/// </summary>
private static void EnsurePartyNpcRoamLoop(TcpSession session, PartyNpcRoamState state)
{
lock (state.SyncRoot)
{
if (state.Loop is { IsCompleted: false })
return;
state.Cts?.Dispose();
var cts = new CancellationTokenSource();
state.Cts = cts;
state.Loop = Task.Run(() => RunPartyNpcRoamLoopAsync(session, state, cts.Token));
}
}
private static async Task RunPartyNpcRoamLoopAsync(
TcpSession session, PartyNpcRoamState state, CancellationToken token)
{
session.Log.Info($"[PARTYNPC] 驻留行为循环启动 mode={PrivateServerConfigStore.Current.Gameplay.PartyNpcRoamMode}");
var waypointMode = PrivateServerConfigStore.Current.Gameplay.PartyNpcRoamMode
.Equals("waypoint", StringComparison.OrdinalIgnoreCase);
while (!token.IsCancellationRequested)
{
try
{
var cfg = PrivateServerConfigStore.Current.Gameplay;
var tick = Math.Clamp(cfg.PartyNpcRoamTickMs, 200, 30_000);
await Task.Delay(tick, token);
// 客户端挂起期间只睡不推 —— 见 GameRouter.IsBackgroundPushPaused 的说明。
if (IsBackgroundPushPaused(session))
continue;
if (!PartyRoamActive())
continue;
List<PartyNpcRoamEntry> snapshot;
lock (state.SyncRoot)
snapshot = [.. state.Entries];
if (snapshot.Count == 0)
continue;
var now = Environment.TickCount64;
var rearmMs = (long)(MathF.Max(3f, cfg.PartyNpcRoamRearmSeconds) * 1000f);
foreach (var entry in snapshot)
{
// wander 档:到点就整条续期;waypoint 档:上一腿预计走完才派下一腿。
var due = waypointMode
? now >= entry.EtaTicks
: now - entry.ArmedTicks >= rearmMs;
if (!due)
continue;
await PushPartyRoamIntentAsync(session, entry, cfg, token);
}
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
// session 断了就会走到这里(往死 socket 写)。记一条然后退出,不要刷屏。
session.Log.Info($"[PARTYNPC] 驻留行为循环退出:{ex.GetType().Name}: {ex.Message}");
break;
}
}
}
}