Метатаблицы и паттерны классов Lua в Build 42
Метатаблицы и паттерны классов Lua в Build 42
Руководство учит разбираться в классах игры вроде ISPanel, ISBaseTimedAction и LuaList, создавать свои классы по паттерну ISBaseObject, наследовать и вызывать методы родителя, а также использовать metamethods Kahlua. Страница указывает Build 42 без точной минорной версии; утверждения о VM требуют проверки для целевой сборки.
В Lua нет классов — есть метатаблицы
В Lua нет ключевого слова class. Метатаблица — обычная таблица, которая меняет поведение другой таблицы с помощью специальных ключей, например __index, __tostring и __add. Эти ключи называют metamethods. setmetatable назначает метатаблицу и возвращает ту же таблицу. Её можно установить только для таблицы Lua, но не для Java-объектов вроде IsoPlayer или предмета.
local datos = { nombre = "sopaipilla" }
local meta = {}
setmetatable(datos, meta) -- desde ahora, meta es la metatabla de datos
print(getmetatable(datos) == meta) -- true
local datos = setmetatable({ nombre = "sopaipilla" }, meta)
__index: где искать отсутствующее поле
При чтении отсутствующего поля Lua проверяет __index. Если там таблица, поиск продолжается в ней; уже существующее поле экземпляра всегда имеет приоритет. Запись не использует __index: она создаёт поле в экземпляре. Поиск может идти дальше по цепочке — это основа наследования. __index также может быть функцией (таблица, ключ); автор приводит пример client/ISUI/ISUIElementJoypad.lua, где поиск проходит через несколько классов.
local valoresPorDefecto = { hambre = 10, peso = 0.2 }
local comida = setmetatable({ peso = 0.5 }, { __index = valoresPorDefecto })
print(comida.peso) -- 0.5 (está en comida)
print(comida.hambre) -- 10 (no está: lo saca de valoresPorDefecto)
print(comida.sabor) -- nil (no está en ninguna)
setmetatable(o, {
__index = function (table, key)
local result;
if NewClass then
result = rawget(NewClass, key);
if result then
return result;
end
end
result = rawget(ISUIElementJoypad, key);
if result then
return result;
elseif Class then
return Class[key];
end
return nil;
end,
});
Внутри функции __index используйте rawget(t, k), чтобы читать поле без повторного вызова __index и не зациклить поиск.
Двоеточие и self
При объявлении функции : добавляет self как первый параметр. При вызове : передаёт объект первым аргументом. self не ключевое слово, а обычное имя параметра. Если вызвать методом . функцию, объявленную через :, self может получить не тот аргумент.
function Contador.sumar(self, k) self.n = self.n + k end
function Contador:sumar(k) self.n = self.n + k end -- el ":" agrega self como primer parámetro
c.sumar(c, 5)
c:sumar(5) -- el ":" pasa c como primer argumento
c.sumar(5) -- self = 5, k = nil -> error al hacer self.n
Статья предупреждает о регистрации метода с : как обработчика события: если событие передаёт зомби первым аргументом, он попадёт в self. В примере FightStep ошибки нет, потому что функция меняет таблицу FightStep напрямую и не использует параметры. Если бы она выполняла self.momDead, запись затронула бы переданный Java-объект, а не таблицу класса. Чтобы событие вызвало метод конкретного экземпляра, автор показывает closure, которая захватывает self, сохраняет callback и затем снимает его.
function FightStep:OnMomDead(zed)
Events.OnZombieDead.Remove(FightStep.OnMomDead);
FightStep.momDead = true;
end
function FishingManager:eventHooks()
self.onTickFunc = function()
self:update()
end
Events.OnTick.Add(self.onTickFunc)
end
function FishingManager:removeEventHooks()
Events.OnTick.Remove(self.onTickFunc)
end
Паттерн игровых классов: derive и new
Описанный паттерн: класс — таблица с методами; каждый экземпляр — таблица с данными, метатаблица которой указывает на класс; __index класса указывает на него же. В shared/ISBaseObject.lua derive(type) создаёт пустую дочернюю таблицу, чья метатаблица — родительский класс, и задаёт Type и SuperType. new() создаёт экземпляр. Оба метода устанавливают self.__index = self.
ISBaseObject = {};
ISBaseObject.Type = "ISBaseObject";
function ISBaseObject:initialise()
end
function ISBaseObject:derive(type)
local o = {}
setmetatable(o, self)
self.__index = self
o.Type = type
o.SuperType = self
return o
end
function ISBaseObject:new()
local o = {}
setmetatable(o, self)
self.__index = self
o.SuperType = nil
return o
end
В качестве примера автор показывает shared/Util/LuaList.lua: derive создаёт класс, add и size — методы, а new создаёт экземпляр с отдельными таблицами items и отдельным счётчиком. При выражении lista:add("x") поиск add не находит метод в экземпляре, переходит к LuaList и вызывает LuaList.add(lista, "x"). Свойство Type приходит от класса; initialise может наследоваться из ISBaseObject.
LuaList = ISBaseObject:derive("LuaList");
function LuaList:add(item)
if self.debug == true then
print("add");
end
table.insert(self.items, item);
self.count = self.count + 1;
end
function LuaList:size()
return self.count;
end
function LuaList:new ()
local o = {}
setmetatable(o, self)
self.__index = self
o.items = {}
o.count = 0;
return o
end
Не размещайте в классе таблицу, которая должна принадлежать каждому экземпляру. Если создать LuaList.items один раз на уровне класса, все экземпляры будут получать одну и ту же таблицу через __index. Числа и строки могут быть значениями по умолчанию: при присваивании экземпляр создаёт собственное поле. Изменяемые таблицы экземпляра следует создавать в new.
-- MAL
LuaList.items = {}
function LuaList:new()
local o = ISBaseObject.new(self)
return o
end
local a = LuaList:new()
local b = LuaList:new()
table.insert(a.items, "sopaipilla")
print(#b.items) -- 1: a y b comparten LA MISMA tabla items
Первый класс курса CursoLab.Contador наследуется от ISBaseObject, создаёт n и history для каждого экземпляра и задаёт метод sumar. Вызов ISBaseObject.new(self) с точкой и self вручную создаёт экземпляр класса Contador. Статья сравнивает это с игровым кодом ISBaseTimedAction.new(self, character).
require "ISBaseObject"
CursoLab = CursoLab or {}
CursoLab.Contador = ISBaseObject:derive("CursoLabContador")
local Contador = CursoLab.Contador
function Contador:new(inicio)
local o = ISBaseObject.new(self) -- self es Contador (o una hija, ver sección 5)
o.n = inicio or 0
o.historial = {} -- tabla propia de cada instancia
return o
end
function Contador:sumar(k)
k = k or 1
self.n = self.n + k
table.insert(self.historial, k)
return self.n
end
local c = Contador:new(10)
c:sumar()
c:sumar(5)
print(c.n, #c.historial) -- 16 2
Наследование: ISPanel:derive и вызов родителя
Дочерний класс создаётся через derive родителя; метод derive наследуется. В примере ISPanel наследуется от ISUIElement. Чтобы вызвать метод родителя, Lua не использует super: назовите таблицу родителя, используйте точку и передайте self вручную, например ISUIElement.initialise(self). При вызове через двоеточие self стал бы классом ISUIElement.
ISPanel = ISUIElement:derive("ISPanel");
function ISPanel:initialise()
ISUIElement.initialise(self);
end
По примеру источника, ISPanel:new вызывает new родителя, затем задаёт экземпляру метатаблицу ISPanel. Если дочерний класс скопирует только первый вызов, экземпляр может получить метатаблицу родителя, а собственный render дочернего класса не найдётся; ошибка при этом может не появиться. Автор рекомендует вызывать ISPanel.new(self, x, y, w, h), передавая дочерний класс.
function ISPanel:new (x, y, width, height)
local o = {}
o = ISUIElement:new(x, y, width, height);
setmetatable(o, self)
self.__index = self
MiPanel = ISPanel:derive("MiPanel")
function MiPanel:new(x, y, w, h)
local o = ISPanel:new(x, y, w, h) -- MAL: self dentro de ISPanel:new es ISPanel
o.titulo = "Hola"
return o
end
function MiPanel:render()
self:drawText(self.titulo, 10, 10, 1, 1, 1, 1, UIFont.Small)
end
Пример ContadorConTope наследуется от Contador, вызывает родительский new с self дочернего класса, переопределяет sumar для ограничения значения и в остальных случаях вызывает метод родителя. Type хранит имя класса, SuperType — ссылку на родителя. По статье ISBaseObject:instanceof(obj, "Nombre") проходит по цепочке SuperType. Автор отличает этот метод от глобального instanceof для Java-объектов и приводит getmetatable(instance).Type как ещё один приём игры.
CursoLab.ContadorConTope = Contador:derive("CursoLabContadorConTope")
local ConTope = CursoLab.ContadorConTope
function ConTope:new(inicio, tope)
local o = Contador.new(self, inicio) -- el new del padre, con mi clase como self
o.tope = tope
return o
end
function ConTope:sumar(k) -- sobrescribe el método del padre...
if self.n >= self.tope then
return self.n -- ...cambia el comportamiento en mi caso...
end
return Contador.sumar(self, k) -- ...y en el resto llama al padre
end
local t = ConTope:new(0, 3)
for i = 1, 10 do t:sumar() end
print(t.n) -- 3
print(t.Type) -- CursoLabContadorConTope
print(t.SuperType == Contador) -- true
print(ISBaseObject:instanceof(t, "CursoLabContador")) -- true
print(instanceof(getPlayer(), "IsoPlayer")) -- true: este es el global, para objetos de Java
Metamethods: операторы для таблиц
Автор перечисляет поддерживаемые VM Kahlua metamethods: __newindex, __call, __add, __sub, __mul, __div, __mod, __pow, __unm, __concat, __eq, __lt, __le, __len, __tostring и __metatable. В игровом паттерне метатаблица экземпляра — сам класс, поэтому metamethods задаются в таблице класса. У Point2D из client/erosion/debug используется другой стиль: отдельная метатаблица и функции внутри new для каждого экземпляра, вызываемые через точку; цена этого подхода — новая функция для каждой точки.
Point2D = {};
Point2D.meta = {
__tostring = function (self) return tostring(self.x)..":"..tostring(self.y); end,
__add = function (self,targ) return self.Point2D:new(self.x + targ.x, self.y + targ.y); end,
__sub = function (self,targ) return self.Point2D:new(self.x - targ.x, self.y - targ.y); end,
__mul = function (self,targ) return self.Point2D:new(self.x * targ.x, self.y * targ.y); end,
__div = function (self,targ) return self.Point2D:new(self.x / targ.x, self.y / targ.y); end,
__unm = function (self) return self.Point2D:new(-self.x, -self.y); end,
__eq = function (self,targ) if self.x == targ.x and self.y == targ.y then return true; end return false; end,
Пример CursoLab.Vec задаёт сложение, вычитание, унарный минус, сравнение на равенство и строковое представление; затем строит вектор положения игрока и демонстрирует операции.
CursoLab.Vec = ISBaseObject:derive("CursoLabVec")
local Vec = CursoLab.Vec
function Vec:new(x, y)
local o = ISBaseObject.new(self)
o.x, o.y = x, y
return o
end
function Vec:largo()
return math.sqrt(self.x * self.x + self.y * self.y)
end
Vec.__add = function(a, b) return Vec:new(a.x + b.x, a.y + b.y) end
Vec.__sub = function(a, b) return Vec:new(a.x - b.x, a.y - b.y) end
Vec.__unm = function(a) return Vec:new(-a.x, -a.y) end
Vec.__eq = function(a, b) return a.x == b.x and a.y == b.y end
Vec.__tostring = function(v) return "(" .. v.x .. ", " .. v.y .. ")" end
local p = getPlayer()
local yo = Vec:new(p:getX(), p:getY())
local norte = Vec:new(0, -10)
print(yo + norte) -- usa __add y después __tostring
print(Vec:new(1, 2) == Vec:new(1, 2)) -- true gracias a __eq
print((yo - (yo + norte)):largo()) -- 10
Поведение Kahlua, которое статья приписывает KahluaThread и KahluaUtil: print/tostring используют __tostring, но сначала ищут через __index поле tostring (без подчёркиваний); если оно функция, вызывают её. Без __tostring таблица, по источнику, отображается с идентификатором и содержимым, а поле Type может заменить слово table. __eq вызывается только если у обеих таблиц есть метатаблица и одно и то же значение-функция __eq. Для __add и похожих операторов сначала проверяется первый операнд, затем второй; порядок аргументов остаётся исходным. __concat не использует __tostring, поэтому автор советует "Pos: " .. tostring(v). По статье, __len для таблиц игнорируется, а __mode не поддержан — слабых таблиц нет.
Metamethods не наследуются автоматически: VM проверяет их через rawget в метатаблице, не следуя __index. У экземпляра Vec3 метатаблица — Vec3, хотя обычные методы наследуются. Руководство копирует или переопределяет __tostring в Vec3 и присваивает ей ту же функцию __eq, что у Vec, чтобы сравнение работало между двумя классами.
CursoLab.Vec3 = Vec:derive("CursoLabVec3")
local Vec3 = CursoLab.Vec3
function Vec3:new(x, y, z)
local o = Vec.new(self, x, y)
o.z = z
return o
end
local v = Vec3:new(1, 2, 3)
print(v:largo()) -- funciona: los métodos normales sí se heredan por __index
print(tostring(v)) -- NO usa Vec.__tostring: Vec3 no tiene __tostring propio
Vec3.__tostring = function(v) return "(" .. v.x .. ", " .. v.y .. ", " .. v.z .. ")" end
Vec3.__eq = Vec.__eq -- la MISMA función, así un Vec y un Vec3 se pueden comparar
__call позволяет вызывать таблицу как функцию. Статья ссылается на UI._mt в Meta.lua и показывает CursoLab.log с флагом activo. Автор не советует помещать __call в ISBaseObject, чтобы сделать вызываемыми все классы: это изменило бы общую таблицу игры. Конвенция :new понятнее. __newindex вызывается при записи отсутствующего поля и может сообщать об изменении read-only конфигурации или опечатке.
UI._mt.__call = function(self, _args)
local res = setmetatable(UI._mergeTables(UI._copyValue(self), UI._copyValue(_args)), UI._mt)
res.super = self
return res
end
CursoLab.log = setmetatable({ activo = true }, {
__call = function(self, ...)
if not self.activo then return end
print("[CursoLab]", ...)
end,
})
CursoLab.log("hola", 42) -- [CursoLab] hola 42
CursoLab.log.activo = false -- y se apaga sin tocar el resto del mod
local CONFIG = { maxSopaipillas = 4 }
CursoLab.CONFIG = setmetatable({}, {
__index = CONFIG,
__newindex = function(t, k, v)
print("[CursoLab] CONFIG es de solo lectura, ignoré", k)
end,
})
print(CursoLab.CONFIG.maxSopaipillas) -- 4
CursoLab.CONFIG.maxSopaipilas = 10 -- mal escrito: avisa en vez de crear un campo nuevo
CursoLab: класс Temporizador
Пример объединяет класс Temporizador, дочерний Repetidor и отдельный OnTick handler для каждого экземпляра. Temporizador регистрирует closure, захватывающую self, рассчитывает следующий момент через getTimestampMs(), увеличивает счётчик vueltas, вызывает alSonar() и решает, продолжать ли работу; detener() снимает handler. Repetidor наследуется, переопределяет debeSeguir() и расширяет alSonar(). Полный фрагмент автора ниже; он зависит от ISBaseObject и CursoLab и не запускался.
-- shared/CursoLab_Temporizador.lua
require "ISBaseObject"
CursoLab = CursoLab or {}
CursoLab.Temporizador = ISBaseObject:derive("CursoLabTemporizador")
local Temporizador = CursoLab.Temporizador
function Temporizador:new(nombre, ms)
local o = ISBaseObject.new(self)
o.nombre = nombre
o.ms = ms
o.vueltas = 0
o.activo = false
return o
end
function Temporizador:iniciar()
if self.activo then return end
self.activo = true
self.proximo = getTimestampMs() + self.ms
self.onTickFunc = function() self:tick() end -- closure: recuerda self
Events.OnTick.Add(self.onTickFunc)
end
function Temporizador:detener()
if not self.activo then return end
self.activo = false
Events.OnTick.Remove(self.onTickFunc)
self.onTickFunc = nil
end
function Temporizador:tick()
local ahora = getTimestampMs()
if ahora < self.proximo then return end
self.vueltas = self.vueltas + 1
self:alSonar()
if self:debeSeguir() then
self.proximo = ahora + self.ms
else
self:detener()
end
end
function Temporizador:alSonar()
print("[CursoLab] sonó", tostring(self))
end
function Temporizador:debeSeguir()
return false -- un temporizador simple suena una vez
end
Temporizador.__tostring = function(t)
return t.Type .. "(" .. t.nombre .. ", " .. t.ms .. " ms, vueltas=" .. t.vueltas .. ")"
end
-- Hija: suena varias veces
CursoLab.Repetidor = Temporizador:derive("CursoLabRepetidor")
local Repetidor = CursoLab.Repetidor
Repetidor.__tostring = Temporizador.__tostring -- los metamétodos no se heredan
function Repetidor:new(nombre, ms, veces)
local o = Temporizador.new(self, nombre, ms)
o.veces = veces
return o
end
function Repetidor:debeSeguir()
return self.vueltas < self.veces
end
function Repetidor:alSonar()
Temporizador.alSonar(self) -- lo que hace el padre...
local p = getPlayer()
if p then p:Say(self.vueltas .. " de " .. self.veces) end -- ...y algo más
end
Пример использования из debug-консоли: создать таймер «чай» на 3 000 мс и повторитель на пять шагов с интервалом 1 000 мс, затем проверить tostring и instanceof.
local t = CursoLab.Temporizador:new("té", 3000)
t:iniciar()
local r = CursoLab.Repetidor:new("pasos", 1000, 5)
r:iniciar()
print(r) -- CursoLabRepetidor(pasos, 1000 ms, vueltas=0)
print(ISBaseObject:instanceof(r, "CursoLabTemporizador")) -- true
Метод tick написан один раз в родителе и вызывает self:alSonar() и self:debeSeguir(). Для экземпляра Repetidor поиск находит методы дочернего класса, поэтому поведение меняется без копирования tick. Источник считает отдельный OnTick handler нормальным для нескольких таймеров; для сотен предлагает единый handler со списком, как CursoLab.cada из L08.
Упражнения
Упражнение L9.1 · Точка и двоеточие
В первом фрагменте три ошибки использования . и :. Найдите их без запуска, определите, что попадает в self, и исправьте. В решении источника: ISBaseObject:new(self) передаёт дочерний класс лишним аргументом и создаёт экземпляр с неправильной метатаблицей; нужно ISBaseObject.new(self). Вызов c.meter("sopaipilla") помещает текст в self вместо cosa; нужно c:meter(...). Событие вызывает c.meter без аргументов, и self равен nil; зарегистрируйте closure, захватывающую c.
CursoLab.Caja = ISBaseObject:derive("CursoLabCaja")
function CursoLab.Caja:new(capacidad)
local o = ISBaseObject:new(self)
o.capacidad = capacidad
o.cosas = {}
return o
end
function CursoLab.Caja:meter(cosa)
if #self.cosas >= self.capacidad then return false end
table.insert(self.cosas, cosa)
return true
end
local c = CursoLab.Caja:new(2)
c.meter("sopaipilla")
Events.EveryOneMinute.Add(c.meter)
CursoLab.Caja = ISBaseObject:derive("CursoLabCaja")
function CursoLab.Caja:new(capacidad)
local o = ISBaseObject.new(self)
o.capacidad = capacidad
o.cosas = {}
return o
end
function CursoLab.Caja:meter(cosa)
if #self.cosas >= self.capacidad then return false end
table.insert(self.cosas, cosa)
return true
end
local c = CursoLab.Caja:new(2)
c:meter("sopaipilla")
local alMinuto = function() c:meter("minuto") end
Events.EveryOneMinute.Add(alMinuto)
Критерий: коробка принимает две вещи, отклоняет третью и каждую игровую минуту кладёт в себя «минута».
Упражнение L9.2 · Стек как класс
Реализуйте CursoLab.Pila на основе ISBaseObject с методами poner, sacar (возвращает и удаляет последний элемент или nil), mirar и tamano. Создайте два независимых стека; по критерию a:poner(1), b:poner(2) должны дать размеры 1 и 1. Если хранить items в классе, обе инстанции разделят одну таблицу и получится 2 и 2.
CursoLab.Pila = ISBaseObject:derive("CursoLabPila")
local Pila = CursoLab.Pila
function Pila:new()
local o = ISBaseObject.new(self)
o.items = {} -- en new, no en la clase
return o
end
function Pila:poner(x)
table.insert(self.items, x)
end
function Pila:sacar()
return table.remove(self.items) -- nil si está vacía
end
function Pila:mirar()
return self.items[#self.items]
end
function Pila:tamano()
return #self.items
end
Упражнение L9.3 · Операторы для Vec
Добавьте __mul для умножения вектора на число в обоих порядках — vec * число и число * vec — и метод distancia(otro). Используйте его, чтобы печатать, сколько клеток прошёл игрок за десять секунд. Критерий: Vec:new(1, 2) * 3 и 3 * Vec:new(1, 2) выводят (3, 6). Источник поясняет, что для 3 * v используются аргументы в исходном порядке, даже если metamethod взят у второго операнда.
local Vec = CursoLab.Vec
Vec.__mul = function(a, b)
if type(a) == "number" then a, b = b, a end -- 3 * v llega como (3, v)
return Vec:new(a.x * b, a.y * b)
end
function Vec:distancia(otro)
return (self - otro):largo()
end
local antes
CursoLab.cada(10000, function()
local p = getPlayer()
if not p then return end
local ahora = Vec:new(p:getX(), p:getY())
if antes then
print("[CursoLab] me moví", math.floor(ahora:distancia(antes)), "cuadros")
end
antes = ahora
end)
Упражнение L9.4 · Наследуемая тревога
Создайте CursoLab.Alarma как подкласс Temporizador: при срабатывании выполните логику родителя и заставьте игрока произнести имя тревоги. Добавьте posponer(ms), который сдвигает следующее срабатывание, не сбрасывая число повторов. Критерии: tostring выводит CursoLabAlarma(...), а instanceof распознаёт наследование от CursoLabTemporizador. Автор отмечает, что new можно наследовать: при вызове Alarma:new(...) внутри self будет Alarma.
local Temporizador = CursoLab.Temporizador
CursoLab.Alarma = Temporizador:derive("CursoLabAlarma")
local Alarma = CursoLab.Alarma
Alarma.__tostring = Temporizador.__tostring
function Alarma:alSonar()
Temporizador.alSonar(self)
local p = getPlayer()
if p then p:Say(self.nombre) end
end
function Alarma:posponer(ms)
if self.activo then
self.proximo = self.proximo + ms
end
end
local alarma = Alarma:new("¡A comer!", 5000)
alarma:iniciar()
alarma:posponer(2000) -- suena a los ~7 segundos
Упражнение L9.5 · Разобрать игровой класс
Прочитайте shared/Util/LuaList.lua и проверьте: индексация get(index) начинается с 0 или 1 и почему; что выводит print(LuaList:new()) в Kahlua (сначала предскажите); что происходит, если remove(x) получает дублирующийся элемент. Решение статьи: индексация начинается с 0 через items[index+1]; tostring выводит содержимое и Type, а адреса меняются при каждом запуске; remove удаляет первое совпадение.
Задача со звёздочкой L9
Напишите CursoLab.clase(nombre, padre): создайте класс через (padre or ISBaseObject):derive(nombre) и автоматически копируйте в дочерний класс собственные metamethods родителя: __tostring, __eq, __add, __sub, __mul, __div, __unm, __lt и __le. Перепишите с этим помощником Vec3, Repetidor и Alarma, удалите ручное копирование metamethods. Затем объясните, что будет, если родитель добавит metamethod после создания ребёнка, и предложите решение.