Skip to content

Commit 3a3bd29

Browse files
committed
enhancement: smoother NPC and Critter random movement logic
This update refactors how NPCs and Critters wander, replacing single-tile "shuffling" with a more natural, range-based pathing system.
1 parent dcbd209 commit 3a3bd29

2 files changed

Lines changed: 124 additions & 126 deletions

File tree

Intersect.Client.Core/Entities/Critter.cs

Lines changed: 65 additions & 110 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,141 +53,93 @@ public Critter(MapInstance map, byte x, byte y, MapCritterAttribute att) : base(
5053

5154
public override bool Update()
5255
{
53-
if (base.Update())
54-
{
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-
}
56+
if (!base.Update()) return false;
7057

58+
// Only skip if we are NOT in the middle of a range-walk AND the frequency timer is active
59+
if (_randomMoveRange <= 0 && _lastMove >= Timing.Global.MillisecondsUtc)
60+
{
7161
return true;
7262
}
7363

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

7779
private void MoveRandomly()
7880
{
79-
DirectionMoving = Randomization.NextDirection();
80-
var tmpX = (sbyte)X;
81-
var tmpY = (sbyte)Y;
82-
IEntity? blockedBy = null;
83-
81+
// Don't start a new step if currently mid-animation (sliding between tiles)
8482
if (IsMoving || MoveTimer >= Timing.Global.MillisecondsUtc)
8583
{
8684
return;
8785
}
8886

87+
// Path Selection: If no range left, pick a new direction and range
88+
if (_randomMoveRange <= 0)
89+
{
90+
DirectionFacing = Randomization.NextDirection();
91+
_randomMoveRange = (byte)Randomization.Next(1, 5); // 1-4 tiles
92+
}
93+
8994
var deltaX = 0;
9095
var deltaY = 0;
91-
92-
switch (DirectionMoving)
96+
switch (DirectionFacing)
9397
{
94-
case Direction.Up:
95-
deltaX = 0;
96-
deltaY = -1;
97-
break;
98-
99-
case Direction.Down:
100-
deltaX = 0;
101-
deltaY = 1;
102-
break;
103-
104-
case Direction.Left:
105-
deltaX = -1;
106-
deltaY = 0;
107-
break;
98+
case Direction.Up: deltaY = -1; break;
99+
case Direction.Down: deltaY = 1; break;
100+
case Direction.Left: deltaX = -1; break;
101+
case Direction.Right: deltaX = 1; break;
102+
case Direction.UpLeft: deltaX = -1; deltaY = -1; break;
103+
case Direction.UpRight: deltaX = 1; deltaY = -1; break;
104+
case Direction.DownLeft: deltaX = -1; deltaY = 1; break;
105+
case Direction.DownRight: deltaX = 1; deltaY = 1; break;
106+
}
108107

109-
case Direction.Right:
110-
deltaX = 1;
111-
deltaY = 0;
112-
break;
108+
var newX = (sbyte)X + deltaX;
109+
var newY = (sbyte)Y + deltaY;
110+
IEntity? blockedBy = null;
113111

114-
case Direction.UpLeft:
115-
deltaX = -1;
116-
deltaY = -1;
117-
break;
112+
// Collision and Map Boundary Check
113+
var isBlocked = -1 == IsTileBlocked(new Point(newX, newY), Z, MapId, ref blockedBy, true, true, mAttribute.IgnoreNpcAvoids);
114+
var playerOnTile = PlayerOnTile(MapId, newX, newY);
118115

119-
case Direction.UpRight:
120-
deltaX = 1;
121-
deltaY = -1;
122-
break;
116+
if (isBlocked && !playerOnTile &&
117+
newX >= 0 && newX < Options.Instance.Map.MapWidth &&
118+
newY >= 0 && newY < Options.Instance.Map.MapHeight)
119+
{
120+
// Execute Step
121+
X = (byte)newX;
122+
Y = (byte)newY;
123+
IsMoving = true;
123124

124-
case Direction.DownLeft:
125-
deltaX = -1;
126-
deltaY = 1;
127-
break;
125+
// Visual offsets for smooth sliding animation [web:7]
126+
OffsetX = deltaX == 0 ? 0 : (deltaX > 0 ? -Options.Instance.Map.TileWidth : Options.Instance.Map.TileWidth);
127+
OffsetY = deltaY == 0 ? 0 : (deltaY > 0 ? -Options.Instance.Map.TileHeight : Options.Instance.Map.TileHeight);
128128

129-
case Direction.DownRight:
130-
deltaX = 1;
131-
deltaY = 1;
132-
break;
133-
}
129+
MoveTimer = Timing.Global.MillisecondsUtc + (long)GetMovementTime();
130+
_randomMoveRange--;
134131

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))
132+
// If this was the last step, set the IDLE pause timer
133+
if (_randomMoveRange <= 0)
153134
{
154-
tmpX += (sbyte)deltaX;
155-
tmpY += (sbyte)deltaY;
156-
IsMoving = true;
157-
DirectionFacing = DirectionMoving;
158-
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-
}
167-
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-
}
135+
_lastMove = Timing.Global.MillisecondsUtc + mAttribute.Frequency + Globals.Random.Next((int)(mAttribute.Frequency * .5f));
176136
}
177137
}
178-
179-
if (IsMoving)
180-
{
181-
X = (byte)tmpX;
182-
Y = (byte)tmpY;
183-
MoveTimer = Timing.Global.MillisecondsUtc + (long)GetMovementTime();
184-
}
185-
else if (DirectionMoving != DirectionFacing)
138+
else
186139
{
187-
DirectionFacing = DirectionMoving;
140+
// Blocked: End range early and trigger pause
141+
_randomMoveRange = 0;
142+
_lastMove = Timing.Global.MillisecondsUtc + mAttribute.Frequency;
188143
}
189144
}
190145

Intersect.Server.Core/Entities/Npc.cs

Lines changed: 59 additions & 16 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,51 @@ public override void Update(long timeMs)
12161243

12171244
private void MoveRandomly()
12181245
{
1246+
// Init: Pick new direction and range if current path is finished
12191247
if (_randomMoveRange <= 0)
12201248
{
1221-
Dir = Randomization.NextDirection();
1222-
LastRandomMove = Timing.Global.Milliseconds + Randomization.Next(1000, 2000);
1223-
_randomMoveRange = (byte)Randomization.Next(0, Descriptor.SightRange + Randomization.Next(0, 3));
1249+
ChangeDir(Randomization.NextDirection());
1250+
_randomMoveRange = (byte)Randomization.Next(1, Descriptor.SightRange + 1);
12241251
}
1225-
else if (CanMoveInDirection(Dir))
1252+
1253+
// Mid-Path Deviation: 25% chance to change behavior while walking
1254+
if (_randomMoveRange > 1 && Randomization.Next(0, 100) < 25)
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+
_randomMoveRange = 0;
1265+
LastRandomMove = Timing.Global.Milliseconds + Randomization.Next(840, 1420);
1266+
return;
12331267
}
1268+
}
12341269

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

1238-
if (_randomMoveRange <= Randomization.Next(0, 3))
1276+
// Pacing: Use walking speed for steps, or idle pause for the end of a path
1277+
if (_randomMoveRange > 0)
12391278
{
1240-
Dir = Randomization.NextDirection();
1279+
LastRandomMove = Timing.Global.Milliseconds + (long)GetMovementTime();
1280+
}
1281+
else
1282+
{
1283+
LastRandomMove = Timing.Global.Milliseconds + Randomization.Next(840, 1420);
12411284
}
1242-
1243-
_randomMoveRange--;
12441285
}
12451286
else
12461287
{
1247-
Dir = Randomization.NextDirection();
1288+
// Blocked: Clear range and pause briefly to re-evaluate
1289+
_randomMoveRange = 0;
1290+
LastRandomMove = Timing.Global.Milliseconds + 420;
12481291
}
12491292
}
12501293

0 commit comments

Comments
 (0)