Перейти к содержанию

Метатаблицы и паттерны классов Lua в Build 42

Разберитесь с поиском через __index, паттерном ISBaseObject, вызовом методов родителя и metamethods; изучите классы и упражнения.

Метатаблицы и паттерны классов 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 после создания ребёнка, и предложите решение.

Информация
Тридцать три исходных фрагмента кода сохранены буквально. Утверждения о поддержке metamethods в Kahlua, работе __eq, __len, __mode, tostring, поиске операторов и классах игры требуют независимой проверки для конкретной версии. Примеры зависят от игры или CursoLab; код и упражнения не выполнялись.