📜 ⬆️ ⬇️

Creating a game on Lua and LÖVE - 1

image

Introduction


In this series of tutorials, we will cover the creation of a complete game using Lua and LÖVE . The tutorial is intended for programmers who have some experience, but are just beginning to master game development, or for game developers who have already had experience working with other languages ​​or frameworks, but who want to learn better Lua or LÖVE.

The game we are creating will be a combination of Bit Blaster XL and the Path of Exile passive skill tree . It is simple enough to be considered in several articles that are not very large in size, but contain too much knowledge for a beginner.

Gif

In addition, the tutorial has a level of complexity that is not disclosed in most tutorials on creating games. Most of the problems that newcomers have in game development are related to the scale of the project. It is usually advised to start small and gradually expand the volume. Although this is a good idea, but if you are interested in such projects that cannot be done less, then there are quite a few resources on the Internet that can help you in solving the problems encountered.
')
As for me, I was always interested in creating games with a lot of objects / passive capabilities / skills, so when I got to work, it was difficult for me to find a good way to structure the code so as not to get lost in it. I hope my tutorial series will help someone with this.

Gif

Requirements


Before starting, I will list some of the knowledge necessary to master this tutorial:


In fact, this tutorial is not intended for people taking the first steps in programming. In addition, here I will give exercises. If you have ever had a situation where you finished the tutorial and did not know where to go next, then perhaps it was because you didn’t have exercises. If you do not want this to be repeated, then I recommend at least try to do them.

Gif

Table of contents



13. Skill Tree

14. Console

15. Final

Part 1: Game Cycle


Getting Started


First, we need to install LÖVE in the system and learn how to run LÖVE projects. We will use the LÖVE version 0.10.2, which can be downloaded here . If you are reading this article from the future and a new version of LÖVE has already been released, then 0.10.2 can be downloaded from here . Detailed instructions are described on this page . Having done everything necessary, create a file in your project main.lua with the following contents:

 function love.load() end function love.update(dt) end function love.draw() end 

If you run the project, you will see a pop-up window with a black screen. In the code above, the LÖVE project performs the function love.load once the program is started, and love.update and love.draw are executed in each frame. That is, for example, if you want to upload an image and draw it, then write something like this:

 function love.load() image = love.graphics.newImage('image.png') end function love.update(dt) end function love.draw() love.graphics.draw(image, 0, 0) end 

love.graphics.newImage loads the image texture into the image variable, and then in each frame it is drawn at position 0, 0. To see that love.draw actually draws the image in each frame, try this:

 love.graphics.draw(image, love.math.random(0, 800), love.math.random(0, 600)) 

By default, the window has a size of 800x600 , that is, this function will very quickly randomly draw an image on the screen:

Shimmering gif

Note that before each frame the screen is cleared, otherwise the image being drawn would gradually fill the entire screen, drawing in random positions. This is because LÖVE provides its projects with a standard game loop that performs screen cleaning after each frame. Now I will talk about the game cycle and how it can be changed.

Game cycle


The standard game loop used by LÖVE is on love.run . It looks like this:

 function love.run() if love.math then love.math.setRandomSeed(os.time()) end if love.load then love.load(arg) end --   ,   dt    ,   love.load. if love.timer then love.timer.step() end local dt = 0 --   . while true do --  . if love.event then love.event.pump() for name, a,b,c,d,e,f in love.event.poll() do if name == "quit" then if not love.quit or not love.quit() then return a end end love.handlers[name](a,b,c,d,e,f) end end --  dt,        update if love.timer then love.timer.step() dt = love.timer.getDelta() end --  update  draw if love.update then love.update(dt) end --  0,  love.timer  if love.graphics and love.graphics.isActive() then love.graphics.clear(love.graphics.getBackgroundColor()) love.graphics.origin() if love.draw then love.draw() end love.graphics.present() end if love.timer then love.timer.sleep(0.001) end end end 

When the program love.run , love.run is love.run , and then everything else starts from here. The function is well commented out, and the purpose of each function can be found in the LÖVE wiki. But we will go through the basics:

 if love.math then love.math.setRandomSeed(os.time()) end 

In the first line, we check love.math for nil inequality. All values ​​in Lua are true, except false and nil, so if love.math condition will be true if love.math defined. In the case of LÖVE, these variables are set in the conf.lua file. You do not need to worry about this file yet, but I mentioned it because it is possible to enable and disable certain systems, such as love.math , in it, so before working with its functions, you need to make sure that it is enabled in this file.

In general, if a variable is not defined in Lua and you somehow refer to it, then it will return the value nil. That is, if you create an if random_variable condition, then it will be false if the variable has not been defined previously, for example random_variable = 1 .

Be that as it may, if the love.math module love.math enabled (and by default it is), then its initial number (seed) is set based on the current time. See love.math.setRandomSeed and os.time . After that, the love.load function is love.load :

 if love.load then love.load(arg) end 

arg is the command line argument passed to the executable LÖVE file when it runs the project. As you can see, love.load is executed only once because it is called only once, and the update and draw functions are called in a loop (and each iteration of this loop corresponds to a frame).

 --   ,   dt    ,   love.load. if love.timer then love.timer.step() end local dt = 0 

After calling love.load and performing the function of all our work, we check that love.timer set and call love.timer.step , which measures the time spent between the last two frames. As written in the comment, processing love.load can take a long time (because it can contain all sorts of things, such as images and sounds), and this time should not be the first value returned by love.timer.getDelta in the first frame of the game.

Also, dt is initialized to 0. Variables in Lua are global by default, so by assigning local dt we assign the current block only a local scope, that is, we restrict it to the love.run function. Read more about the blocks here .

 --   . while true do --  . if love.event then love.event.pump() for name, a,b,c,d,e,f in love.event.poll() do if name == "quit" then if not love.quit or not love.quit() then return a end end love.handlers[name](a,b,c,d,e,f) end end end 

This is where the main loop begins. The first thing that happens every frame is event handling. love.event.pump passes events to the event queue and according to its description, these events are somehow generated by the user. These can be keystrokes, mouse clicks, window resizing, window focus changes and the like. The loop using love.event.poll goes through the event queue and processes each event. love.handlers is a table of functions that calls the appropriate event handling mechanisms. For example, love.handlers.quit will call the function love.quit , if it exists.

One of the features of LÖVE is that you can define event handling mechanisms in the main.lua file that will be called when the event main.lua . A full list of event handlers is available here . I’ll no longer look at event handlers in detail, but will briefly explain how everything happens. Arguments a, b, c, d, e, f passed to love.handlers[name] are all possible arguments that the corresponding functions can use. For example, love.keypressed gets the key pressed as an argument, its scancode and information about whether a keystroke event repeats. That is, in the case of love.keypressed values ​​of a, b, c will be defined, and d, e, f will be nil.

 --  dt,        update if love.timer then love.timer.step() dt = love.timer.getDelta() end --  update  draw if love.update then love.update(dt) end --  0,  love.timer  

love.timer.step measures the time between the last two frames and changes the value returned by love.timer.getDelta . That is, in this case dt will contain the time it took to complete the last frame. This is useful because then this value is passed to the love.update function, and from this point on it can be used by the game to provide constant speeds regardless of the change in the frame rate.

 if love.graphics and love.graphics.isActive() then love.graphics.clear(love.graphics.getBackgroundColor()) love.graphics.origin() if love.draw then love.draw() end love.graphics.present() end 

After calling love.update is love.draw . But before that, we make sure that the love.graphics module exists and check with love.graphics.isActive that we can draw on the screen. The screen is cleared, filled with the specified background color (initially black) using love.graphics.clear , using love.graphics.origin transformations are reset, love.draw is love.draw , and then love.graphics.present used to transfer everything drawn in love.draw on the screen. And finally:

 if love.timer then love.timer.sleep(0.001) end 

I never understood why love.timer.sleep should be here, at the end of the file, but the explanation from the LÖVE developer seems quite logical.

And this is where the love.run function ends. Everything that happens inside the while true refers to the frame, that is, love.update and love.draw are called once per frame. The whole game is essentially a very fast repetition of the contents of the loop (for example, at 60 frames per second), so get used to this thought. I remember that at first it took me some time to instinctively realize why everything is so arranged.

If you want to read more about this, there is a helpful discussion of this feature on the LÖVE forums .

If you don’t want to, then you don’t have to figure it out right from the start, but it’s useful to change the game cycle in the right way. There is an excellent article that discusses various techniques of gaming cycles with a qualitative explanation. She is here .

Exercises for the game cycle


1. What role does Vsync play in the game loop? By default, it is turned on and you can disable it by calling love.window.setMode with the vsync attribute vsync to false.

2. Implement the Fixed Delta Time cycle from the Fix Your Timestep article by changing love.run .

3. Implement the Variable Delta Time cycle from Fix Your Timestep by changing love.run .

4. Implement the Semi-Fixed Timestep from the article Fix Your Timestep, changing love.run .

5. Implement the Free the Physics cycle from Fix Your Timestep by changing love.run .



Part 2: Libraries


Introduction


In this part, we will look at some of the Lua / LÖVE libraries that are necessary for the project, and we will also explore the principles that are unique to Lua that you need to start learning. By the end of this part we will learn four libraries. One of the goals of this part is to get used to the idea of ​​loading libraries collected by other people, to read their documentation, to study their work and how to use it in their project. Lua and LÖVE, by themselves, do not have extensive capabilities, so downloading and using code written by other people is a standard and necessary practice.

Object Orientation


The first thing I will consider here is the orientation of objects. There are many ways to implement the orientation of objects in Lua, but we just use the library. Most of all I like the rxi / classic OOP library because of its small size and efficiency. To install it, simply download it and drag the classic folder into the project folder. I usually create a libraries folder and drop all libraries there.

Having finished with this, we can import the library into the game at the top of the main.lua file by doing the following:

 Object = require 'libraries/classic/classic' 

As it is written on the github page, you can perform all normal OOP actions with this library, and they should work fine. When creating a new class, I usually do it in a separate file and put this file in the objects folder. Then, for example, creating a Test class and one of its instances will look like this:

 --   objects/Test.lua Test = Object:extend() function Test:new() end function Test:update(dt) end function Test:draw() end 

 --   main.lua Object = require 'libraries/classic/classic' require 'objects/Test' function love.load() test_instance = Test() end 

That is, when you call require 'objects/Test' in main.lua , everything that is defined in the Test.lua file is Test.lua , which means that the global variable Test now contains the definition of the class Test. In our game, each class definition will be performed in this way, that is, class names must be unique, as they are bound to a global variable. If you do not want to do this, you can make the following changes:

 --   objects/Test.lua local Test = Object:extend() ... return Test 

 --   main.lua Test = require 'objects/Test' 

If we make the Test variable local in Test.lua , then it will not be bound to a global variable, that is, it will be possible to bind it to any name when it is needed in main.lua . At the end of the Test.lua script, a local variable is returned, and therefore in main.lua when declaring Test = require 'objects/Test' definition of the class Test assigned to the global variable Test .

Sometimes, for example, when writing libraries for other people, it is better to do so in order not to pollute their global state with the variables of their library. The classic library also does this, which is why we need to initialize it by assigning it to the Object variable. One of the good consequences of this is that if you assign a library to a variable, if we want, we can give Object name Class , and then our class definitions will look like Test = Class:extend() .

The last thing I do is automate the require process for all classes. To add a class to the environment, you need to require 'objects/ClassName' . The problem here is that there may be a multitude of classes, and entering this string for each class can be tedious. So to automate this process, you can do something like this:

 function love.load() local object_files = {} recursiveEnumerate('objects', object_files) end function recursiveEnumerate(folder, file_list) local items = love.filesystem.getDirectoryItems(folder) for _, item in ipairs(items) do local file = folder .. '/' .. item if love.filesystem.isFile(file) then table.insert(file_list, file) elseif love.filesystem.isDirectory(file) then recursiveEnumerate(file, file_list) end end end 

Let's break this code down. The recursiveEnumerate function recursively lists all the files within a given folder and adds them to the table as rows. It uses the LÖVE filesystem module , which contains many useful functions for performing such operations.

The first line inside the loop creates a list of all files and folders in the specified folder and returns them using love.filesystem.getDirectoryItems as a string table. Then it iterates through all of them and gets the full path to the file by concatenation (the concatenation of the lines in Lua is performed using .. ) the folder string and the item string.

Assume that the folder string is set to 'objects' , and inside the objects folder there is a single file called GameObject.lua . Then the items list will look like items = {'GameObject.lua'} . When iterating through the list, the string local file = folder .. '/' .. item sparsya in local file = 'objects/GameObject.lua' , that is, in the full path to the corresponding file.

This full path is then used to check, using the love.filesystem.isFile and love.filesystem.isDirectory functions, whether it is a file or a directory. If this is a file, then we simply add it to the file_list table passed by the called function, otherwise we call recursiveEnumerate again, but this time we use this path as the folder variable. When this process is completed, the file_list table will be filled with lines corresponding to the paths to all files within the folder . In our case, the object_files variable will be a table filled with rows corresponding to all classes in the objects folder.

There is one more step left: adding all these paths to require:

 function love.load() local object_files = {} recursiveEnumerate('objects', object_files) requireFiles(object_files) end function requireFiles(files) for _, file in ipairs(files) do local file = file:sub(1, -5) require(file) end end 

It's all much clearer. The code simply goes through the files and calls for them. The only thing left is to remove .lua from the end of the line, because the require function produces an error if you leave it. This can be done with the string local file = file:sub(1, -5) , which uses one of the built-in Lua string functions . So after doing this, all classes defined inside the objects folder will be automatically loaded. Later, the recursiveEnumerate function will also be used to automatically load other resources, such as images, sounds, and shaders.

OOP exercises


6. Create the Circle class, which in its constructor receives the x , y and radius arguments, which have the x , y , radius and creation_time attributes, as well as the update and draw methods. The x , y and radius attributes must be initialized with the values ​​passed from the constructor, and the creation_time attribute must be initialized with a relative instance creation time (see love.timer ). The update method should take the argument dt , and the draw function should draw the filled-in cycle centered at x, y with a radius of radius (see love.graphics ). An instance of this Circle class must be created at position 400, 300 with a radius of 50. It must also be updated and drawn on the screen. Here's what the screen should look like:


7. Create a HyperCircle class that inherits from the Circle class. HyperCircle is similar to Circle , only the outer circle is drawn around it. It should receive additional line_width and outer_radius arguments in the constructor. An instance of this HyperCircle class HyperCircle be created at position 400, 300 with a radius of 50, a line width of 10, and an outer radius of 120. The screen should look like this:


8. What does the operator use in Lua:? How is it different from . and when should you use each of them?

9. Suppose we have the following code:

 function createCounterTable() return { value = 1, increment = function(self) self.value = self.value + 1 end, } end function love.load() counter_table = createCounterTable() counter_table:increment() end 

What will be the value of counter_table.value ? Why does the increment function take an argument called self ? Can this argument have some other name? And what is the variable that is represented by self in this example?

10. Create a function that returns a table that contains the attributes a , b , c and sum . a , b and c must be initialized with values ​​of 1, 2, and 3, and sum must be a function that adds a , b and c . The value of the sum must be stored in the attribute c table (that is, after all operations are performed, the table must have the attribute c with a value of 6).

11. If a class has a method called someMethod , can it have an attribute with the same name? If not, why not?

12. What is a “global table” in Lua?

13. Based on how we organized the automatic loading of classes, if one class inherits from another, the code will look like this:

 SomeClass = ParentClass:extend() 

Is there a guarantee that when this line is processed, the ParentClass variable ParentClass already be defined? Or, in other words, is there any guarantee that the required ParentClass will be earlier than SomeClass ? If so, how is this guaranteed? If not, how can you fix this problem?

14. Suppose that all class files define a class not globally, but locally, something like this:

 local ClassName = Object:extend() ... return ClassName 

How do I need to change the requireFiles function requireFiles that it can still automatically load all classes?

Input


We now turn to the processing of input. By default, several event handlers are used in LÖVE. If these event-handling functions are defined, they can be called when the corresponding event is executed, after which you can intercept the execution of the game and take the necessary actions:

 function love.load() end function love.update(dt) end function love.draw() end function love.keypressed(key) print(key) end function love.keyreleased(key) print(key) end function love.mousepressed(x, y, button) print(x, y, button) end function love.mousereleased(x, y, button) print(x, y, button) end 

In this case, when you press a key or click the mouse anywhere on the screen, information will be displayed in the console.One of the biggest problems with this kind of processing is that it forces you to structure everything you need to get input to bypass these calls.

Suppose we have an object gamewithin which there is an object level, within which there is an object player. In order for the player object to receive keyboard input, all these three objects must have two call handlers associated with the keyboard, because at the top level we only want to call game:keypressedinside love.keypressed, because we don’t want lower levels to know about the level or the player. Therefore, I created a library to solve this problem. You can download and install it like any other library we have reviewed. Here are some examples of how it works:

 function love.load() input = Input() input:bind('mouse1', 'test') end function love.update(dt) if input:pressed('test') then print('pressed') end if input:released('test') then print('released') end if input:down('test') then print('down') end end 

That's what the library does: instead of relying on input event handling functions, it simply asks if a particular key was pressed in this frame and receives a response in the form of true or false. In the example above, the frame where the button was pressed mouse1will be printed on the screen pressed, and the button release frame will be printed released. In all other frames when pressing is not performed, the challenges input:pressedand input:releasedwill return false, and everything will be performed inside the conditional. The same applies to the function input:down, only it returns true in each frame when the button is held down, and false otherwise.

Often we need behavior that is repeated when you hold the key at a certain interval, and not in each frame. For this purpose, you can use the function down:

 function love.update(dt) if input:down('test', 0.5) then print('test event') end end 

In this example, if the key associated with the action is held test, then every 0.5 seconds the console will be printed test event.

Typing exercises


15. Suppose we have the following code:

 function love.load() input = Input() input:bind('mouse1', function() print(love.math.random()) end) end 

Will something happen when pressed mouse1? And when you let go? And while holding?

16. Bind the alphanumeric key +to the action add; then, while holding the action key, addincrease the value of the variable sum(initially 0) by 1 every 0,25second. Print the value sumto the console with each increment.

17. Is it possible to attach several keys to a single action? If not, why not? And is it possible to attach several actions to one key? If not, why not?

18. If you have a controller, you can tie him directions DPAD buttons (fup, fdown ...) to act up, left, rightanddownand then output the action name to the console when each button is pressed.

19. If you have a controller, then attach one of its trigger buttons (l2, r2) to the action trigger. Trigger buttons return a Boolean value from 0 to 1, indicating a click. How will you get this value?

20. Repeat the previous exercise, but for the horizontal and vertical position of the left and right sticks.

Timer


Another critical part of the code is general time-fixing functions. For them, we will use hump , and more specifically hump.timer .

 Timer = require 'libraries/hump/timer' function love.load() timer = Timer() end function love.update(dt) timer:update(dt) end 

According to the documentation, it can be used directly through a variable Timeror create a new instance. I decided to choose the second option. I use a global variable for global timers timer, and when I need timers inside objects, for example, in the Player class, they will have their own instances of timers created locally.

The most important timing functions used throughout the game are after, everyand tween. And although I personally do not use the function script, for some it may be useful, so it is worth mentioning it. Let's look at the timing functions:

 function love.load() timer = Timer() timer:after(2, function() print(love.math.random()) end) end 

The function is afterquite simple. It receives the number and function, and performs the function after the specified number of seconds. In the example above, two seconds after starting the game, a random number should be displayed in the console. One of the convenient features afteris that this function can be connected in chains. For example:

 function love.load() timer = Timer() timer:after(2, function() print(love.math.random()) timer:after(1, function() print(love.math.random()) timer:after(1, function() print(love.math.random()) end) end) end) end 

In this example, two seconds after launch, a random number will be displayed, then another after one second (three seconds after launch), and finally, after one second another (four seconds after launch). This is somewhat similar to how the function works script, so you can choose the one that is most convenient for you.

 function love.load() timer = Timer() timer:every(1, function() print(love.math.random()) end) end 

In this example, a random number will be displayed every second. Like a function after, it receives a number and a function, and then performs the function after a specified number of seconds. In addition, it may also receive a third argument, in which the number of operations is passed. For example:

 function love.load() timer = Timer() timer:every(1, function() print(love.math.random()) end, 5) end 

This code will output five random numbers for the first five operations. One of the ways to complete the function triggering everywithout explicitly specifying the number of retries is to force it to return false. This is useful in situations where the stop condition is not fixed or unknown at the time of the call every.

Another way to use the behavior of a function everyis to use a function after, for example, like this:

 function love.load() timer = Timer() timer:after(1, function(f) print(love.math.random()) timer:after(1, f) end) end 

I never studied the inner workings of this function, but the author of the library decided to implement it this way and documented it in the instructions, so I just used it. The convenience of the implementation of the functional everyin this way lies in the fact that we can change the time between operations by changing the value in the second call afterinside the first:

 function love.load() timer = Timer() timer:after(1, function(f) print(love.math.random()) timer:after(love.math.random(), f) end) end 

In this application, the time between each operation is variable (from 0 to 1, since love.math.random returns values ​​in this interval by default). This default behavior cannot be achieved with a function every. Triggering at variable intervals is very useful in a variety of situations, so you should know how they are implemented. We now turn to the function tween:

 function love.load() timer = Timer() circle = {radius = 24} timer:tween(6, circle, {radius = 96}, 'in-out-cubic') end function love.update(dt) timer:update(dt) end function love.draw() love.graphics.circle('fill', 400, 300, circle.radius) end 

The function is tweenharder to master, because it takes many arguments: it gets the number of seconds, the worktable, the target table, and the transition mode. It jumps to the target table in the worksheet. In the example above, the table circlehas a key radiuswith an initial value of 24. For 6 seconds, the value will change to 96 in the transition mode in-out-cubic. (Here is a useful list of all transition modes ) It seems complicated, but it looks like this:

Gif

The function tweencan also receive an additional argument after the transition mode - a function that will be called after the transition is completed. It can be used in many cases, but if we take the previous example, we can use it to compress the circle after expanding back:

 function love.load() timer = Timer() circle = {radius = 24} timer:after(2, function() timer:tween(6, circle, {radius = 96}, 'in-out-cubic', function() timer:tween(6, circle, {radius = 24}, 'in-out-cubic') end) end) end 

It will look like this:

Gif

These three functions - after, everyand tween- remain in the group of the most useful functions in my code base. They are very flexible and with their help you can achieve a lot. So understand them to have an intuitive understanding of what you are doing!



An important aspect of the timer library is that each of these calls returns a handle. You can use this handle in conjunction with a call cancelto cancel a specific timer:

 function love.load() timer = Timer() local handle_1 = timer:after(2, function() print(love.math.random()) end) timer:cancel(handle_1) 

This is what happens in this example: first, we call a afterrandom number for output to the console two seconds later and store the handle of this timer in a variable handle_1. Then we cancel this call by calling cancelwith an argument handle_1. It is very important to learn how to do this, because often we have situations when we create timer calls based on certain events. For example, when a player presses a key r, we want to output a random number to the console in two seconds:

 function love.keypressed(key) if key == 'r' then timer:after(2, function() print(love.math.random()) end) end end 

If you add this code to the file main.luaand run the project, then after clicking ron the screen with a delay, a random number should appear. If you press rseveral times, several numbers will appear with a delay, one after the other. But sometimes we need this behavior so that when you repeat an event several times, it would reset the timer and start counting from 0 again. This means that when you click on, rwe want all previous timers created during the execution of this event to be canceled in the past. One way to do this is to somehow store all the descriptors, somehow bind them to the event identifier and call a certain undo function for the event identifier itself, which will undo the descriptors of all the timers associated with this event. Here is the solution:

 function love.keypressed(key) if key == 'r' then timer:after('r_key_press', 2, function() print(love.math.random()) end) end end 

I created an extension to an existing timer module that supports adding event labels. Then in our case the event is r_key_pressattached to a timer that is created when the key is pressed r. If the key is pressed repeatedly several times, the module automatically sees that the event has other registered timers and, by default, cancels the previous timers, which is what we are striving for. If the label is not used, the default behavior is the usual module behavior.

The extended version can be downloaded here and replace the import of the timer in main.luawith libraries/hump/timerthe file location EnhancedTimer.lua. Personally, I put it in libraries/enhanced_timer/EnhancedTimer. This also means that the library humpis located inside the folder.libraries. If you name your folders in some other way, then you need to change the path at the top of the file EnhancedTimer. In addition, you can also use the library written by me , which has the same functionality as hump.timer, plus handles event labels.

Timer Exercises


21. Using only the loop forand one function declaration afterwithin this loop, print 10 random numbers on the screen with an interval of 0.5 seconds before each output.

22. Suppose we have the following code:

 function love.load() timer = Timer() rect_1 = {x = 400, y = 300, w = 50, h = 200} rect_2 = {x = 400, y = 300, w = 200, h = 50} end function love.update(dt) timer:update(dt) end function love.draw() love.graphics.rectangle('fill', rect_1.x - rect_1.w/2, rect_1.y - rect_1.h/2, rect_1.w, rect_1.h) love.graphics.rectangle('fill', rect_2.x - rect_2.w/2, rect_2.y - rect_2.h/2, rect_2.w, rect_2.h) end 

Using only the function tween, move the attribute of the wfirst rectangle within 1 second in the transition mode in-out-cubic. After that, perform the transition attribute of the hsecond triangle for 1 second in the transition mode in-out-cubic. After that, move both rectangles back to their original attributes after 2 seconds in transition mode in-out-cubic. It should look like this:

Gif

23. For this exercise, you will need to create a strip of energy. Each time you press a key, an denergy bar should simulate the damage taken. It should look like this:

Gif

As you can see, there are two layers in this strip of energy and when receiving damage the upper layer moves faster, while the background layer lags behind it a little.

24. Consider the previous example with an expanding and tapering circle: it expands once and shrinks once too. How to change the code so that it expands and contracts infinitely?

25. Get the results of the previous exercise, using only the function after.

26. Assign a key eto the expansion of the circle when it is pressed, and the key sto compression when pressed. Each new keystroke should cancel the current expansion / contraction.

27. Suppose we have the following code:

 function love.load() timer = Timer() a = 10 end function love.update(dt) timer:update(dt) end 

How is it possible, using only a function tweenand without placing a variable ainside another table, to make the transition of its value to 20 within 1 second in the transition mode linear?

Table functions


Finally, the last library I want to consider is Yonaba / Moses , which contains many functions for more convenient processing of tables in Lua. Library documentation is here . Now you can read it yourself and understand how to install it and use it yourself.

But before proceeding to the exercises, you need to learn how to display a table in the console to check its values:

 for k, v in pairs(some_table) do print(k, v) end 

Exercises with tables


In all the exercises we will use the following tables:

 a = {1, 2, '3', 4, '5', 6, 7, true, 9, 10, 11, a = 1, b = 2, c = 3, {1, 2, 3}} b = {1, 1, 3, 4, 5, 6, 7, false} c = {'1', '2', '3', 4, 5, 6} d = {1, 4, 3, 4, 5, 6} 

In addition, in each exercise, you can use only one function from the library, unless otherwise stated.

28. Output the contents of the table ato the console using the function each.

29. Count the number of values ​​1 inside the table b.

30. Add 1 to all values ​​of the table d, using the function map.

31. Using the function map, apply the afollowing transformations to the table : if the value is a number, then it should be doubled; if the value is a string, then add to it by concatenation 'xD'; if the value is boolean, then you need to switch its state; and finally, if the value is a table, you should skip it.

32.Sum all the values ​​in the list d. The result should be equal to 26.

33. Suppose we have the following code:

 if _______ then print('table contains the value 9') end 

What library function should be used in the underlined place to check if the table contains the bvalue 9?

34. Find the first index in which the value 7 of the table is located c.

35. Filter the table dso that only the numbers less than 5 are left.

36. Filter the table cso that only the rows are left.

37. Check whether all of the values of tables cand dfigures. The code should return false for the first table and true for the second.

38. Shuffle the table randomly d.

39. Make the table dgo in reverse order.

40Remove all occurrences of values ​​1 and 4 from the table d.

41. Create a combination of tables b, cand dno duplicates.

42. Find the common values ​​in the tables band d.

43. Attach a table bto a table d.



Part 3: Rooms and Areas


Introduction


In this part we will look at the structured code needed before moving on to the game itself. We will study the principle (Room), which are similar to scenes in other engines. We will also study the principle (Area), the type of construction for managing objects that may be inside the room. As in the two previous parts, there will be no game-related code in this, we will only consider high-level architectural solutions.

Room


I took the idea of ​​rooms from the GameMaker documentation . When thinking about approaches to solving the problem of the game's architecture, I like to watch how others solved it, and in this case, even though I never used GameMaker, the authors' idea of ​​the concept of a room and the associated functions gave me good tips.

Judging by the description, rooms (Rooms) - this is the place where everything happens in the game. These are spaces in which all game objects are created, updated and rendered. It is possible to move from one room to another. These rooms are also ordinary objects that are located inside the folder rooms. Here's what a room might look like Stage:

 Stage = Object:extend() function Stage:new() end function Stage:update(dt) end function Stage:draw() end 

Simple rooms


In its simplest form, the operation of this system requires only one additional variable and one additional function:

 function love.load() current_room = nil end function love.update(dt) if current_room then current_room:update(dt) end end function love.draw() if current_room then current_room:draw() end end function gotoRoom(room_type, ...) current_room = _G[room_type](...) end 

First love.load, a global variable is defined current_room. The idea is that only one room can be active at a time, so this variable will store a link to the current active room object. Then, if there is a current active room, it will be drawn in love.updateand love.draw. This means that all rooms must have the update and draw functions defined.

You can use the function to change rooms gotoRoom. It receives room_type, which is a simple string with the class name of the room to go to. Therefore, if, for example, we have a class Stagedefined as a room, then this function can be passed a string'Stage'. The implementation of this functionality depends on how the automatic loading of classes in the previous part of the tutorial was configured, that is, from loading all classes as global variables.

Global variables in Lua are stored in a global environment table, called _G, that is, they can be accessed like any other variable in a regular table. If the global variable Stagecontains the definition of the Stage class, then it can be accessed by simply using the program anywhere Stage, or _G['Stage'], or _G.Stage. Since we want to be able to load any arbitrary room, it will be logical to get a row room_typeand get access to the class definition via the global table.

That is, as a result, if room_typeis a string'Stage', the line inside the function gotoRoomparses it in current_room = Stage(...). This means that a copy of the new room will be created Stage. It also means that every time you move to a new room, this new room is created from scratch, and the previous room is deleted. This works in Lua as follows: when no variable is referenced to a table, the garbage collector deletes it. And when a variable is no longer referenced to an instance of the previous room current_room, it will be deleted by the garbage collector.

There are obvious limitations in this scheme. For example, we often do not need rooms to be removed when moving to the next room, and often we don’t want a new room to be created from scratch with each transition to it. With such a system, it is impossible to avoid this.

However, in my game I use this scheme. There will be only three or four rooms in the game, and all these rooms should not be connected with each other, that is, they can be created from scratch without any problems and deleted when switching between them.



Let's look at a small example of how we can integrate this system into a real-life game. To do this, use Nuclear Throne:


Watch the first minute of this video until the hero dies to see what this game is about.

The game loop is quite simple and it fits perfectly to demonstrate a simple room layout, because none of the rooms need to be linked to the previous ones. (For example, it is impossible to return to the previous map.) The first screen of the game is the main menu:


I would make it a room MainMenuand in it I would create all the logic necessary for the main menu, that is, the background, five options, the effect arising when choosing a new option, small lightning along the edges of the screen, etc. And when a player chooses an option, I would call gotoRoom(option_type)that would replace the current room with the one created for this option. In our case it would be an extra room Play, CO-OP, Settingsand Stats.

Alternatively, you can create one room MainMenuthat processes all these additional options without having to separate them into different rooms. It is often better to store everything in the same room and process the transitions inside, rather than in an external system. The choice depends on the situation and in this case I do not have enough information to say which is better.

Anyway, the next thing that happens in the video is that the player chooses the Play option, and it looks like this:


New options appear and the player can select the normal, daily or weekly mode. As far as I remember, they only change the initial number of levels generated, that is, in this case we do not need new rooms for each of these options (we can simply pass different initial values ​​as an argument when calling gotoRoom). The player selects the normal option and the following screen appears:


I would call it a room CharacterSelect. Like the others, she would do everything necessary on this screen: the background, the characters in the background, the effects that occur when changing the character, the choice of character and all the logic necessary for this. After selecting a character, the boot screen appears:


During the game:


When the player finishes the current level, this screen appears before moving on to the next:


After the player selects the passive skill on the previous screen, the loading screen reappears. Then the game again goes to the next level. And then when the player dies, this screen appears:


All these different screens, and if I had followed the same logic, I would have sold them as separate rooms: LoadingScreen, Game, MutationSelectand DeathScreen. But if you think about it, some of them may be redundant.

For example, there is no reason to separate a room LoadingScreenfrom Game. The loading process most likely performs the generation of the level that should occur in the room Game, so there is no point in separating them from each other, because the loading would occur in the room LoadingScreenand not in the room Game, and then the data created in the first one would have to be transferred in the second. In my opinion, this is an unnecessary over-complication.

Another example: the screen of death is just another layer superimposed on top of the game (which is still running), that is, it is most likely running in the same room as the game. I think that as a result the screen should remain the only separate room MutationSelect.

This means that from the point of view of the room system, the Nuclear Throne game loop from the video looked like this: MainMenu-> Play-> CharacterSelect-> Game-> MutationSelect-> Game-> ... When death happens, the player can either return to MainMenuor try again and restart the new one Game. All these transitions can be implemented through a simple function gotoRoom.

Persisting rooms


For the sake of completeness, even though my game will not use this system, I will talk about it as supporting more situations:

 function love.load() rooms = {} current_room = nil end function love.update(dt) if current_room then current_room:update(dt) end end function love.draw() if current_room then current_room:draw() end end function addRoom(room_type, room_name, ...) local room = _G[room_type](room_name, ...) rooms[room_name] = room return room end function gotoRoom(room_type, room_name, ...) if current_room and rooms[room_name] then if current_room.deactivate then current_room:deactivate() end current_room = rooms[room_name] if current_room.activate then current_room:activate() end else current_room = addRoom(room_type, room_name, ...) end end 

In this case, besides passing the string, the room_typevalue is also passed room_name. This is necessary because in this case I want to be able to refer to the rooms using an identifier, that is, each room_namemust be unique. This room_namecan be a string or a number - if they are unique, then it does not matter.

Thus, in this new system there is a function addRoomthat simply creates an instance of a room and stores it inside the table. Then, gotoRoominstead of creating a new instance of a room, the function can check this table, and if the room already exists, then simply restore it, and otherwise create it from scratch.

Another difference here is the use of functions activateanddeactivate. If the room already exists and you want to go into it again by calling gotoRoom, the current room is deactivated first, then it changes to the target room, and then the target room is activated. These calls are useful in many cases, for example, to save data or to load data from disk, dereference of variables (so that they can be deleted) and so on.

This new system allows you to save the state of the rooms and keep them in memory, even when they are not active. Since the table always refers to them rooms, when changing current_roomto another room, the previous one will not be deleted by the garbage collector and can be obtained in the future.



Let's look at an example in which this new system is very useful. This time it will be The Binding of Isaac:


Watch the first minute of this video . This time I’ll skip all the menus and focus on the gameplay itself. It consists of moving from room to room, killing enemies and selecting items. The player can return to previous rooms and these rooms retain the state in which the player left them, so if he killed the enemies and destroyed the stones in the room, then returning to it, he will not see any enemies or stones. This is perfect for our system.

I will create the following system: we will have a room Roomin which the whole gameplay of the room takes place. And there will be a common room Gamethat coordinates the data at a higher level. For example, the Gamealgorithm will generate levels in the room and from its results by callingaddRoommultiple instances will be created Room. Each of these instances will have its own unique ID and will be used gotoRoomto activate one of them when the game starts . In the process of moving the player and exploring the dungeons, further calls will be made gotoRoomand the instances already created Roomwill be activated / deactivated as the player moves.

When moving from one room to another in Isaac, there is a small transition that looks like this:

Gif

I did not mention this in the Nuclear Throne example, but it also has small transitions between rooms. These transitions can be implemented in various ways, but in the case of Isaac, this means that two rooms must be rendered at the same time, so using only one variable current_roomwill not work. I will not now consider ways to change the code to solve this problem, but I thought that it is worth mentioning that the code presented here will not fully correspond to what is happening in the game and I have simplified everything a bit. When I move on to writing my own game and implementing transitions, I’ll talk about this in more detail.

Exercises with rooms


44. Create three rooms: CircleRoomwhich draws a circle in the center of the screen, RectangleRoomwhich draws a rectangle in the center of the screen and PolygonRoomwhich draws a polygon in the center of the screen. Assign keys F1, F2and F3to switch between rooms.

45. What is the closest analogue of a room in the following engines: Unity , GODOT , HaxeFlixel , Construct 2 and Phaser ? Examine their documentation and try to figure it out. Also look at what methods objects have and how you can switch from one room to another.

46.Choose two single-player games and break them into pieces from the point of view of the rooms, as I did with Nuclear Throne and Isaac. Try to look at everything realistically and evaluate whether each aspect should have its own room or not. And try to describe what will happen when you call addRoomor gotoRoom.

47. How does the Lua garbage collector work in general? (If you don’t know what a garbage collector is, read about it.) How do memory leaks occur in Lua? What ways can you avoid their occurrence or recognize them?

Areas


We now turn to the idea (Area). One of the operations usually performed inside the room is the management of various objects. All objects must be updated and drawn, as well as added to the room and deleted after death. Sometimes it is also required to request objects in a certain area (for example, when an explosion occurs, we need to cause damage to all objects around it, that is, take all the objects inside the circle and cause them damage), and also apply certain general actions to them, for example, sorting by the depth of their layer so that they can be drawn in a specific order. All these functions were the same in different rooms and different games that I created, so I gathered them into a class called Area:

 Area = Object:extend() function Area:new(room) self.room = room self.game_objects = {} end function Area:update(dt) for _, game_object in ipairs(self.game_objects) do game_object:update(dt) end end function Area:draw() for _, game_object in ipairs(self.game_objects) do game_object:draw() end end 

The idea is that this instance of this object will be created in the room. At first, the above code will have only a list of potential game objects, and these game objects should be updated and drawn. All game objects in the game will inherit from a single class GameObjectthat has several common attributes that all objects in the game have. This class looks like this:

 GameObject = Object:extend() function GameObject:new(area, x, y, opts) local opts = opts or {} if opts then for k, v in pairs(opts) do self[k] = v end end self.area = area self.x, self.y = x, y self.id = UUID() self.dead = false self.timer = Timer() end function GameObject:update(dt) if self.timer then self.timer:update(dt) end end function GameObject:draw() end 

The constructor takes four arguments:, a areaposition x, yand a table optscontaining additional optional arguments. The first thing that happens in the code is that this additional table is taken optsand all its attributes are assigned to this object. For example, if we create it GameObjectthis way:, game_object = GameObject(area, x, y, {a = 1, b = 2, c = 3})then the line for k, v in pairs(opts) do self[k] = vessentially copies the ads a = 1, b = 2and c = 3into this newly created instance. Now you should understand what is happening here, but if you do not understand, then reread the section on OOP in the previous part, and also about the work of the tables in Lua.

Next, the transmitted reference to this instance of the area is stored in self.area, and the position inself.x, self.y. Then this game object is assigned an ID. This ID must be unique for each object so that we can identify each object without conflict. A simple UUID generation function is quite suitable for our game. Such a function is in a library called lume in lume.uuid. We will not use this library, we need only this one function, that is, it will be more logical to take only it, and not to install the entire library:

 function UUID() local fn = function(x) local r = math.random(16) - 1 r = (x == "x") and (r + 1) or (r % 4) + 9 return ("0123456789abcdef"):sub(r, r) end return (("xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx"):gsub("[xy]", fn)) end 

I copied this code to a file utils.lua. This file will contain support functions that cannot be assigned to any particular category. This function produces a string of the form '123e4567-e89b-12d3-a456-426655440000'that will be unique for any purpose.

It is worth noting that this function uses the function math.random. If you use print(UUID())to see what it generates, then we will see that when the project is executed, it will generate the same ID. This problem occurs because it always uses the same seed number. One way to solve this problem is that each time you start the program, you can randomize the seed number based on the time. You can do this with math.randomseed(os.time()).

However, I just instead math.randomusedlove.math.random. As we remember from the first part of the tutorial, the first function called in love.runis the love.math.randomSeed(os.time())one that deals with the randomization of the seed, but only for the random number generator LÖVE. Since I use LÖVE, when I need randomness, I will use its functions, not the Lua functions. Having made such a change to a function UUID, you will see that it will begin to generate different IDs.

Returning to the game object, a variable is defined here dead. The idea is that when deadit is true, the game object is removed from the game. The same thing happens with a class instance.Timerassigned to each game object. I saw that time functions are used in almost every object, so it seemed logical to use them by default for all objects. Finally, the function updateupdates the timer.

Given all this, the class Areamust be changed as follows:

 Area = Object:extend() function Area:new(room) self.room = room self.game_objects = {} end function Area:update(dt) for i = #self.game_objects, 1, -1 do local game_object = self.game_objects[i] game_object:update(dt) if game_object.dead then table.remove(self.game_objects, i) end end end function Area:draw() for _, game_object in ipairs(self.game_objects) do game_object:draw() end end 

The update function now takes into account the state of the variable deadand acts accordingly. First, the game object is updated in the usual way, then it is checked for dead. If it is true, the object is simply removed from the list game_objects. It is important here that the loop is performed in reverse order, from the end of the list to the beginning. This is because if we delete the elements from the Lua table, moving in a straightforward order, as a result, we will skip some elements, as can be seen from this discussion .

Finally, the last thing to add is a function addGameObjectthat adds to the Areanew game object:

 function Area:addGameObject(game_object_type, x, y, opts) local opts = opts or {} local game_object = _G[game_object_type](self, x or 0, y or 0, opts) table.insert(self.game_objects, game_object) return game_object end 

It will be called as follows: area:addGameObject('ClassName', 0, 0, {optional_argument = 1}). The variable game_object_typewill work the same way as the lines in the function gotoRoom, that is, they are the class names of the object being created. _G[game_object_type]in the example above, it performs the parsing into a global variable ClassName, which will contain the definition of the class ClassName. An instance of the target class is created, added to the list game_objects, and then returned. Now this instance will be updated and drawn in each frame.

This is how this class will work. We will actively change it during the development of the game, but in general we have described its necessary basic behavior (adding, deleting, updating, and drawing objects).

Exercises with Area


48. Create a room Stagein which there is Area. Then create an object Circlethat inherits from GameObjectand add an instance of this object to the room Stagein a random position every two seconds. The instance Circlemust destroy itself at a random time interval from 2 to 4 seconds.

49. Create a room Stagethat does not exist Area. Create an object Circlethat does not inherit from GameObjectand add an instance of this object to the scene Stageat a random position every two seconds. The instance Circlemust destroy itself at a random time interval from 2 to 4 seconds.

50. In the solution to exercise 1, the function is appliedrandom. Improve this function so that it receives only one value instead of two and generates a random real number from 0 to a value in this case (when only one argument is obtained). Also improve function so that values minand maxcan be reversed, that is, to the first value may be greater than the second.

51. What is the purpose of local opts = opts or {}the function addGameObject?



Part 4: Exercises


In the previous three parts, we looked at a lot of code that is not directly related to the game. All this code can be used regardless of the game you create, so I call it code for myself engine, although I understand that in fact it is not an engine. The further we progress in the game, the more and more I will add code belonging to this category, which can be used in various games. If you want to take the most important of these tutorials, then this should definitely be the code. He was very welcome to me many times.

Before proceeding to the next part, where we proceed to the game itself, we need to fully master some of the concepts discussed in the previous parts, so I will give additional exercises.

52. Create a Areafunction inside a class.getGameObjects which will work as follows:

 --      Enemy all_enemies = area:getGameObjects(function(e) if e:is(Enemy) then return true end end) --        50 healthy_objects = area:getGameObjects(function(e) if e.hp and e.hp >= 50 then return true end end) 

It receives the function that receives the game object and performs its check. If the check result is true, then the game object is added to the table that is returned after completion of execution getGameObjects.

53. What values are a, b, c, d, e, fand g?

 a = 1 and 2 b = nil and 2 c = 3 or 4 d = 4 or false e = nil or 4 f = (4 > 3) and 1 or 2 g = (3 > 4) and 1 or 2 

54. Create a function printAllthat takes an unknown number of arguments and prints them all to the console. printAll(1, 2, 3)will output 1, 2 and 3 printAll(1, 2, 3, 4, 5, 6, 7, 8, 9)to the console , and will output numbers from 1 to 9 to the console. The number of arguments passed is unknown and may vary.

55. Similar to the previous exercise, create a function printTextthat receives an unknown number of lines, which performs their concatenation into a common string and outputting this string to the console.

56. How to start a garbage collection cycle?

57. How to show how much memory your Lua program takes?

58. How to cause an error that will stop the execution of the program and give an arbitrary error message?

59. Create a classRectangledrawing a rectangle with a width and height at the creation position. Create 10 instances of this class at random positions with random width and height. When clicked, a drandom instance should be deleted from the environment. When the number of copies reaches 0, 10 more new copies with random width and height should be created in random positions of the screen.

60. Create a classCircledrawing a circle with some radius in the creation position. Create 10 copies of this class in random positions on the screen with a random radius and with an interval of 0.25 seconds between each copy. After creating all instances (that is, after 2.5 seconds), start deleting one random instance every [0.5, 1] ​​seconds (random number from 0.5 to 1). After deleting all instances, repeat the entire process of recreating 10 instances and their sequential deletion. This process should be repeated endlessly.

61. Create a Areafunction inside the class queryCircleAreathat works as follows:

 --     'Enemy'  'Projectile'    50   100, 100 objects = area:queryCircleArea(100, 100, 50, {'Enemy', 'Projectile'}) 

She gets the position x, y, radiusand a list of strings containing the names of the target classes. Then it returns all the objects that belong to these classes and are inside a circle radiuswith a radius centered on the position x, y.

62. Create a Areafunction inside the class getClosestGameObjectthat works like this:

 --     'Enemy'    50   100, 100 closest_object = area:getClosestObject(100, 100, 50, {'Enemy'}) 

It takes the same arguments as the function queryCircleArea, but returns only one object (closest).

63. How can we check if a method exists in an object before calling it? And how to check if an attribute exists before using its value?

64. How can I write the contents of one table to another with just a loop for?


If you like this series of tutorials, then you can stimulate me to write something similar in the future:


By purchasing a tutorial on itch.io, you will have access to the complete source code of the game, answers to the exercises from parts 1-9, the code broken into parts of the tutorial (the code will look like it should look at the end of each part) and the key games on Steam.

Source: https://habr.com/ru/post/349276/


All Articles