Skip to content

Commit 127d7f9

Browse files
committed
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.
1 parent dcbd209 commit 127d7f9

2 files changed

Lines changed: 117 additions & 91 deletions

File tree

Intersect.Client.Core/Entities/Critter.cs

Lines changed: 57 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,10 @@ namespace Intersect.Client.Entities;
1515
public partial class Critter : Entity
1616
{
1717
private readonly MapCritterAttribute mAttribute;
18-
private long mLastMove = -1;
18+
19+
// Critter's Movement
20+
private long _lastMove = -1;
21+
private byte _randomMoveRange;
1922

2023
public Critter(MapInstance map, byte x, byte y, MapCritterAttribute att) : base(Guid.NewGuid(), null, EntityType.GlobalEntity)
2124
{
@@ -50,65 +53,65 @@ public Critter(MapInstance map, byte x, byte y, MapCritterAttribute att) : base(
5053

5154
public override bool Update()
5255
{
53-
if (base.Update())
56+
if (!base.Update())
5457
{
55-
if (mLastMove < Timing.Global.MillisecondsUtc)
56-
{
57-
switch (mAttribute.Movement)
58-
{
59-
case 0: //Move Randomly
60-
MoveRandomly();
61-
break;
62-
case 1: //Turn?
63-
DirectionFacing = Randomization.NextDirection();
64-
break;
65-
66-
}
67-
68-
mLastMove = Timing.Global.MillisecondsUtc + mAttribute.Frequency + Globals.Random.Next((int)(mAttribute.Frequency * .5f));
69-
}
58+
return false;
59+
}
7060

61+
// Only skip if we are NOT in the middle of a range-walk AND the frequency timer is active
62+
if (_randomMoveRange <= 0 && _lastMove >= Timing.Global.MillisecondsUtc)
63+
{
7164
return true;
7265
}
7366

74-
return false;
67+
switch (mAttribute.Movement)
68+
{
69+
case 0: // Move Randomly
70+
MoveRandomly();
71+
break;
72+
case 1: // Turn Randomly
73+
DirectionFacing = Randomization.NextDirection();
74+
// Set pause after turning
75+
_lastMove = Timing.Global.MillisecondsUtc + mAttribute.Frequency + Globals.Random.Next((int)(mAttribute.Frequency * .5f));
76+
break;
77+
}
78+
79+
return true;
7580
}
7681

7782
private void MoveRandomly()
7883
{
79-
DirectionMoving = Randomization.NextDirection();
80-
var tmpX = (sbyte)X;
81-
var tmpY = (sbyte)Y;
82-
IEntity? blockedBy = null;
83-
84+
// Don't start a new step if currently moving between tiles
8485
if (IsMoving || MoveTimer >= Timing.Global.MillisecondsUtc)
8586
{
8687
return;
8788
}
8889

90+
// No range left: pick a new direction and range
91+
if (_randomMoveRange <= 0)
92+
{
93+
DirectionFacing = Randomization.NextDirection();
94+
_randomMoveRange = (byte)Randomization.Next(1, 5);
95+
}
96+
8997
var deltaX = 0;
9098
var deltaY = 0;
91-
92-
switch (DirectionMoving)
99+
switch (DirectionFacing)
93100
{
94101
case Direction.Up:
95-
deltaX = 0;
96102
deltaY = -1;
97103
break;
98104

99105
case Direction.Down:
100-
deltaX = 0;
101106
deltaY = 1;
102107
break;
103108

104109
case Direction.Left:
105110
deltaX = -1;
106-
deltaY = 0;
107111
break;
108112

109113
case Direction.Right:
110114
deltaX = 1;
111-
deltaY = 0;
112115
break;
113116

114117
case Direction.UpLeft:
@@ -132,59 +135,37 @@ private void MoveRandomly()
132135
break;
133136
}
134137

135-
if (deltaX != 0 || deltaY != 0)
136-
{
137-
var newX = tmpX + deltaX;
138-
var newY = tmpY + deltaY;
139-
var isBlocked = -1 ==
140-
IsTileBlocked(
141-
new Point(newX, newY),
142-
Z,
143-
MapId,
144-
ref blockedBy,
145-
true,
146-
true,
147-
mAttribute.IgnoreNpcAvoids
148-
);
149-
var playerOnTile = PlayerOnTile(MapId, newX, newY);
150-
151-
if (isBlocked && newX >= 0 && newX < Options.Instance.Map.MapWidth && newY >= 0 && newY < Options.Instance.Map.MapHeight &&
152-
(!mAttribute.BlockPlayers || !playerOnTile))
153-
{
154-
tmpX += (sbyte)deltaX;
155-
tmpY += (sbyte)deltaY;
156-
IsMoving = true;
157-
DirectionFacing = DirectionMoving;
138+
var newX = (sbyte)X + deltaX;
139+
var newY = (sbyte)Y + deltaY;
140+
IEntity? blockedBy = null;
158141

159-
if (deltaX == 0)
160-
{
161-
OffsetX = 0;
162-
}
163-
else
164-
{
165-
OffsetX = deltaX > 0 ? -Options.Instance.Map.TileWidth : Options.Instance.Map.TileWidth;
166-
}
142+
// Boundary checks
143+
var isBlocked = -1 == IsTileBlocked(new Point(newX, newY), Z, MapId, ref blockedBy, true, true, mAttribute.IgnoreNpcAvoids);
144+
var playerOnTile = PlayerOnTile(MapId, newX, newY);
167145

168-
if (deltaY == 0)
169-
{
170-
OffsetY = 0;
171-
}
172-
else
173-
{
174-
OffsetY = deltaY > 0 ? -Options.Instance.Map.TileHeight : Options.Instance.Map.TileHeight;
175-
}
176-
}
177-
}
178-
179-
if (IsMoving)
146+
if (isBlocked && !playerOnTile &&
147+
newX >= 0 && newX < Options.Instance.Map.MapWidth &&
148+
newY >= 0 && newY < Options.Instance.Map.MapHeight)
180149
{
181-
X = (byte)tmpX;
182-
Y = (byte)tmpY;
150+
X = (byte)newX;
151+
Y = (byte)newY;
152+
IsMoving = true;
153+
OffsetX = deltaX == 0 ? 0 : (deltaX > 0 ? -Options.Instance.Map.TileWidth : Options.Instance.Map.TileWidth);
154+
OffsetY = deltaY == 0 ? 0 : (deltaY > 0 ? -Options.Instance.Map.TileHeight : Options.Instance.Map.TileHeight);
183155
MoveTimer = Timing.Global.MillisecondsUtc + (long)GetMovementTime();
156+
_randomMoveRange--;
157+
158+
// Critter's last step: set an idle pause timer
159+
if (_randomMoveRange <= 0)
160+
{
161+
_lastMove = Timing.Global.MillisecondsUtc + mAttribute.Frequency + Globals.Random.Next((int)(mAttribute.Frequency * .5f));
162+
}
184163
}
185-
else if (DirectionMoving != DirectionFacing)
164+
else
186165
{
187-
DirectionFacing = DirectionMoving;
166+
// Blocked by something: end range early and trigger pause
167+
_randomMoveRange = 0;
168+
_lastMove = Timing.Global.MillisecondsUtc + mAttribute.Frequency;
188169
}
189170
}
190171

Intersect.Server.Core/Entities/Npc.cs

Lines changed: 60 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,8 @@ public Npc(NPCDescriptor npcDescriptor, bool despawnable = false) : base()
159159
private bool IsStunnedOrSleeping => CachedStatuses.Any(PredicateStunnedOrSleeping);
160160

161161
private bool IsUnableToCastSpells => CachedStatuses.Any(PredicateUnableToCastSpells);
162+
163+
private bool IsUnableToMove => CachedStatuses.Any(PredicateUnableToMove);
162164

163165
public override EntityType GetEntityType()
164166
{
@@ -538,6 +540,31 @@ private static bool PredicateUnableToCastSpells(Status status)
538540
}
539541
}
540542

543+
private static bool PredicateUnableToMove(Status status)
544+
{
545+
switch (status?.Type)
546+
{
547+
case SpellEffect.Stun:
548+
case SpellEffect.Sleep:
549+
case SpellEffect.Snare:
550+
return true;
551+
case SpellEffect.Silence:
552+
case SpellEffect.None:
553+
case SpellEffect.Blind:
554+
case SpellEffect.Stealth:
555+
case SpellEffect.Transform:
556+
case SpellEffect.Cleanse:
557+
case SpellEffect.Invulnerable:
558+
case SpellEffect.Shield:
559+
case SpellEffect.OnHit:
560+
case SpellEffect.Taunt:
561+
case null:
562+
return false;
563+
default:
564+
throw new ArgumentOutOfRangeException();
565+
}
566+
}
567+
541568
protected override bool IgnoresNpcAvoid => false;
542569

543570
/// <inheritdoc />
@@ -1159,7 +1186,7 @@ public override void Update(long timeMs)
11591186

11601187
CheckForResetLocation();
11611188

1162-
if (targetMap != Guid.Empty || LastRandomMove >= Timing.Global.Milliseconds || IsCasting)
1189+
if (IsUnableToMove || targetMap != Guid.Empty || LastRandomMove >= Timing.Global.Milliseconds || IsCasting)
11631190
{
11641191
return;
11651192
}
@@ -1216,35 +1243,53 @@ public override void Update(long timeMs)
12161243

12171244
private void MoveRandomly()
12181245
{
1246+
// Pick new direction and range
12191247
if (_randomMoveRange <= 0)
12201248
{
12211249
Dir = Randomization.NextDirection();
1222-
LastRandomMove = Timing.Global.Milliseconds + Randomization.Next(1000, 2000);
1223-
_randomMoveRange = (byte)Randomization.Next(0, Descriptor.SightRange + Randomization.Next(0, 3));
1250+
_randomMoveRange = (byte)Randomization.Next(0, Descriptor.SightRange + 1);
12241251
}
1225-
else if (CanMoveInDirection(Dir))
1252+
1253+
// Mid-Path Deviation: 35% chance to change behavior while walking
1254+
if (_randomMoveRange > 1 && Randomization.Next(0, 100) < 35)
12261255
{
1227-
foreach (var status in CachedStatuses)
1256+
if (Randomization.Next(0, 2) == 0)
12281257
{
1229-
if (status.Type is SpellEffect.Stun or SpellEffect.Snare or SpellEffect.Sleep)
1230-
{
1231-
return;
1232-
}
1258+
// 5% chance to "pivot": change direction but continue the current range
1259+
ChangeDir(Randomization.NextDirection());
1260+
}
1261+
else
1262+
{
1263+
// 5% chance to "stop and think": abandon path and trigger an idle pause
1264+
ChangeDir(Randomization.NextDirection());
1265+
_randomMoveRange = 0;
1266+
LastRandomMove = Timing.Global.Milliseconds + Randomization.Next(840, 1000);
1267+
return;
12331268
}
1269+
}
12341270

1271+
// Check if the tile is clear before moving and if the NPC is able to move
1272+
if (CanMoveInDirection(Dir) && !IsUnableToMove)
1273+
{
12351274
Move(Dir, null);
1236-
LastRandomMove = Timing.Global.Milliseconds + (long)GetMovementTime();
1275+
_randomMoveRange--;
12371276

1238-
if (_randomMoveRange <= Randomization.Next(0, 3))
1277+
// Pacing: Use walking speed for steps and idle pause for the end of a path
1278+
if (_randomMoveRange > 0)
12391279
{
1240-
Dir = Randomization.NextDirection();
1280+
LastRandomMove = Timing.Global.Milliseconds + (long)GetMovementTime();
1281+
}
1282+
else
1283+
{
1284+
ChangeDir(Randomization.NextDirection());
1285+
LastRandomMove = Timing.Global.Milliseconds + Randomization.Next(420, 840);
12411286
}
1242-
1243-
_randomMoveRange--;
12441287
}
12451288
else
12461289
{
1247-
Dir = Randomization.NextDirection();
1290+
// Blocked by something: Clear range and pause briefly to re-evaluate
1291+
_randomMoveRange = 0;
1292+
LastRandomMove = Timing.Global.Milliseconds + 420;
12481293
}
12491294
}
12501295

0 commit comments

Comments
 (0)