I would like to be able to have a chunk of Lua code (a "script") that could be shared among enemy types in a game but where each instance of a script gets a unique execution environment. To illustrate my problem, this is my first attempt at what a script might look like:
time_since_last_shoot = 0
tick = function(entity_id, dt)
time_since_last_shoot = time_since_last_shoot + dt
if time_since_last_shoot > 10 then
enemy = find_closest_enemy(entity_id)
shoot(entity_id, enemy)
time_since_last_shoot = 0
end
end
But that fails since I'd be sharing the global time_since_last_shoot variable among all my enemies. So then I tried this:
spawn = function(entity)
entity.time_since_last_shoot = 0;
end
tick = function(entity, dt)
entity.time_since_last_shoot = entity.time_since_last_shoot + dt
if entity.time_since_last_shoot > 10 then
enemy = find_closest_enemy(entity)
shoot(entity, enemy)
entity.time_since_last_shoot = 0
end
end
And then for each entity I create a unique table and then pass that as the first argument when I call the spawn and tick functions. And then somehow map that table back to an id at runtime. Which could work, but I have a couple concerns.
First, it's error prone. A script could still accidentally create global state that could lead to difficult to debug problems later in the same script or even others.
And second, since the update and tick functions are themselves global, I'll still run into issues when I go to create a second type of enemy which tries to use the same interface. I suppose I could solve that with some kind of naming convention but surely there's a better way to handle that.
I did find this question which seems to be asking the same thing, but the accepted answer is light on specifics and refers to a lua_setfenv function that isn't present in Lua 5.3. It seems that it was replaced by _ENV, unfortunately I'm not familiar enough with Lua to fully understand and/or translate the concept.
[edit] A third attempt based on the suggestion of @hugomg:
-- baddie.lua
baddie.spawn = function(self)
self.time_since_last_shoot = 0
end
baddie.tick = function(self, dt)
entity.time_since_last_shoot = entity.time_since_last_shoot + dt
if entity.time_since_last_shoot > 10 then
enemy = find_closest_enemy(entity)
shoot(entity, enemy)
entity.time_since_last_shoot = 0
end
end
And in C++ (using sol2):
// In game startup
sol::state lua;
sol::table global_entities = lua.create_named_table("global_entities");
// For each type of entity
sol::table baddie_prototype = lua.create_named_table("baddie_prototype");
lua.script_file("baddie.lua")
std::function<void(table, float)> tick = baddie_prototype.get<sol::function>("tick");
// When spawning a new instance of the enemy type
sol::table baddie_instance = all_entities.create("baddie_instance");
baddie_instance["entity_handle"] = new_unique_handle();
// During update
tick(baddie_instance, 0.1f);`
This works how I expected and I like the interface but I'm not sure if it follows the path of least surprise for someone who might be more familiar with Lua than I. Namely, my use of the implicit self parameter and my distinction between prototype/instance. Do I have the right idea or have I done something weird?
The way _ENV works in 5.3 is that global variable are "syntactic" sugar for reading fields from the _ENV variable. For example, a program that does
local x = 10
y = 20
print(x + y)
is equivalent to
local x = 10
_ENV.y = 20
_ENV.print(x + _ENV.y)
By default, _ENV is a "global table" that works like you would expect global variables to behave. However, if you create a local variable (or function argument) named _ENV then in that variable's scope any unbound variables will point to this new environment instead of point to the usual global scope. For example, the following program prints 10:
local _ENV = {
x = 10,
print=print
}
-- the following line is equivalent to
-- _ENV.print(_ENV.x)
print(x)
In your program, one way to use this technique would be to add an extra parameter to your functions for the environment:
tick = function(_ENV, entity, dt)
-- ...
end
then, any global variables inside the function will actually just be accessing fields in the _ENV parameter instead of actually being global.
That said, I'm not sure _ENV is the best tool to solve your problem. For your first problem, of accidentally creating globals, a simpler solution would be to use a linter to warn you if you assign to an undeclared global variable. As for the second problem, you could just put the update and tick functions in a table instead of having them be global.