Our code is built to follow the SOLID principles: the Single-Responsibility Principle, the Open-Closed Principle, Liskov’s Substitution Principle, the Interface Segregation Principle, and Dependency Inversion. Although our code at its current state has over 6,000 lines of code, the fact we follow the Single-Responsibility Principle allows for this code to be evenly distributed across a multitude of classes rather than having one “super-class” that has thousands of lines with the game logic in it. The Open-Closed Principle allows us to have easily testable entity classes as one can simply use encapsulation to test a completely different class. Liskov’s Substitution Principle allows for attributes to be clearly defined for entities, such as if it is walkable or what not. This, quite similarly, goes hand-in-hand with the interface segregation principle which more or less says to divide interfaces into singly-responsible interfaces rather than handling multiple different tasks. Dependency Inversion is perhaps the most heavily used principle in our project. This usage of Dependency Inversion is what solidies all the other principles. For example, to construct the client, there is a MoribundClientFactory that creates all the dependencies the MoribundClient uses. This strengthens and secures the client immensely, while still keeping everything at ease, as the client factory can be made only in the package-private, and the client can only be made if all the dependencies are provided. An example in code is the following:
Code:
import lombok.val;
class MoribundClientFactory {
MoribundClient createMoribundClient() {
val players = createPlayersMap();
val networkBootstrapper = createNetworkBootstrapper();
val packetDispatcher = createPacketDispatcher(networkBootstrapper);
val titleScreenFactory = createTitleScreenFactory();
return new MoribundClient(players, networkBootstrapper, packetDispatcher, titleScreenFactory);
}
private ScreenFactory createTitleScreenFactory() {
return new TitleScreenFactory();
}
private PacketDispatcher createPacketDispatcher(NetworkBootstrapper networkBootstrapper) {
return networkBootstrapper.createPacketDispatcher();
}
private NetworkBootstrapper createNetworkBootstrapper() {
return new NetworkBootstrapper();
}
private AbstractInt2ObjectMap<PlayableCharacter> createPlayersMap() {
return new Int2ObjectOpenHashMap<>();
}
}
public class MoribundClient extends Game {
...
@Getter
private final AbstractInt2ObjectMap<PlayableCharacter> players;
private final ScreenFactory titleScreenFactory;
private final NetworkBootstrapper networkBootstrapper;
private final PacketDispatcher packetDispatcher;
MoribundClient(AbstractInt2ObjectMap<PlayableCharacter> players,
NetworkBootstrapper networkBootstrapper,
PacketDispatcher packetDispatcher,
ScreenFactory titleScreenFactory) {
this.players = players;
this.networkBootstrapper = networkBootstrapper;
this.packetDispatcher = packetDispatcher;
this.titleScreenFactory = titleScreenFactory;
}
...
}
In this code, the MoribundClientFactory creates the dependencies that the client needs in order to function. To further explain, through the use of Dependency Inversion, the client factory utilizes the Single-Responsibility Principle as it is solely responsible for creating a MoribundClient and its dependencies. Another example of how the the Dependency Inversion Principle strengthens our usage of all the other principles is in our ScreenFactory interface.
Code:
public interface ScreenFactory {
Screen createScreen();
}
public class TitleScreenFactory implements ScreenFactory {
@Override
public Screen createScreen() {
val musicPlayer = createMusicPlayer();
val gameScreenFactory = createGameScreenFactory();
val settingsScreenFactory = createSettingsScreenFactory();
return new TitleScreen(musicPlayer, gameScreenFactory, settingsScreenFactory);
}
private ScreenFactory createGameScreenFactory() {
return new GameScreenFactory();
}
private ScreenFactory createSettingsScreenFactory() {
return new SettingsScreenFactory();
}
private MusicPlayer createMusicPlayer() {
return new MusicPlayer();
}
}
The fact that we were wishing to use Dependency Inversion motivated us to use the Open-Closed Principle. By means of using the Open-Closed Principle, we now have flexible ways to change the screen factories for a respective class with minimal effort. For example, if we wished to start the game at the GameScreen instead of the TitleScreen, all we would have to do is go to the MoribundClientFactory and make a method that creates a GameScreen using the GameScreenFactory which implements ScreenFactory. Then, we would simply pass in the newly created GameScreen dependency to the constructor of the MorbundClient and the game would start with the GameScreen with no errors at all.
Another heavily used practice is the Singleton pattern. The classes that utilize the Singleton pattern are the MusicContainer, SpriteContainer, and MoribundClient classes. The music and sprite container classes utilize the pattern to allow a cache of the music and sprites loaded from the start of the program with attachments to their third-party class implementations in LibGDX. A visualization of this would be the following code:
Code:
private final Object2ObjectOpenHashMap<MusicFile, Music> musicForFile;
In this case, the MusicFile is coupled with the Music class itself, which allows for us to use Music in the LibGDX library in relation to the MusicFile itself. Using a Singleton allows us to not only have lazy initialization of the music files, but also lets us have a cache of Music in a data structure of O(1) time complexity. The same is true for the SpriteContainer class.
The MoribundClient is a unique case in this respect of the Singleton pattern. The entire use of Singleton pattern in the class is due to the following variables:
Code:
@Getter @Setter
private PlayableCharacter player;
@Getter
private final PacketDispatcher packetDispatcher;
The accessibility of the player variable is integral for the game as this variable is the exact PlayableCharacter being controlled by the respective client. The packetDispatcher is also needed in order to allow packets to be sent from places other than the Listeners. This topic will be discussed next.
Networking is done with the NetworkBootstrapper, Packets, the PacketDispatcher, and Listeners. The NetworkBootstrapper is responsible for giving the initial instructions to start the networking process and listeners. Important configurations can be found within this class.
Code:
private static final int INITIAL_TIMEOUT = 3000;
private static final String IP_ADDRESS = "localhost";
private static final int PORT = 43594;
Moreover, all the Packets (and the serializable classes they use) are registered in this class.
Code:
private void registerPackets(Kryo kryo) {
kryo.register(DrawNewPlayerPacket.class);
kryo.register(LoginPacket.class);
kryo.register(LoginRequestPacket.class);
kryo.register(ArrayList.class, new JavaSerializer());
kryo.register(Pair.class, new JavaSerializer());
kryo.register(Integer.class, new JavaSerializer());
kryo.register(KeyPressedPacket.class);
kryo.register(KeyPressedResponsePacket.class);
kryo.register(KeyUnpressedPacket.class);
kryo.register(KeyUnpressedResponsePacket.class);
kryo.register(LocationPacket.class);
kryo.register(RotationPacket.class);
}
Note that we have plans to possibly use the classpath-scanner library to automatically scan for all classes that implement the Packet interface and auto-register them. However, this is a slight possibility, as this would disable the flexibility of custom serialization possibly.
The Packet interface and PacketDispatcher go hand in hand. The PacketDispatcher class is responsible for the sending of packets from the client to the server. It provides a restrictive access to the KryoNet Client to the public classes. At the current time, the PacketDispatcher is used to send UDP packets restrictively. This constraint is enforced as the methods require a Packet class in the parameter rather than the Object class that default KryoNet gives. This allows for less vulnerability of unneeded data being sent to the server.
Code:
public class PacketDispatcher {
private final Client client;
PacketDispatcher(Client client) {
this.client = client;
}
public void sendTCP(Packet packet) {
client.sendTCP(packet);
}
}
The Listeners are what make the networking work. These classes listen to packets that are related to separate components of the game, which enforces the Single-Responsibility Principle. These packets are resolved by instanceof comparison and class casting.
Code:
@Override
public void received(Connection connection, Object object) {
if (object instanceof KeyPressedResponsePacket) {
val keyPressedResponsePacket = (KeyPressedResponsePacket) object;
val player = MoribundClient.getInstance().getPlayers().get(keyPressedResponsePacket.getPlayerId());
player.keyPressed(keyPressedResponsePacket.getKeyPressed());
} else if (object instanceof KeyUnpressedResponsePacket) {
val keyUnpressedResponsePacket = (KeyUnpressedResponsePacket) object;
val player = MoribundClient.getInstance().getPlayers().get(keyUnpressedResponsePacket.getPlayerId());
player.keyUnpressed(keyUnpressedResponsePacket.getKeyUnpressed());
}
}
The current flow of the program in regards to networking is the following:
> Player starts the game, NetworkBootstrapper is connected.
> Once player clicks “Find Match,” the LoginRequestPacket is sent.
> The server receives the LoginRequestPacket and creates a player using the connection ID as the player ID. The player is then given a default x and y location in the game, added to the list of players in the server, and two packets are sent: the LoginPacket and the DrawNewPlayerPacket.
> The LoginPacket is sent to the client of the player that has just been made. The data that is sent to the client is the list of all the player IDs, the player locations, player rotations, and the player ID of the newly created player.
> The DrawNewPlayerPacket is sent to all of the other players. This data that is sent is solely information about the newly created player, such as the player ID and the location of the player.
> A player has now logged in and the client and server have been synchronized with the same data.
> When a player clicks a button, a keybind of the respective PlayableCharacter is invoked. This provokes the Runnable interface to be run. Once the player has released the key, the keybind is invoked again, but this time of its key release component. This key is sent synchronously to the server.
Code:
public interface PlayableCharacter {
...
void bindKeys();
AbstractInt2ObjectMap<PlayerAction> getKeyBinds();
Set<Flag> getFlags();
void keyPressed(int keyPressed);
void keyUnpressed(int keyUnpressed);
}
public interface PlayerAction {
void keyPressed();
void keyUnpressed();
}
...
keyBinds.put(Input.Keys.UP, new PlayerAction() {
@Override
public void keyPressed() {
flag(Flag.MOVE_UP);
}
@Override
public void keyUnpressed() {
unflag(Flag.MOVE_UP);
sendTilePacket();
}
});
...
> Every 100 milliseconds, a game state packet is sent by the server to the client and the client blindly obeys whatever the server says. This means that the server follows an asynchronous pattern, which allows for packets to be enacted appropriately.