From 73b4af55e9cb587c85cbcddc5cf61ee29c19f355 Mon Sep 17 00:00:00 2001
From: Arufonsu <17498701+Arufonsu@users.noreply.github.com>
Date: Sat, 27 Dec 2025 16:50:44 -0300
Subject: [PATCH] update: NPC and Critter movement
NPC and Critter movement:
- Refactors how both, NPCs and Critters randomly move around, replacing the boring single-tile-paused movement with a more natural, range-based path logic.
- NPCs now intelligently navigate around obstacles and other entities
- Collision detection overhead with optimized valid direction finding
- Prevents movement patterns from messing with current path finder's target
- Code chore: cached status check consolidation
- Code chore: updated naming conventions for movement variables
- Code chore: TODOs - soft reset (walk) to spawn point and hard reset (warp) when pathfinder is unable to lead back to spawnpoint
(New) NPC Patrol Patterns:
- Horizontal patrol (left/right from spawn)
- Vertical patrol (up/down from spawn)
- Diagonal backslash patrol (UpLeft <-> DownRight)
- Diagonal forwardslash patrol (UpRight <-> DownLeft)
- Patrolling NPCs return to their spawn/patrol point when there's no target left to smack or out of former map
All patrol modes use NPC sight range as patrol distance and keep track of their spawn position for reseting purposes.
---
Intersect (Core)/Enums/NpcMovement.cs | 8 +
Intersect.Client.Core/Entities/Critter.cs | 133 ++---
Intersect.Editor/Localization/Strings.cs | 4 +
Intersect.Server.Core/Entities/Npc.cs | 680 ++++++++++++++++------
Intersect.Server.Core/Entities/Player.cs | 22 +-
5 files changed, 594 insertions(+), 253 deletions(-)
diff --git a/Intersect (Core)/Enums/NpcMovement.cs b/Intersect (Core)/Enums/NpcMovement.cs
index b001c35567..37e4ef4515 100644
--- a/Intersect (Core)/Enums/NpcMovement.cs
+++ b/Intersect (Core)/Enums/NpcMovement.cs
@@ -9,4 +9,12 @@ public enum NpcMovement
StandStill,
Static,
+
+ HorizontalPatrol,
+
+ VerticalPatrol,
+
+ BackslashPatrol,
+
+ ForwardslashPatrol,
}
diff --git a/Intersect.Client.Core/Entities/Critter.cs b/Intersect.Client.Core/Entities/Critter.cs
index 55a0619e7e..11aa138976 100644
--- a/Intersect.Client.Core/Entities/Critter.cs
+++ b/Intersect.Client.Core/Entities/Critter.cs
@@ -15,7 +15,10 @@ namespace Intersect.Client.Entities;
public partial class Critter : Entity
{
private readonly MapCritterAttribute mAttribute;
- private long mLastMove = -1;
+
+ // Critter's Movement
+ private long _lastMove = -1;
+ private byte _randomMoveRange;
public Critter(MapInstance map, byte x, byte y, MapCritterAttribute att) : base(Guid.NewGuid(), null, EntityType.GlobalEntity)
{
@@ -50,65 +53,65 @@ public Critter(MapInstance map, byte x, byte y, MapCritterAttribute att) : base(
public override bool Update()
{
- if (base.Update())
+ if (!base.Update())
{
- if (mLastMove < Timing.Global.MillisecondsUtc)
- {
- switch (mAttribute.Movement)
- {
- case 0: //Move Randomly
- MoveRandomly();
- break;
- case 1: //Turn?
- DirectionFacing = Randomization.NextDirection();
- break;
-
- }
-
- mLastMove = Timing.Global.MillisecondsUtc + mAttribute.Frequency + Globals.Random.Next((int)(mAttribute.Frequency * .5f));
- }
+ return false;
+ }
+ // Only skip if we are NOT in the middle of a range-walk AND the frequency timer is active
+ if (_randomMoveRange <= 0 && _lastMove >= Timing.Global.MillisecondsUtc)
+ {
return true;
}
- return false;
+ switch (mAttribute.Movement)
+ {
+ case 0: // Move Randomly
+ MoveRandomly();
+ break;
+ case 1: // Turn Randomly
+ DirectionFacing = Randomization.NextDirection();
+ // Set pause after turning
+ _lastMove = Timing.Global.MillisecondsUtc + mAttribute.Frequency + Globals.Random.Next((int)(mAttribute.Frequency * .5f));
+ break;
+ }
+
+ return true;
}
private void MoveRandomly()
{
- DirectionMoving = Randomization.NextDirection();
- var tmpX = (sbyte)X;
- var tmpY = (sbyte)Y;
- IEntity? blockedBy = null;
-
+ // Don't start a new step if currently moving between tiles
if (IsMoving || MoveTimer >= Timing.Global.MillisecondsUtc)
{
return;
}
+ // No range left: pick a new direction and range
+ if (_randomMoveRange <= 0)
+ {
+ DirectionFacing = Randomization.NextDirection();
+ _randomMoveRange = (byte)Randomization.Next(1, 5);
+ }
+
var deltaX = 0;
var deltaY = 0;
-
- switch (DirectionMoving)
+ switch (DirectionFacing)
{
case Direction.Up:
- deltaX = 0;
deltaY = -1;
break;
case Direction.Down:
- deltaX = 0;
deltaY = 1;
break;
case Direction.Left:
deltaX = -1;
- deltaY = 0;
break;
case Direction.Right:
deltaX = 1;
- deltaY = 0;
break;
case Direction.UpLeft:
@@ -132,59 +135,37 @@ private void MoveRandomly()
break;
}
- if (deltaX != 0 || deltaY != 0)
- {
- var newX = tmpX + deltaX;
- var newY = tmpY + deltaY;
- var isBlocked = -1 ==
- IsTileBlocked(
- new Point(newX, newY),
- Z,
- MapId,
- ref blockedBy,
- true,
- true,
- mAttribute.IgnoreNpcAvoids
- );
- var playerOnTile = PlayerOnTile(MapId, newX, newY);
-
- if (isBlocked && newX >= 0 && newX < Options.Instance.Map.MapWidth && newY >= 0 && newY < Options.Instance.Map.MapHeight &&
- (!mAttribute.BlockPlayers || !playerOnTile))
- {
- tmpX += (sbyte)deltaX;
- tmpY += (sbyte)deltaY;
- IsMoving = true;
- DirectionFacing = DirectionMoving;
+ var newX = (sbyte)X + deltaX;
+ var newY = (sbyte)Y + deltaY;
+ IEntity? blockedBy = null;
- if (deltaX == 0)
- {
- OffsetX = 0;
- }
- else
- {
- OffsetX = deltaX > 0 ? -Options.Instance.Map.TileWidth : Options.Instance.Map.TileWidth;
- }
+ // Boundary checks
+ var isBlocked = -1 == IsTileBlocked(new Point(newX, newY), Z, MapId, ref blockedBy, true, true, mAttribute.IgnoreNpcAvoids);
+ var playerOnTile = PlayerOnTile(MapId, newX, newY);
- if (deltaY == 0)
- {
- OffsetY = 0;
- }
- else
- {
- OffsetY = deltaY > 0 ? -Options.Instance.Map.TileHeight : Options.Instance.Map.TileHeight;
- }
- }
- }
-
- if (IsMoving)
+ if (isBlocked && !playerOnTile &&
+ newX >= 0 && newX < Options.Instance.Map.MapWidth &&
+ newY >= 0 && newY < Options.Instance.Map.MapHeight)
{
- X = (byte)tmpX;
- Y = (byte)tmpY;
+ X = (byte)newX;
+ Y = (byte)newY;
+ IsMoving = true;
+ OffsetX = deltaX == 0 ? 0 : (deltaX > 0 ? -Options.Instance.Map.TileWidth : Options.Instance.Map.TileWidth);
+ OffsetY = deltaY == 0 ? 0 : (deltaY > 0 ? -Options.Instance.Map.TileHeight : Options.Instance.Map.TileHeight);
MoveTimer = Timing.Global.MillisecondsUtc + (long)GetMovementTime();
+ _randomMoveRange--;
+
+ // Critter's last step: set an idle pause timer
+ if (_randomMoveRange <= 0)
+ {
+ _lastMove = Timing.Global.MillisecondsUtc + mAttribute.Frequency + Globals.Random.Next((int)(mAttribute.Frequency * .5f));
+ }
}
- else if (DirectionMoving != DirectionFacing)
+ else
{
- DirectionFacing = DirectionMoving;
+ // Blocked by something: end range early and trigger pause
+ _randomMoveRange = 0;
+ _lastMove = Timing.Global.MillisecondsUtc + mAttribute.Frequency;
}
}
diff --git a/Intersect.Editor/Localization/Strings.cs b/Intersect.Editor/Localization/Strings.cs
index 9321a0d29d..9c83dddd14 100644
--- a/Intersect.Editor/Localization/Strings.cs
+++ b/Intersect.Editor/Localization/Strings.cs
@@ -4582,6 +4582,10 @@ public partial struct NpcEditor
{1, @"Turn Randomly"},
{2, @"Stand Still"},
{3, @"Static"},
+ {4, @"Horizontal Patrol"},
+ {5, @"Vertical Patrol"},
+ {6, @"Backslash Patrol (\)"},
+ {7, @"Forwardslash Patrol (/)"},
};
public static LocalizedString mpregen = @"MP (%):";
diff --git a/Intersect.Server.Core/Entities/Npc.cs b/Intersect.Server.Core/Entities/Npc.cs
index 118855cdbb..c215337cdc 100644
--- a/Intersect.Server.Core/Entities/Npc.cs
+++ b/Intersect.Server.Core/Entities/Npc.cs
@@ -64,25 +64,23 @@ public Entity DamageMapHighest
public bool Despawnable;
//Moving
- public long LastRandomMove;
- private byte _randomMoveRange;
+ private long _lastMovement;
+ private byte _movementRange;
+ private int _patrolOriginX = -1;
+ private int _patrolOriginY = -1;
+ private bool _movingAwayFromPatrolOrigin;
+ private readonly Direction[] _lastFleeDirs = new Direction[3];
//Pathfinding
private Pathfinder mPathFinder;
-
private Task mPathfindingTask;
-
public byte Range;
//Respawn/Despawn
- public long RespawnTime;
-
public long FindTargetWaitTime;
public int FindTargetDelay = 500;
-
private int mTargetFailCounter = 0;
private int mTargetFailMax = 10;
-
private int mResetDistance = 0;
private int mResetCounter = 0;
private int mResetMax = 100;
@@ -107,6 +105,21 @@ public Entity DamageMapHighest
/// The Z value on which this NPC was "aggro'd" and started chasing a target.
///
public int AggroCenterZ;
+
+ ///
+ /// The map on which this NPC originally spawned.
+ ///
+ private MapController? _spawnMap;
+
+ ///
+ /// The X coordinate where this NPC originally spawned.
+ ///
+ private int _spawnX;
+
+ ///
+ /// The Y coordinate where this NPC originally spawned.
+ ///
+ private int _spawnY;
public Npc(NPCDescriptor npcDescriptor, bool despawnable = false) : base()
{
@@ -159,6 +172,8 @@ public Npc(NPCDescriptor npcDescriptor, bool despawnable = false) : base()
private bool IsStunnedOrSleeping => CachedStatuses.Any(PredicateStunnedOrSleeping);
private bool IsUnableToCastSpells => CachedStatuses.Any(PredicateUnableToCastSpells);
+
+ private bool IsUnableToMove => CachedStatuses.Any(PredicateUnableToMove);
public override EntityType GetEntityType()
{
@@ -170,11 +185,7 @@ public override void Die(bool generateLoot = true, Entity killer = null)
lock (EntityLock)
{
base.Die(generateLoot, killer);
-
- AggroCenterMap = null;
- AggroCenterX = 0;
- AggroCenterY = 0;
- AggroCenterZ = 0;
+ ResetAggroCenter();
if (MapController.TryGetInstanceFromMap(MapId, MapInstanceId, out var instance))
{
@@ -335,13 +346,9 @@ public override bool CanAttack(Entity entity, SpellDescriptor spell)
return false;
}
- //Check if the attacker is stunned or blinded.
- foreach (var status in CachedStatuses)
+ if (IsStunnedOrSleeping)
{
- if (status.Type == SpellEffect.Stun || status.Type == SpellEffect.Sleep)
- {
- return false;
- }
+ return false;
}
if (entity.HasStatusEffect(SpellEffect.Stealth))
@@ -538,6 +545,31 @@ private static bool PredicateUnableToCastSpells(Status status)
}
}
+ private static bool PredicateUnableToMove(Status status)
+ {
+ switch (status?.Type)
+ {
+ case SpellEffect.Stun:
+ case SpellEffect.Sleep:
+ case SpellEffect.Snare:
+ return true;
+ case SpellEffect.Silence:
+ case SpellEffect.None:
+ case SpellEffect.Blind:
+ case SpellEffect.Stealth:
+ case SpellEffect.Transform:
+ case SpellEffect.Cleanse:
+ case SpellEffect.Invulnerable:
+ case SpellEffect.Shield:
+ case SpellEffect.OnHit:
+ case SpellEffect.Taunt:
+ case null:
+ return false;
+ default:
+ throw new ArgumentOutOfRangeException();
+ }
+ }
+
protected override bool IgnoresNpcAvoid => false;
///
@@ -717,14 +749,14 @@ private void TryCastSpells()
var dirToEnemy = DirectionToTarget(target);
if (dirToEnemy != Dir)
{
- if (LastRandomMove >= Timing.Global.Milliseconds)
+ if (_lastMovement >= Timing.Global.Milliseconds)
{
return;
}
//Face the target -- next frame fire -- then go on with life
ChangeDir(dirToEnemy); // Gotta get dir to enemy
- LastRandomMove = Timing.Global.Milliseconds + Randomization.Next(1000, 3000);
+ _lastMovement = Timing.Global.Milliseconds + Randomization.Next(1000, 3000);
return;
}
@@ -805,7 +837,110 @@ public bool IsFleeing()
return false;
}
- // TODO: Improve NPC movement to be more fluid like a player
+ private bool TrySmartFlee(Entity? target)
+ {
+ if (target == null || mResetting || IsUnableToMove || IsStunnedOrSleeping || IsCasting)
+ {
+ return false;
+ }
+
+ var bestDir = GetBestFleeDirection(target);
+ if (bestDir == Direction.None)
+ {
+ return false;
+ }
+
+ ChangeDir(bestDir);
+ if (CanMoveInDirection(bestDir, out var blockerType, out _, out _) || blockerType == MovementBlockerType.Slide)
+ {
+ Move(bestDir, null);
+ ShiftFleeHistory(bestDir);
+ return true;
+ }
+
+ return false;
+ }
+
+ private Direction GetBestFleeDirection(Entity target)
+ {
+ var bestDirs = new List<(Direction dir, int score)>();
+ var dirs = new[]
+ {
+ Direction.Up, Direction.Down,
+ Direction.Left, Direction.Right,
+ Direction.UpLeft, Direction.UpRight,
+ Direction.DownLeft, Direction.DownRight
+ };
+ var tx = target.X;
+ var ty = target.Y;
+ var curDistSq = (X - tx) * (X - tx) + (Y - ty) * (Y - ty);
+
+ foreach (var dir in dirs)
+ {
+ if (CanMoveInDirection(dir, out var blockerType2, out _, out _) || blockerType2 == MovementBlockerType.Slide)
+ {
+ var (dx, dy) = GetDirOffset(dir);
+ var nx = X + dx;
+ var ny = Y + dy;
+ var newDistSq = (nx - tx) * (nx - tx) + (ny - ty) * (ny - ty);
+ var delta = newDistSq - curDistSq;
+ if (delta <= 0)
+ {
+ continue;
+ }
+
+ var score = delta * delta;
+ if (!IsDiagonal(dir))
+ {
+ score += delta / 2;
+ }
+
+ score += GetAntiLoopMultiplier(dir) * delta;
+ bestDirs.Add((dir, score));
+ }
+ }
+
+ if (bestDirs.Count == 0)
+ {
+ return Direction.None;
+ }
+
+ var maxScore = bestDirs.Max(d => d.score);
+ var topDirs = bestDirs.Where(d => d.score >= maxScore - (maxScore / 20)).Select(d => d.dir).ToArray();
+ return topDirs[Randomization.Next(topDirs.Length)];
+ }
+
+ private static (int dx, int dy) GetDirOffset(Direction dir) => dir switch
+ {
+ Direction.Up => (0, -1),
+ Direction.Down => (0, 1),
+ Direction.Left => (-1, 0),
+ Direction.Right => (1, 0),
+ Direction.UpLeft => (-1, -1),
+ Direction.UpRight => (1, -1),
+ Direction.DownLeft => (-1, 1),
+ Direction.DownRight => (1, 1),
+ _ => (0, 0)
+ };
+
+ private static bool IsDiagonal(Direction dir) => dir is Direction.UpLeft or Direction.UpRight or Direction.DownLeft or Direction.DownRight;
+
+ private int GetAntiLoopMultiplier(Direction dir)
+ {
+ var mult = _lastFleeDirs.Where(t => t == dir).Aggregate(1, (current, t) => current - 1);
+ return Math.Max(mult, -2);
+ }
+
+ private void ShiftFleeHistory(Direction dir)
+ {
+ for (var i = _lastFleeDirs.Length - 1; i > 0; i--)
+ {
+ _lastFleeDirs[i] = _lastFleeDirs[i - 1];
+ }
+
+ _lastFleeDirs[0] = dir;
+ }
+
//General Updating
public override void Update(long timeMs)
{
@@ -815,17 +950,20 @@ public override void Update(long timeMs)
Monitor.TryEnter(EntityLock, ref lockObtained);
if (lockObtained)
{
+ if (_spawnMap == null && MapController.TryGet(MapId, out var currentMap))
+ {
+ _spawnMap = currentMap;
+ _spawnX = X;
+ _spawnY = Y;
+ }
+
var curMapLink = MapId;
base.Update(timeMs);
-
var tempTarget = Target;
- foreach (var status in CachedStatuses)
+ if (IsStunnedOrSleeping)
{
- if (status.Type is SpellEffect.Stun or SpellEffect.Sleep)
- {
- return;
- }
+ return;
}
var fleeing = IsFleeing();
@@ -860,7 +998,7 @@ public override void Update(long timeMs)
// Are we resetting? If so, regenerate completely!
if (mResetting)
{
- var distance = GetDistanceTo(AggroCenterMap, AggroCenterX, AggroCenterY);
+ var distance = GetDistanceTo(_spawnMap, _spawnX, _spawnY);
// Have we reached our destination? If so, clear it.
if (distance < 1)
{
@@ -881,6 +1019,7 @@ public override void Update(long timeMs)
mResetCounter++;
if (mResetCounter > mResetMax)
{
+ Warp(_spawnMap.Id, _spawnX, _spawnY);
ResetAggroCenter(out targetMap);
mResetCounter = 0;
mResetDistance = 0;
@@ -941,11 +1080,33 @@ public override void Update(long timeMs)
if (mPathFinder.GetTarget() == null)
{
- mPathFinder.SetTarget(new PathfinderTarget(targetMap, targetX, targetY, targetZ));
+ if (fleeing && tempTarget != null && !mResetting)
+ {
+ // Safe far flee spot
+ var fx = X; var fy = Y;
+ if (tempTarget.X < X)
+ {
+ fx = Math.Min(X + 5, Options.Instance.Map.MapWidth - 1);
+ }
+ else if (tempTarget.X > X)
+ {
+ fx = Math.Max(X - 5, 0);
+ }
+
+ if (tempTarget.Y < Y)
+ {
+ fy = Math.Min(Y + 5, Options.Instance.Map.MapHeight - 1);
+ }
+ else if (tempTarget.Y > Y)
+ {
+ fy = Math.Max(Y - 5, 0);
+ }
- if (tempTarget != null && tempTarget != Target)
+ mPathFinder.SetTarget(new PathfinderTarget(MapId, fx, fy, Z));
+ }
+ else
{
- tempTarget = Target;
+ mPathFinder.SetTarget(new PathfinderTarget(targetMap, targetX, targetY, targetZ));
}
}
@@ -954,13 +1115,10 @@ public override void Update(long timeMs)
if (mPathFinder.GetTarget() != null && Descriptor.Movement != (int)NpcMovement.Static)
{
TryCastSpells();
- // TODO: Make resetting mobs actually return to their starting location.
if ((!mResetting && !IsOneBlockAway(
mPathFinder.GetTarget().TargetMapId, mPathFinder.GetTarget().TargetX,
mPathFinder.GetTarget().TargetY, mPathFinder.GetTarget().TargetZ
- )) ||
- (mResetting && GetDistanceTo(AggroCenterMap, AggroCenterX, AggroCenterY) != 0)
- )
+ )) || (mResetting && GetDistanceTo(_spawnMap, _spawnX, _spawnY) != 0))
{
var pathFinderResult = mPathFinder.Update(timeMs);
switch (pathFinderResult.Type)
@@ -969,35 +1127,11 @@ public override void Update(long timeMs)
var nextPathDirection = mPathFinder.GetMove();
if (nextPathDirection > Direction.None)
{
- if (fleeing)
- {
- nextPathDirection = nextPathDirection switch
- {
- Direction.Up => Direction.Down,
- Direction.Down => Direction.Up,
- Direction.Left => Direction.Right,
- Direction.Right => Direction.Left,
- Direction.UpLeft => Direction.UpRight,
- Direction.UpRight => Direction.UpLeft,
- Direction.DownRight => Direction.DownLeft,
- Direction.DownLeft => Direction.DownRight,
- _ => nextPathDirection,
- };
- }
-
if (CanMoveInDirection(nextPathDirection, out var blockerType, out var blockingEntityType, out var blockingEntity) || blockerType == MovementBlockerType.Slide)
{
- //check if NPC is snared or stunned
- // ReSharper disable once LoopCanBeConvertedToQuery
- foreach (var status in CachedStatuses)
+ if (IsUnableToMove)
{
- // ReSharper disable once MergeIntoLogicalPattern
- if (status.Type == SpellEffect.Stun ||
- status.Type == SpellEffect.Snare ||
- status.Type == SpellEffect.Sleep)
- {
- return;
- }
+ return;
}
Move(nextPathDirection, null);
@@ -1031,18 +1165,11 @@ public override void Update(long timeMs)
// Are we resetting?
if (mResetting)
{
- // Have we reached our destination? If so, clear it.
- if (GetDistanceTo(AggroCenterMap, AggroCenterX, AggroCenterY) == 0)
+ // Have we reached our spawn destination? If so, clear it.
+ if (GetDistanceTo(_spawnMap, _spawnX, _spawnY) == 0)
{
targetMap = Guid.Empty;
-
- // Reset our aggro center so we can get "pulled" again.
- AggroCenterMap = null;
- AggroCenterX = 0;
- AggroCenterY = 0;
- AggroCenterZ = 0;
- mPathFinder?.SetTarget(null);
- mResetting = false;
+ ResetAggroCenter();
}
}
}
@@ -1068,65 +1195,18 @@ public override void Update(long timeMs)
default:
throw new ArgumentOutOfRangeException();
}
+
+ if (tempTarget == null)
+ {
+ SoftReset();
+ }
}
else
{
var fleed = false;
if (tempTarget != null && fleeing)
{
- var dir = DirectionToTarget(tempTarget);
- switch (dir)
- {
- case Direction.Up:
- dir = Direction.Down;
-
- break;
- case Direction.Down:
- dir = Direction.Up;
-
- break;
- case Direction.Left:
- dir = Direction.Right;
-
- break;
- case Direction.Right:
- dir = Direction.Left;
-
- break;
- case Direction.UpLeft:
- dir = Direction.UpRight;
-
- break;
- case Direction.UpRight:
- dir = Direction.UpLeft;
- break;
-
- case Direction.DownRight:
- dir = Direction.DownLeft;
-
- break;
- case Direction.DownLeft:
- dir = Direction.DownRight;
-
- break;
- }
-
- if (CanMoveInDirection(dir, out var blockerType, out _) || blockerType == MovementBlockerType.Slide)
- {
- //check if NPC is snared or stunned
- foreach (var status in CachedStatuses)
- {
- if (status.Type == SpellEffect.Stun ||
- status.Type == SpellEffect.Snare ||
- status.Type == SpellEffect.Sleep)
- {
- return;
- }
- }
-
- Move(dir, null);
- fleed = true;
- }
+ fleed = TrySmartFlee(tempTarget);
}
if (!fleed)
@@ -1159,28 +1239,40 @@ public override void Update(long timeMs)
CheckForResetLocation();
- if (targetMap != Guid.Empty || LastRandomMove >= Timing.Global.Milliseconds || IsCasting)
+ if (IsUnableToMove || targetMap != Guid.Empty || _lastMovement >= Timing.Global.Milliseconds || IsCasting)
+ {
+ return;
+ }
+
+ if (Target != null || mResetting || fleeing)
{
return;
}
switch (Descriptor.Movement)
{
- case (int)NpcMovement.StandStill:
- LastRandomMove = Timing.Global.Milliseconds + Randomization.Next(1000, 3000);
+ case (byte)NpcMovement.StandStill:
+ _lastMovement = Timing.Global.Milliseconds + Randomization.Next(1000, 3000);
return;
- case (int)NpcMovement.TurnRandomly:
+ case (byte)NpcMovement.TurnRandomly:
ChangeDir(Randomization.NextDirection());
- LastRandomMove = Timing.Global.Milliseconds + Randomization.Next(1000, 3000);
+ _lastMovement = Timing.Global.Milliseconds + Randomization.Next(1000, 3000);
return;
- case (int)NpcMovement.MoveRandomly:
+ case (byte)NpcMovement.MoveRandomly:
MoveRandomly();
break;
- }
-
- if (fleeing)
- {
- LastRandomMove = Timing.Global.Milliseconds + (long)GetMovementTime();
+ case (byte)NpcMovement.HorizontalPatrol:
+ HorizontalPatrol();
+ break;
+ case (byte)NpcMovement.VerticalPatrol:
+ VerticalPatrol();
+ break;
+ case (byte)NpcMovement.BackslashPatrol:
+ BackslashPatrol();
+ break;
+ case (byte)NpcMovement.ForwardslashPatrol:
+ ForwardslashPatrol();
+ break;
}
}
@@ -1216,35 +1308,238 @@ public override void Update(long timeMs)
private void MoveRandomly()
{
- if (_randomMoveRange <= 0)
+ var currentTime = Timing.Global.Milliseconds;
+
+ // Pick a valid random direction and range
+ if (_movementRange <= 0)
{
- Dir = Randomization.NextDirection();
- LastRandomMove = Timing.Global.Milliseconds + Randomization.Next(1000, 2000);
- _randomMoveRange = (byte)Randomization.Next(0, Descriptor.SightRange + Randomization.Next(0, 3));
+ ChangeDir(FindValidRandomPath());
+ _movementRange = (byte)Randomization.Next(0, Descriptor.SightRange + 1);
}
- else if (CanMoveInDirection(Dir))
+
+ // chance to change behavior while walking
+ if (_movementRange >= 1 && Randomization.Next(0, 100) < 35)
{
- foreach (var status in CachedStatuses)
+ if (Randomization.Next(0, 100) < 50)
{
- if (status.Type is SpellEffect.Stun or SpellEffect.Snare or SpellEffect.Sleep)
- {
- return;
- }
+ // change to a random valid direction
+ ChangeDir(FindValidRandomPath());
+ }
+ else
+ {
+ // stop and think: abandon path and trigger an idle pause
+ _movementRange = 0;
+ _lastMovement = currentTime + Randomization.Next(840, 1000);
+ return;
}
+ }
+ // try to move in current direction
+ if (CanMoveInDirection(Dir) && !IsUnableToMove)
+ {
Move(Dir, null);
- LastRandomMove = Timing.Global.Milliseconds + (long)GetMovementTime();
+ _movementRange--;
+ _lastMovement = _movementRange > 0 ? currentTime + (long)GetMovementTime() : currentTime + Randomization.Next(420, 840);
+ }
+ else
+ {
+ // Blocked: try valid alternative directions
+ var alternativeDir = FindValidRandomPath();
+ if (alternativeDir != Direction.None)
+ {
+ Move(alternativeDir, null);
+ _movementRange--;
+ _lastMovement = currentTime + (long)GetMovementTime();
+ }
+ else
+ {
+ // Completely stuck: pick new random direction and wait
+ ChangeDir(Randomization.NextDirection());
+ _movementRange = 0;
+ _lastMovement = currentTime + 420;
+ }
+ }
+ }
+
+ private Direction FindValidRandomPath()
+ {
+ var allDirections = Enum.GetValues();
+ var maxDirs = Options.Instance.Map.EnableDiagonalMovement ? 8 : 4;
+ var start = Randomization.Next(maxDirs);
- if (_randomMoveRange <= Randomization.Next(0, 3))
+ for (int i = 0; i < maxDirs; i++)
+ {
+ var dir = allDirections[(start + i) % maxDirs];
+ if (dir != Direction.None && CanMoveInDirection(dir))
{
- Dir = Randomization.NextDirection();
+ return dir;
}
+ }
+
+ return Direction.None;
+ }
+
+ // Horizontal patrol (left/right from spawn)
+ private void HorizontalPatrol()
+ {
+ var currentTime = Timing.Global.Milliseconds;
+
+ if (_patrolOriginX == -1)
+ {
+ _patrolOriginX = X;
+ _movingAwayFromPatrolOrigin = true;
+ }
+
+ // Initialize direction
+ if (Dir != Direction.Left && Dir != Direction.Right)
+ {
+ Dir = Randomization.Next(0, 2) == 0 ? Direction.Left : Direction.Right;
+ }
+
+ var distanceFromSpawn = Math.Abs(X - _patrolOriginX);
+
+ switch (_movingAwayFromPatrolOrigin)
+ {
+ // Only reverse at endpoints
+ case true when distanceFromSpawn >= Descriptor.SightRange:
+ Dir = Dir == Direction.Left ? Direction.Right : Direction.Left;
+ _movingAwayFromPatrolOrigin = false;
+ break;
+ case false when distanceFromSpawn == 0:
+ _movingAwayFromPatrolOrigin = true;
+ break;
+ }
+
+ if (CanMoveInDirection(Dir) && !IsUnableToMove)
+ {
+ Move(Dir, null);
+ _lastMovement = currentTime + (long)GetMovementTime();
+ }
+ else
+ {
+ _lastMovement = currentTime + 420;
+ }
+ }
+
+ // Vertical patrol (up/down from spawn)
+ private void VerticalPatrol()
+ {
+ var currentTime = Timing.Global.Milliseconds;
+
+ if (_patrolOriginY == -1)
+ {
+ _patrolOriginY = Y;
+ _movingAwayFromPatrolOrigin = true;
+ }
+
+ if (Dir != Direction.Up && Dir != Direction.Down)
+ {
+ Dir = Randomization.Next(0, 2) == 0 ? Direction.Up : Direction.Down;
+ }
+
+ var distanceFromSpawn = Math.Abs(Y - _patrolOriginY);
+
+ switch (_movingAwayFromPatrolOrigin)
+ {
+ case true when distanceFromSpawn >= Descriptor.SightRange:
+ Dir = Dir == Direction.Up ? Direction.Down : Direction.Up;
+ _movingAwayFromPatrolOrigin = false;
+ break;
+ case false when distanceFromSpawn == 0:
+ _movingAwayFromPatrolOrigin = true;
+ break;
+ }
+
+ if (CanMoveInDirection(Dir) && !IsUnableToMove)
+ {
+ Move(Dir, null);
+ _lastMovement = currentTime + (long)GetMovementTime();
+ }
+ else
+ {
+ _lastMovement = currentTime + 420;
+ }
+ }
+
+ // Diagonal patrol (Backslash pattern: \)
+ private void BackslashPatrol()
+ {
+ var currentTime = Timing.Global.Milliseconds;
+
+ if (_patrolOriginX == -1)
+ {
+ _patrolOriginX = X;
+ _patrolOriginY = Y;
+ _movingAwayFromPatrolOrigin = true;
+ }
+
+ if (Dir != Direction.UpLeft && Dir != Direction.DownRight)
+ {
+ Dir = Randomization.Next(0, 2) == 0 ? Direction.UpLeft : Direction.DownRight;
+ }
+
+ var distanceFromSpawn = Math.Max(Math.Abs(X - _patrolOriginX), Math.Abs(Y - _patrolOriginY));
+
+ switch (_movingAwayFromPatrolOrigin)
+ {
+ case true when distanceFromSpawn >= Descriptor.SightRange:
+ Dir = Dir == Direction.UpLeft ? Direction.DownRight : Direction.UpLeft;
+ _movingAwayFromPatrolOrigin = false;
+ break;
+ case false when X == _patrolOriginX && Y == _patrolOriginY:
+ _movingAwayFromPatrolOrigin = true;
+ break;
+ }
+
+ if (CanMoveInDirection(Dir) && !IsUnableToMove)
+ {
+ Move(Dir, null);
+ _lastMovement = currentTime + (long)GetMovementTime();
+ }
+ else
+ {
+ _lastMovement = currentTime + 420;
+ }
+ }
+
+ // Diagonal patrol (Forwardslash pattern: /)
+ private void ForwardslashPatrol()
+ {
+ var currentTime = Timing.Global.Milliseconds;
+
+ if (_patrolOriginX == -1)
+ {
+ _patrolOriginX = X;
+ _patrolOriginY = Y;
+ _movingAwayFromPatrolOrigin = true;
+ }
+
+ if (Dir != Direction.UpRight && Dir != Direction.DownLeft)
+ {
+ Dir = Randomization.Next(0, 2) == 0 ? Direction.UpRight : Direction.DownLeft;
+ }
+
+ var distanceFromSpawn = Math.Max(Math.Abs(X - _patrolOriginX), Math.Abs(Y - _patrolOriginY));
+
+ switch (_movingAwayFromPatrolOrigin)
+ {
+ case true when distanceFromSpawn >= Descriptor.SightRange:
+ Dir = Dir == Direction.UpRight ? Direction.DownLeft : Direction.UpRight;
+ _movingAwayFromPatrolOrigin = false;
+ break;
+ case false when X == _patrolOriginX && Y == _patrolOriginY:
+ _movingAwayFromPatrolOrigin = true;
+ break;
+ }
- _randomMoveRange--;
+ if (CanMoveInDirection(Dir) && !IsUnableToMove)
+ {
+ Move(Dir, null);
+ _lastMovement = currentTime + (long)GetMovementTime();
}
else
{
- Dir = Randomization.NextDirection();
+ _lastMovement = currentTime + 420;
}
}
@@ -1261,45 +1556,98 @@ private void ResetAggroCenter(out Guid targetMap)
AggroCenterX = 0;
AggroCenterY = 0;
AggroCenterZ = 0;
- mPathFinder?.SetTarget(null);
+ mPathFinder.SetTarget(null);
+
+ // Reset patrol origin
+ _patrolOriginX = -1;
+ _patrolOriginY = -1;
+ _movingAwayFromPatrolOrigin = false;
+
mResetting = false;
}
+ private void ResetAggroCenter()
+ {
+ _ = mPathFinder.GetTarget()?.TargetMapId ?? Guid.Empty;
+ ResetAggroCenter(out _);
+ }
+
private bool CheckForResetLocation(bool forceDistance = false)
{
// Check if we've moved out of our range we're allowed to move from after being "aggro'd" by something.
// If so, remove target and move back to the origin point.
- if (Options.Instance.Npc.AllowResetRadius && AggroCenterMap != null && (GetDistanceTo(AggroCenterMap, AggroCenterX, AggroCenterY) > Math.Max(Options.Instance.Npc.ResetRadius, Math.Min(Descriptor.ResetRadius, Math.Max(Options.Instance.Map.MapWidth, Options.Instance.Map.MapHeight))) || forceDistance))
+ if (Options.Instance.Npc.AllowResetRadius && _spawnMap != null && AggroCenterMap != null && (GetDistanceTo(AggroCenterMap, AggroCenterX, AggroCenterY) > Math.Max(Options.Instance.Npc.ResetRadius, Math.Min(Descriptor.ResetRadius, Math.Max(Options.Instance.Map.MapWidth, Options.Instance.Map.MapHeight))) || forceDistance))
{
- Reset(Options.Instance.Npc.ResetVitalsAndStatuses);
-
+ Reset(IsFleeing() || Options.Instance.Npc.ResetVitalsAndStatuses);
mResetCounter = 0;
mResetDistance = 0;
+ mPathFinder.SetTarget(null);
- // Try and move back to where we came from before we started chasing something.
- mResetting = true;
- mPathFinder.SetTarget(new PathfinderTarget(AggroCenterMap.Id, AggroCenterX, AggroCenterY, AggroCenterZ));
+ // If on different map when resetting: warp immediately
+ if (_spawnMap.Id != MapId)
+ {
+ mResetting = true;
+ Warp(_spawnMap.Id, _spawnX, _spawnY);
+ ResetAggroCenter();
+ }
+ else
+ {
+ // Same map - set pathfinder to spawn point
+ mResetting = true;
+ mPathFinder.SetTarget(new PathfinderTarget(_spawnMap.Id, _spawnX, _spawnY, Z));
+ }
return true;
}
return false;
}
+ private void SoftReset()
+ {
+ if (_spawnMap == null)
+ {
+ return;
+ }
+
+ Reset(IsFleeing(), true);
+ mResetCounter = 0;
+ mResetDistance = 0;
+ mPathFinder.SetTarget(null);
+
+ // Move back to where we SPAWNED (only for patrol movement patterns)
+ switch (Descriptor.Movement)
+ {
+ case (byte)NpcMovement.HorizontalPatrol:
+ case (byte)NpcMovement.VerticalPatrol:
+ case (byte)NpcMovement.BackslashPatrol:
+ case (byte)NpcMovement.ForwardslashPatrol:
+ // If on different map when resetting: warp immediately
+ if (_spawnMap.Id != MapId)
+ {
+ mResetting = true;
+ Warp(_spawnMap.Id, _spawnX, _spawnY);
+ ResetAggroCenter();
+ }
+ else
+ {
+ // Same map - set pathfinder to spawn point
+ mResetting = true;
+ mPathFinder.SetTarget(new PathfinderTarget(_spawnMap.Id, _spawnX, _spawnY, Z));
+ }
+ break;
+ }
+ }
+
private void Reset(bool resetVitals, bool clearLocation = false)
{
// Remove our target.
RemoveTarget();
-
DamageMap.Clear();
LootMap.Clear();
LootMapCache = Array.Empty();
if (clearLocation)
{
- mPathFinder.SetTarget(null);
- AggroCenterMap = null;
- AggroCenterX = 0;
- AggroCenterY = 0;
- AggroCenterZ = 0;
+ ResetAggroCenter();
}
// Reset our vitals and statusses when configured.
@@ -1319,9 +1667,9 @@ private void Reset(bool resetVitals, bool clearLocation = false)
// Completely resets an Npc to full health and its spawnpoint if it's current chasing something.
public override void Reset()
{
- if (AggroCenterMap != null)
+ if (_spawnMap != null)
{
- Warp(AggroCenterMap.Id, AggroCenterX, AggroCenterY);
+ Warp(_spawnMap.Id, _spawnX, _spawnY);
}
Reset(true, true);
diff --git a/Intersect.Server.Core/Entities/Player.cs b/Intersect.Server.Core/Entities/Player.cs
index 54c789b863..0a3453fdda 100644
--- a/Intersect.Server.Core/Entities/Player.cs
+++ b/Intersect.Server.Core/Entities/Player.cs
@@ -1118,16 +1118,7 @@ public override void Die(bool dropItems = true, Entity killer = null)
{
CastTime = 0;
CastTarget = null;
-
- //Flag death to the client
- PacketSender.SendPlayerDeath(this);
-
- //Event trigger
- foreach (var evt in EventLookup)
- {
- evt.Value.PlayerHasDied = true;
- }
-
+
// Remove player from ALL threat lists.
foreach (var instance in MapController.GetSurroundingMapInstances(Map.Id, MapInstanceId, true))
{
@@ -1135,17 +1126,26 @@ public override void Die(bool dropItems = true, Entity killer = null)
{
if (entity is Npc npc)
{
+ npc.RemoveTarget();
npc.RemoveFromDamageMap(this);
}
}
}
+ //Flag death to the client
+ PacketSender.SendPlayerDeath(this);
+
+ //Event trigger
+ foreach (var evt in EventLookup)
+ {
+ evt.Value.PlayerHasDied = true;
+ }
+
lock (EntityLock)
{
base.Die(dropItems, killer);
}
-
// EXP Loss - don't lose in shared instance, or in an Arena zone
if (InstanceType != MapInstanceType.Shared || Options.Instance.Instancing.LoseExpOnInstanceDeath)
{