jMonkeyEngine Artificial Intelligence
Most games written need some type of Artificial Intelligence to deliver a feeling of realism, excitement or challenge to the player. AI can be as simple as having an NPC (Non Player Character) respond to some action taken by a player or as complicated as smoothly navigating your way through a scene full of obstacles without getting stuck. It’s a time-consuming and significant challenge to develop these systems so its much easier to use an existing library to do the heavy lifting for you.
Unfortunately, the jMonkeyEngine comes with no official library for dealing with AI. There is, however, the jme3 Artificial Intelligence library that is probably the closest there is to an official release. Although it never made it into any official releases, it was designed, in part, by core team members. It consists of two separate AI models, a Navigation Mesh library using path-finding, and a simple Steering Behaviours library that uses path-following.
You can read about the introduction of the library in the forum thread: AI plugin now with NavMesh path-finding.
Requirements
-
jme3 Artificial Intelligence Library - The library and javaDocs for jme3AI. This is also where you can report problems or help in maintaining the library.
-
CritterAI - Stephen Pratt’s NMGen Study project files to generate the navmesh.
-
To get the assets (3D models) used in this example, add the jME3-testdata.jar to your classpath.
-
Java SDK 8+.
Stephen Pratt explains in detail the configuration parameters of CritterAI/Jme3AI in a easy to follow format and is suggested reading.
Use Example
The jme3 Artificial Intelligence Library contains:
-
NavMesh - A Navigation Mesh path-finding AI system using the A* algorithm.[1]
-
Steering - The foundations of an Autonomous Agent system that uses path-following and forces to move a character through its environment. Includes a test case as well.[2]
This scope of this tutorial is restricted to the NavMesh part of the library and expands upon the lessons taught in the tutorials. It demonstrates the use of some classes and methods found in the Medium and Advanced topics of the wiki as well. You can find the source code for this tutorial in the jMonkeyEngine/docs-examples repository. |
Moving a character through your scene requires three things.
-
A navigation mesh.
-
A path-finding component that uses that navigation mesh to calculate a path.
-
A way to move the character.
NavMesh Creation
The first thing you need for path-finding is a navigation mesh. There are two ways to generate the NavMesh, procedural or the jMonkey SDK.
-
The SDK has a built-in command, but comes with a trade-off that no parameter exceptions are thrown. This means you are flying blind when the NavMesh fails generation.
-
If you choose procedural, you see any generation exceptions, but you will have to do a little more work like saving, loading and/or displaying the NavMesh.
Both methods produce exactly the same NavMesh and both will be covered in this tutorial.
From the SDK
-
Open your scene in the Terrain Editor or Scene Explorer by RMB selecting the file in your assets folder and choosing
Edit Terrain
orEdit in SceneComposer
. -
Once open, RMB select the root node in the
SceneExplorer
and then select.
This will open the Create NavMesh
dialog with default settings. You can read in depth about each parameter by following the Configuration Parameters
link under Requirements.
The jme3AI system uses CritterAI, which is based off Recast and Detour navigation. The author of Recast lays out a few specific rules for NavMesh creation in this blog post, which logically apply to jme3AI. Below is a translation of this post as it pertains to jme3AI.
-
First you should decide the size of your character "capsule". For example, if you are using meters as units in your game world, a good size of human sized character might be (r)adius=0.4, (h)eight=2.0.
-
Next the voxelization cell size (cs) will be derived from that. Usually good value for cs is r/2 or r/3. In outdoor environments, r/2 might be enough, indoors you sometimes want the extra precision and you might choose to use r/3 or smaller.
-
The voxelization cell height (ch) is defined separately in order to allow greater precision in height tests. Good starting point for ch is cs/2. If you get small holes where there are discontinuities in the height (steps), you may want to decrease cell height.
-
Next up is the character definition values. First up is
minTraversableHeight
, which defines the height of the agent. -
The
maxTraversableStep
defines how high steps the character can climb. -
The parameter
traversableAreaBorderSize
defines the agent radius. If this value is greater than zero, the navmesh will be shrunken by thetraversableAreaBorderSize
. If you want to have tight fit navmesh, use zero radius. -
The parameter
maxTraversableSlope
is used before voxelization to check if the slope of a triangle is too high and those polygons will be given a non-walkable flag. The parameter is in radians. -
In certain cases really long outer edges may decrease the triangulation results. Sometimes this can be remedied by just tessellating the long edges. The parameter
maxEdgeLength
defines the max edge length. A good value formaxEdgeLength
is something liketraversableAreaBorderSize*8
. A good way to tweak this value is to first set it really high and see if your data creates long edges. If so, then try to find as big value as possible which happen to create those few extra vertices which makes the tessellation better. -
When the rasterized areas are converted back to vectorized representation the
edgeMaxDeviation
describes how loosely the simplification is done. Good values are between 1.1-1.5 (1.3 usually yield good results). If the value is less, some stair-casing starts to appear at the edges and if it is more than that, the simplification starts to cut some corners.
A summary of the parameter effects is included in the comments of the NavMeshState.java file and discussed in the Procedural code examples that follow this section. |
If there are problems with your parameter settings, you will only know if the NavMesh doesn’t appear under the Node you selected and there is no task running in the status area located at the bottom right of the SDK.
If the NavMesh doesn’t appear, then you will have to make adjustments to the Configuration Parameters
until it completes successfully. Minor adjustments to cell size will usually work.
Cell size has the greatest impact on your NavMesh. The smaller the cell size, the more accurate the NavMesh, the longer it takes to generate. Generating a 1024x1024 NavMesh can take anywhere from 30 seconds to ten minutes to complete, depending on terrain complexity. Even larger NavMeshes can take many hours. |
Selecting the NavMesh node in the SceneExplorer will show the NavMesh in the Terrain Editor or SceneComposer view-port. If it doesn’t show, with the NavMesh node selected, change the Cull Hint to Never in the NavMesh - Properties panel.
|
Procedural Method
There are many ways to create a NavMesh. If you look at the constructor for the Jme3AI.java file, you will see I use a BaseAppState named NavMeshState.java which creates a generator
object and builds the NavMesh
new every time the program is ran.
public Jme3AI() {
super(new StatsAppState(), new DebugKeysAppState(), new TerrainState(),
new NavMeshState(), new PCState(), new KeyboardRunState());
}
It can take from seconds to hours to build a NavMesh, depending on how complicated it is. Therefore, you would normally build the NavMesh or meshes, add them to your Assets
folder and load them at startup. The NavMeshState
and NavMeshGenerator
classes are both convenience classes and are not required to create a NavMesh. If you wish to keep your game minimalist, you can set the variables for the CritterAI NavmeshGenerator (note the lower case 'm' in mesh) in the method call directly or by variable, and pass the IndexBuffer and VertexBuffer of your mesh into the CritterAI NavmeshGenerator object.
NavmeshGenerator nmgen = new NavmeshGenerator(cellSize, cellHeight, minTraversableHeight,
maxTraversableStep, maxTraversableSlope,
clipLedges, traversableAreaBorderSize,
smoothingThreshold, useConservativeExpansion,
minUnconnectedRegionSize, mergeRegionSize,
maxEdgeLength, edgeMaxDeviation, maxVertsPerPoly,
contourSampleDistance, contourMaxDeviation);
...
Get mesh buffers and set IntermediateData
...
//Pass buffers and IntermediateData to build process
TriangleMesh triMesh = nmgen.build(positions, indices, intermediateData);
...
Process trimesh
...
Let’s examine what it takes to create the NavMesh
using the NavMeshState
and NavMeshGenerator
helper classes.
/**
* creates the NavMesh
*/
private void createNavMesh() {
generator = new NavMeshGenerator();
//The width and depth resolution used when sampling the source geometry.
//outdoors = agentRadius/2, indoors = agentRadius/3, cellSize =
//agentRadius for very small cells.
//Constraints > 0 , default=1
generator.setCellSize(.25f);
//The height resolution used when sampling the source geometry.
//minTraversableHeight, maxTraversableStep, and contourMaxDeviation
//will need to be greater than the value of cellHeight in order to
//function correctly. maxTraversableStep is especially susceptible to
//impact from the value of cellHeight.
//cellSize/2
//Constraints > 0, default=1.5
generator.setCellHeight(.125f);
//Represents the minimum floor to ceiling height that will still allow
//the floor area to be considered traversable.
//minTraversableHeight should be at least two times the value of
//cellHeight in order to get good results. Max spatial height.
//Constraints > 0, default=7.5
generator.setMinTraversableHeight(2f);
//Represents the maximum ledge height that is considered to still be
//traversable.
//maxTraversableStep should be greater than two times cellHeight.
//Constraints >= 0, default=1
generator.setMaxTraversableStep(0.3f);
//The maximum slope that is considered traversable. (In degrees.)
//Constraints >= 0, default=48
generator.setMaxTraversableSlope(50.0f);
//Indicates whether ledges should be considered un-walkable.
//Constraints None, default=false
generator.setClipLedges(false);
//Represents the closest any part of a mesh can get to an obstruction in
//the source geometry.
//traversableAreaBorderSize value must be greater than the cellSize to
//have an effect. Radius of the spatial.
//Constraints >= 0, default=1.2
generator.setTraversableAreaBorderSize(0.6f);
//The amount of smoothing to be performed when generating the distance
//field used for deriving regions.
//Constraints >= 0, default=2
generator.setSmoothingThreshold(0);
//Applies extra algorithms to help prevent malformed regions from
//forming.
//Constraints None, default=true
generator.setUseConservativeExpansion(true);
//The minimum region size for unconnected (island) regions.
//Constraints > 0, default=3
generator.setMinUnconnectedRegionSize(8);
//Any regions smaller than this size will, if possible, be merged with
//larger regions.
//Constraints >= 0, default=10
generator.setMergeRegionSize(20);
//The maximum length of polygon edges that represent the border of
//meshes.
//setTraversableAreaBorderSize * 8
//Constraints >= 0, default=0
generator.setMaxEdgeLength(4.0f);
//The maximum distance the edges of meshes may deviate from the source
//geometry.
//1.1 to 1.5 for best results.
//Constraints >= 0 , default=2.4
generator.setEdgeMaxDeviation(1.3f);
//The maximum number of vertices per polygon for polygons generated
//during the voxel to polygon conversion process.
//Constraints >= 3, default=6
generator.setMaxVertsPerPoly(6);
//Sets the sampling distance to use when matching the detail mesh to the
//surface of the original geometry.
//Constraints >= 0, default=25
generator.setContourSampleDistance(5.0f);
//The maximum distance the surface of the detail mesh may deviate from
//the surface of the original geometry.
//Constraints >= 0, default=25
generator.setContourMaxDeviation(5.0f);
//Time allowed before generation process times out in milliseconds.
//default=10000
generator.setTimeout(40000);
//the data object to use for storing data related to building the
//navigation mesh.
IntermediateData data = new IntermediateData();
generator.setIntermediateData(data);
Mesh mesh = new Mesh();
GeometryBatchFactory.mergeGeometries(findGeometries(app.getRootNode(),
new LinkedList<>(), generator), mesh);
//uncomment to show mesh
// Geometry meshGeom = new Geometry("MeshGeometry");
// meshGeom.setMesh(mesh);
// showGeometry(meshGeom, ColorRGBA.Yellow);
// saveNavMesh(meshGeom);
Mesh optiMesh = generator.optimize(mesh);
navMesh.loadFromMesh(optiMesh);
Geometry geom = new Geometry(DataKey.NAVMESH);
geom.setMesh(optiMesh);
//display the mesh
showGeometry(geom, ColorRGBA.Green);
//save the navmesh to Scenes/NavMesh for loading
exportNavMesh(geom, DataKey.NAVMESH);
//save geom to rootNode if you wish
saveNavMesh(geom);
}
First, we create the NavMeshGenerator object and then use it to set the parameters for the NavMesh.
generator = new NavMeshGenerator();
...
generator.setCellSize(.25f);
...
In our next step we create an IntermediateData object.
//the data object to use for storing data related to building the
//navigation mesh.
IntermediateData data = new IntermediateData();
generator.setIntermediateData(data);
The IntermediateData object can be used to get information about the build process of the NavMesh such as build times. You query this object after building the NavMesh. If you don’t wish to see the data, set it to null.
At this point, you now have a generator
object that you use to create the NavMesh with.
Included in the NavMeshState.java file is the helper method findGeometries
.
//Gathers all geometries in supplied node into supplied List. Uses
//NavMeshGenerator to merge found Terrain meshes into one geometry prior to
//adding. Scales and sets translation of merged geometry.
private List<Geometry> findGeometries(Node node, List<Geometry> geoms,
NavMeshGenerator generator)
It is used to collect all geometries, attached to a node, into a List. If a child of the node is a Terrain instance (which can consist of many meshes), it will use the generator
object to merge them into one mesh, then scale and set translation of the merged mesh prior to being added to the list. You then use GeometryBatchFactory to merge all the geometries in the list into a single mesh
object.
Mesh mesh = new Mesh();
GeometryBatchFactory.mergeGeometries(findGeometries(app.getRootNode(),
new LinkedList<>(), generator), mesh);
After these methods execute, you have a single mesh
object that is now ready to be optimized.
Mesh optiMesh = generator.optimize(mesh);
This is where the parameters you set with the generator
object are applied to the supplied mesh
. The optimize method will return a new Mesh object that reflects your generator settings. Now is when any problems with your parameters will show themselves as either warnings or exceptions. You should keep changing the various parameters, one at a time and in small increments/decrements, until your mesh
generates with no errors. See each parameter’s notes for suggestions on how to do so.
After the mesh generates, you need to link all of its cells together so it can be used as your NavMesh
object. You do this by calling loadFromMesh()
or loadFromData()
, depending on your implementation, on your optiMesh
object.
navMesh.loadFromMesh(optiMesh);
If you look at the second constructor for the NavMesh
class you will see this is all it does. You would use this constructor if you were loading a Mesh
from a geometry that had already been optimized and saved into your Assets
folder for example.
public NavMesh(Mesh mesh) {
loadFromMesh(mesh);
}
The NavMesh
object is now ready for use in your game, but you still need to create the geometry for it if you wish to save or view it. You do this the same as you would for any newly created mesh.
Geometry geom = new Geometry(DataKey.NAVMESH);
geom.setMesh(navMesh);
Now that you have your Mesh you should save it.
//save the navmesh to Scenes/NavMesh for loading
exportNavMesh(geom, DataKey.NAVMESH);
//save geom to rootNode if you wish
saveNavMesh(geom);
In this instance, the object is exported to the projects Assets
folder so it can be loaded rather than generated every time the game starts. This is the preferred method. The saveNavMesh()
method just attaches the geometry to the rootNode
. How and where you choose to save depends on your implementation and personal preferences.
Pathfinding
There are many ways to implement the NavMeshPathfinder
class of the jme3AI library. You can create a control, instantiate the NavMeshPathFinder
class, and query the newly created object in a thread. You could use a single AppState to calculate all your paths. You could, as in this tutorial, extend the NavMeshPathFinder class in a custom control.
You also need a way to communicate Vector3f
changes to the NavMeshPathfinder
. This tutorial uses an ActionListener and Interface. You could just as easily create a public method in the control, and call it from the ActionListener, or store the Vector3f
in UserData
and look for changes from the control itself.
These are implementation decisions that are left up to you.
Loading the NavMesh
In this tutorial example, the optimized mesh was exported as a geometry using the jMonkey binary format .j3o
. Doing so means the loading of your NavMeshes
is done the same way you load any model, by using the AssetManager
. Once you load the .j3o
, you grab its Mesh
and create the NavMesh
object to be passed to the NavigationControl constructor. This tutorial uses a BaseAppState for model loading so access to the Application
class is built in.
//load NavMesh geometry saved to assets folder
Geometry navGeom = (Geometry) getApplication().getAssetManager().
loadModel("Scenes/NavMesh/NavMesh.j3o");
NavigationControl navControl = new NavigationControl(new NavMesh(
navGeom.getMesh()), getApplication(), true)
charNode.addControl(navControl);
//NavigationControl implements Pickable Interface
picked = navControl;
This tutorial uses a custom control,
|
Communicating with NavigationControl
This tutorial makes use of the Hello Picking and Mouse Picking tutorials so you should already be familiar with this method for picking and how to add the input mappings to your game. How you implement your ActionListener is up to you.
private class ClickedListener implements ActionListener {
@Override
public void onAction(String name, boolean isPressed, float tpf) {
if (name.equals(ListenerKey.PICK) && !isPressed) {
CollisionResults results = new CollisionResults();
Vector2f click2d = getInputManager().getCursorPosition().clone();
Vector3f click3d = app.getCamera().getWorldCoordinates(click2d,
0f).clone();
Vector3f dir = app.getCamera().getWorldCoordinates(
click2d, 1f).subtractLocal(click3d).normalizeLocal();
Ray ray = new Ray(click3d, dir);
app.getRootNode().collideWith(ray, results);
for (int i = 0; i < results.size(); i++) {
// For each hit, we know distance, impact point, name of geometry.
float dist = results.getCollision(i).getDistance();
Vector3f pt = results.getCollision(i).getContactPoint();
String hit = results.getCollision(i).getGeometry().getName();
System.out.println("* Collision #" + i);
System.out.println(
" You shot " + hit
+ " at " + pt
+ ", " + dist + " wu away.");
}
if (results.size() > 0) {
// The closest collision point is what was truly hit:
CollisionResult closest = results.getClosestCollision();
// Let's interact - we mark the hit with a red dot.
mark.setLocalTranslation(closest.getContactPoint());
app.getRootNode().attachChild(mark);
picked.setTarget(closest.getContactPoint());
System.out.println(" Closest Contact " + closest.
getContactPoint());
} else {
// No hits? Then remove the red mark.
app.getRootNode().detachChild(mark);
}
}
}
}
The main line of interest here is,
picked.setTarget(closest.getContactPoint());
where picked
is the reference object used to communicate our Vector3f
changes to the NavigationControl
.
//NavigationControl implements Pickable Interface
picked = navControl;
At this point you have loaded your NavMesh
, added the NavigationControl
to your spatial, and instituted a method for communicating with the NavMeshPathFinder
. Next we will delve into the details of the NavigationControl
.
NavigationControl
The NavigationControl is a custom control that extends the NavMeshPathFinder class of the Jme3AI library and implements the Pickable
interface.
public class NavigationControl extends NavMeshPathfinder implements Control,
JmeCloneable, Pickable {
}
The Pickable interface is straightforward and its sole purpose in this implementation is to communicate changes made to the pick target.
/**
* @param target the target to set
*/
@Override
public void setTarget(Vector3f target) {
this.target = target;
}
The heartbeat of the control lies in the pathfinding thread which makes calls to the computePath()
method. Potentially long running tasks like this should always be ran from a thread. Below, is the constructor you would normally use to instantiate your control.
public NavigationControl(NavMesh navMesh) {
super(navMesh); //sets the NavMesh for this control
executor = Executors.newScheduledThreadPool(1);
startPathFinder();
}
First, you call super(navMesh)
to set the NavMesh
for the control, then setup your ExecutorService
and start the pathfinding thread.
This is a custom thread implementation so it’s up to you to handle shutting it down. This is done in the controls setSpatial()
method.
if (spatial == null) {
shutdownAndAwaitTermination(executor);
...
} else {
...
}
//standard shutdown process for executor
private void shutdownAndAwaitTermination(ExecutorService pool) {
pool.shutdown(); // Disable new tasks from being submitted
try {
// Wait a while for existing tasks to terminate
if (!pool.awaitTermination(6, TimeUnit.SECONDS)) {
pool.shutdownNow(); // Cancel currently executing tasks
// Wait a while for tasks to respond to being cancelled
if (!pool.awaitTermination(6, TimeUnit.SECONDS)) {
LOG.log(Level.SEVERE, "Pool did not terminate {0}", pool);
}
}
} catch (InterruptedException ie) {
// (Re-)Cancel if current thread also interrupted
pool.shutdownNow();
// Preserve interrupt status
Thread.currentThread().interrupt();
}
}
The easiest way to move a physics character is by using the BetterCharacterControl class. In this implementation, this is done in the PCControl class by extending BetterCharacterControl
. Since BetterCharacterControl
is required to be present on the spatial for pathfinding, in the setSpatial()
method, we throw an exception to let us know if it’s missing.
if (spatial == null) {
...
} else {
pcControl = spatial.getControl(PCControl.class);
if (pcControl == null) {
throw new IllegalStateException(
"Cannot add NavigationControl to spatial without PCControl!");
}
}
Pathfinding Thread
//Computes a path using the A* algorithm. Every 1/2 second checks target
//for processing. Path will remain until a new path is generated.
private void startPathFinder() {
executor.scheduleWithFixedDelay(() -> {
if (target != null) {
clearPath();
setWayPosition(null);
pathfinding = true;
//setPosition must be set before computePath is called.
setPosition(spatial.getWorldTranslation());
//warpInside(target) moves endpoint within the navMesh always.
warpInside(target);
System.out.println("Target " + target);
boolean success;
//compute the path
success = computePath(target);
System.out.println("SUCCESS = " + success);
if (success) {
//clear target if successful
target = null;
...
}
pathfinding = false;
}
}, 0, 500, TimeUnit.MILLISECONDS);
}
How you setup your pathfinding thread makes a significant difference.
executor.scheduleWithFixedDelay(() -> {
...
}, 0, 500, TimeUnit.MILLISECONDS);
This ExecutorService
is set to start immediately (0) with a fixed delay of (500) milliseconds. This means the task has a fixed delay of 1/2 second between the end of an execution and the start of the next execution, i.e. it doesn’t take into account the actual duration of the task. If you were to use scheduleAtFixedRate()
, you risk that the task doesn’t complete in the time allocated.
When you use the BetterCharacterControl
, all that’s required to move the spatial is that you setWalkDirection()
and the spatial will continuously move in that direction. The following code breakdown explains how the NavigationControl
takes advantage of this.
It starts by having the pathfinding thread check a target
variable for changes.
if (target != null) {
...
}
If it finds a target, it will compute a new path to that target
, and if successful, update the NavMeshPathfinder
path variable. The update()
loop of the control continuously checks this path variable, and if its non-null, takes an appropriate action.
Before you compute the path you first clear the existing path, and set wayPosition to null.
if (target != null) {
clearPath();
setWayPosition(null);
pathfinding = true;
...
}
Doing this allows the player to select a new target
at any time and immediately start moving along the new path. Otherwise, the character must finish the path they are on, then backtrack to the position the character was at when the target
change was made, before then continuing on the new path.
Next, you must call setPosition()
before calling the computePath()
method.
if (target != null) {
...
setPosition(spatial.getWorldTranslation());
...
//compute the path
success = computePath(target);
...
}
There are some things you need to know about how a path is computed.
-
The first waypoint on any path is the one you set with
setPosition()
. -
The last waypoint on any path is always the
target
Vector3f. -
computePath() adds one waypoint to the cell nearest to the target only if you are not in the goalCell (the cell target is in), and if there is a cell between first and last waypoint, and if there is no direct line of sight.
-
If inside the goalCell when a new target is selected, computePath() will do a direct line of sight placement of target. This means there will only be two waypoints set,
setPosition()
andtarget
. -
If the
target
is outside theNavMesh
, your endpoint will be as well.
To guarantee that target
is always inside the NavMesh
, call
if (target != null) {
...
//warpInside(target) moves endpoint within the navMesh always.
warpInside(target);
...
//compute the path
success = computePath(target);
...
}
before calling computePath()
and the endpoint of the path will be moved to the closest cell to the target
that’s inside the NavMesh
.
Character Movement
@Override
public void update(float tpf) {
if (getWayPosition() != null) {
Vector3f spatialPosition = spatial.getWorldTranslation();
Vector2f aiPosition = new Vector2f(spatialPosition.x,
spatialPosition.z);
Vector2f waypoint2D = new Vector2f(getWayPosition().x,
getWayPosition().z);
float distance = aiPosition.distance(waypoint2D);
//move char between waypoints until waypoint reached then set null
if (distance > .25f) {
Vector2f direction = waypoint2D.subtract(aiPosition);
direction.mult(tpf);
pcControl.setViewDirection(new Vector3f(direction.x, 0,
direction.y).normalize());
pcControl.onAction(ListenerKey.MOVE_FORWARD, true, 1);
} else {
setWayPosition(null);
}
} else if (!isPathfinding() && getNextWaypoint() != null
&& !isAtGoalWaypoint()) {
if (showPath) {
showPath();
showPath = false;
}
//advance to next waypoint
goToNextWaypoint();
setWayPosition(new Vector3f(getWaypointPosition()));
//set spatial physical position
if (getPositionType() == EnumPosition.POS_STANDING.position()) {
setPositionType(EnumPosition.POS_RUNNING.position());
stopFeetPlaying();
stopTorsoPlaying();
}
} else {
//waypoint null so stop moving and set spatials physical position
if (getPositionType() == EnumPosition.POS_RUNNING.position()) {
setPositionType(EnumPosition.POS_STANDING.position());
stopFeetPlaying();
stopTorsoPlaying();
}
pcControl.onAction(ListenerKey.MOVE_FORWARD, false, 1);
}
}
If the computePath()
successfully computes a new path, the path variable of the NavMeshPathfinder
will no longer be null. The update loop of the NavigationControl
checks this path variable, every iteration that wayPosition is null, by calling the getNextWaypoint()
method. If the path has another waypoint, it will advance to the next position in the path and set the wayPosition
variable of the NavigationControl
to that position.
} else if (!isPathfinding() && getNextWaypoint() != null
&& !isAtGoalWaypoint()) {
...
//advance to next waypoint
goToNextWaypoint();
setWayPosition(new Vector3f(getWaypointPosition()));
...
}
Remember, the first waypoint in the path is always the spatials current position. This is why you always advance the position first. |
On the next iteration of the controls update()
method, it sees that wayPosition
is no longer null and calculates the distance from the spatials current position to the wayPosition
.
if (getWayPosition() != null) {
Vector3f spatialPosition = spatial.getWorldTranslation();
Vector2f aiPosition = new Vector2f(spatialPosition.x,
spatialPosition.z);
Vector2f waypoint2D = new Vector2f(getWayPosition().x,
getWayPosition().z);
float distance = aiPosition.distance(waypoint2D);
...
}
If it’s greater than the distance specified, it will setViewDirection()
of the PCControl
(which extends BetterCharacterControl) and then notify the PCControl
that the spatial can move by calling the controls onAction()
method directly.
if (getWayPosition() != null) {
...
//move char between waypoints until waypoint reached then set null
if (distance > .25f) {
Vector2f direction = waypoint2D.subtract(aiPosition);
direction.mult(tpf);
pcControl.setViewDirection(new Vector3f(direction.x, 0,
direction.y).normalize());
pcControl.onAction(ListenerKey.MOVE_FORWARD, true, 1);
} else {
...
}
}
It’s up to the NavigationControl
to determine when the character should stop moving. Each time the spatial reaches a point that is less than the specified distance, it sets the wayPosition to null.
if (distance > .25f) {
...
} else {
setWayPosition(null);
}
If the path position has not yet reached the end, it will once again be advance to the next waypoint in the path and update the wayPosition.
} else if (!isPathfinding() && getNextWaypoint() != null
&& !isAtGoalWaypoint()) {
...
//advance to next waypoint
goToNextWaypoint();
setWayPosition(new Vector3f(getWaypointPosition()));
...
}
When the last waypoint is reached, the NavigationControl
notifies the PCControl
that the spatial can no longer move.
} else {
...
pcControl.onAction(ListenerKey.MOVE_FORWARD, false, 1);
}
The PCControl class handles the actual movement of the spatial in its update()
loop. It does this by checking the forward
variable every iteration. This variable is set when you call the onAction()
method from the NavigationControl
update loop.
@Override
public void onAction(String name, boolean isPressed, float tpf) {
if (name.equals(ListenerKey.MOVE_FORWARD)) {
forward = isPressed;
}
}
@Override
public void update(float tpf) {
super.update(tpf);
this.moveSpeed = 0;
walkDirection.set(0, 0, 0);
if (forward) {
Vector3f modelForwardDir = spatial.getWorldRotation().mult(Vector3f.UNIT_Z);
position = getPositionType();
for (EnumPosition pos : EnumPosition.values()) {
if (pos.position() == position) {
switch (pos) {
case POS_RUNNING:
moveSpeed = EnumPosition.POS_RUNNING.speed();
break;
default:
moveSpeed = 0f;
break;
}
}
}
walkDirection.addLocal(modelForwardDir.mult(moveSpeed));
}
setWalkDirection(walkDirection);
}
The PCControl
will then set the walk direction, based off spatials world rotation, and set the speed.
Conclusion
The intent of this tutorial was to give you a general breakdown of how the Jme3AI navigation system works as well as demonstrate how flexible its implementation is. All the code in this tutorial is free for your use and can be found in the jme3 documentation repository. The implementations design is such that you can easily change each of the parameters and then visually see how they affect the NavMesh. If you have questions or suggestions on improving this tutorial you can do so in the jMonkeyEngine forum.
Other AI Options
There are other jME3 specific options available you can read about in the wiki under the topic Artificial Intelligence (AI).
Further Reading
-
A* path-finding for Beginners by Patrick Lester
-
The Nature of Code by Daniel Shiffman
-
Steering Behaviors For Autonomous Characters by Craig W. Reynolds
-
Study: Navigation Mesh Generation Java by Stephen Pratt