-
Notifications
You must be signed in to change notification settings - Fork 142
BYTEPATH #3 - Rooms and Areas #17
Comments
Instead of using:
and calling
which removes the need to pass in the room name as a string and allows us to pass in the name as the variable itself such as |
The Moses library from the previous part also includes a UUID function It seems to generate numbers sequentially from 0 (0, 1, 2, 3, ...); I haven't completed the tutorial but can't see why there would be a problem with that. |
Hi, SSYGEN, can i ask you a question? Why do you abstract the concept "Area"? I think the "Room" can handle gameObjects management, will there be multiple areas in one room? |
There won't be multiple areas in one room. I separate both concepts because I prefer having one thing that deals with object management and another thing that is used for everything else. You could have a Room inherit from Area, or have Area be a mixin that gets injected into Room objects, or you could just have the same object management code for all rooms that you create without an Area object and the same effect would be achieved. |
maybe i miss something but when using |
First, thanks for your sharing! :)
function gotoRoom(room_type, room_name, ...)
if current_room and current_room.deactivate then
current_room:deactivate()
end
if rooms[room_name] then
current_room = rooms[room_name]
else
current_room = addRoom(room_type, room_name, ...)
end
if current_room.activate then current_room:activate() end
end |
Hey, I noticed a typo:
|
That isn't a typo. |
Isn't it? In answers You say that it is required for
Edit: clarification |
Oh, sorry, I didn't notice that these are two different exercises. |
Introduction
In this article we'll cover some structural code needed before moving on to the actual game. We'll explore the idea of
Rooms
, which are equivalent to what's called a scene in other engines. And then we'll explore the idea of anArea
, which is an object management type of construct that can go inside a Room. Like the two previous tutorials, this one will still have no code specific to the game and will focus on higher level architectural decisions.Room
I took the idea of Rooms from GameMaker's documentation. One thing I like to do when figuring out how to approach a game architecture problem is to see how other people have solved it, and in this case, even though I've never used GameMaker, their idea of a Room and the functions around it gave me some really good ideas.
As the description there says, Rooms are where everything happens in a game. They're the places where all game objects will be created, updated and drawn and you can change from one Room to the other. Those rooms are also normal objects that I'll place inside a
rooms
folder. This is what one room calledStage
would look like:Simple Rooms
At its simplest form this system only needs one additional variable and one additional function to work:
At first in
love.load
a globalcurrent_room
variable is defined. The idea is that at all times only one room can be currently active and so that variable will hold a reference to the current active room object. Then inlove.update
andlove.draw
, if there is any room currently active it will be updated and drawn. This means that all rooms must have an update and a draw function defined.The
gotoRoom
function can be used to change between rooms. It receives aroom_type
, which is just a string with the name of the class of the room we want to change to. So, for instance, if there's aStage
class defined as a room, it means the'Stage'
string can be passed in. This works based on how the automatic loading of classes was set up in the previous tutorial, which loads all classes as global variables.In Lua, global variables are held in a global environment table called
_G
, so this means that they can be accessed like any other variable in a normal table. If theStage
global variable contains the definition of the Stage class, it can be accessed by just sayingStage
anywhere on the program, or also by saying_G['Stage']
or_G.Stage
. Because we want to be able to load any arbitrary room, it makes sense to receive theroom_type
string and then access the class definition via the global table.So in the end, if
room_type
is the string'Stage'
, the line inside thegotoRoom
function parses tocurrent_room = Stage(...)
, which means that a newStage
room is being instantiated. This also means that any time a change to a new room happens, that new room is created from zero and the previous room is deleted. The way this works in Lua is that whenever a table is not being referred to anymore by any variables, the garbage collector will eventually collect it. And so when the instance of the previous room stops being referred to by thecurrent_room
variable, eventually it will be collected.There are obvious limitations to this setup, for instance, often times you don't want rooms to be deleted when you change to a new one, and often times you don't want a new room to be created from scratch every time you change to it. Avoiding this becomes impossible with this setup.
For this game though, this is what I'll use. The game will only have 3 or 4 rooms, and all those rooms don't need continuity between each other, i.e. they can be created from scratch and deleted any time you move from one to the other and it works fine.
Let's go over a small example of how we can map this system onto a real existing game. Let's look at Nuclear Throne:
Watch the first minute or so of this video until the guy dies once to get an idea of what the game is like.
The game loop is pretty simple and, for the purposes of this simple room setup it fits perfectly because no room needs continuity with previous rooms. (you can't go back to a previous map, for instance) The first screen you see is the main menu:
I'd make this a
MainMenu
room and in it I'd have all the logic needed for this menu to work. So the background, the five options, the effect when you select a new option, the little bolts of lightning on the edges of screen, etc. And then whenever the player would select an option I would callgotoRoom(option_type)
, which would swap the current room to be the one created for that option. So in this case there would be additionalPlay
,CO-OP
,Settings
andStats
rooms.Alternatively, you could have one
MainMenu
room that takes care of all those additional options, without the need to separate it into multiple rooms. Often times it's a better idea to keep everything in the same room and handle some transitions internally rather than through the external system. It depends on the situation and in this case there's not enough details to tell which is better.Anyway, the next thing that happens in the video is that the player picks the play option, and that looks like this:
New options appear and you can choose between normal, daily or weekly mode. Those only change the level generation seed as far as I remember, which means that in this case we don't need new rooms for each one of those options (can just pass a different seed as argument in the
gotoRoom
call). The player chooses the normal option and this screen appears:I would call this the
CharacterSelect
room, and like the others, it would have everything needed to make that screen happen, the background, the characters in the background, the effects that happen when you move between selections, the selections themselves and all the logic needed for that to happen. Once the character is chosen the loading screen appears:Then the game:
When the player finishes the current level this screen popups before the transition to the next one:
Once the player selects a passive from previous screen another loading screen is shown. Then the game again in another level. And then when the player dies this one:
All those are different screens and if I were to follow the logic I followed until now I'd make them all different rooms:
LoadingScreen
,Game
,MutationSelect
andDeathScreen
. But if you think more about it some of those become redundant.For instance, there's no reason for there to be a separate
LoadingScreen
room that is separate fromGame
. The loading that is happening probably has to do with level generation, which will likely happen inside theGame
room, so it makes no sense to separate that to another room because then the loading would have to happen in theLoadingScreen
room, and not on theGame
room, and then the data created in the first would have to be passed to the second. This is an overcomplication that is unnecessary in my opinion.Another one is that the death screen is just an overlay on top of the game in the background (which is still running), which means that it probably also happens in the same room as the game. I think in the end the only one that truly could be a separate room is the
MutationSelect
screen.This means that, in terms of rooms, the game loop for Nuclear Throne, as explored in the video would go something like:
MainMenu
->Play
->CharacterSelect
->Game
->MutationSelect
->Game
-> .... Then whenever a death happens, you can either go back to a newMainMenu
or retry and restart a newGame
. All these transitions would be achieved through the simplegotoRoom
function.Persistent Rooms
For completion's sake, even though this game will not use this setup, I'll go over one that supports some more situations:
In this case, on top of providing a
room_type
string, now aroom_name
value is also passed in. This is because in this case I want rooms to be able to be referred to by some identifier, which means that eachroom_name
must be unique. Thisroom_name
can be either a string or a number, it really doesn't matter as long as it's unique.The way this new setup works is that now there's an
addRoom
function which simply instantiates a room and stores it inside a table. Then thegotoRoom
function, instead of instantiating a new room every time, can now look in that table to see if a room already exists, if it does, then it just retrieves it, otherwise it creates a new one from scratch.Another difference here is the use of the
activate
anddeactivate
functions. Whenever a room already exists and you ask to go to it again by callinggotoRoom
, first the current room is deactivated, the current room is changed to the target room, and then that target room is activated. These calls are useful for a number of things like saving data to or loading data from disk, dereferencing variables (so that they can get collected) and so on.In any case, what this new setup allows for is for rooms to be persistent and to remain in memory even if they aren't active. Because they're always being referenced by the
rooms
table, whenevercurrent_room
changes to another room, the previous one won't be garbage collected and so it can be retrieved in the future.Let's look at an example that would make good use of this new system, this time with The Binding of Isaac:
Watch the first minute or so of this video. I'm going to skip over the menus and stuff this time and mostly focus on the actual gameplay. It consists of moving from room to room killing enemies and finding items. You can go back to previous rooms and those rooms retain what happened to them when you were there before, so if you killed the enemies and destroyed the rocks of a room, when you go back it will have no enemies and no rocks. This is a perfect fit for this system.
The way I'd setup things would be to have a
Room
room where all the gameplay of a room happens. And then a generalGame
room that coordinates things at a higher level. So, for instance, inside theGame
room the level generation algorithm would run and from the results of that multipleRoom
instances would be created with theaddRoom
call. Each of those instances would have their unique IDs, and when the game starts,gotoRoom
would be used to activate one of those. As the player moves around and explores the dungeon furthergotoRoom
calls would be made and already createdRoom
instances would be activated/deactivated as the player moves about.One of the things that happens in Isaac is that as you move from one room to the other there's a small transition that looks like this:
I didn't mention this in the Nuclear Throne example either, but that also has a few transitions that happen in between rooms. There are multiple ways to approach these transitions, but in the case of Isaac it means that two rooms need to be drawn at once, so using only one
current_room
variable doesn't really work. I'm not going to go over how to change the code to fix this, but I thought it'd be worth mentioning that the code I provided is not all there is to it and that I'm simplifying things a bit. Once I get into the actual game and implement transitions I'll cover this is more detail.Room Exercises
44. Create three rooms:
CircleRoom
which draws a circle at the center of the screen;RectangleRoom
which draws a rectangle at the center of the screen; andPolygonRoom
which draws a polygon to the center of the screen. Bind the keysF1
,F2
andF3
to change to each room.45. What is the closest equivalent of a room in the following engines: Unity, GODOT, HaxeFlixel, Construct 2 and Phaser. Go through their documentation and try to find out. Try to also see what methods those objects have and how you can change from one room to another.
46. Pick two single player games and break them down in terms of rooms like I did for Nuclear Throne and Isaac. Try to think through things realistically and really see if something should be a room on its own or not. And try to specify when exactly do
addRoom
orgotoRoom
calls would happen.47. In a general way, how does the garbage collector in Lua work? (and if you don't know what a garbage collector is then read up on that) How can memory leaks happen in Lua? What are some ways to prevent those from happening or detecting that they are happening?
Areas
Now for the idea of an
Area
. One of the things that usually has to happen inside a room is the management of various objects. All objects need to be updated and drawn, as well as be added to the room and removed from it when they're dead. Sometimes you also need to query for objects in a certain area (say, when an explosion happens you need to deal damage to all objects around it, this means getting all objects inside a circle and dealing damage to them), as well as applying certain common operations to them like sorting them based on their layer depth so they can be drawn in a certain order. All these functionalities have been the same across multiple rooms and multiple games I've made, so I condensed them into a class calledArea
:The idea is that this object will be instantiated inside a room. At first the code above only has a list of potential game objects, and those game objects are being updated and drawn. All game objects in the game will inherit from a single
GameObject
class that has a few common attributes that all objects in the game will have. That class looks like this:The constructor receives 4 arguments: an
area
,x, y
position and anopts
table which contains additional optional arguments. The first thing that's done is to take this additionalopts
table and assign all its attributes to this object. So, for instance, if we create aGameObject
like thisgame_object = GameObject(area, x, y, {a = 1, b = 2, c = 3})
, the linefor k, v in pairs(opts) do self[k] = v
is essentially copying thea = 1
,b = 2
andc = 3
declarations to this newly created instance. By now you should be able to understand how this works, if you don't then read up more on the OOP section in the past article as well as how tables in Lua work.Next, the reference to the area instance passed in is stored in
self.area
, and the position inself.x, self.y
. Then an ID is defined for this game object. This ID should be unique to each object so that we can identify which object is which without conflict. For the purposes of this game a simple UUID generating function will do. Such a function exists in a library called lume inlume.uuid
. We're not going to use this library, only this one function, so it makes more sense to just take that one instead of installing the whole library:I place this code in a file named
utils.lua
. This file will contain a bunch of utility functions that don't really fit anywhere. What this function spits out is a string like this'123e4567-e89b-12d3-a456-426655440000'
that for all intents and purposes is going to be unique.One thing to note is that this function uses the
math.random
function. If you try doingprint(UUID())
to see what it generates, you'll find that every time you run the project it's going to generate the same IDs. This problem happens because the seed used is always the same. One way to fix this is to, as the program starts up, randomize the seed based on the time, which can be done like thismath.randomseed(os.time())
.However, what I did was to just use
love.math.random
instead ofmath.random
. If you remember the first article of this series, the first function called in thelove.run
function islove.math.randomSeed(os.time())
, which does exactly the same job of randomizing the seed, but for LÖVE's random generator instead. Because I'm using LÖVE, whenever I need some random functionality I'm going to use its functions instead of Lua's as a general rule. Once you make that change in theUUID
function you'll see that it starts generating different IDs.Back to the game object, the
dead
variable is defined. The idea is that wheneverdead
becomes true the game object will be removed from the game. Then an instance of theTimer
class is assigned to each game object as well. I've found that timing functions are used on almost every object, so it just makes sense to have it as a default for all of them. Finally, the timer is updated on theupdate
function.Given all this, the
Area
class should be changed as follows:The update function now takes into account the
dead
variable and acts accordingly. First, the game object is update normally, then a check to see if it's dead happens. If it is, then it's simply removed from thegame_objects
list. One important thing here is that the loop is happening backwards, from the end of the list to the start. This is because if you remove elements from a Lua table while moving forward in it it will end up skipping some elements, as this discussion shows.Finally, one last thing that should be added is an
addGameObject
function, which will add a new game object to theArea
:It would be called like this
area:addGameObject('ClassName', 0, 0, {optional_argument = 1})
. Thegame_object_type
variable will work like the strings in thegotoRoom
function work, meaning they're names for the class of the object to be created._G[game_object_type]
, in the example above, would parse to theClassName
global variable, which would contain the definition for theClassName
class. In any case, an instance of the target class is created, added to thegame_objects
list and then returned. Now this instance will be updated and drawn every frame.And that how this class will work for now. This class is one that will be changed a lot as the game is built but this should cover the basic behavior it should have (adding, removing, updating and drawing objects).
Area Exercises
48. Create a
Stage
room that has anArea
in it. Then create aCircle
object that inherits fromGameObject
and add an instance of that object to theStage
room at a random position every 2 seconds. TheCircle
instance should kill itself after a random amount of time between 2 and 4 seconds.49. Create a
Stage
room that has noArea
in it. Create aCircle
object that does not inherit fromGameObject
and add an instance of that object to theStage
room at a random position every 2 seconds. TheCircle
instance should kill itself after a random amount of time between 2 and 4 seconds.50. The solution to exercise 1 introduced the
random
function. Augment that function so that it can take only one value instead of two and it should generate a random real number between 0 and the value on that case (when only one argument is received). Also augment the function so thatmin
andmax
values can be reversed, meaning that the first value can be higher than the second.51. What is the purpose of the
local opts = opts or {}
in theaddGameObject
function?BYTEPATH on Steam
Tutorial files
The text was updated successfully, but these errors were encountered: