From 5deebc558bff4782e6967ad1a8cc0c4d2d43acb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=82ngelo=20Tadeucci?= Date: Sun, 8 Feb 2026 23:52:08 -0300 Subject: [PATCH] Add spawn radius and pool-based NPC spawning Apply random offset within a SpawnRadius when spawning NPCs and snap the resulting position to the navmesh using DotRecast (with logging). Refactor TriggerContext spawn logic to build a per-spawn pool from NpcList, shuffle and cap to NpcCount, validate metadata, pass the SpawnPointNPC to SpawnNpc, and broadcast spawned NPCs. Also add required using imports and minor cleanup around spawn position handling and logging. --- .../Field/FieldManager/FieldManager.State.cs | 29 ++++++++++- .../Trigger/TriggerContext.Npc.cs | 48 ++++++++++++------- 2 files changed, 57 insertions(+), 20 deletions(-) diff --git a/Maple2.Server.Game/Manager/Field/FieldManager/FieldManager.State.cs b/Maple2.Server.Game/Manager/Field/FieldManager/FieldManager.State.cs index bbd8a3e9..89b3d89f 100644 --- a/Maple2.Server.Game/Manager/Field/FieldManager/FieldManager.State.cs +++ b/Maple2.Server.Game/Manager/Field/FieldManager/FieldManager.State.cs @@ -2,6 +2,7 @@ using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Numerics; +using DotRecast.Core.Numerics; using DotRecast.Detour.Crowd; using Maple2.Database.Storage; using Maple2.Model.Common; @@ -16,6 +17,7 @@ using Maple2.Server.Game.Util; using Maple2.Tools; using Maple2.Tools.Collision; +using Maple2.Tools.DotRecast; using Maple2.Tools.Extensions; using Maple2.Tools.VectorMath; using Serilog; @@ -103,10 +105,33 @@ public FieldPlayer SpawnPlayer(GameSession session, Player player, int portalId } public FieldNpc? SpawnNpc(NpcMetadata npc, Vector3 position, Vector3 rotation, FieldMobSpawn? owner = null, SpawnPointNPC? spawnPointNpc = null, string spawnAnimation = "") { - DtCrowdAgent agent = Navigation.AddAgent(npc, position); + // Apply random offset if SpawnRadius is set + Vector3 spawnPosition = position; + if (spawnPointNpc?.SpawnRadius > 0) { + float angle = Random.Shared.NextSingle() * MathF.PI * 2; + float distance = Random.Shared.NextSingle() * spawnPointNpc.SpawnRadius; + Vector3 offset = new Vector3( + MathF.Cos(angle) * distance, + MathF.Sin(angle) * distance, + 0 + ); + Vector3 randomizedPosition = position + offset; + + // Validate and snap to nearest valid navmesh position + if (FindNearestPoly(randomizedPosition, out long nearestRef, out RcVec3f validPosition) && nearestRef != 0) { + spawnPosition = DotRecastHelper.FromNavMeshSpace(validPosition); + logger.Debug("[SpawnNpc] Applied spawn radius {Radius} to NPC {NpcId} at SpawnPoint {SpawnPointId}, offset: {Offset}, snapped from {Original} to {Final}", + spawnPointNpc.SpawnRadius, npc.Id, spawnPointNpc.SpawnPointId, offset, randomizedPosition, spawnPosition); + } else { + logger.Warning("[SpawnNpc] Randomized position {Position} invalid for NPC {NpcId}, using original spawn point {Original}", + randomizedPosition, npc.Id, position); + spawnPosition = position; + } + } + + DtCrowdAgent agent = Navigation.AddAgent(npc, spawnPosition); AnimationMetadata? animation = NpcMetadata.GetAnimation(npc.Model.Name); - Vector3 spawnPosition = position; var fieldNpc = new FieldNpc(this, NextLocalId(), agent, new Npc(npc, animation), npc.AiPath, patrolDataUUID: spawnPointNpc?.PatrolData, spawnAnimation: spawnAnimation) { Owner = owner, Position = spawnPosition, diff --git a/Maple2.Server.Game/Trigger/TriggerContext.Npc.cs b/Maple2.Server.Game/Trigger/TriggerContext.Npc.cs index 3786f7e6..3faacf12 100644 --- a/Maple2.Server.Game/Trigger/TriggerContext.Npc.cs +++ b/Maple2.Server.Game/Trigger/TriggerContext.Npc.cs @@ -287,30 +287,42 @@ private void SpawnNpc(int spawnId, bool useSpawnAnimation = false) { return; } - List npcList = spawn.NpcList.OrderBy(_ => Random.Shared.Next()).Take(spawn.NpcCount).ToList(); + DebugLog("[SpawnNpc] spawnId:{SpawnId}, SpawnRadius:{SpawnRadius}, NpcCount:{NpcCount}, Position:{Position}", + spawnId, spawn.SpawnRadius, spawn.NpcCount, spawn.Position); + + // NpcCount is the total number of NPCs that can exist at this spawn point. + // Each NpcList entry's Count is how many of that type can spawn. + // Build a pool of individual NPC ids from the list, shuffle, and take up to NpcCount. + List npcPool = []; + foreach (SpawnPointNPCListEntry entry in spawn.NpcList) { + for (int j = 0; j < entry.Count; j++) { + npcPool.Add(entry.NpcId); + } + } + + // Shuffle and cap at NpcCount + IEnumerable npcIdsToSpawn = npcPool.OrderBy(_ => Random.Shared.Next()).Take(spawn.NpcCount); - foreach (SpawnPointNPCListEntry entry in npcList) { - if (!Field.NpcMetadata.TryGet(entry.NpcId, out NpcMetadata? npc)) { - logger.Error("[SpawnNpc] Invalid npcId:{NpcId}", entry.NpcId); + foreach (int npcId in npcIdsToSpawn) { + if (!Field.NpcMetadata.TryGet(npcId, out NpcMetadata? npc)) { + logger.Error("[SpawnNpc] Invalid npcId:{NpcId}", npcId); continue; } - for (int i = 0; i < entry.Count; i++) { - string spawnAnimationString = string.Empty; - if (!string.IsNullOrEmpty(spawn.SpawnAnimation) && useSpawnAnimation) { - spawnAnimationString = spawn.SpawnAnimation; - } - FieldNpc? fieldNpc = Field.SpawnNpc(npc, spawn.Position, spawn.Rotation, spawnAnimation: spawnAnimationString); - if (fieldNpc == null) { - logger.Error("[SpawnNpc] Failed to spawn npcId:{NpcId}", entry.NpcId); - continue; - } + string spawnAnimationString = string.Empty; + if (!string.IsNullOrEmpty(spawn.SpawnAnimation) && useSpawnAnimation) { + spawnAnimationString = spawn.SpawnAnimation; + } + FieldNpc? fieldNpc = Field.SpawnNpc(npc, spawn.Position, spawn.Rotation, spawnPointNpc: spawn, spawnAnimation: spawnAnimationString); + if (fieldNpc == null) { + logger.Error("[SpawnNpc] Failed to spawn npcId:{NpcId}", npcId); + continue; + } - fieldNpc.SpawnPointId = spawnId; + fieldNpc.SpawnPointId = spawnId; - Field.Broadcast(FieldPacket.AddNpc(fieldNpc)); - Field.Broadcast(ProxyObjectPacket.AddNpc(fieldNpc)); - } + Field.Broadcast(FieldPacket.AddNpc(fieldNpc)); + Field.Broadcast(ProxyObjectPacket.AddNpc(fieldNpc)); } } }