So just like everyone else, I too have been working on my own 2D game for a few months now. Partially to practice with C++ meta-programming and partially for fun (although
SFINAE is lots of fun too). Also, I wanted to create a complete game for once and I think chances are good that it will happen this time! (well, some time)
I'd like to show you how I handled scripting. As the title suggests, the language I picked is Lua...
LuaJIT 5.1 to be precise.
For starters, entities. From Lua's point of view, there are only two types of entities: characters and cameras. They share some common behavior (such as movement) but obviously cameras are composed of less components.
While default values for entity attributes are defined in an external JSON file, most of them can be easily overriden by Lua at runtime. Attributes can be accessed and modified with the use of a dot operator, for example:
player.x = 100 -- sets player's X coordinate to 100
local new_y = player.y + 100
player.y = new_y -- increases player's Y coordinate by 100
print(player.is_moving) -- prints out whether player is currently moving
There are obviously lots of attributes I can get and set, including entity's collision box offset, current direction or even whether its emitting light.
Methods are used purely for actions an entity can do, such as movement or talking. They can be chained together in order to create a process queue, such that one action will take place after another in a given sequence, for example:
player:move(player.x, player.y + 200)
:wait(1000)
:move(player.x + 100, player.y + 200) -- will first move south, then wait one second, then move east
Each entity has two process queues: one for primary and one for secondary processes. Some behaviors, such as moving or waiting are
primary, meaning that if I chain
move and
wait,
wait will happen after
move finishes. However, actions such as talking are
secondary, so that they can happen simultaneously with primary actions, for example:
player:move(player.x + 150, player.y)
:talk("hello")
:move(player.x, player.y) -- will move east and say text of ID "hello" at the same time, afterwards will move back immediately regardless of whether it's still talking
To make it more complete, entities can also react to certain events that happen to them, such as collision or being triggered by another entity. For example:
player.on_collided = function(entity_id)
print("Collided with entity of ID " .. id) -- will print on collision
end
It's also possible to set callbacks for responding to whenever a process starts or ends, for example:
player.on_movement_ended = function(complete)
if complete then
player:play_sound("movement_complete.ogg") -- will play sound if movement process completed successfully
end
end
This allows for creating complex chains of actions for each entity individually and it's what is needed most of the time. However, the problem arises when creating a cutscene and trying to make one entity do something after another has finished (for example, make
entity2 say something after player has moved).
Though this could be achieved with callbacks to
on_movement_ended and similar, it would be awfully inconvenient to write all of that boilerplate code every time just to show a simple cutscene. Not to mention that if there was already a callback stored, it would have had to be saved first and then re-set after the cutscene. This is why I created a simple convenience object, simply called
Cutscene, for situations just like these. In addition to coordinating actions between different entities, it also disables player's controls, darkens the display and ensures that only one cutscene can be played at any given time.
Cutscene:play(
{ Action.Move, player, player.x, player.y + 300 },
{ Action.Talk, entity2, "hello-player" },
{ Action.Wait, entity2, 1000 },
function() -- I can add arbitrary functions too
camera.x = player.y
camera.y = player.y
camera:record()
end,
{ Action.Move, camera, entity2.x, entity2.y },
{ Action.Move, entity2, entity2.x - 200, entity2.y }
)
There are also ways to interact with display, audio and interface directly from Lua, for example:
Audio:play_music("theme-music.ogg", true) -- will play theme music, 'true' will do a fade-in effect
Display.post_processing = PostProcessing.Sepia -- this is why all my gifs are in sepia :)
Interface:show_message("hello") -- will show a pop-up for user to confirm
There are obviously more methods and attributes I can set for devices, though perhaps I will talk about them another time.
At last, there are two more things I wanted to show:
event dispatching and
save data store.
When writing demo scripts I quickly realized that anything more complex than a very simple game would lead to a lot of coupling and direct dependencies between various parts of the script. That's especially true if action of one entity should cause some other entities to react accordingly. So instead of using global variables and functions all over the codebase, I decided to use an observer pattern by implementing a centralized event dispatcher.
local function callback(what)
print("Something interesting happened " .. what)
end
Event:subscribe(event.SOMETHING_INTERESTING_HAPPENED, callback) -- registers the callback
...
-- somewhere else in the code; will notify all subscribers registered for this type of event
local subscribers_called_count = Event:raise(event.SOMETHING_INTERESTING_HAPPENED, "some interesting info")
As far as saving data goes, I wanted to make it very straightforward. There is a globally accessible save data store, which is basically a Lua table that provides a getter and setter for key-value pairs. I can read and write from/to the store as many times as I want but the data is only written to the disk when changes are actually commited (via an explicit method call). So if I want to operate on a value that should be persisent across play sessions, I should read it and write it from/to the save object directly.
-- retrieves progress from save; if nil, provided default value will be returned instead
local story_progress = Save:get(KEY_STORY_PROGRESS, DEFAULT_STORY_PROGRESS)
if story_progress == STORY_PROGRESS_BEGINNING then
show_some_intro_cutscene()
Save:put(KEY_STORY_PROGRESS, STORY_PROGRESS_EARLY_GAME) -- updates progress
Save:commit() -- writes current contents of save to disk
end
As I was playing around with demo scripts, I realized that most of the times the type of data I was writing to save file were characters' attributes. Moreover, as characters are usually very dynamic and their states change frequently, I needed a way to easily update values associated with entities whenever the data was written to the disk. At first I tried with functions that would serialize each entity and add it to the save file. The problem was, I needed to call these functions every time before calling the
commit method. This was not only error-prone but created unnecessary coupling between various parts of the code. Eventually I decided to implement a method that would register an entity and then serialize it and save automatically whenever
commit method was called.
Save:register_character(KEY_PLAYER, player)
...
-- somewhere else in the code
Save:commit() -- player's entity data within the save is automatically updated before changes are written to the disk
Loading a character from save can be accomplished with a single method call too.
Save:read_character(KEY_PLAYER, player) -- loads player's state from save data
That's all for now. I hope the post wasn't too boring and that perhaps some approaches presented here will be useful to other projects too.
Oh, and one last thing - I didn't do the art. I mean, I wish I could draw like that, but for the time being I'm focused exclusively on the programming side. The sprites and textures come from
Lost Garden and
OpenGameArt.
Thanks for attention!