From 46a13da4827da253d0d5cf1d428425cb8e845124 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] enhancement: smoother NPC and Critter random movement This update refactors how both, NPCs and Critters randomly move around, replacing the boring single-tile shuffling-around with a more natural, range-based pathing system. --- Intersect.Client.Core/Entities/Critter.cs | 133 ++++++++++------------ Intersect.Server.Core/Entities/Npc.cs | 114 ++++++++++++++++--- 2 files changed, 155 insertions(+), 92 deletions(-) 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.Server.Core/Entities/Npc.cs b/Intersect.Server.Core/Entities/Npc.cs index 118855cdbb..55e121e5f5 100644 --- a/Intersect.Server.Core/Entities/Npc.cs +++ b/Intersect.Server.Core/Entities/Npc.cs @@ -159,6 +159,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() { @@ -538,6 +540,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; /// @@ -1159,7 +1186,7 @@ public override void Update(long timeMs) CheckForResetLocation(); - if (targetMap != Guid.Empty || LastRandomMove >= Timing.Global.Milliseconds || IsCasting) + if (IsUnableToMove || targetMap != Guid.Empty || LastRandomMove >= Timing.Global.Milliseconds || IsCasting) { return; } @@ -1216,36 +1243,91 @@ public override void Update(long timeMs) private void MoveRandomly() { + var currentTime = Timing.Global.Milliseconds; + + // Pick new direction and range if (_randomMoveRange <= 0) { - Dir = Randomization.NextDirection(); - LastRandomMove = Timing.Global.Milliseconds + Randomization.Next(1000, 2000); - _randomMoveRange = (byte)Randomization.Next(0, Descriptor.SightRange + Randomization.Next(0, 3)); + Dir = FindValidDirectionOrRandom(); + _randomMoveRange = (byte)Randomization.Next(0, Descriptor.SightRange + 1); } - else if (CanMoveInDirection(Dir)) + + // Mid-path deviation: 35% chance to change behavior while walking + if (_randomMoveRange > 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; - } + // Pivot: change to a valid direction + Dir = FindValidDirectionOrRandom(); } + else + { + // Stop and think: abandon path and trigger an idle pause + _randomMoveRange = 0; + LastRandomMove = currentTime + Randomization.Next(840, 1000); + return; + } + } + // Check if path is clear + if (CanMoveInDirection(Dir) && !IsUnableToMove) + { Move(Dir, null); - LastRandomMove = Timing.Global.Milliseconds + (long)GetMovementTime(); + _randomMoveRange--; - if (_randomMoveRange <= Randomization.Next(0, 3)) + LastRandomMove = _randomMoveRange > 0 + ? currentTime + (long)GetMovementTime() + : currentTime + Randomization.Next(420, 840); + } + else + { + // Blocked: try to find alternative direction immediately + var alternativeDir = FindValidDirection(); + if (alternativeDir != Direction.None) + { + Dir = alternativeDir; + Move(Dir, null); + _randomMoveRange--; + LastRandomMove = currentTime + (long)GetMovementTime(); + } + else { - Dir = Randomization.NextDirection(); + // Completely blocked: clear range and wait + _randomMoveRange = 0; + LastRandomMove = currentTime + 420; } + } + } - _randomMoveRange--; + // Finds a valid unblocked direction, or returns Direction.None if all blocked + private Direction FindValidDirection() + { + // Get all directions from Intersect's Direction enum + var directions = Enum.GetValues().Where(d => d != Direction.None).ToArray(); + + // Shuffle for randomization + for (int i = directions.Length - 1; i > 0; i--) + { + int j = Randomization.Next(0, i + 1); + (directions[i], directions[j]) = (directions[j], directions[i]); } - else + + foreach (var dir in directions) { - Dir = Randomization.NextDirection(); + if (CanMoveInDirection(dir)) + { + return dir; + } } + + return Direction.None; + } + + // Finds a valid direction, falls back to random if all blocked + private Direction FindValidDirectionOrRandom() + { + var validDir = FindValidDirection(); + return validDir != Direction.None ? validDir : Randomization.NextDirection(); } ///