Goblin Wars II.NET - the story of creating a network game on C # from scratch
Good afternoon, dear habrovchane. I present to you my small project - a network 2D shooter on C #. Despite the fact that the visual component is very simple - in this century you will not be interested in anyone with 2D games, some architectural solutions may interest people who are going to write their own game. In the article I will talk about options for implementing the key points of the game.
Prehistory
Long before I became an electronics engineer, I developed purely software projects. From the very moment I wrote my first line of code (in the second class, on Turbo Basic, in the “Palace of Pioneers”), I had no desire to write a game. I think that almost everyone who started learning to program faced this. Of course, then there was not enough knowledge for something large-scale, there were only simple text games on the same Turbo Basic and then on Quick Basic. Sometimes I used scanty BASIC graphics output capabilities — for example, there was a game where you had to control a green pixel, running away from red and setting traps in the form of white pixels. However, as time went on, I learned more and more, and as a result, in the eighth class I decided to write a 2D shooter for two people. Why for two? Because the networks were not so common then, and the most accessible multiplayer game mode was Hot-Seat. At that time, we often fought with friends in Worms and Heroes in this way, but we wanted to drive real-time. So I decided to write a game where I could run, shoot from various weapons, pick up boxes with supplies, etc. in real time. - but at the same time, it would be possible to play together. One player stood out the alphabetic part of the keyboard, the other - the numeric. Since the mouse was one, it was not used to not give advantages to one of the players. And since the monitor was one, the fighting was limited to an unrolled multiple. So there was Goblin Wars.
Game process ')
The same
The game was written - let Von Neumann have mercy on me - in Visual Basic and used BitBlt to display graphics. I drew all the graphics myself, to the best of my skills, in 3D Max, including tilesets. We voiced the game with friends. The game even had a prehistory, which, briefly, was that goblins used to live on the earth, then people came who began to destroy them, and the goblins had to go underground. Since then, goblin cities have existed under the major cities of the people. Goblins collect all kinds of technological rubbish, such as old idle televisions, which were thrown out by people, and construct from them their robots and similar handicrafts. And since there are few of them left, it was decided to resolve conflicts between cities not by bloody wars, but by special tournaments to which each city sent the best fighter. These tournaments were called Goblin Wars. There were quite a few types of weapons in the game - bombs, grenades, a pistol, a machine gun, rockets, distance bombs, mines, a phase gun (teleportator) and - the main feature of the game is phosphoric louse + weapon against it - ABO. After the goblin launched a phosphoric louse, control switched to it. It was impossible to stop it, just change the direction of motion. Crashing into a wall or another goblin, it exploded, causing great damage to the epicenter. The funniest thing was that if the goblin, her manager, was killed or got into the louse from the ABO, she became “wild” - her speed doubled and she started running around the map uncontrollably, with a distinctive sound rushing to the players who were close and trying to get to them. In addition, in boxes sometimes a “bonus” fell out, throwing wild lice at once onto the card, which also added a drive. After the match, stats with ranks were issued: Can you become a legendary magician?
We very often played Goblins with friends, and also distributed them at school - in the computer science class, people from our and parallel class played goblins leading the teacher with shouts from the “Do you like lice?” Columns published by goblins. In general, this game has captured us with something, so even many years later, in the intervals between StarCraft 2, we played no-no-no and played a match-other in goblins to recall the old times. More than ten years have passed since the first release of Goblin Wars.
Goblin Wars II.Net
I had the idea to write the second version a long time ago, since now a fast network connection is no longer a problem, I wanted to be able to play with friends over the network, not just 1x1 using a single keyboard. When I finally matured for writing, I started writing GWII in C ++. Brought to the state of the alpha version, and something enthusiasm faded. Not so long ago, the enthusiasm returned again, and I, having chosen, this time C #, decided to implement my plans. Initially, the plans were more ambitious - at least to replace the top view with isometry. But since I had very little time (work, other projects), and I still didn’t observe an artist, in the end I decided to do this: I should take the graphics to the maximum from the old ones, redrawn, except what looked at all disgustingly (for example, the tiles in the new version of steel were 64x64 and they just needed to be redrawn somehow), leave the view from above, but implement a network game, remove unused weapons (such as bombs and grenades in old ones), etc. Immediately present a screenshot of what happened: In a new light
And below, at the request of readers - a gameplay video, a test three-minute match with a friend. Unfortunately, the rest of the friends are now out of reach, so I had to play 1x1.
So what are Goblin WarsII? The game is built on the principle of client-server, all calculations are conducted on the server side, the client is designed exclusively for rendering. Drawing is done using OpenGL, sound is output through OpenAL, images are loaded by DevIL. Network Library - Lindgren network. OpenGL, OpenAL and DevIL are connected to sharpe through the Tao Framework wrapper. No ready-made engines were used - yes, I know, it would probably be possible to take a ready-made 3D engine and get the best result, but I just wanted to write everything myself from the very beginning, from scratch, enjoy my “bicycle”. The architecture of this bike, and I will present in the next chapters of the article.
Game architecture: general overview
What I would like to mention first of all is modular game. Very modular. In the sense that all subsystems of the game are separate dll-ki, most of which are in no way connected with each other, and those that are connected know only about the interfaces. This makes it easy to throw out one part of the system and replace it with another. Do not like the network on UDP, lindgren network? Please take one dll, Network.dll, and write your own, not forgetting the implementation of interfaces INetworkClient, INetworkServer. For example, to implement a single player, the so-called Zero-network was implemented, which is simply a connection for the client and the server without any networks — a pure invocation of interface methods. At the same time, the client and server do not care at all whether they work with Zero-Network or with a real network. The same can be said with respect to, for example, graphics. The architecture allows you to rewrite Graphics.dll, replacing OpenGL output with anything - even WinAPI, even D3D. Also replacing one dlki, in principle, you can replace the top view on the isometry, if such a desire arises. Below is the dependency harf at the build level:
Game architecture
GoblinWarsII.exe and Server.exe are actually executable files. They contain only a few lines, because All logic is in dlki. For example, all server code looks like this:
classProgram { staticvoidMain(string[] args) { var game = new Game(); var networkServer = new UDPGameServer(game, int.Parse(args[2])); var matchParameters = new MatchParameters {Difficulty = DifficultyLevel.Easy, FragLimit=0, TimeLimit = uint.Parse(args[0]), MapName=args[1]}; game.StartMatch(matchParameters); networkServer.Start(); while (Console.ReadKey().KeyChar != 'q'); game.Halt(); networkServer.Stop(); } }
Common.dll contains common declarations and helper classes, such as a special timer. Basically, there is a description of data structures, for example - enum types of items that are needed by both the server and the client:
Network.dll, of course, contains the network logic, the connecting client and server together. ServerLogic.dll is used only by the server and contains all the game logic, this is where all the calculations for game objects take place. Media.dll is used only by the client and contains the logic of displaying server objects on the client (I will focus on this in more detail later). And finally, Graphics.dll contains the code for directly drawing objects. Almost every dll starts its own processing thread and does not stop others - ServerLogic - the thread of calculating game logic, Network - the reception-transfer threads, Media - the processing thread of media objects (representing server objects on the client), Graphics - the render thread. We turn to the consideration of a specific implementation, and we begin with the implementation of the server.
Game architecture: server
So, as was already clear from the above code, the main in the server is the Game class, an instance of which is created in the Server.exe binary:
var game = new Game();
All other systems can only see the server logic interface, which looks like this:
Server architecture
Thus, from the outside you can start the game, add or remove players, get various information about the match, and, most importantly, get all the states of the game objects.
public IList<GameObjectState> GetAllObjectStates()
- One of the most important functions of the game. It is she who returns a snapshot of the world through which it can be fully drawn. Thus, the network system in its thread of transmission simply periodically requests Game from the Game world, in order to further serialize it, compress it and transfer it to clients. Interaction with a game object that represents, in fact, a player (for example, transferring actions from a client to it) also takes place not directly, but through the IPlayerDescriptor interface:
As a result, we obtain what was described above - the subsystem with the game logic completely separated from the rest. Let us now consider how, in fact, it works inside. "Top layer" looks pretty standard thread safe Dictionary <ID_Object, Object> -
and the processing cycle that is caused by a specified number of times per second and counting the time elapsed since the last miscalculation:
privatevoidObjectProcessingTaskRoutine() { quantTimer.Tick(); Statistics.TimeQuantPassed(quantTimer.QuantValue); if (currentMatchParameters.TimeLimit > 0 && Statistics.MatchTime > currentMatchParameters.TimeLimit) EndMatch(); foreach(var gameObject in gameObjects) { if (!gameObject.Value.Destroyed()) gameObject.Value.Process(quantTimer.QuantValue); else gameContext.RemoveObject(gameObject.Key); } }
GameContext is a class whose link to the instance is passed to the designer of all game objects created, it contains all the necessary information about the game, such as a link to the game map, a list of players, and methods for adding a new object to the game so that you do not need to pass the link on myself
All fields of the GameContext are immutable, readonly, which excludes the “corruption” of the context by game objects.
But the implementation of game objects is more interesting. Initially, I intended to do quite trivially, creating a basic GameObject and inheriting concrete implementations from it, such as Player, Bomb, etc. But this approach is not without flaw. When there are a lot of classes, and especially when there are many subclasses , such as “teleport objects”, “objects with health” - the whole architecture becomes cumbersome and inconvenient. The slightest change makes it redraw from the very beginning. Therefore, I turned to the experience of the creator of the game Dungeon Siege, which I learned from his presentation. In short, the essence is as follows: all game objects are only containers for game components and do not contain logic and data, except for the message exchange logic. Components are complete blocks containing the necessary logic and data for some fixed task. The interaction between objects occurs through the exchange of messages that are routed to components.
What does this give us? This provides a very, very flexible and convenient way to implement game logic. What an object will become in the game world is now determined only by the set of its components and their design parameters. Once you have implemented a component with some part of the logic, you can shove it into any game object without confusing methods of decomposing into classes, unlike the traditional approach. Moreover, now all objects can be made of one class, GameObject, and the list of components can be loaded from some config on the go.
In this case, I have not yet taken advantage of the last item - I left the download from the config to “later” and did different classes of objects, but don't let that bother you - this was done only to not load the configuration from external files, other differences between the classes Player, Bomb, Bullet, etc. does not exist, and as soon as my hands reach this, they will all be replaced by a single GameObject. Let's now take a closer look at how this is all implemented. So, IGameObject is at the core of everything:
A game object can only get such an interface of another object, which provides additional protection from misuse - a third-party object can neither add components to an object, nor interact with them in any way, just check for the presence of one or another component, for which IComponent is present [ ] GetComponents ();
The interface implementation, GameObject, contains the logic of exchanging messages and receiving state (which is used when getting a snapshot of the world):
publicvoidSendMessage(ComponentMessageBase msg) { messageQueue.Enqueue(msg); } public GameObjectState GetState() { var states = new List<ComponentState>(components.Count); lock (lockObj) { if (Destroyed()) returnnull; foreach (var component in components) { var state = component.GetState(); if (state != null) states.Add(state); } } returnnew GameObjectState(Id, type, states); } publicvoidProcess(double quantValue) { if (Destroyed()) return; lock (lockObj) { SendMessage(new MsgTimeQuantPassed(this, quantValue)); // , while (messageQueue.Count > 0) { ComponentMessageBase msg; messageQueue.TryDequeue(out msg); foreach (var component in components) component.ProcessMessage(msg); } } }
The blocking object is necessary in order to ensure that the state of the object remains unchanged at the time of the network request.
Messages add up to a queue view private readonly ConcurrentQueue messageQueue;
List
privatereadonly List<Component> components;
contains, of course, all the components of the object.
Game architecture: components
The base component class contains, above all, three main functions:
ProcessMessage must be redefined as a successor, since it is she who is responsible for the logic implemented by the component. GetState, the only function available through the IComponent interface to external systems, returns the shared state of the object, if any - for example, health or coordinates. If the object does not have a shared state, then it can be not overridden. The Probe function checks component dependencies. If the component does not depend on anything, then you can not touch it. If there is a dependency - like, for example, the Collector component is dependent on the Inventory component, then it should be checked in this function. It is used not only for checking, but also for caching references to corresponding dependencies, in order not to look for them again each time. Now the above may look foggy, but let's look at examples, and everything should become clear. How, for example, in such an architecture to make a bullet? Rocket?
So, the first implemented component was SolidBody. This component is responsible for the physical embodiment of the object in the game world - that is, for its coordinates, interaction with the map and dimensions.
Like all components, the Owner is the owner object and the gameContext is the game context from which, in this case, it takes a link to the GameMap. The rest is already specific for SolidBody — the coordinates of the object, its physical dimensions, the angle of rotation, and a pair of flags — to optimize calculations, objects that should not collide with each other are called semisolid — collisions are calculated only for solid-solid and solid-semisolid, two semisolid do not collide. These include, for example, bullets that cannot collide with each other.
The bullet will certainly be SolidBody, because has coordinates and dimensions. Moreover, it will be semisolid, since we do not need useless miscalculations of collisions of bullets with each other, this must be remembered.
I will omit the implementation of SolidBody at the moment, since it is quite large. I will only note that in order to speed up the calculations, a list of objects that relate to it is attached to each map tile. When you move an object around the map, these lists are updated. Thus, when calculating collisions, we do not need to count the distances from each object to each, but rather quickly select the tiles in the radius of interest and only calculate the distances for the objects that touch them.
Next, we will implement a component that is the concentration of logic for all bullets, rockets and other projectiles — it will be responsible for a simple, uniform, straight-line flight.
Now it should become more clear. To make it completely clear, I will demonstrate another component, very simple, and without dependencies - DieOnTTL. As the name suggests, the component is responsible for the death of an object after a specified period of time:
How, then, will our bullets look like? Very simple. Here, for example, a pistol bullet:
classPistolBullet : GameObject { publicPistolBullet(GameContext context, IGameObject parent, double x, double y, byte angle, double speed, double ttl) : base(context, GameObjectType.PistolBullet, parent) { AddComponents( new SolidBody(this, context.GameMap, x, y, 9, 9, angle, true), new DieOnTTL(this, ttl), new Projectile(this, speed), new DieOnCollide(this,new GameObjectType[]{GameObjectType.Player, GameObjectType.WildLouse}, true, newushort[]{parent.GetId()}), new DecayOnDeath(this) ); } }
As I have already said, a separate class was created only because it has not yet reached its hands before loading the config from the outside. What does this code do? First of all, it transfers GameObjectType.PistolBullet to the basic GameObject constructor, the GameObjectType.PistolBullet type on the client will go, this is what the various components that are important for collisions with the bullet and similar interactions will check. All that remains is to add components that will make a bullet a bullet. In this case, the parameters are transmitted from the outside, to the constructor of the object itself, and from there to the constructors of the components. But no one bothers to hard-write them right here in the code, or download along with a list of components from some XML. First of all - SolidBody, because the bullet is quite a physical object, having coordinates and colliding with others. Do not forget to specify true in the semisolid parameter - we don’t need extra calculations. Bullets must disappear after a certain flight time, even if they do not encounter anything. So let's add the DieOnTTL object that we have already created with the lifetime parameter. The bullet must fly forward. We implemented this in Projectile, we add it with the appropriate speed. The bullet must die from the collision. Add DieOnCollide. I omitted its implementation, but it is quite trivial - MsgCollide sends messages to SolidBody, so we don’t need to re-implement anything, just check MsgCollide.CollidedObject for who we are all the same. The parameters here indicate that the collide with whom we should not ignore, said the collider with the walls and the object ID that we need to ignore is specified - the ID of the bullet that created this bullet, the parent, is transferred so that the bullets do not hurt themselves. And finally, the last thing we need to do is to somehow react to the messages that we have died - DecayOnDeath will just silently kill the object when receiving the message MsgDeath. Well, what if we want a rocket? There is nothing easier:
classRocket : GameObject { publicRocket(GameContext context, IGameObject parrent, double x, double y, byte angle, double speed, double ttl, double radius) : base(context, GameObjectType.Rocket, parrent) { AddComponents( new SolidBody(this, context.GameMap, x, y, 15, 15, angle, true), new DieOnTTL(this, ttl), new Projectile(this, speed), new DieOnCollide(this,new[]{GameObjectType.Player, GameObjectType.WildLouse}, true, newushort[]{parrent.GetId()}), new ExplodeOnDeath(this,context, radius) ); } }
We do the same, except that the rocket is a little bigger than the bullet (the geometric dimensions of SolidBody are larger), and the rockets no longer collide with objects like WildLouse, which, as you probably noticed, were on the list of colliding objects. in the designer of DieOnCollide in a bullet, and most importantly - the rocket does not quietly die at death, but explodes loudly, so instead of DecayOnDeath we add ExplodeOnDeath with the parameter of the radius of destruction. Everything!We made a rocket on almost the same components - no rewriting of the existing code. All we needed was to make another component responsible for the explosion at death (it behaves just like DecayOnDeath, but creates a new object at the point of death, the Explosion), which no doubt will be useful elsewhere. Yes, also do not forget to specify the appropriate type of object - GameObjectType.Rocket.
A teleport bullet, for example, differs only in the presence of the Teleporter component:
AddComponents( new SolidBody(this, context.GameMap, x, y, 30, 30, angle,false, false, true), new DieOnTTL(this, ttl), new Projectile(this, speed), new Teleporter(this, context), new DieOnCollide(this, new GameObjectType[] {}, true, newushort[] { parrent.GetId() }), new PhaseOnDeath(this, context, radius) );
He is responsible for teleporting objects containing the Teleportable component. To make an object teleported you simply add Teleportable to it in the list of components. The vast majority of components do not even require any additional inheritance, which makes the whole architecture very flexible and transparent.
I will give a list of components used in the game:
AOE - is responsible for the area of ​​effect exposure. Objects within the radius of action are sent a MsgAOE message with an Effect field that shows how strong the effect is (it is calculated depending on the distance to the epicenter; an impact profile can be passed to the component designer to customize how the effect will change with distance). Used by a teleportation bullet.
This is an almost complete list of the main components of the game, not tied to the implementation. This is part of the engine. I also implemented several components that are directly related to GoblinWars - these include implementations of the heirs of BaseWeapon and BaseModidierManager, which implement already specific processing, WildLouseLogic is the component responsible for the behavior of wild lice, etc.
External systems, or rather the network, interacts with the player, as already mentioned, with the same messages. To do this, the network jerks the corresponding PlayerDescriptor for the PerformAction () method; The implementation of this method is the usual creation of messages and sending them to PlayerRescriptor.PlayerObject.SendMeddage () depending on the desired Action.
Conclusion
That is, in principle, everything that forms the basis of ServerLogic.dll. In the next article I will talk about the network part of the server and the client and its interaction with other subsystems. I am not going to post the full source code yet, but if someone has any questions about the implementation, I'm ready to answer. The game itself on the test lay out at the end of the last article, if anyone is interested.
If there are artists who are ready to redraw graphics purely for their own pleasure, I will be very grateful. All the graphics here are simple two-dimensional sprites, you need to draw goblin in 8 directions, phosphoric louse in 8 directions and, most importantly, tiles, but now they are sorely lacking. Also, in principle, interested in porting the client to other platforms, but I don’t want to do this alone. If anyone wants to try porting to a mobile system or to the web, on some HTML5, this is also discussed. Thanks for attention.