I'm making a game on Playdate in pure C. Chapter 4

I'm writing a game for the Playdate game console in pure C. A survival game like Vampire Survivors. Since pure C lacks many modern object-oriented conveniences, I have to dodge in every possible way to adapt my ideas into code. In these notes you will learn first-hand how a game is created from scratch from idea to publication.

If you haven't read the previous chapters, it's best to start with them.

Chapter 1 – creating an analogue of a dynamic array object for future needs in pure C;

Chapter 2 – programming the SUV and desert objects, initializing and clearing game resources;

Chapter 3 is a description of tick processing, in particular, processing user input, as well as updating the data model.

====================

In this chapter you will find fifth grade math, drunken tumbleweeds, and curbing undefined behavior.

So, most people in the world are visual people. This means that they are most accustomed to perceiving information through their eyes. In previous chapters I created a whole world, but what's the point if it can't be seen? No, of course, you can talk in a bar about what incredible code I wrote, but the interlocutor will not be able to see it because he does not have a Playdate (do you remember that I live in Kazakhstan? We have three people in the whole country who have a Playdate), well, because the interlocutor is drunk in cabbage soup, drunk in zyuzya, drunk, under the fly, on the horns, green as snow.

In general, what am I talking about… Our cherished function GameDraw… She draws a game (suddenly). Let me remind you, we have a car (SUV or jeep), tumbleweeds, cacti, mounds of sand and that’s it.

Start of GameDraw function body

Start of GameDraw function body

We start everything by drawing the car. We're not actually drawing the car in the top screenshot, but the comment on line 233 states that we are. It just so happened. If you remember in the first chapter, I told you that my artist drew the car for me in eight versions, since we can use the cross to indicate 8 directions, just like on any self-respecting console. All these 8 images are stored in an array game->vehicleImage (I also talked about this in previous chapters), and we determine the index of the picture in this array by the direction of the car on line 234. We call a tricky function GameVehicleImageIndexFromAnglewhich has the simplest logic possible:

Implementation of the GameVehicleImageIndexFromAngle function

Implementation of the GameVehicleImageIndexFromAngle function

Next we need to do tricks with the picture. Since we want to center the picture according to the position of the car on the map (and ultimately on the screen), we need to get the dimensions of the picture. On line 238 this is exactly what happens. Function getBitmapData at PlaydateAPI returns all useful information about the picture, while the output data is indicated by the function arguments in the form of pointers. If you pass NULL, which means you won’t receive the data. Because the last three arguments are NULL because this is data for raw bytes of the image, mask and image data (I don’t remember exactly how this differs from raw bytes, to be honest), and I don’t need that yet, I only need the width and height, wrap it in a bag, please shake it, but don't mix. Values newVehicleImageWidth And newVehicleImageHeight I'll need them a little later. Let's move on.

On line 241 we clear the screen with white using the function fillRect. 1 at the end it is white, and if you put 0 – it will be black. We redraw the background on the entire screen. The screen size is 400 by 240 pixels, as I mentioned in previous chapters.

Next we draw the environment. The first is cacti.

Drawing cacti

Drawing cacti

Cacti are drawn relative to the position of the car, that is, the cacti are not centered on the screen, which means we need to cleverly calculate their position on the screen. And another important detail: let’s agree in advance that the picture of the cactus will be “attached” to the position of the cactus on the map with the lower middle. That is, if the cactus is in position x = 5; y=5then you need to draw a picture of a cactus in the position x = 5 – width / 2, y = 5 – heightWhere width And height this is the width and height of the cactus picture, respectively. Why exactly the lower middle? Because we look as if in 3D, and it’s as if we stuck the cactus with its butt into the field, like a sofa into foam. For all this, we pull out the dimensions of the cactus picture on line 248 using a familiar function getBitmapData.

(UPD: in fact, the cactus is centered in the code; the cactus anchor will be changed in future commits, but for now it’s as it is).

Next we go through the array of cacti in a loop, and on line 252 we have a pointer to the next cactus in the iteration, a constant pointer, because when drawing we do not intend to change the state of the cactus. All this is done for the sake of calling a function drawBitmap on line 259. It is this function that paints the picture on Playdate, and it is this function that will be the main character in this chapter. Function drawBitmap as simple as a Kalashnikov assault rifle. It takes 4 arguments: the image itself to be drawn, the coordinate xcoordinate y and also an enum indicating whether you want to draw a picture rotated along any axis (we won’t need this, but if you need it, I’ll definitely let you know, I bet you won’t see the will for a century). Coordinates x And y are indicated on the device screen. PlaydateAPI I don’t know about my scene and the objects on it – these are all my personal abstractions that I came up with to better understand the construction of the game after experience with all sorts of Unity, Godot and cocos2d-x.

On line 256 there is cactusRect – this is the rectangle for drawing the cactus in screen coordinates. If this rectangle does not intersect with the screen rectangle, then drawBitmap we don't call. In fact, this check is redundant because the operating system, when calling drawBitmap also does the same check internally. But at that moment I decided that it was necessary. The intersection is checked by the function RectIsOutOfBounds

RectIsOutOfBounds function body

RectIsOutOfBounds function body

Is everything clear with cacti? Why not everything? What is not clear about the position? Okay, I'll explain. The cactus has a position, we drag it out into a convenient constant on line 253. And on the next two lines we calculate x And y – coordinates of the cactus in screen coordinates. For this transformation we use a simple linear function: take game->cameraOffset with a minus sign, subtract half the size of the picture of the cactus, add half the size of the screen and the position of the cactus directly in the game field, add salt and pepper to the eye (always a fool with the expression “pepper to the eye” – this is extremely unpleasant for the eye, although I have never tried it). Why exactly this formula? Damn, can I not tell you? Well, I'm just lazy. Thank you friend!

Now we draw piles of sand.

Sand rendering

Sand rendering

Everything here is exactly the same as when drawing a cactus, only instead of an array of cacti game->cactuses we iterate over the sand mass game->sands. And we have a picture game->sandImagebut not game->cactusImage. In general, some of you here will object “why repeat the code, in OOP this is easily done in one cycle, and if you use ECS, then you can actually fly into space.” Yes. in OOP this takes less code, but it slightly increases runtime due to virtual calls (yes, in ECS too). It’s not that it’s a lot of time, it’s saving on matches, and this saving is not my goal, but we’re in a small town, we don’t have virtual functions here. You can screw up function pointers and somehow end up emulating a virtual table, but it still won’t be the same because the pointer this won't work out of the box.

What do we have next? Next comes the drawing of tumbleweeds. And here not everything is as banal as with cacti and heaps of sand. The tumbleweed has a shadow, and the object itself bounces in a sine wave as described in the last chapter. Let me show you the video again of how it all looks.

And here’s what the tumbleweed rendering code looks like:

Tumbleweed rendering code

Tumbleweed rendering code

Here we have two cycles instead of one. This is because first we draw the shadow, and then the tumbleweed carcass itself. And note that all the shadows are drawn first, and then all the carcasses. You can do everything in one cycle and first draw the shadow, then the carcasses of each object in turn, and that’s what I did at the beginning, but in this case it turns out that when different tumbleweeds intersect with each other, they can have a shadow on top of the carcass. That is, the shadow of the second in the tumbleweed cycle is drawn after the carcass of the first, and if their positions are nearby on the map, then visually it turns out that the shadow of the second is “higher” than the carcass of the first, which is impossible in real life because in life the shadow is always is drawn on the surface on which this shadow falls (in our case, this is the plane of the earth (no, I'm not a fan of flat earth)). In general, if you do one cycle, then everything will look like shit. How sucky? Let me show you with a gif:

Shadow over the carcass

Shadow over the carcass

That’s why all the tumbleweed shadows are drawn first, and then all the carcasses. Moreover, if in the future other objects also have shadows, then they should also be drawn before the bodies of all objects are drawn. This is the logic, and we are hostage to it, regardless of the platform.

The first cycle is banal and familiar:

  • we get a constant pointer to an object Tumbleweed (line 295);

  • we take its position into a separate constant purely for convenience (line 296);

  • calculate x And y shadows to draw on the screen (lines 297 and 298) – object position Tumbleweed this is actually the position of the center of his shadow;

  • if the resulting rectangle overlaps the screen with at least one pixel, then draw it (line 302).

The interesting things start in the second cycle. First, we repeat the first lines of the first loop in the second loop (lines 308-311) and I don't regret it at all. I mean, such code is incorrect in the academic sense because repetition of code is fu-fu-fu, you need to move the repeated code into a separate function, call this function from different points, cover it with unit tests, SOLID, wrung out, Scrum Master meetings , clean code, retrospective, planning poker and John Carmack. But I’m somehow equally divided in this case, because here we don’t lose anything in runtime, and the repeating code is super simple, and I don’t have the goal of making academically correct code (in general, it’s a bad goal to make academically correct code because practically no one needs such code never needed). I need to make code that can be written in a reasonable time, then read without difficulty, and that this code clearly fulfills its goals. As you can see, academic fidelity is not on this list. In general, I repeat the code, don’t learn beautiful code from me, kids.

Secondly, I check that tumbleweedFrameIndex is within the range of [0; 4) на строке 315. Эта константа это тикающий индекс кадра объекта перекати-поля. Если индекс привести в целому числу (а то он-то сам по себе float), то это будет индекс картинки перекати-поля в массиве картинок используемых для отрисовки. Всего их 4 штуки, как я ранее упоминал. И tumbleweed->frameIndex тикает в секции обновления данных (описывалось в третьей главе), и там же проверяется на выход их границ, но я тут всё равно его проверяю. Зачем? Просто для верности. Так как на строке 319 я лезу в массив по этому индексу мне надо быть уверенным, что индекс валиден. Потому что если индекс будет невалиден, а я всё равно обращусь к массиву по такому индексу, то я не получу исключение как C#, Swift или даже C++ (std::vector::at кидает исключение), а просто получу какое-то значение, которое не будет представлять ничего вразумительного. Такое поведение называют UB или undefined behavior – неопределённое поведение. Конкретно в данном случае понятно что будет – я просто чуть выйду за пределы массива, получу реальные данные приведённые к указателю на картинку, и потом когда я её буду пытаться отрисовать на строке 326 игруля будет бурагозить: либо отрисует полную ерудну (видал такое), либо операционная система грохнет процесс потому процесс будет пытаться залезть в чужой кусок памяти, либо ещё какая дичь может случиться.

А давай не отходя от кассы так и сделаем! Смотри: на строке 319 мы обращаемся к массиву по максимально правильному кошерному проверенному индексу. Но мы с тобой устроим моему коду небольшой саботаж! Давай я вместо

LCDBitmap *tumbleweedImage = game->tumbleweedImage[tumbleweedFrameIndex];

I'll write

LCDBitmap *tumbleweedImage = game->tumbleweedImage[tumbleweedFrameIndex + 1];

that is, let’s simulate the index going beyond the bounds by one and see what happens. So, the code was sabotaged, we compile, we launch (the sound of a rocket launch, Elon Musk joyfully looks into the sky with his hands clasped around his head, the Khabar TV channel is broadcasting live, and I am fired from the programmers for intentional UB in the code).

(Sound from "X-Files")

(Soundbite from “The X-Files”)

And suddenly we get a very strange picture: the frame of the carcass of a tumbleweed sometimes turns into its own shadow, displaying an implausible situation – a double shadow. Imagine this in life: a person walks, casts a shadow, and sometimes instead of the person himself, another shadow of his hangs in the air. Why did this happen? Why didn't undefined behavior crash the game instead? The reason is that immediately after the array in memory there is just a picture of the shadow. And the order of the fields in the structure is guaranteed. Remember, in the second chapter I showed the structure Game? There, on lines 20 and 21, there is an array of frames of a tumbleweed carcass and its shadow. The array has 4 pictures, that is, it’s as if I simply declared 4 fields with pictures of frames in a row – it would lie in memory the same way. And next is a picture of a shadow, which is schematically identical if instead of an array of 4 and one picture there was an array of 5. That is, instead of indices[0;4)weuseindexes[1;5)andthelastindexequalto4justfallsintotheshadowYesthisisnotC#thatwouldspitexceptionsWehaveachickwithcontrolledundefinedbehaviormotherfucker![0;4)мыиспользуеминдексы[1;5)ипоследнийиндексравный4какразпопадаетвтеньДаэтотебенеC#которыйбыплевалсяисключениямиУнассишкасконтролируемымнеопределённымповедениеммазафака!

Since it’s such a booze, I’ll allow myself to digress and tell an awesome story from my experience of programming in Objective-C, which happened in the old days when Swift did not yet exist, and all native iOS development was carried out in Objective-C. And so I’m writing code, I have a class in Objective-C, it also has a field, a static array of 20 objects, and objects in Objective-C, in principle, are always stored as C pointers. The objects in this array inherit the same protocol (interface in C# and Java and fieldless abstract class in C++) and I have an index of the form int'ah, using this index I go into the array and call protocol functions. Do you smell what it smells like? So, I wrote all this code, run the tool on my iPhone, and at a certain moment I get an exception saying that I am calling a protocol function on the class (!) in which all these fields are stored, that is, as if this function is static, although I'm definitely not calling static functions anywhere – I've tested my code several times. However, when starting, I consistently get an error that I am calling a protocol function on the class itself, which contains the array I specified, but the class does not have an implementation of this function.

Objective-C aficionados likely already have the answer. And this is what happened. I access the array by index, which I store in the same class. And at the fateful moment when the exception was thrown, this index was equal to -1. This means that when accessing the array (and the array is cis, not objectivs), we went back one value. In the example above with tumbleweeds, I went one value ahead, so I grabbed the picture that lies next in memory, or rather, the pointer to the picture. Here in Objective-C I went beyond the array back. What does it mean? The same as when going forward, only you need to look at what is in memory before the array. Realizing this in the moment, I went to look at the fields of the class. But here’s the problem: the array is the very first field, the class has no fields before it. Why then is the protocol function call recognized as if it were static? And at that moment a realization came to me. In Objective-C, objects are also structures under the hood, but each object has one special pointer in front of its fields. This is a pointer to its object class. A class object is an analogue of a virtual table in C++, but on steroids, because it stores all the information about public functions in a format that allows you to iterate through the functions and properties of the class, and even add new ones right in runtime as if we were writing in JavaScript 'e. And another important detail about Objective-C – calling functions of class members in Objective-C is not the same as in C++, where you just get the address of the function, substitute the arguments and off you go. In Objective-C, you send a message to an object – this is a higher-level operation. And you can send any message to any object with any arguments. This is in C++, C#, Java and Swift, if you try to call a function on a class that does not exist in it, you will get a compilation error. And in Objective-C everything will compile, but there will be an error in runtime saying that this class cannot respond to such a message. The original exception was precisely about this: that the class does not have such a static function. For fun, I took and implemented such a static function in a class and set a breakpoint (breakpoint, a break point in the debug) in it, and then I finally understood everything. Due to the index equal to -1, we are shifted from the array, which is also the field of the class, to the class object of this object, any messages sent to class objects are considered calls to static functions, I did not have an implementation of a static protocol function (although in the end I added it solely for the sake of experiment), and in the end we fell with an exception. This is what undefined behavior can lead to!

Okay, back to the tumbleweeds. In general, initially I told you about the peculiarity of the second cycle in drawing tumbleweeds. The first thing I outlined was code repetition. Second – check tumbleweedFrameIndex so that he doesn't go overboard. I think now my intentions for checking the index have become much clearer to you. I’m not saying that every programmer should do this, I’m just explaining why I think this way, and whether you do the same in your code or not is up to you to decide. Third, interesting things happen on line 325. We recalculate the value of the variable y for drawing the carcass of a tumbleweed. x we leave it as is because the width of the field and the width of the carcass are the same. A y should wag up and down in a sine wave as I showed on the graph in the third chapter. To do this, each tumbleweed object has a field tumbleweed->jumpAngle, I'm sure you remember this. This is the “rotating” float value from which we take the sine (actually a cosine (line 323), but a sine is a cosine with an offset, so everything is fine). On line 323, the modulus of the cosine is taken – what I talked about at the end of the last chapter. And on line 325 we multiply this value by 13 and draw a little higher from the shadow (by 20 pixels, pay attention to the constant spaceBetweenTumbleweedAndShadow on line 322). To better show the importance of the coefficient 13, let's change it a little – twice and ten times – and see what we got.

What would it be like if there were different numbers?

What would it be like if there were different numbers?

That’s why there are 13. At the end of the cycle, we finally simply draw the tumbleweed carcass itself with the already known function drawBitmap.

Well, that's all. No, of course, at the end the car is drawn. But everything there is super banal that I don’t even see the point of showing it: we calculate the coordinates using the well-known formula x And y and draw newVehicleImagewhich we calculated at the very beginning of the function GameDrawfunction drawBitmap.

Okay, so be it, let me tell you how the coordinates are calculated. At the root of everything is a linear function. Let's take another look at the calculation in the tumbleweed code

int x = -game->cameraOffset.x – tumbleweedShadowImageSize.x / 2 + screenWidth / 2 + tumbleweedPosition.x;int y = -game->cameraOffset.y – tumbleweedShadowImageSize.y / 2 + screenHeight / 2 + tumbleweedPosition.y;

and in cacti

const int x = -game->cameraOffset.x – cactusImageWidth / 2 + screenWidth / 2 + cactusPosition.x;const int y = -game->cameraOffset.y – cactusImageHeight / 2 + screenHeight / 2 + cactusPosition.y;

In both formulas there is a pattern that can be written like this:

screenPosition = -cameraOffset – imageSize / 2 + screenSize / 2 + objectPosition

This is our linear function. How did we get there? Let's try to come up with it from scratch, it's a fun activity.

If we assume that the formula is actually like this

screenPosition = objectPosition

then if the cactus has position {5; 5}, then it will be drawn in position {5; 5} is always on the screen regardless of the machine position. This is obviously an incorrect formula, but that's where we start. Moreover, in position {5; 5} will be the upper right corner of the cactus picture, which is not exactly what we want – we want the cactus picture to be centered relative to that very point. To center, subtract half the size from the original position. Thus the formula became

screenPosition = objectPosition – imageSize / 2

Now let's figure out the camera. The camera, in theory, can fly over the world as it wishes. And if, say, the camera moves from the zero position to the left by 5 pixels, that is cameraOffset will be equal to {-5; 0}, then the cactus located in position {5; 5} should be drawn on the contrary to the right in position {10; 5} minus half the size of his picture. If the camera moves away {-10; 0}, then the cactus will move to {15; 5} – even further to the right on the screen. That is, to calculate the position of an object on the screen, we need to subtract the camera position. This is how the formula became

screenPosition = -cameraOffset – imageSize / 2 + objectPosition

Everything is fine, but the camera is not centered on the screen, but left-topped, that is, “glued” not to the center of the screen, but to the upper left corner because this is the zero angle, that is, the default angle from which life begins in the coordinate plane. To center the camera, you need to move it by half the size of the screen. As a result, we get our linear function

screenPosition = -cameraOffset – imageSize / 2 + screenSize / 2 + objectPosition

Quite easy in fact, and this is, in theory, a mathematics course for the fifth grade – linear functions on the coordinate plane. That rare case when the school curriculum was useful to me in adult life!

That's all we have with rendering. In the following chapters you will find the first semblance of a virtual table, the origins of the framework and the logic of accelerating the machine. In the meantime, you can support me on patreon And busty to speed up the release of the next chapters and new games.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *