#4: Bootstrapping, menu transitions and level loading
There are 6 main screens in Hook: the title screen, level select, gameplay, aquarium, settings and about.
Each of these is a separate .collection file in Defold with an additional main.collection acting as the bootstrapper that loads and enables/disables all the others. For localization purposes there are different settings screen and title screen collections for each language (settings_english.collection, title_french.collection etc.) but that can wait for a post about localization. For the purposes of this post there are 7 collections: main plus one for each screen.
First, a disclaimer
I don’t know what I’m doing. This is my first project in Defold and a lot of very important project and code structure decisions were made under extreme time constraints during the first few weeks of the project (see devlog #1). It’s very likely I’m doing some really weird stupid stuff which isn’t necessarily a good idea you should copy. So long as it works I really don’t care. Also my game and especially my assets are tiny so I can get away with basically whatever I want without having to worry about performance.
So, what happens on startup?
The bootstrap main collection in the project settings is my aptly named main.collection. This contains a game object called state_controller which has a collection proxy for every other collection and the also aptly named state_controller.script.
The relevant bits from state_controller.script look something like this:
function init(self)
load_save_data()
lang_suffix = language_suffix(g_save_data.language)
g_current_state = g_STATE_UNINITIALIZED
self.num_proxies_loading = 6
self.startup_load = true
self.load_pause = 0
msg.post("#proxy_title"..lang_suffix, "load")
msg.post("#proxy_level_select", "load")
msg.post("#proxy_game", "load")
msg.post("#proxy_aquarium", "load")
msg.post("#proxy_about", "load")
msg.post("#proxy_settings"..lang_suffix, "load")
end
function update(self, dt)
if self.load_pause > 0 then
self.load_pause = self.load_pause - dt
if self.load_pause <= 0 then
change_state(g_STATE_INTRO_CLOUDS)
end
end
end
function on_message(self, message_id, message, sender)
if message_id == hash("proxy_loaded") then
msg.post(sender, "enable")
if g_current_state == g_STATE_SETTINGS and string.find(""..sender, "settings") then
begin_settings_state("language")
else
msg.post(sender, "disable")
end
if self.startup_load == true then
self.num_proxies_loading = self.num_proxies_loading - 1
if self.num_proxies_loading <= 0 then
self.startup_load = false
self.load_pause = 0.01
end
end
end
end
Since you can change your language in the settings menu the current language is stored in the save data. The load_save_data()
function will either load an existing save file or populate a new save file with the current system language.
The key thing is that the other six proxies all begin loading right at startup and are never unloaded. My game and assets are so small that memory is not even remotely an issue, so the only loading ‘hitch’ should be right here on startup and then every menu transition at runtime should be seamless. In reality these loads are near instantaneous anyway. Making incredibly low resolution pixel art games has its advantages.
As each collection finishes loading we enable then immediately disable it, just to flush all the game object state properly. Since each version of the settings screen in each language is a different collection, changing the language triggers some proxy loading and we don’t want to disable the settings menu if we’re in there currently. Once everything has loaded there is an additional brief pause to let everything settle before kicking off the intro/startup cloud pan cinematic and transitioning to the title screen.
Changing the current state/menu
The gameplay and title screen rest at the ocean surface while the other screens reside underwater. This gives a natural, simple and visually pleasing menu transition of simply panning the camera up or down, but does restrict which screens you can move between. For example you cannot go directly from settings to about (you have to go up to the title first), or directly from the game to the title (you must go down to level select). A side benefit here is that we can safely make some assumptions, for example we know that if we’re transitioning to the level select screen we must be coming from the surface. Some systems such as the camera pan don’t need to know or care whether we’re coming from gameplay or the title screen, it’s just surface to underwater.
Incidentally yes, the underwater parts of the game really are just happening several hundred pixels below the surface. The change_state
function of state_controller.script manages acquiring and releasing input focus, enabling the collection we’re transitioning to, setting up the collection if necessary (through an “entering” message or similar) and triggering the camera pan itself. Once the pan completes we disable the collection that we just left.
function change_state(new_state, level_num)
local to_disable = nil
if g_current_state == g_STATE_LEVEL_SELECT then
to_disable = "main:/state_controller#proxy_level_select"
msg.post("level_select:/controller", "release_input_focus")
elseif g_current_state == g_STATE_GAME then
-- ...etc...
end
g_current_state = new_state
if g_current_state == g_STATE_LEVEL_SELECT then
msg.post("main:/state_controller#proxy_level_select", "enable")
msg.post("level_select:/controller", "acquire_input_focus")
msg.post("level_select:/controller", "entering")
start_cam_move(CAM_Y_SURFACE, CAM_Y_UNDERWATER, to_disable)
elseif g_current_state == g_STATE_GAME then
-- ...etc...
end
end
Level previews on the level select screen
Each level is just a plain tilemap. There’s a level.go game object with every tilemap attached and a factory for each type of game object a level can consist of (fish, seaweed etc). This level.go object is used both in the gameplay collection and the level select screen collection.
Whenever you change the currently selected level the generate_minimap
function of level_select.script updates the preview by spawning a set of sprites to represent the level tilemap. It was a lot of fun trying to represent things in 8x8 sprites, then fun again to try and boil those down to 3x3. Here are a few:
At some point I’ll update the following code to have a fixed array of sprites that are reused and enabled/disabled as needed, but since a lot of tiles are empty space it was quicker to just throw them all away and only spawn in the ones that are needed. Major time constraints therefore inefficient code and magic numbers blah blah here’s the code:
local function generate_minimap(self)
go.delete_all(self.minimap_tiles)
self.minimap_tiles = {}
local tile, tx, ty, id = nil, 0, 0, nil
local pos, anim = vmath.vector3(0, 0, 0), ""
for x = 1, 16 do
for y = -11, 0 do
tile = tilemap.get_tile("tilemap_parent"..get_tilemap_name(self.cursor), "layer1", x, y)
pos.x = 71 + x * 3
pos.y = Y_OFFSET - 5 + y * 3
if tile ~= 0 then
id = factory.create("#factory_minimap", pos)
table.insert(self.minimap_tiles, id)
anim = "coral"
for i = 1, #g_SPAWN_TILES do
if tile == g_SPAWN_TILES[i].tile then
anim = g_SPAWN_TILES[i].type
break
end
end
msg.post(id, "play_animation", { id = hash(anim) })
end
end
end
end
So, what happens on level load?
When you actually load a level the tilemap is iterated across and whenever a tile that should spawn a gameobject is encountered (e.g. a fish) that gameobject is spawned from a factory and the tile in the tilemap is set to 0 (blank). This is because the tilemap is referred to by gameplay logic for collision checks with the walls. When the level is unloaded any edited tiles in the tilemap are reset to their original values. Here’s a very simplified version of the level loading and unloading functions:
g_SPAWN_TILES = {
[1] = { tile = 27, type = "crab" },
[2] = { tile = 25, type = "fish" },
-- ...etc...
}
local function unload_level()
tilemap_name = get_tilemap_name(g_level_num)
for i = 1, #reset_tiles do
tilemap.set_tile(tilemap_name, "layer1", reset_tiles[i].x, reset_tiles[i].y, reset_tiles[i].original)
end
reset_tiles = {}
for i = 1, #g_creatures do
go.delete(g_creatures[i].id, true)
end
g_creatures = {}
collectgarbage()
end
local function load_level(level_num)
unload_level()
-- Only enable the tilemap for the current level
local tilemap_name = nil
for i = 1, g_NUM_TILEMAPS do
tilemap_name = get_tilemap_name(i)
msg.post(tilemap_name, "disable")
end
tilemap_name = get_tilemap_name(level_num)
msg.post(tilemap_name, "enable")
-- Loop through the tilemap and spawn creatures
local tile, fact, type, anim, frame, new_id
local prev_creature = nil
for x = 1, 16 do -- left to right
prev_creature = nil
for y = -11, 0 do -- bottom to top
tile = tilemap.get_tile(tilemap_name, "layer1", x, y)
fact = nil
type = nil
for spawn = 1, #g_SPAWN_TILES do
if tile == g_SPAWN_TILES[spawn].tile then
type = g_SPAWN_TILES[spawn].type
fact = "#factory_"..type
frame = tile - g_SPAWN_TILES[spawn].tile
anim = type..frame
break
end
end
if fact == nil then
prev_creature = nil
else
new_id = factory.create(fact, vmath.vector3(8 * (x - 1), 8 * y, -go.get_position().z))
msg.post(new_id, "set_parent", { parent_id = go.get_id(), keep_world_transform = 0 })
msg.post(new_id, "play_animation", { id = hash(anim) })
table.insert(g_creatures, { id = new_id, type = type, frame = frame, removed = false })
tilemap.set_tile(tilemap_name, "layer1", x, y, 0)
table.insert(reset_tiles, { x = x, y = y, original = tile })
if type == "squid" then
add_ink(g_creatures[#g_creatures], 5)
elseif type == "sting" then
g_creatures[#g_creatures].marked_for_removal = false
if prev_creature ~= nil and prev_creature.type == "sting" then
prev_creature.above = g_creatures[#g_creatures]
g_creatures[#g_creatures].below = prev_creature
end
elseif type == "sponge" then
-- ...etc...
end
prev_creature = g_creatures[#g_creatures]
end
end
end
end
By now you may have realized that some creatures such as jellyfish are actually composites. The root of the jellyfish is one creature, then each tile of stingers is a separate independent ‘creature’ as this makes it easy to have jellies with any number of stingers. However there is some behaviour that groups them together, for example cutting a stinger will also remove all the stingers attached below it. This is why the level is parsed vertically instead of horizontally and why I track the previously spawned creature; jellyfish stingers act as a doubly-linked list to make cutting them easier.
As always you can follow development of the game on Twitter @rhythm_lynx