This repository has been archived on 2026-10-31. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files

377 lines
18 KiB
C#
Raw Permalink Normal View History

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