• entries
15
6
• views
2481

3D Fantasy Online RPG located in ancient Greece.

Event system

When I realized that the classes are getting really big and complicated, I splitted them into smaller classes. So the Actor class doesn't do everything anymore, but has some components that do the work instead. A Common problem with this approach is, how do Compositors (in this case the Actor) and Components communicate? Some might say they shouldn't communicate at all, use something like an ECS etc. I looked at ECS et al., and they may be fine when you design your application around it, but I didn't. Up to now I just friend'ed everything and called private methods, but that doesn't seem to be very elegant. Another approach is some event/messaging system. So interested parties subscribe to interesting events, and other may trigger those events. There are many such event systems out there, but all seemed to be too heavy for me, so I made a minimal implementation of such a system. I think this implementation has some Pro's and many Con's: Pros Type safety Event function can have any signature even with return value. The CallOne() and CallAll() functions returns the same type as the called function (or a vector of it). You must define at compile time the signatures of functions your a going to call, and you can not call anything else. You get a compile error when a function signature is not found. A called function may be a std::function or Lambda. No inheritance needed. Single header file events.h, only ~110 lines. Minimal run time overhead. Cons Function signature must be passed to each call of Subscribe(), CallOne() and CallAll(). You must define at compile time the signatures of functions your a going to call If you do anything wrong (e.g. wrong function signature), you get weird error messages Increased compile time, because a lot is done at compile time. But what you can do at compile time, doesn't have to be done at run time. You can not unsubscribe from events :(. Examples Lambda sa::Events< int(int, int) > events; events.Subscribe<int(int, int)>(1, [](int i, int j) -> int { return i * j; }); auto result = events.CallOne<int(int, int)>(1, 2, 3); static_assert(std::is_same<decltype(result), int>::value); assert(result == 6); Lambda 2 sa::Events< int(int, int) > events; auto func = [](int i, int j) -> int { return i + j; }; events.Subscribe<int(int, int)>(2, func); auto result = events.CallOne<int(int, int)>(2, 4, 5); static_assert(std::is_same<decltype(result), int>::value); assert(result == 9); Event not found When an event does not exist or nobody subscribed to the event it returns a default value, e.g. 0 for an int: sa::Events< int(int, int) > events; events.Subscribe<int(int, int)>(1, [](int i, int j) -> int { return i * j; }); auto result = events.CallOne<int(int, int)>(2, 2, 3); static_assert(std::is_same<decltype(result), int>::value); assert(result == 0); Methods class Foo { private: sa::Events< int(int, int) > events; int Bar(int i, int j) { return i * j; } public: Foo() { events.Subscribe<int(int, int)>(1, std::bind(&Foo::Bar, this, std::placeholders::_1, std::placeholders::_2)); } int DoBar(int i, int j) { return events.CallOne<int(int, int)>(1, i, j); } }; Foo foo; auto result = foo.DoBar(3, 2); assert(result == 6); Different signatures sa::Events< int(int, int), bool(int), void(void) > events; events.Subscribe<int(int, int)>(1, [](int i, int j) -> int { return i * j; }); events.Subscribe<bool(int)>(2, [](int i) -> bool { return i != 0; }); events.Subscribe<void(void)>(3, []() { std::count << "No arguments :(" << std::endl; }); auto result = events.CallOne<int(int, int)>(1, 4, 5); static_assert(std::is_same<decltype(result), int>::value); assert(result == 20); auto result2 = events.CallOne<bool(int)>(2, 5); static_assert(std::is_same<decltype(result2), bool>::value); assert(result2 == true); // void events.CallOne<void(void)>(3); Multiple subscribers sa::Events< void(const std::string&) > events; events.Subscribe<void(const std::string&)>(1, [](const std::string& s) { std::cout << "Subscriber 1 " << s << std::endl; }); events.Subscribe<void(const std::string&)>(1, [](const std::string& s) { std::cout << "Subscriber 2 " << s << std::endl; }); events.CallAll<void(const std::string&)>(1, "Hello Subscribers!"); Should print: Subscriber 1 Hello Subscribers! Subscriber 2 Hello Subscribers! Conclusion I was able to get rid of many virtual functions and the classes got lighter. But the usage is a bit cumbersome, because you always have to pass the function signature of the event as template argument, but you get type safety in return. Download Get it from the Github respository if you are interested (MIT).

Projectiles

Projectiles Unfortunately Bows and Spears already existed in ancient Greece, so I had to implement something that feels like a projectile. Projectiles are fast moving Actors heading towards a target. If they hit the target it damages the target. Like in other online RPGs a projectile can not hit the wrong target, it either hits the desired target or it misses it. Projectiles have some special problems. Problems They are fast moving, they fly, the target can be obstructed. Fast moving The game may not check if the projectile collides with the target, because it runs only 20 game updates per second, and that is by far too low. The projectile will just fly through the target and the game won't even notice. So it must approximate if the projectile would hit the target. For now I solved this as follows. If the distance to the target is increasing, check if it's lower than a threshold, which also considers the moving speed. Also projectiles may move with a different speed. Flying All actors stick to the ground, except projectiles, so they need some special treatment. Unlike other actors, projectiles also move along the Y axis. Obstacles If the target is behind an obstacle, the projectile can not hit it. This applies also for the terrain, e.g. when the target hides behind a hill. The Terrain is not a regular game object and is not considered in a Raycast query. But there are Terrain patches, which are regular game objects, and therefore are in an octand of the octree, so a Raycast query will return Terrain patches. In the picture bellow we can see that Bob can shoot at Alice, but he will not hit her, because Terrain Patch 2 in in the way. Unlike other game objects, a Terrain patch can not use its bounding box, because it would be too big, see bellow. Terrain patches must use the height values of the terrain to check if the ray collides. Game mechanic considerations If you ever threw an ashtray after a fly you know that hitting a moving target is really hard. So the player will need some assistance from the game to make it easier, or nobody would ever use projectiles. In this game a projectile will always hit the target, even when the target is moving. A target can dodge when the target is changing the move direction after the projectile started. Area of Effect As the name implies, this is a place which has an effect on the actors inside. The screenshot bellow shows such an area of effect, it's some toxic mushrooms that damages and poisons actors in it. Other use cases are Traps, effects that are spawend by skills, e.g. skills that make damage in a certain range over time, etc.

Linux and Client prediction

Linux Some time ago I started to port all the servers to Linux. This process is more or less finished now, it compiles fine with Clang (didn't try GCC) and it even runs without any problems. For that I also had to port DirectXMath, which is used by the game server, to Linux. Even the client compiles and runs on Linux, but it doesn't work well, it always loses the connection to the game server. Now that everything compiles and runs, I tried to connect with the client to the server running inside a Linux VM. What I realized first was that I'm dealing now with real world conditions when it comes to network latency. When the client connects to a server running on the same machine, I always had a Ping (actually a round trip) of 50ms, which is fine. When the client is connected to the Linux server, I get a round trip of ~200ms - ouch! To be honest, I have no idea why it is so high, because the server and client still runs on the same hardware. Anyway, I think an online RPG must deal with pings above 200ms, and even much higher pings. Client prediction In the screenshot above, the round trip time (RTT) is ~250ms, this means it takes 250ms from sending the move command to get the new position. 250ms doesn't sound much, but it is perceivable and feels really bad. To deal with that problem, there are different techniques, one is Client prediction, what I made now. Client prediction means, the players client calculates its position on its own and moves the player to this likely position. It does not wait to get a position from the server. From time to time, the client checks if the client position and the server position are too different and adjusts the client position to the server position, also known as rubberbanding. This causes several problems: The position of the player on the client and server are probably not the same. Rubberbanding Sometimes the players character is controlled by the server, e.g. when following an other actor. This is easy to fix, the server just sends a message to the player to turn off client prediction. The players client is always ahead of the server and all other clients, but that might not be a big problem, since it's just visual. The client is very dump and does just a rough estimation of the likely position, without considering collisions resulting in even worse rubberbanding. While the first are mere details, the collision checking seems to be a tough one. For now I have just added a message that forces the client to set the position of an object, e.g. when it would collide with an object. This results in even more jittering, but I look forward to implement basic collision checking also on the client.

Gameplay

For the last two years I worked almost only on the foundation of this project, e.g. networking, how servers communicate, database back end, different network protocols. Now that this works more or less, I started to work on the gameplay. Party Cooperation is a big thing in this game. You meet in outposts, form a party and enter a game instance with this party and do stuff together, e.g save the world. Implementing this was quite tricky for several reasons: A lot of communication between the server and the clients is needed. Player and Party objects exist only on the game server. The clients party window is complex and needs to handle many events. There are many situations a party must behave differently. Client/Server communication First a player must invite another player. The invited player can accept or decline the invitation. If the player accepts the invitation, it will be added to the party. Of course, party members can leave the party, and party leaders (the first player in the window) has special rights. They can kick party members and invite new players. Entering a game as party As mentioned earlier, when a player enters a game, it disconnects from the game server and reconnects to it telling the server what map it wants to enter. Since party objects only exist on the game server, these are destroyed when the players disconnect. But to be able to reconstruct the party when entering the new game instance, this information must be saved. I decided to (ab)use the data server for that. Mapping The are two distinct map types: Outposts. People can meet there and make a party. Battle areas. People face foes there and fight them. You can map from one outpost to another outpost. Party members can not do that anymore, except the party leader. If other members want to map to another outpost, they must leave the party first. If there is a portal that brings players to a battle area, all members can go through it, and all members of the party will enter the same battle area instance as a party. Once in a battle area, players can not leave the party anymore, but they can map to an outpost. This will cause that these players will leave the party in the battle area. If all players of a party in a battle area type /resign they will be brought back to the last outpost. Also the leader can not kick members anymore. There are probably more edge cases I'm not aware of at the moment. Melee attacks When players are in a battle area, they can fight foes. However, identification friend or foe does not work great yet. At the moment you can attack anyone not in your party. The damage depends on many different things: The weapon that is used by the attacker Any effects that increase damage of the attacker The armor of the attacked Any effects of the target that reduces damage At the moment the damage is always 5, that's because equipment is not implemented yet, so the attacker does not have any weapon (just their fists), and the target does not have an armor. Once the target is dead, the attacker stop attacking. Now that all foes are dead, we can resign. Oh, by the way, I celebrated the 1000th commit some days ago! Next celebration is the 1337th commit 😛. Some other stats (only code I wrote, no third party libraries, STL etc.): ------------------------------------------------------------------------------- Language files blank comment code ------------------------------------------------------------------------------- C++ 308 6804 2209 47292 C/C++ Header 395 3049 1558 18012 LESS 15 833 7 5069 Lua 74 301 329 1384 XML 12 0 1 1354 HTML 12 90 24 1032 JavaScript 1 28 24 183 DOS Batch 6 9 8 22 Markdown 3 6 0 12 CSS 1 0 7 2 ------------------------------------------------------------------------------- SUM: 827 11120 4167 74362 -------------------------------------------------------------------------------

Skills

This is an RPG and RPGs usually have skills. In this game a character can have up to eight skills. NPCs and Players usually can equip skills from the same pool. But I'm not yet sure if this game will have a PvE part, where you fight against NPCs, or if it's just a PvP game. Anyway, NPCs can have and use skills. Skills There should be different types of skills, skills that make damage to a target or make AOE damage. Other skills can heal a target or all Actors in an area, etc. All this should be controlled by the Lua script, the server itself has no knowledge what a skill does, and the Client, well, the Client knows nothing. The basic effect of a skill can be: Resurrect Heal Protect (avoid, reduce damage) Damage And a skill can have an effect to: Self Selected Target AOE So a single skill can have one or more of the above effects and one or more of the above listed targets. At the moment the skill pool is very limited, there are just three skills: Sudden Death (instant suicide): Targets self and makes damage. Instant Kill: Targets the selected target and makes damage. Instant Rezz: Targets the selected target and resurrects. All these skills are only for testing and don't make sense in a real game. Skills scripts Skills are implemented as simple Lua scripts, like bellow the "Instant Kill" skill: -- Use own include function, because the Lua search path is a mess -- and it makes it possible to precompile and cache the script include("/scripts/includes/consts.lua") include("/scripts/includes/skill_consts.lua") costEnergy = 0 activation = 0 recharge = 0 range = RANGE_MAP -- If there will be some AI then the AI must have some basic knowledge of what a skill does effect = SkillEffectDamage | SkillTargetTarget function onStartUse(source, target) if (target == nil) then -- This skill needs a target return false end if (source:GetId() == target:GetId()) then -- Can not use this skill on self return false end; if (self:IsInRange(target) == false) then -- The target must be in range return false end if (target:IsDead()) then -- Can not kill what's already dead :( return false end return true end function onEndUse(source, target) -- print("Using Instant Kill on " .. target:GetName()) return target:Die() end function onCancelUse() end Of course, this skill is a bit overpowered, but it's just for testing. Player using skills A player has a skillbar, usually at the bottom, with all equipped skills: As almost any action in this game, a hotkey can be assigned to each skill, but is also triggered when the button is clicked. NPC using skills In the same way a player uses skills, NPCs can use skills. They also have a skillbar with max. eight skills. To make this work the NPC Lua scripts have to be extended, like the Pedestrian, to make her Rezz machine: include("/scripts/includes/chat.lua") include("/scripts/includes/consts.lua") include("/scripts/includes/skill_consts.lua") name = "Pedestrian" level = 20 modelIndex = 10 -- Female Pedestrian 1 body model sex = SEX_FEMALE creatureState = CREATURESTATE_IDLE prof1Index = 3 -- Monk prof2Index = 0 -- None --behavior = "wander" local rezzTarget = nil function onInit() self:SetSpeed(0.5) -- Let's make it a rezz machine :D local skillBar = self:GetSkillBar() -- Instant rezz skill skillBar:AddSkill(9996) return true end function onUpdate(timeElapsed) if (self:IsDead() == false and rezzTarget == nil) then local actors = self:GetActorsInRange(RANGE_CASTING) for i, v in ipairs(actors) do if (v:IsDead()) then rezzTarget = v break end end if (rezzTarget ~= nil) then self:FollowObject(rezzTarget) self:Say(CHAT_CHANNEL_GENERAL, rezzTarget:GetName() .. ", you noob!") end end end function onClicked(creature) end -- self was selected by creature function onSelected(creature) self:Say(CHAT_CHANNEL_GENERAL, "Not now!") end -- creature collides with self function onCollide(creature) end function onArrived() -- self:FollowObject(rezzTarget) was called to go to the target. Now she is there. if (rezzTarget ~= nil) then local skillBar = self:GetSkillBar() local skills = skillBar:GetSkillsWithEffect(SkillEffectResurrect) if (skills[1] ~= nil) then self:SetSelectedObject(rezzTarget) self:UseSkill(skills[1]) end rezzTarget = nil end end function onEndUseSkill() rezzTarget = nil self:SetState(CREATURESTATE_IDLE) end function onStartUseSkill(skill) local targetName = "Everything!" local target = skill:GetTarget() if (target ~= nil) then targetName = target:GetName() end print("Using skill " .. skill:GetName() .. " on " .. targetName) end function onDied() self:Say(CHAT_CHANNEL_GENERAL, "Aaaaarrrrrrggghhh") end function onResurrected() self:Say(CHAT_CHANNEL_GENERAL, "Oh, ty") end This NPC will go to dead people if they are in a certain range, and will resurrect them. Video

The Game server doesn't scale vertically (i.e. throw better hardware at a problem) well, because it's mostly single-threaded, so it can host only a limited amount of concurrent games. The second option is to make it scale horizontally (throwing more hardware at a problem) well, so make it possible to spawn/shutdown Game servers dynamically across different hardware. Only the File and Game server are meant to be spawned dynamically. All other server are needed that these servers can work together. For this I made an Admin interface (btw. meanwhile I'm no longer sure what's this all about, is it a game server or a framework for scaleable servers?), which can spawn Game and File server as needed. Oh yes, I wrote a Web server for it, based on SimpleWeb which is also used for the File server, and it uses a nice Bootstrap theme (Gentelella Admin). I just added support for Cookies, Sessions and it has simple templating, because I don't want to write Markup in C++. Hell, I totally forgot how fast a Web server can be, like response times under 1ms. A central part in this process takes the Message server, which is for inter-server communication. It works like a Chat server, Clients connect to it and can broadcast messages to all other connected clients, or they can send a message to a certain client identified by its ID. A server can only spawn another instance of itself, it can not spawn a different server, like a File server can not spawn a Game server. So there is a persistent (master) server that can be told to spawn child servers, and it'll do so.
Exactly this is happening when clicking the green Spawn button. The Admin server sends a message to the Message server, which forwards the message to this particular server and this is spawning a new server of the same type with the same configuration. Well, with almost same configuration, of course it needs a unique ID (I use UUIDs), a unique Name and it must use a free Port. To get a free Port, this is what works for me: uint16_t GetFreePort() { asio::io_service service; asio::ip::tcp::acceptor acceptor(service); uint16_t port(0); asio::ip::tcp::endpoint endPoint(asio::ip::tcp::endpoint(asio::ip::tcp::v4(), port)); acceptor.open(endPoint.protocol()); acceptor.set_option(asio::ip::tcp::acceptor::reuse_address(true)); acceptor.bind(endPoint); acceptor.listen(); asio::ip::tcp::endpoint le = acceptor.local_endpoint(); port = le.port(); acceptor.close(); return port; } Shutting down a server is different, it can stop only itself. So if one server wants to shutdown another server, it sends a message to the message server, which sends the target server a message to stop itself. Even a "Master" server can not shutdown a child process, it would need to send a shutdown message to the Message server.

Migrating and Porting

Migrating the Database For some reason I thought it would be a good idea to migrate the database from MySQL to PostgreSQL. Installing PostgreSQL on Debian (in my case Debian 8 Jessie) is just one command line. I also made a simple backup script which is run by a chron job, I have a similar script for MySQL: #!/bin/sh # sudo crontab -e # Backup all PostreSQL databases every day 3 am #* 3 * * * /mnt/sda1/bak/pgsqlbak.sh TIMESTAMP=$(date +"%F") BACKUP_DIR="/mnt/sda1/bak/pgsql/$TIMESTAMP" USERNAME="my_name" export PGPASSWORD="*********" find /mnt/sda1/bak/pgsql/ -maxdepth 1 -type d -mtime +7 -exec rm -Rf {} \; mkdir -p $BACKUP_DIR databases=psql -h 127.0.0.1 -U$USERNAME -q -t -c "SELECT datname FROM pg_database" | sed -n 4,/\eof/p | grep -v rows\) | grep -v template0 | grep -v template1 | awk {'print $1'} for i in$databases; do /usr/bin/vacuumdb -z -h 127.0.0.1 -U $USERNAME$i >/dev/null 2>&1 /usr/bin/pg_dump -U $USERNAME -i -F c -b$i -h 127.0.0.1 -f $BACKUP_DIR/$i.backup /usr/bin/pg_dump -U $USERNAME -i -F p -b$i -h 127.0.0.1 -f $BACKUP_DIR/$i.sql done Migrating the database structure required some manual work, but fortunately the database is still very small. So I just used mysqldump to get a SQL file, changed some types and syntax and imported it into PostgreSQL. Because I use a caching data server, which is the only program that accesses the database, and this data server is database agnostic (so i can just switch back to MySQL with changing the configuration file), I can not use any advanced database features anyway. This made the migration of the database much easier. I can't even use auto incremented values for primary keys or other database generated values, instead it uses UUIDs as primary keys. The PostgreSQL backend was never tested before, so it surprised me a bit that the data server worked without any changes with PostgreSQL. Porting the Data server I am developing this with MSVC 2015 on Window 7 64 Bit, x86_64 architecture (I also tried VC2017 but the linker crashes all the time). The target is Debian 8 on ARMv7 (32 Bit) architecture. So, not just that the target is a different tools chain, but also the OS and architecture is different. To generate the make files (and VS solution, project files) I use premake5, because I'm not smart enough for CMake. Compiling I didn't compile anything on Linux in ages, so I thought this could be fun. Getting the source to compile with GCC and Clang was not hard, every library I use is also available for Linux, or also compiles on Linux (Lua, SQlite, Asio). What I needed to do was: Just ignore many unknown pragma warnings. Get rid of MS' secure CRT functions (e.g. sprintf_s() -> sprintf()). Get rid of the nifty #pragma comment(lib, ...) and add the lib files to the project and make files instead 😟. Throw out the ODBC database driver. I thought about also targeting MSSQL, so it seemed to be a good idea to have support for ODBC, but it's not used. Turn off Linktime Optimization (full program optimization). Took me a while until i realized my compiler does not support it. Linking So making the program just to compile was very easy, but making it also link was a pain, especially the PostgreSQL client library has many dependencies (e.g. libldap2-dev, libssl-dev, libgsasl7-dev). So I ended up having something like this in my premake5.lua: if (_TARGET_OS == "windows") then links { "abscommon", "abcrypto", "sqlite3", "libpq", "libmysql" } elseif (_TARGET_OS == "linux") then links { "pthrerad", "abscommon", "abcrypto", "sqlite3", "dl", "pq", "ssl", "crypto", "mysqlclient", "z", "gssapi_krb5" } end Finally it complied and linked, but there are still some problems as you can see on the screenshot. It runs now on the same machine as the database server. The data server is very lightweight (without load 🤣😞 Update The linking issues have been solved with upgrading VS2017 to version 15.8.6. Now everything (18 projects) compile, link and run fine with VS2017. Also the obvious bugs on Linux have been solved, but the Linux version of the data server is still not reliable.

Part 4: Portals and UI changes

Portals In games you find something that take you to other maps, sometimes this is called a Portal. On the server side Portals are just scriptable objects like NPCs. The game script adds Portals like it would add an NPC: -- Game start up function onStart() local portal = self:AddNpc("/scripts/creatures/logic/portal.lua") if (portal ~= nil) then -- Map ID where this portal leads to portal:SetVarString("destination", "75e3dfcf-479a-11e8-ad09-02100700d6f0") -- Will call onTrigger() when it collides portal:SetTrigger(true) -- Set position of portal local x = -20.059 local z = -0.00870347 local y = 26.7 portal:SetPosition(x, y, z) end -- ... end The script for the portal is similar to a script for an NPC: name = "Temple of Athene" level = 20 modelIndex = 11 sex = 0 creatureState = 1 prof1Index = 0 prof2Index = 0 function onInit() -- Player collides with BB. Make it a bit larger than the default BB. self:SetBoundingBox(-1, -1, -1, 1, 1, 1) return true end function onTrigger(creature) local player = creature:AsPlayer() if (player ~= nil) then player:ChangeGame(self:GetVarString("destination")) end end The process for changing a game instance is a bit complicated. The player is only connected to the game server when he/she is in a game instance. When you click on a map in the map window the client disconnects from the game server and connects to it again, telling the game server which map it would like to access. The game server creates an instance of this map, and adds the player to this instance. A Portal is a bit different, because the server initiates the switch to a different map. Also we must distinguish whether the client enters an existing instance—which is always the case when using the map window and the instance is not full, because these are outposts where people can meet—or the server creates a new instance. This is the case when entering a battle field, which is exclusive for a party or two, e.g. a PvP combat. But when it comes to gameplay and game mechanics, nothing has been implemented yet. Video UI changes Several small changes to the UI were made. The people from Urho3D have updated the UI texture and styles, which I really like, it is so friendly and ... green xD. A big Thank You to the Urho3D developers for this great library! Additionally the position of the health bar above the characters looks now more natural and speech bubbles have been added.