In the games I am writing in Love2D, I tend to use various sorts of “managers” to cache things for me.
One of the more notable managers I use is an image repository. I want to simply do the following:
--loads the library
local images = require "game.images"
--caches the images
images.load() -- during love.load
--retrieves an image
local img = images.get("imageName") -- during love.draw
Typically, I don’t lazy load my images, but I could easily configure my images.lua file to do so.
I find myself faced with a number of these managers, however. I’ll have one for images, one for sound effects, one for music, one for options, one for image regions, and so on.
What I like to use for this is a meta-manager called a bootstrapper, which each of my managers will automatically add themselves to, and then during love.load, I only have to call bootstrapper.load().
So let us overengineer this solution. I’ve got my current implementation for bootstrapper:
local bootstrapper = {}
local loaders={}
function bootstrapper.add(loader)
assert(type(loader)=="function","bootstrapper.add - loader must be a function")
--TODO: idempotency?
table.insert(loaders,loader)
end
function bootstrapper.load()
for _,v in ipairs(loaders) do
v()
end
end
return bootstrapper
And I want to add a few more bits of functionality:
- I only want calls to bootstrapper.add to work before a call to bootstrapper.load.
- I want to enforce only one call to bootstrapper.load.
- I want calls to bootstrapper.add to be idempotent, as in my TODO
- Do unit tests
- Make bootstrapper use a proxy object, to keep it from being subverted by client code
If that all seems like a bit of overkill for something like a bootstrapper, then you are going to not like this series of articles.
The changes to the main part of the code are minor:
--bootstrapper library object
--initialize to empty object, as is my habit
local bootstrapper = {}
--static data for this library is hidden from the outside
--starts as empty table
--gets changed to nil after a call to bootstrapper.load
local loaders={}
--bootstrapper.add
--adds a function to the bootstrapper
--requires that the loader parameter is a function
--will throw an exception when called after bootstrapper.load
function bootstrapper.add(loader)
assert(type(loader)=="function","bootstrapper.add - loader must be a function")
assert(type(loaders)=="table","bootstrapper.add - cannot call after bootstrapper.load hase been called")
--idempotency...
--if the loader already exists in loaders, don't re-add it and just return
for _,v in ipairs(loaders) do
if v == loader then
return
end
end
table.insert(loaders,loader)
end
function bootstrapper.load()
assert(type(loaders)=="table","bootstrapper.load - cannot call multiple times")
--perform the load actions
for _,v in ipairs(loaders) do
v()
end
--eliminate loaders to keep from being re-called
loaders=nil
end
I decided to make use of the static variable loaders instead of having a separate boolean flag for whether or not load has been called. There was no reason to hold on to that array after the call anyway.
Unit tests were a wholly different thing. Firstly, I don’t always want to run unit tests. I do when I’m developing, but not in release code. Of course, the scope of my activity today does not include any sort of unit testing framework, so I decided to at least mock out what I needed, with a plan to augment it later.
--runTests library
--This will eventually be a require that returns a function, so that I can turn off unit tests in one place
local runTests = function(f) f() end
runTests(function()
--save off old static state
local oldLoaders = loaders
loaders = {}
local start = os.clock()
--bootstrapper.add with non-function
local status, err = pcall(function() bootstrapper.add(nil) end)
assert(not status)
local counter = 0
local called1 = false
local called2 = false
function f1()
called1 = true
counter = counter + 1
end
function f2()
called2 = true
counter = counter + 1
end
--bootstrapper.add with function
bootstrapper.add(f1)
assert(#loaders==1)
--bootstrapper.add with same function
bootstrapper.add(f1)
assert(#loaders==1)
--bootstrapper.add with second function
bootstrapper.add(f2)
assert(#loaders==2)
--bootstrapper.load
bootstrapper.load()
assert(loaders==nil)
assert(called1)
assert(called2)
assert(counter==2)
--bootstrapper.add after load
status, err = pcall(function() bootstrapper.add(f1) end)
assert(not status)
--bootstrapper.load after load
status, err = pcall(function() bootstrapper.load() end)
assert(not status)
local elapsed = os.clock()-start
print(string.format("bootstrapper unit tests - %f",elapsed))
--restore original static state
loaders = oldLoaders
end)
I set up my runTests function to call whatever function is passed to it. Eventually, this can be a library that I load instead, and I can configure that library to either run or not run the functions it passed depending on whether or not I am in “release” mode.
Before you go out and comment something about “pcall is inefficient!”, let me give you the results of these unit tests on each run of my application:
That’s right… the unit tests do not take up enough time to actually register time passing.
Finally, I make a protected proxy using a metatable:
local mt = {
__index=function (t,k)
return bootstrapper[k]
end,
__newindex=function(t,k,v) end,
__metatable="bootstrapper"
}
local proxy = {}
setmetatable(proxy,mt)
return proxy
From what I read, this is all I need to do in order to protect bootstrapper from casual changes from outside code.
It occurs to me that this is going to be a common pattern I want to use, and so a more generic version of this code is likely to wind up in a utility library.
So, this concentration on making a robust bootstrapper seems to fly in the face of how I normally develop code. Almost invariably, I will recommend that one writes exactly the code that one needs, and once it is “good enough” then stop.
Actually, that is exactly what I am doing. The main difference is that this code has been deemed useful enough (by me) to be part of the framework I use in other games going forward, which means it needs to be library code – as bulletproof as I can make it.