S&box Wiki

Revision Difference

MakingASimpleAI#560445

<cat>Code.Game</cat> <title>Making a Simple AI</title> <deprecated>This refers to Entities, the old NavMesh system, Client/Server split and GameEvents which are all deprecated in the new scene system</deprecated>⤶ # Simple AI Example This is a very simple AI entity aimed at beginners that follows and kills nearby players. Copy and paste this for a good starting point and/or read on to understand more about how it works. Note that this example uses the `Player` class, which is technically deprecated. Therefore, you might want to swap in your own player class. Or not. The map you're spawning this on needs to have its navigation mesh built. See other tutorials for how to do that if you're using your own map. If you're using someone else's map it probably has that already. # The Code ``` using Editor; using Sandbox; using System.Linq; [EditorModel( "models/sbox_props/watermelon/watermelon.vmdl" )] [Library( "evil_melon" ), HammerEntity] [Title( "Evil Melon" ), Category( "NPCs" )] public class EvilMelon : AnimatedEntity { protected string State; protected Vector3[] Path; protected int CurrentPathSegment; protected TimeSince TimeSinceGeneratedPath = 0; const float CHASE_DISTANCE = 1000f; const float MOVEMENT_SPEED = 2f; const float ATTACK_RANGE = 50f; public override void Spawn() { SetModel( "models/sbox_props/watermelon/watermelon.vmdl" ); } [GameEvent.Tick.Server] private void Tick() { switch ( State ) { case "idle": PerformStateIdle(); break; case "attacking_player": PerformStateAttackingPlayer(); break; default: State = "idle"; break; } } protected void PerformStateIdle() { var player = GetClosestPlayer(); if ( player != null && player.Position.Distance( Position ) <= CHASE_DISTANCE ) State = "attacking_player"; } protected void PerformStateAttackingPlayer() { var player = GetClosestPlayer(); if ( player == null || player.Position.Distance( Position ) > CHASE_DISTANCE ) { State = "idle"; return; } if ( TimeSinceGeneratedPath >= 1 ) GeneratePath( player ); TraversePath(); if ( player.Position.Distance( Position ) <= ATTACK_RANGE ) player.TakeDamage( new DamageInfo { Damage = 1f } ); } protected Player GetClosestPlayer() { return All.OfType<Player>() .OrderByDescending( x => x.Position.Distance( Position ) ) .FirstOrDefault(); } protected void GeneratePath( Player target ) { TimeSinceGeneratedPath = 0; Path = NavMesh.PathBuilder( Position ) .WithMaxClimbDistance( 16f ) .WithMaxDropDistance( 16f ) .WithStepHeight( 16f ) .WithMaxDistance( 99999999 ) .WithPartialPaths() .Build( target.Position ) .Segments .Select( x => x.Position ) .ToArray(); CurrentPathSegment = 0; } protected void TraversePath() { if ( Path == null ) return; var distanceToTravel = MOVEMENT_SPEED; while ( distanceToTravel > 0 ) { var currentTarget = Path[CurrentPathSegment]; var distanceToCurrentTarget = Position.Distance( currentTarget ); if ( distanceToCurrentTarget > distanceToTravel ) { var direction = (currentTarget - Position).Normal; Position += direction * distanceToTravel; return; } else { var direction = (currentTarget - Position).Normal; Position += direction * distanceToCurrentTarget; distanceToTravel -= distanceToCurrentTarget; CurrentPathSegment++; } if ( CurrentPathSegment == Path.Count() ) { Path = null; return; } } } } ``` # The Explanation ## Class Information ``` [EditorModel( "models/sbox_props/watermelon/watermelon.vmdl" )] [Library( "evil_melon" ), HammerEntity] [Title( "Evil Melon" ), Category( "NPCs" )] public class EvilMelon : AnimatedEntity ``` Here we add some class attributes so that the entity shows up in Hammer. We inherit from `AnimatedEntity` because most AI will probably use animations at some point, but you could use `ModelEntity`, or something else. ## Variables ``` protected string State; protected Vector3[] Path; protected int CurrentPathSegment; protected TimeSince TimeSinceGeneratedPath = 0; const float CHASE_DISTANCE = 1000f; const float MOVEMENT_SPEED = 2f; const float ATTACK_RANGE = 50f; ``` We'll see what these do in a second. Note that it is not standard practice to capitalise variables LIKE_THIS in C#. However, it's just how I like to do it when it comes to constants that don't change. We set the class variables to protected because these are not usually things that would be of interest to other classes. Then again, there are circumstances where they would be, so feel free to make them public or whatever if you need to. ## Spawn() ``` public override void Spawn() { SetModel( "models/sbox_props/watermelon/watermelon.vmdl" ); } ``` Basically just set the model of this entity to a melon. ## Tick() ``` [Event.Tick.Server] private void Tick() { switch ( State ) { case "idle": PerformStateIdle(); break; case "attacking_player": PerformStateAttackingPlayer(); break; default: State = "idle"; break; } } ``` One of the biggest dilemmas when it comes to writing NPC logic is how to avoid writing nonsense spaghetti code. There is no "correct" way to implement NPC logic, but a good way is to create some kind of state machine. A state machine means that at any point in time, the NPC is in one of many pre-defined states (in this case we have "idle" and "attacking player"). Each state is responsible for defining its own behaviour as well as under what circumstances it can switch to other states. Here we simply implement all that with a basic switch statement. We call this on the server tick event, so this code will only run on the server, where it probably belongs. ## PerformStateIdle() ``` protected void PerformStateIdle() { var player = GetClosestPlayer(); if ( player != null && player.Position.Distance( Position ) <= CHASE_DISTANCE ) State = "attacking_player"; } ``` Each state is its own function, and this is one of them. In the idle state, we check to see if there are any players within `CHASE_DISTANCE`. If there are, we switch our state to "attacking player". If not, we do nothing. ## PerformStateAttackingPlayer() ``` protected void PerformStateAttackingPlayer() { var player = GetClosestPlayer(); if ( player == null || player.Position.Distance( Position ) > CHASE_DISTANCE ) { State = "idle"; return; } if ( TimeSinceGeneratedPath >= 1 ) GeneratePath( player ); TraversePath(); if ( player.Position.Distance( Position ) <= ATTACK_RANGE ) player.TakeDamage( new DamageInfo { Damage = 1f } ); } ``` First, we get the closest player we can find. If we didn't find a player, or they were further than `CHASE_DISTANCE`, we set our state to idle and give up. If it's been at least one second since we generated a path to the player, then we generate one. We do this every second because if we generated a path every tick then that wouldn't be very efficient. We then call `TraversePath()`, which makes the NPC move along the path. If the player is within `ATTACK_RANGE`, we inflict damage on them. ## GetClosestPlayer() ``` protected Player GetClosestPlayer() { return All.OfType<Player>() .OrderByDescending( x => x.Position.Distance( Position ) ) .FirstOrDefault(); } ``` This quite literally gets the closest player, or null if no players were found. ## GeneratePath() ``` protected void GeneratePath( Player target ) { TimeSinceGeneratedPath = 0; Path = NavMesh.PathBuilder( Position ) .WithMaxClimbDistance( 16f ) .WithMaxDropDistance( 16f ) .WithStepHeight( 16f ) .WithMaxDistance( 99999999 ) .WithPartialPaths() .Build( target.Position ) .Segments .Select( x => x.Position ) .ToArray(); CurrentPathSegment = 0; } ``` Here we use s&box's navigation mesh API to generate a path. Let's go through each bit of it: - `PathBuilder( Position )` We initialise the `PathBuilder` starting from our current position. - `WithMaxClimbDistance( 16f )` We don't want the melon to climb walls etc so we set a low value. Typically 16f is the size of a human-walkable step. - `WithMaxDropDistance( 16f )` We don't want the melon to jump down walls etc so we set a low value. - `WithStepHeight( 16f )` As mentioned, 16f is the size of a typical step, so that's what we set here. - `WithMaxDistance( 99999999 )` Make the path as long as possible. - `WithPartialPaths()` Partial paths means that the navigation generator can generate incomplete paths to the player (e.g. if it can only figure out how to get halfway or something). - `Build( target.Position )` Build a path to the player's position. - `Segments` A path is split into segments. We access that here. - `Select( x => x.Position )` We get the position from each segment. - `ToArray()` Return it as an array, because `Path` is an array. The end result is just that we get an array of `Vector3`s representing our newly-generated path. We set `CurrentPathSegment` to 0 because we have generated a new path and so we need to start at the beginning (0) of it. ## TraversePath() ``` protected void TraversePath() { if ( Path == null ) return; var distanceToTravel = MOVEMENT_SPEED; while ( distanceToTravel > 0 ) { var currentTarget = Path[CurrentPathSegment]; var distanceToCurrentTarget = Position.Distance( currentTarget ); if ( distanceToCurrentTarget > distanceToTravel ) { var direction = (currentTarget - Position).Normal; Position += direction * distanceToTravel; return; } else { var direction = (currentTarget - Position).Normal; Position += direction * distanceToCurrentTarget; distanceToTravel -= distanceToCurrentTarget; CurrentPathSegment++; } if ( CurrentPathSegment == Path.Count() ) { Path = null; return; } } } ``` This actually moves the NPC along its path. If there is no path, we return and give up. We need to move `MOVEMENT_SPEED` every tick (NB: this function is called every tick). However, this is not as simple as just taking the next segment in the path and moving towards it. We cannot assume that we will only ever move towards one segment per tick. For example, if our movement speed is 100f, but each path segment is only 10f away from the next, then we will actually move through 10 segments per tick (this is an exaggerated example, but you get the idea). So, first we check to see if the next path segment is further away than the amount we can move this tick (`distanceToTravel`). If it is, then we simply move towards it and return, because we have expended the amount we can move this tick. If the next path segment is closer than the amount we can move, then we move to the next segment and decrease `distanceToTravel` by the amount we have moved. We move on to the next segment by doing `CurrentPathSegment++` and repeat over again, until `distanceToTravel` is zero. If we reach the end of the path, we set the path to null so we don't use it anymore and we return. # Challenges - The melon doesn't always touch the floor properly. Using `Trace.Ray`, can you trace a ray to the floor and adjust its position based on its distance to the ground? - The melon has no collision. Can you use `SetupPhysicsFromModel()` to give it some? - The melon is immortal. Can you implement `TakeDamage()` to make it killable? - The melon can move through objects. Can you implement `MoveHelper` in order to make it collide with stuff? - The melon is a bit boring. Can you add sound effects, e.g. when the melon attacks?