Networked FPS games are a genre of games that never seem to lose popularity. In this recipe, we'll look at the basics to get a server and multiple clients up and running. We will emulate a server with a persistent environment, where players can connect and disconnect at any time.
We have the benefit of using some of the code generated in earlier chapters. The code we'll use requires some changes to be adapted to a networked game, but it will again show the benefit of using jMonkeyEngine's Control
and AppState
classes.
Good recipes to read up on before this are the previous recipes in this chapter (especially Making a networked game – Battleships, on which the architecture relies heavily) and also the Creating a reusable character control recipe from Chapter 2, Cameras and Game Controls, as we will use a similar pattern here for our NetworkedPlayerControl
implementations. To avoid repetition, this recipe will not show or explain all of the regular gameplay code.
We begin by defining a few classes that will be used commonly across both server and client:
NetworkedPlayerControl
extending AbstractControl
. We will use this both as an identifier for a player object and as a control for the spatial representation of the player.ID
.onMessageReceived
, taking PlayerMessage
as input. This is the method that our message handlers will call to apply changes. In ServerPlayerControl
, the message will contain the actual input from the player, whereas ClientPlayerControl
simply replicates what has happened on the server.Game
, which will be shared by both the client and server.HashMap
object called players
, where playerId
is the key and NetworkedPlayerControl
is the value. It keeps track of the players.We will need a couple of new messages for this example. All messages are assumed to be in a bean pattern with getters and setters. We define the messages with the following steps:
PlayerMessage
, extending AbstractMessage
. This only needs an integer called playerId
.PlayerMessage
. It is called PlayerActionMessage
and handles player input. This should be set to be reliable as we don't want to ever miss a player's input.pressed
and a float value called floatValue
.action
.PlayerMessage
in another class called PlayerUpdateMessage
. This will be used to distribute player location information from the server to the clients. This should not be reliable to avoid unnecessary delays.Vector3f
field called position
and a Quaternion
field called lookDirection
.With the messages defined, let's see what the server code looks like:
FPSServer
, which extends SimpleApplication
.Server
field, it also keeps track of the next ID to give to a connecting player, a Game, and a Map of all the currently connected players, with their connection as the key:private Server server; private int nextPlayerId = 1; private Game game; private HashMap<HostedConnection, ServerPlayerControl> playerMap = new HashMap<HostedConnection, ServerPlayerControl>();
GameUtil
to register all our message classes. We also set frameRate
to 30 fps
. This might be different depending on the game type. Finally, we start the application in the headless mode, to save resources as follows:public static void main(String[] args ) throws Exception{ GameUtil.initialize(); FPSServer gameServer = new FPSServer(); AppSettings settings = new AppSettings(true); settings.setFrameRate(30); gameServer.setSettings(settings); gameServer.start(JmeContext.Type.Headless); }
ConnectionListener
instance to look for connecting and disconnecting players. This will call addPlayer
and removePlayer
respectively, when players connect or disconnect.addPlayer
method, we create a new ServerPlayerControl
instance, which is the server-side implementation of NetworkedPlayerControl
, and assign an ID to it for easier reference, as follows:private void addPlayer(Game game, HostedConnection conn){ ServerPlayerControl player = new ServerPlayerControl(); player.setId(nextPlayerId++); playerMap.put(conn, player); game.addPlayer(player);
Node s = new Node(""); s.addControl(player); rootNode.attachChild(s);
playerId
in all messages, so the server sends the assigned ID back to the client in WelcomeMessage
. It broadcasts the message using the client's connection as a filter, as follows:WelcomeMessage welcomeMessage = new WelcomeMessage(); welcomeMessage.setMyPlayerId(player.getId()); server.broadcast(Filters.in(conn), welcomeMessage);
Collection<NetworkedPlayerControl> players = game.getPlayers().values(); for(NetworkedPlayerControl p: players){ PlayerJoinMessage joinMessage = new PlayerJoinMessage(); joinMessage.setPlayerId(p.getId()); server.broadcast(Filters.in(conn), joinMessage); }
PlayerJoinMessage joinMessage = new PlayerJoinMessage(); joinMessage.setPlayerId(player.getId()); server.broadcast(joinMessage); }
removePlayer
method works similarly, but it only has to send a message to each player currently connected about the disconnected player. It also uses PlayerJoinMessage
but it sets the leaving
Boolean to true
to indicate the player is leaving, not joining the game.fps
to 30
, it will try to do this every 33 ms as follows:public void simpleUpdate(float tpf) { super.simpleUpdate(tpf); Collection<NetworkedPlayerControl> players = game.getPlayers().values(); for(NetworkedPlayerControl p: players){ p.update(tpf); PlayerUpdateMessage updateMessage = new PlayerUpdateMessage(); updateMessage.setPlayerId(p.getId()); updateMessage.setLookDirection(p.getSpatial().getLocalRotation()); updateMessage.setPosition(p.getSpatial().getLocalTranslation()); updateMessage.setYaw(p.getYaw()); server.broadcast(updateMessage); } }
ServerMessageHandler
class that implements MessageListener
. It's a short class in this case, which will only listen to messages extending PlayerMessage
and pass it on to the correct NetworkedPlayerControl
class to update it. In this recipe, this will mean the input coming from the player, as follows:public void messageReceived(HostedConnection source, Message m) { if(m instanceof PlayerMessage){ PlayerMessage message = (PlayerMessage)m; NetworkedPlayerControl p = game.getPlayer(message.getPlayerId()); p.onMessageReceived(message); } }
NetworkedPlayerControl
class, we extend it to a new class called ServerPlayerControl
.GameCharacterControl
class from Chapter 2, Cameras and Game Controls, we will use a set of Booleans to keep track of the input, as follows:boolean forward = false, backward = false, leftRotate = false, rightRotate = false, leftStrafe = false, rightStrafe = false;
onMessageReceived
method, listen for PlayerMessages
. We don't know if it will contain Boolean or float values, so we look for both, as follows:public void onMessageReceived(PlayerMessage message) { if(message instanceof PlayerActionMessage){ String action = ((PlayerActionMessage) message).getAction(); boolean value = ((PlayerActionMessage) message).isPressed(); float floatValue = ((PlayerActionMessage) message).getFloatValue();
if (action.equals("StrafeLeft")) { leftStrafe = value; } else if (action.equals("StrafeRight")) { rightStrafe = value; } ... else if (action.equals("RotateLeft")) { rotate(floatValue); } else if (action.equals("RotateRight")) { rotate(-floatValue); }
controlUpdate
method, we then modify the position and rotation of the spatial based on the input, just like we did in the Creating a reusable character control recipe of Chapter 2, Cameras and Game Controls.The client is simple in many ways, since it basically only does two things. It takes a player's input, sends it to the server, receives updates from the server, and applies them as follows:
FPSClient
extending SimpleApplication
.Properties prop = new Properties(); prop.load(getClass().getClassLoader().getResourceAsStream("network/resources/network.properties")); client = Network.connectToServer(prop.getProperty("server.name"), Integer.parseInt(prop.getProperty("server.version")), prop.getProperty("server.address"), Integer.parseInt(prop.getProperty("server.port")));
Node
class called playerModel
, which will be the visual representation of the players in the game. There should also be a ClientPlayerControl
class called thisPlayer
.simpleInitApp
method, we attach InputAppState
. This has the same functionality as the one in the Creating an input AppState object recipe of Chapter 2, Cameras and Game Controls. The only difference is it will benefit from having a direct way of reaching the client to send messages:public void simpleInitApp() { InputAppState inputAppState = new InputAppState(); inputAppState.setClient(this); stateManager.attach(inputAppState);
playerGeometry
to be used for all the players in this example, as follows:Material playerMaterial = new Material(assetManager, "Common/MatDefs/Misc/Unshaded.j3md"); playerGeometry = new Geometry("Player", new Box(1f,1f,1f)); playerGeometry.setMaterial(playerMaterial);
flyByCamera
instance and create a new game
object, which we will populate when we receive information from the server, as follows:getFlyByCamera().setEnabled(false); game = new Game();
ClientMessageListener
object and add it to the client, as shown in the following code snippet:ClientMessageHandler messageHandler = new ClientMessageHandler(this, game); client.addMessageListener(messageHandler);
createPlayer
method, we create a new ClientPlayerControl
instance and also a Node
instance, which we attach to the scene graph, as follows:ClientPlayerControl player = new ClientPlayerControl(); player.setId(id); final Node playerNode = new Node("Player Node"); playerNode.attachChild(assetManager.loadModel("Models/Jaime/Jaime.j3o"));// playerNode.addControl(player);
enqueue(new Callable(){ public Object call() throws Exception { rootNode.attachChild(playerNode); return null; } });
ClientPlayerControl
instance to the calling method.setThisPlayer
. This method will be called when the player's WelcomeMessage
is received. Inside this, we create CameraNode
, which will be attached to the player, as follows:public void setThisPlayer(ClientPlayerControl player){ this.thisPlayer = player; CameraNode camNode = new CameraNode("CamNode", cam); camNode.setControlDir(CameraControl.ControlDirection.SpatialToCamera); ((Node)player.getSpatial()).attachChild(camNode); }
destroy
method to make sure we close the connection to the server when the client is shutdown. This can be implemented as follows:public void destroy() { super.destroy(); client.close(); }
NetworkedPlayerControl
and extend it in a class called ClientPlayerControl
.Vector3f
field called tempLocation
and a Quaternion
field called tempRotation
. These are used to hold received updates from the server. It can also have a float
field called yaw
for head movement.onMessageReceived
method, we only look for PlayerUpdateMessages
and set tempLocation
and tempRotation
with the values received in the message, as follows:public void onMessageReceived(PlayerMessage message) { if(message instanceof PlayerUpdateMessage){ PlayerUpdateMessage updateMessage = (PlayerUpdateMessage) message; tempRotation.set(updateMessage.getLookDirection()); tempLocation.set(updateMessage.getPosition()); tempYaw = updateMessage.getYaw(); } }
temp
variable values in the controlUpdate
method:spatial.setLocalTranslation(tempLocation); spatial.setLocalRotation(tempRotation); yaw = tempYaw;
Just like on the server side, we need a message handler listening for incoming messages. To do this, perform the following steps:
ClientMessageHandler
, which implements MessageListener<Client>
.ClientMessageHandler
class should have a reference to FPSClient
in a field called gameClient
and Game
itself in another field called game
.messageReceived
method, we need to handle a number of messages. The WelcomeMessage
is most likely to arrive first. When this happens, we create a player object and spatial and assign it to be this client's player, as follows:public void messageReceived(Client source, Message m) { if(m instanceof WelcomeMessage){ ClientPlayerControl p = gameClient.createPlayer(((WelcomeMessage)m).getMyPlayerId()); gameClient.setThisPlayer(p); game.addPlayer(gameClient.getThisPlayer());
PlayerJoinMessage
is received both when player joins and leaves a game. What sets it apart is the leaving
Boolean. We call both the game
and gameClient
methods based on whether the player is joining or leaving, as shown in the following code snippet:PlayerJoinMessage joinMessage = (PlayerJoinMessage) m; int playerId = joinMessage.getPlayerId(); if(joinMessage.isLeaving()){ gameClient.removePlayer((ClientPlayerControl) game.getPlayer(playerId)); game.removePlayer(playerId); } else if(game.getPlayer(playerId) == null){ ClientPlayerControl p = gameClient.createPlayer(joinMessage.getPlayerId()); game.addPlayer(p); }
PlayerUpdateMessage
is received, we first find the corresponding ClientPlayerControl
class and pass on the message to it, as follows:} else if (m instanceof PlayerUpdateMessage){ PlayerUpdateMessage updateMessage = (PlayerUpdateMessage) m; int playerId = updateMessage.getPlayerId(); ClientPlayerControl p = (ClientPlayerControl) game.getPlayer(playerId); if(p != null){ p.onMessageReceived(updateMessage); }
The server is running in the headless mode, which means it won't do any rendering and there will be no graphical output, but we still have access to the full jMonkeyEngine application. In this recipe, one server instance will only have one game active at a time.
We instantiate all network messages inside a class called GameUtil
, since they have to be the same (and serialized in the same order) on the client and server.
The client will try to connect to the server as soon as it launches. Once connected, it will receive playerId
from the server via WelcomeMessage
, as well as PlayerJoinMessages
for all other players that are already connected. Likewise, all other players will receive PlayerJoinMessage
with the new player's ID.
The client sends any actions the players perform to the server using PlayerActionMessage
, which applies them to its instance of the game. The server, which runs at 30 fps, will send positions and directions of each player to all the other players, using PlayerUpdateMessages
.
The InputAppState
class on the client is very similar to the one in Chapter 2, Cameras and Game Controls. The only difference is that instead of directly updating a Control
instance, it creates a message and sends it to the server. In the onAction
class, we set the Boolean value of the message, whereas in onAnalog
(to look and rotate), floatValue
will be used instead, as shown in the following code snippet:
public void onAction(String name, boolean isPressed, float tpf) { InputMapping input = InputMapping.valueOf(name); PlayerActionMessage action = new PlayerActionMessage(); action.setAction(name); action.setPressed(isPressed); action.setPlayerId(client.getThisPlayer().getId()); client.send(action); }
In the event of a player leaving the game, PlayerJoinMessages
will be sent to the other players, with leaving
set to true
.
The NetworkedPlayerControl
class is an abstract class, and doesn't do much on its own. You might recognize the implementation of ServerPlayerControl
from GameCharacterControl
, and they function similarly, but rather than receiving the input directly from the user, ServerPlayerControl
gets it via a networked message instead.
Both the client and server implementation of NetworkedPlayerControl
use the tempRotation
and tempLocation
fields to which they apply any incoming changes. This is so we don't modify the actual spatial transforms outside the main loop.
We shouldn't be fooled by the relative simplicity of this recipe. It merely shows the basics of a real-time networked environment. Making a full game creates much more complexity.
3.16.139.8