#04: Bootstrapping, menu transitions and level loading


This post was originally written in April 2018. At the time the game was still called Hook, Line and Thinker

There are 6 main screens in Hook: the title screen, level select, gameplay, aquarium, settings and about.

Transitions

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:

Example tiles

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.

Follow me on itch or Twitter to see what I work on next

Leave a comment

Log in with itch.io to leave a comment.