WENOPAL

L10 · Lua avanzado en Kahlua

Closures, memoización, error y pcall en PZ, corutinas, patrones de texto, serializar para ModData y rendimiento, con un desafío que junta todo el track.

Curso técnico · Build 42 · Actualizado el

Objetivo: dominar las herramientas de Lua que separan un mod que "funciona" de uno sólido: closures, cachés, manejo de errores, patrones de texto, guardar datos sin sorpresas y código rápido. Todo con lo que hace (y no hace) Kahlua, el Lua de Project Zomboid.

En este módulo hay varias cosas que en Kahlua funcionan distinto que en el Lua de los manuales. Cuando decimos "lo probamos", es que corrimos ese código en el intérprete Kahlua que viene dentro del .jar del juego.

1. Closures y upvalues a fondo

En L04 y L08 viste que una función puede "recordar" variables de afuera. Ahora, el mecanismo completo.

Una closure es una función más las variables locales de afuera que usa. Esas variables se llaman upvalues. La closure no guarda una copia del valor: guarda la variable misma, viva, y la comparte con cualquier otra función que la use.

lua
local function crearMarcador()
    local valor = 0                       -- cada llamada a crearMarcador crea un "valor" nuevo
    local function sumar(k) valor = valor + k end
    local function leer() return valor end
    return sumar, leer                    -- las dos comparten el MISMO upvalue
end

local sumarA, leerA = crearMarcador()
local sumarB, leerB = crearMarcador()
sumarA(5); sumarA(2); sumarB(100)
print(leerA(), leerB())                   -- 7   100

Como guarda la variable y no el valor, si la variable cambia después, la closure ve el cambio:

lua
local x = 1
local function verX() return x end
x = 2
print(verX())                             -- 2, no 1

La trampa del bucle

En un for numérico, cada vuelta tiene su propia variable i. En un while, la variable es una sola para todas las vueltas. Lo probamos en Kahlua:

lua
local conFor = {}
for i = 1, 3 do
    conFor[i] = function() return i end
end
print(conFor[1](), conFor[2](), conFor[3]())          -- 1   2   3

local conWhile = {}
local j = 1
while j <= 3 do
    conWhile[j] = function() return j end
    j = j + 1
end
print(conWhile[1](), conWhile[2](), conWhile[3]())    -- 4   4   4

Si dentro de un while necesitas que cada closure recuerde "su" valor, cópialo a una local nueva dentro del bucle: local esta = j.

Iteradores hechos con closures

El for ... in llama a una función una y otra vez hasta que devuelve nil. Si esa función es una closure, puede llevar la cuenta de dónde va. Así se recorre una lista de Java con for ... in.

lua
local function iterList(_list)
	local list = _list;
	local size = list:size() - 1;
	local i = -1;
	return function()
		i = i + 1;
		if i <= size and not list:isEmpty() then
			return list:get(i), i;
		end;
	end;
end

Y lo usa así (línea 682): for item in iterList(allItems) do. El mismo truco está en media/lua/shared/env.lua (xpairs), que sirve tanto para un ArrayList como para una tabla.

Lo que cuesta

Cada vez que se ejecuta la palabra function, Kahlua crea un objeto closure nuevo. Una vez al cargar el archivo no importa; dentro de OnTick son miles de objetos por minuto que el recolector de basura tiene que limpiar. Define las funciones una vez, afuera, y pásalas.

2. Memoización: no calcules dos veces lo mismo

Memoizar es guardar el resultado de una función para devolverlo directo la próxima vez que la llamen con el mismo argumento. Sirve cuando la función es cara (llama a Java, recorre listas) y se llama muchas veces con los mismos valores.

Caso típico en PZ: el nombre visible de un ítem a partir de su tipo completo. El juego lo hace así en media/lua/shared/Fishing/Fish.lua (línea 232): getScriptManager():FindItem(fishConfig.itemType):getDisplayName(). Con caché:

lua
CursoLab = CursoLab or {}
local nombres = {}                         -- upvalue privado: fullType -> nombre visible

function CursoLab.nombreDe(fullType)
    local n = nombres[fullType]
    if n == nil then                       -- primera vez: preguntamos a Java
        local item = getScriptManager():FindItem(fullType)
        n = item and item:getDisplayName() or fullType
        nombres[fullType] = n
    end
    return n
end

print(CursoLab.nombreDe("Base.Axe"))       -- la primera llama a Java, las demás no

Un memoizador genérico

Con closures puedes envolver cualquier función de un argumento:

lua
local NADA = {}                            -- marca para "el resultado fue nil"

local function memoizar(f)
    local cache = {}
    return function(x)
        if x == nil then return f(x) end   -- una clave nil no se puede guardar
        local r = cache[x]
        if r == nil then
            r = f(x)
            if r == nil then r = NADA end  -- así no vuelve a calcular los nil
            cache[x] = r
        end
        if r == NADA then return nil end
        return r
    end, cache
end

local nombreRapido, cacheNombres = memoizar(function(fullType)
    local item = getScriptManager():FindItem(fullType)
    return item and item:getDisplayName()
end)

Tres cosas propias de Kahlua y de PZ:

  • t[nil] = valor no da error en Kahlua (en Lua estándar sí): la asignación se pierde en silencio. Lo probamos. Por eso el memoizador revisa x == nil a mano.
  • No memoices por objeto de Java (cache[zombi]). Kahlua no tiene tablas débiles: la caché mantiene vivo el objeto aunque salga del mundo (más detalle en el módulo 14 · Datos persistentes y rendimiento). Usa una clave de texto o número: el fullType, un ID, unas coordenadas.
  • Invalida cuando cambie la fuente. Si la caché puede quedar vieja (por ejemplo, el idioma de los textos), vacíala con table.wipe(cacheNombres).

3. error y pcall en PZ

Lanzar un error

error(mensaje) corta la función y todas las que la llamaron, hasta que alguien lo atrape o hasta que llegue al juego. El valor puede ser un texto o una tabla:

lua
local function porcentaje(valor)
    if type(valor) ~= "number" then
        error("porcentaje: se esperaba un número, llegó " .. type(valor))
    end
    return valor * 100
end
lua
function logTo01(value)
    if value < 0.0 or value > 1.0 then
        error("only [0,1] accepted!");
    end

Atrapar un error

pcall(f, a, b) llama a f(a, b) en modo protegido. Si todo va bien devuelve true y los resultados; si hay error, devuelve false y el mensaje:

lua
local ok, resultado = pcall(porcentaje, 0.5)
print(ok, resultado)                      -- true   50

local ok2, msg = pcall(porcentaje, "hola")
print(ok2, msg)                           -- false  porcentaje: se esperaba un número, llegó string

local ok3, err = pcall(function() error({ codigo = 7 }) end)
print(ok3, err.codigo)                    -- false  7   (el error puede ser una tabla)

Lo que cambia en PZ

Esto es lo importante, y lo comprobamos en el código de Kahlua (KahluaThread) y probándolo:

En Lua estándarEn PZ (Kahlua)
Un error atrapado con pcall es silenciosoSe escribe igual en el log con STACK TRACE y sube el contador rojo de errores. pcall solo evita que tu función se corte
error("x") agrega "archivo:línea:" al mensajeEl mensaje queda tal cual: "x"
error("x", 2) cambia el nivel que se reportaEl segundo argumento no es un nivel: no lo uses
pcall devuelve false, mensajeDevuelve además una traza en texto y la excepción de Java. Usa solo los dos primeros
Un error de ejecución (nil.campo) da un mensaje de LuaEl mensaje puede traer pegado el nombre de una clase de Java, por ejemplo attempted index: y of non-table: null java.lang.RuntimeException

Y un dato que lo dice todo: el Lua del juego no usa pcall ni una vez.

Entonces, ¿cuándo usar pcall?

  • Sí, en el borde con código que no controlas: cuando tu mod llama funciones que registraron otros archivos u otros mods, para que una que falle no corte a las demás. Wanaco, uno de nuestros mods, envuelve cada comando de servidor así (módulo 06 · Lua en Project Zomboid).
  • No, para preguntar si un método existe: local f = obj.metodo; if f then f(obj) end (L06).
  • No, en OnTick ni en eventos que corren seguido: si falla, llena el log en segundos (es el Bug 15.3 del módulo 15 · Depuración en serio).
  • No, como control de flujo normal. Para errores esperables, devuelve nil, "motivo":
lua
local function buscarItem(inv, fullType)
    local items = inv:getItems()
    for i = 0, items:size() - 1 do
        local it = items:get(i)
        if it:getFullType() == fullType then return it end
    end
    return nil, "no hay " .. fullType       -- error esperable: sin pcall, sin log
end

local item, motivo = buscarItem(getPlayer():getInventory(), "Base.Axe")
if not item then print("[CursoLab] " .. motivo) end

Un despachador de avisos con pcall en el lugar correcto:

lua
CursoLab.oyentes = CursoLab.oyentes or {}

function CursoLab.alAvisar(fn)
    table.insert(CursoLab.oyentes, fn)
end

function CursoLab.avisar(...)
    for i = 1, #CursoLab.oyentes do
        local ok, err = pcall(CursoLab.oyentes[i], ...)
        if not ok then
            print("[CursoLab] un oyente falló: " .. tostring(err))   -- el log ya tiene la traza
        end
    end
end

Compáralo con ISBaseObject:triggerEvent en media/lua/shared/ISBaseObject.lua: llama a los oyentes uno tras otro sin pcall, así que si uno falla, los que siguen no se enteran.

4. Corutinas: existen, pero el juego no las usa

Una corutina es una función que puede pausarse a la mitad (coroutine.yield) y seguir después desde ahí (coroutine.resume). Kahlua las tiene (create, resume, yield, status, running):

lua
local co = coroutine.create(function(a, b)
    print("empieza", a, b)
    local c = coroutine.yield(a + b)      -- se pausa y entrega a + b
    print("recibí", c)
    return "fin"
end)

print(coroutine.status(co))               -- suspended
print(coroutine.resume(co, 1, 2))         -- empieza 1 2, luego: true 3
print(coroutine.resume(co, 10))           -- recibí 10, luego: true fin
print(coroutine.status(co))               -- dead
print(type(co))                           -- coroutine   (en Lua estándar sería "thread")

Lo probamos todo en Kahlua. Lo que tienes que saber:

  • coroutine.resume sobre una corutina terminada lanza un error ("Can not resume coroutine that is in status: dead") en vez de devolver false. Revisa coroutine.status(co) == "suspended" antes.
  • No puedes hacer yield dentro de un pcall: da "Can not yield outside of a coroutine".
  • En nuestra prueba, un error() lanzado dentro de una corutina rompió también la llamada de afuera ("Internal Kahlua error - coroutine changed in pcall").
  • El Lua del juego no usa corutinas ni una vez. Si algo sale mal, no hay ejemplos del juego que te ayuden.

Para lo que normalmente se usan (repartir un trabajo largo entre varios ticks), en PZ es más seguro guardar el estado a mano:

lua
local pendientes = {}                     -- lista de cosas por procesar
local POR_TICK = 20
local activo = false                      -- ¿está registrado el handler?

local function procesarUnPoco()
    for _ = 1, math.min(POR_TICK, #pendientes) do
        local tarea = table.remove(pendientes)   -- sacar del final es barato
        tarea()
    end
    if #pendientes == 0 then
        Events.OnTick.Remove(procesarUnPoco)     -- terminó: deja de correr
        activo = false
    end
end

function CursoLab.encolar(tarea)
    pendientes[#pendientes + 1] = tarea
    if not activo then
        activo = true
        Events.OnTick.Add(procesarUnPoco)
    end
end

5. Patrones de texto a fondo

Los patrones de Lua no son expresiones regulares, pero se parecen. Los usan string.find, string.match, string.gmatch y string.gsub (y sus versiones con :).

Las piezas

PiezaSignifica
.cualquier carácter
%a %d %l %uletra, dígito, minúscula, mayúscula
%s %w %p %xespacio, letra o dígito, puntuación, dígito hexadecimal
%A %D %S...en mayúscula: lo contrario (%D = no dígito)
[abc] [%w_] [^,]conjunto; ^ al principio lo niega
* + ?0 o más (la mayor cantidad), 1 o más, 0 o 1
-0 o más, la menor cantidad posible
^ al inicio, $ al finalanclas: inicio y fin del texto
( )captura; () vacío captura la posición
%b()texto balanceado entre ( y ) (sirve con cualquier par)
%f[%w]"frontera": el punto donde empieza algo que cumple [%w]
%1lo que capturó la captura 1

Los caracteres mágicos son ^ $ * + ? . ( ) [ ] % -. Para buscarlos tal cual, ponles % delante: %., %$, %%. Kahlua soporta %b y %f (lo verificamos en StringLib).

find, match, gmatch y gsub

lua
local s = "[CursoLab] nivel=3 vida=87.5"

print(s:find("nivel"))                    -- 12  16          (inicio y fin)
print(s:find(".", 1, true))               -- 1   1           (true = buscar texto plano, sin patrones)
print(s:match("nivel=(%d+)"))             -- 3               (devuelve la captura, como texto)
print(s:match("%[(.-)%]"))                -- CursoLab
print(s:match("(%a+)=([%d%.]+)$"))        -- vida  87.5

for clave, valor in s:gmatch("(%a+)=([%d%.]+)") do
    print(clave, tonumber(valor))         -- nivel 3 / vida 87.5
end

gsub reemplaza y devuelve dos valores: el texto nuevo y cuántos reemplazos hizo. El reemplazo puede ser un texto (con %1, %2...), una tabla o una función:

lua
print(("hola mundo"):gsub("o", "0"))                          -- h0la mund0   2
print(("2026-10-13"):gsub("(%d+)-(%d+)-(%d+)", "%3/%2/%1"))   -- 13/10/2026   1

local valores = { nombre = "Ana", n = "3" }
print(("$nombre comió $n"):gsub("%$(%w+)", valores))          -- Ana comió 3   2

print(("hola mundo"):gsub("%f[%w]%w+", function(palabra)
    return palabra:sub(1, 1):upper() .. palabra:sub(2)
end))                                                         -- Hola Mundo   2

Si pones un gsub como último argumento de una función, pasan los dos valores. Para quedarte solo con el texto, envuélvelo en paréntesis.

Ojo: gsub con función o tabla no se comporta como en Lua estándar. Lo leímos en StringLib.addValue y lo probamos:

CasoLua estándarKahlua (PZ)
Patrón con varias capturas y una funciónLa función recibe todas las capturasRecibe solo la primera (las demás llegan como nil)
La función o la tabla devuelve nilDeja el texto encontrado completoPone la primera captura (o el texto completo si no hay capturas)
La función devuelve falseDeja el texto encontrado completoEscribe null
lua
print(("a=1 b=2"):gsub("(%a)=(%d)", function(k, v) return k .. ":" .. tostring(v) end))
-- en Kahlua: a:nil b:nil   2       (v nunca llega)

print(("$a$ y $b$"):gsub("%$(%w+)%$", { a = "A" }))
-- en Kahlua: A y b   2             (la marca $b$ perdió sus signos)

Regla práctica: con función o tabla, usa una sola captura y devuelve siempre un texto.

lua
luautils.trim = function(s)
  return (s:gsub("^%s*(.-)%s*$", "%1"))
end

Y media/lua/shared/Moveables/ISMoveableSpriteProps.lua encuentra la última aparición de algo con un .* (que avanza todo lo posible) y una captura de posición:

lua
local function lastIndexOf(_string, _needle)
    local i = _string:match(".*" .. _needle .. "()")

Dos bugs del juego que enseñan más que la teoría

lua
		ret, n = string.gsub(ret, "$HeShe$", "She");

Pero el $ final es un ancla de "fin del texto": ese patrón busca $HeShe solo al final del texto. Lo probamos en Kahlua: con "Then $HeShe$ left" hace 0 reemplazos, y con el texto terminado en $HeShe$ también 0. El patrón correcto es "%$HeShe%$".

lua
	if functionName:find(".") then
		local t = _G
		local ss = functionName:split("\\.")

El find(".") es un patrón de Lua: . es "cualquier carácter", así que es verdadero para cualquier texto no vacío (lo correcto sería find(".", 1, true) o find("%.")). El split("\\."), en cambio, es correcto, porque split es un extra de Kahlua que usa expresiones regulares de Java, no patrones de Lua.

Lo de split vale la pena tenerlo claro. Lo probamos en Kahlua:

lua
print(#("Base.Axe"):split("."))           -- 0      ("." en Java también es "cualquier carácter")
print(#("Base.Axe"):split("%."))          -- 1      (%. no significa nada en Java: no corta)
print(#("Base.Axe"):split("\\."))         -- 2      Base / Axe
print(#("a,b,,"):split(","))              -- 2      Java borra los vacíos del final
print(#("a,b,,c"):split(","))             -- 4      los del medio sí quedan: a / b / "" / c

Para separadores simples como , o ; (el juego usa split(";") en Vehicles.lua) no hay problema. Con cualquier carácter raro, usa gmatch: for parte in s:gmatch("[^%.]+") do.

Leer un comando con patrones

lua
-- "dar Base.Sopaipilla 3"  ->  "dar", "Base.Sopaipilla", 3
local function leerComando(texto)
    local verbo, fullType, cantidad = texto:match("^%s*(%a+)%s+([%w_]+%.[%w_]+)%s*(%d*)%s*$")
    if not verbo then return nil, "formato: verbo Modulo.Item [cantidad]" end
    return verbo:lower(), fullType, tonumber(cantidad) or 1
end

print(leerComando("dar Base.Sopaipilla 3"))   -- dar  Base.Sopaipilla  3
print(leerComando("  DAR Base.Axe"))          -- dar  Base.Axe  1
print(leerComando("dar sopaipilla"))          -- nil  formato: verbo Modulo.Item [cantidad]

6. Serializar tablas para ModData

Un mod guarda datos con la partida en dos lugares: la tabla de cada objeto (obj:getModData(), por ejemplo la del jugador) y los datos globales del mod (ModData.getOrCreate("CursoLab")). El módulo 14 · Datos persistentes y rendimiento lo explica completo; aquí importa que los dos guardan tablas de Lua. Lo que hace Java al guardarlas está en KahluaTableImpl.save, y conviene saberlo exacto:

Se guardaNo se guarda (se descarta sin aviso)
Claves de texto o númeroClaves booleanas, claves que son tablas u objetos
Valores de texto, número, booleano o tablaFunciones, objetos de Java (un ítem, un jugador), corutinas

Y dos consecuencias que salen de leer ese código:

  • Las referencias compartidas se duplican. Si md.a y md.b apuntan a la misma tabla, al guardar se escribe dos veces y al cargar son dos tablas distintas.
  • Un ciclo no tiene freno. save se llama a sí mismo para cada subtabla sin revisar si ya la visitó: una tabla que se contiene a sí misma (t.yo = t) lo deja en una recursión sin fin al guardar.

Por eso conviene limpiar antes de guardar. Y a veces necesitas la tabla como texto: para escribirla en un archivo con getFileWriter, para mostrarla en el log o para compararla. Este serializador hace las dos cosas: deja solo lo guardable, corta ciclos y produce un texto que se puede volver a leer.

lua
local function escapar(s)
    return '"' .. s:gsub('[\\"\n]', { ['\\'] = '\\\\', ['"'] = '\\"', ['\n'] = '\\n' }) .. '"'
end

-- Devuelve un texto, o nil si el valor no se puede guardar.
local function serializar(v, enCurso)
    local tv = type(v)
    if tv == "string" then return escapar(v) end
    if tv == "boolean" then return tostring(v) end
    if tv == "number" then
        if v ~= v or v == math.huge or v == -math.huge then return nil end   -- nan e infinito no
        return tostring(v)                -- tostring de Kahlua se puede volver a leer con tonumber
    end
    if tv ~= "table" then return nil end  -- funciones, objetos de Java: fuera
    enCurso = enCurso or {}
    if enCurso[v] then return nil end     -- ciclo: esta tabla ya se está escribiendo más arriba
    enCurso[v] = true
    local partes = {}
    for k, val in pairs(v) do
        local tk = type(k)
        if tk == "string" or tk == "number" then
            local sk, sv = serializar(k, enCurso), serializar(val, enCurso)
            if sk and sv then
                partes[#partes + 1] = "[" .. sk .. "]=" .. sv
            end
        end
    end
    enCurso[v] = nil
    return "{" .. table.concat(partes, ",") .. "}"
end

Para leerlo de vuelta no uses loadstring (el juego no lo usa, y ejecutar texto que viene de un archivo es abrirle la puerta a cualquier cosa). Un lector pequeño con patrones alcanza. Fíjate que no usa error: cada función devuelve nil, motivo si algo está mal.

lua
local leerValor                           -- declarada antes porque leerTabla la necesita

local function saltarEspacios(s, pos)
    local _, fin = s:find("^%s*", pos)    -- ^ ancla en pos: solo espacios justo ahí
    return fin + 1
end

local function leerTexto(s, pos)          -- pos apunta a la comilla de apertura
    local partes = {}
    local i = pos + 1
    while true do
        local a = s:find('[\\"]', i)      -- próxima comilla o barra invertida
        if not a then return nil, "texto sin cerrar" end
        partes[#partes + 1] = s:sub(i, a - 1)
        if s:sub(a, a) == '"' then
            return table.concat(partes), a + 1
        end
        local sig = s:sub(a + 1, a + 1)
        partes[#partes + 1] = (sig == "n") and "\n" or sig
        i = a + 2
    end
end

local function leerTabla(s, pos)          -- pos apunta a la llave de apertura
    local t = {}
    pos = pos + 1
    while true do
        pos = saltarEspacios(s, pos)
        local c = s:sub(pos, pos)
        if c == "}" then return t, pos + 1 end
        if c ~= "[" then return nil, "se esperaba [ en la posición " .. pos end
        local k, v
        k, pos = leerValor(s, pos + 1)
        if k == nil then return nil, pos end
        local _, fin = s:find("^%s*%]%s*=", pos)
        if not fin then return nil, "se esperaba ]= en la posición " .. pos end
        v, pos = leerValor(s, fin + 1)
        if v == nil then return nil, pos end
        t[k] = v
        pos = saltarEspacios(s, pos)
        if s:sub(pos, pos) == "," then pos = pos + 1 end
    end
end

leerValor = function(s, pos)
    pos = saltarEspacios(s, pos)
    local c = s:sub(pos, pos)
    if c == "{" then return leerTabla(s, pos) end
    if c == '"' then return leerTexto(s, pos) end
    if s:find("^true", pos) then return true, pos + 4 end
    if s:find("^false", pos) then return false, pos + 5 end
    local _, fin, num = s:find("^(-?[%d%.]+[eE]?[-+]?%d*)", pos)
    if num and tonumber(num) then return tonumber(num), fin + 1 end
    return nil, "valor raro en la posición " .. pos
end

local function deserializar(s)
    local v, pos = leerValor(s, 1)
    if v == nil then return nil, pos end  -- en caso de error, pos trae el motivo
    return v
end

Probado en Kahlua, ida y vuelta:

lua
local datos = { v = 2, nombre = 'Ana "la rápida"', vivo = true, lista = { "a", "b", 3 },
                chico = 1e-5, f = function() end, sub = { x = { y = false } } }
datos.yo = datos                          -- un ciclo a propósito

local txt = serializar(datos)
print(txt)
-- {["v"]=2,["nombre"]="Ana \"la rápida\"",["vivo"]=true,["lista"]={[1]="a",[2]="b",[3]=3},
--  ["chico"]=1.0E-5,["sub"]={["x"]={["y"]=false}}}       (sin f y sin yo)

local copia = deserializar(txt)
print(copia.nombre, copia.lista[3], copia.chico, copia.f, copia.yo)   -- Ana "la rápida"  3  1.0E-5  nil  nil
print(deserializar('{["a"]=x}'))          -- nil  valor raro en la posición 8

Las claves salen en el orden en que se insertaron, porque en Kahlua pairs recorre en orden de inserción. No compares dos textos serializados en máquinas distintas esperando el mismo orden.

Cómo usarlo con ModData:

  • Para guardar en ModData, guarda la tabla directo, sin pasarla a texto. Si no estás seguro de qué trae, límpiala antes: md.CursoLab = deserializar(serializar(misDatos)) devuelve una copia profunda con solo lo guardable y sin ciclos.
  • Para un archivo, escribe el texto con getFileWriter y léelo con getFileReader y :readLine() (el juego lo hace en media/lua/client/OptionScreens/InviteFriends.lua).

7. Rendimiento en Kahlua

El módulo 14 · Datos persistentes y rendimiento explica qué cuesta en general. Aquí van las técnicas de Lua puro.

Cachear globales en locales

Leer una global (math, table, getPlayer) es buscar en la tabla global; leer una local es directo. En una función que corre muchas veces, copia lo que usas a locales al principio del archivo:

lua
local floor, min, max = math.floor, math.min, math.max
local insert, concat = table.insert, table.concat
local format = string.format
lua
local insert, concat, format, print = table.insert, table.concat, string.format, print

Y media/lua/shared/Foraging/forageSystem.lua tiene, cerca del principio, local table = table; y local math = math;.

Una advertencia: cachea solo funciones de librería que nadie cambia (math, string, table). No caches funciones del juego que otros mods pueden envolver (módulo 09 · Sobrescribir comportamiento): tu copia local seguiría apuntando a la versión vieja y te saltarías sus parches.

No crear tablas ni closures en OnTick

lua
-- Mal: una tabla y una closure nuevas en cada frame
Events.OnTick.Add(function()
    local cercanos = {}
    -- ... llenar cercanos ...
    table.sort(cercanos, function(a, b) return a.d < b.d end)
end)

-- Bien: la tabla y la función de orden se crean una vez y se reutilizan
local cercanos = {}
local function porDistancia(a, b) return a.d < b.d end

local function enCadaTick()
    table.wipe(cercanos)                  -- vacía la misma tabla (extra de Kahlua)
    -- ... llenar cercanos ...
    table.sort(cercanos, porDistancia)
end
Events.OnTick.Add(enCadaTick)

Armar textos con table.concat

Cada .. crea un texto nuevo. En un bucle, s = s .. algo copia todo lo anterior en cada vuelta. Junta los pedazos en una tabla y únelos una sola vez:

lua
-- Mal: N textos intermedios cada vez más largos
local s = ""
for i = 1, #lista do s = s .. lista[i] .. ", " end

-- Bien: un solo texto al final
local partes = {}
for i = 1, #lista do partes[#partes + 1] = lista[i] end
local s2 = table.concat(partes, ", ")

table.concat es justo lo que hace LootLog.lua para imprimir el loot de un contenedor: print(concat(list, ", ") .. ".").

string.format y los booleanos

En Kahlua, string.format("%s", true) da error ("bad argument #2 to 'format' (string expected, got nil)"); también con una tabla o con nil. Lo probamos. Pasa por tostring todo lo que no sea texto o número: string.format("vivo=%s", tostring(vivo)).

Resumen

TécnicaCuándo importa
Locales en vez de globalesFunciones que corren cada tick o en bucles grandes
Reutilizar tablas (table.wipe)Eventos frecuentes (OnTick, OnPlayerUpdate)
Funciones definidas afueraIgual: nada de function dentro del evento caliente
table.concatTextos armados en bucles
MemoizarLlamadas a Java repetidas con los mismos argumentos
Sin pcall en eventos calientesSiempre en PZ: cada error atrapado igual se registra

Ejercicios

Ejercicio L10.1 · Un limitador con closures

Escribe CursoLab.crearLimitador(ms), que devuelve una función. Esa función devuelve true como mucho una vez cada ms milisegundos (usa getTimestampMs()) y false el resto del tiempo. Dos limitadores creados por separado no se afectan entre sí.

Listo cuando: en un OnTick, if limitador() then print("[CursoLab] tic") end imprime una vez por segundo con crearLimitador(1000), y un segundo limitador de 3000 ms imprime a su propio ritmo.

Solución

El momento de la última vez es un upvalue: cada llamada a crearLimitador crea uno nuevo.

lua
CursoLab = CursoLab or {}

function CursoLab.crearLimitador(ms)
    local ultima = 0
    return function()
        local ahora = getTimestampMs()
        if ahora - ultima < ms then return false end
        ultima = ahora
        return true
    end
end

local cadaSegundo = CursoLab.crearLimitador(1000)
local cadaTres = CursoLab.crearLimitador(3000)

local function alTick()
    if cadaSegundo() then print("[CursoLab] tic") end
    if cadaTres() then print("[CursoLab] TAC") end
end
Events.OnTick.Add(alTick)

Ejercicio L10.2 · Medir una caché

Agrega a CursoLab.nombreDe (sección 2) un contador de cuántas veces se llamó a Java y otro de cuántas veces respondió la caché. Llámala 1.000 veces con los tipos de todos los ítems de tu inventario y muestra los dos contadores.

Listo cuando: las llamadas a Java coinciden con la cantidad de tipos distintos en tu inventario.

Solución
lua
CursoLab = CursoLab or {}
local nombres = {}
CursoLab.stats = { java = 0, cache = 0 }

function CursoLab.nombreDe(fullType)
    local n = nombres[fullType]
    if n ~= nil then
        CursoLab.stats.cache = CursoLab.stats.cache + 1
        return n
    end
    CursoLab.stats.java = CursoLab.stats.java + 1
    local item = getScriptManager():FindItem(fullType)
    n = item and item:getDisplayName() or fullType
    nombres[fullType] = n
    return n
end

function CursoLab.probarCache()
    local items = getPlayer():getInventory():getItems()
    local total = items:size()
    if total == 0 then return end
    for k = 1, 1000 do
        local it = items:get(k % total)       -- índices de Java: 0 .. total-1
        CursoLab.nombreDe(it:getFullType())
    end
    print(string.format("[CursoLab] java=%d cache=%d", CursoLab.stats.java, CursoLab.stats.cache))
end

k % total siempre da un número entre 0 y total - 1 porque k es positivo (con negativos, el % de Kahlua da resultados negativos: L02).

Ejercicio L10.3 · Leer texto con patrones

Escribe dos funciones:

  1. CursoLab.esFullType(s): true si s tiene forma Modulo.Item (letras, dígitos o _ a cada lado de un solo punto).
  2. CursoLab.leerPares(s): convierte "x=12, y=-4, nombre=Ana" en { x = 12, y = -4, nombre = "Ana" } (los números como números).

Listo cuando: esFullType("Base.Axe") es true, esFullType("Base.Axe.2") y esFullType("Axe") son false, y leerPares devuelve la tabla del ejemplo.

Solución
lua
CursoLab = CursoLab or {}

function CursoLab.esFullType(s)
    return s:match("^[%w_]+%.[%w_]+$") ~= nil
end

function CursoLab.leerPares(s)
    local t = {}
    for clave, valor in s:gmatch("(%w+)%s*=%s*([^,]+)") do
        valor = valor:gsub("%s+$", "")        -- sin espacios al final (gsub en una asignación: solo el texto)
        t[clave] = tonumber(valor) or valor
    end
    return t
end

En valor = valor:gsub(...) la asignación toma solo el primer valor que devuelve gsub; el contador se descarta.

Ejercicio L10.4 · Arreglar el StringReplacer

Escribe CursoLab.reemplazarMarcas(texto, valores) que reemplace marcas como $HeShe$ o $nombre$ usando la tabla valores. Las marcas que no estén en la tabla quedan como están.

Listo cuando: reemplazarMarcas("Then $HeShe$ left with $item$", { HeShe = "She" }) devuelve "Then She left with $item$".

Solución
lua
CursoLab = CursoLab or {}

function CursoLab.reemplazarMarcas(texto, valores)
    return (texto:gsub("%$(%w+)%$", function(nombre)
        return valores[nombre] or ("$" .. nombre .. "$")   -- sin valor: deja la marca entera
    end))
end

Los $ van escapados con %, y la captura (%w+) toma el nombre sin los signos. La función devuelve siempre un texto: en Kahlua, si devolviera nil, gsub pondría solo la captura (item, sin los $), no la marca completa como en Lua estándar. Los paréntesis de afuera descartan el contador.

Ejercicio L10.5 · Sacar un pcall de donde no va

Este código llena el log de errores cuando el jugador no tiene nada en las manos:

lua
Events.OnPlayerUpdate.Add(function(player)
    local ok, nombre = pcall(function()
        return player:getPrimaryHandItem():getDisplayName()
    end)
    if ok then CursoLab.mano = nombre end
end)

Reescríbelo sin pcall, sin crear una closure por llamada y sin trabajo cuando nada cambió.

Listo cuando: el contador rojo no sube con las manos vacías y CursoLab.mano es nil en ese caso.

Solución

El error es esperable (manos vacías = nil), así que se revisa con un if. La función se define una vez, afuera.

lua
CursoLab = CursoLab or {}
local ultimoItem = nil

local function alActualizarJugador(player)
    local item = player:getPrimaryHandItem()
    if item == ultimoItem then return end     -- nada cambió: salida temprana
    ultimoItem = item
    CursoLab.mano = item and item:getDisplayName() or nil
end
Events.OnPlayerUpdate.Add(alActualizarJugador)

Guardar ultimoItem (un objeto de Java) en una sola variable que se reemplaza en cada cambio no crea una fuga: siempre hay como mucho uno, y se suelta cuando cambia.

Desafío L10 · El diario de CursoLab

Junta todo el track en un sistema pequeño: un diario de inventario. Cada 10 minutos de juego, CursoLab compara lo que lleva el jugador con lo que llevaba la vez anterior y anota los cambios (+2 Sopaipilla, -1 Hacha). El diario sobrevive a salir y volver a entrar, y se puede consultar desde la consola.

Requisitos (entre paréntesis, el módulo que lo cubre):

  1. Un archivo client/CursoLab_Diario.lua que agrega todo a la tabla global CursoLab, sin globales sueltas y con el nivel superior mínimo: solo definiciones y registros de eventos (L01, L07).
  2. Textos armados con string.format y tostring, sin errores con booleanos (L02, L10).
  3. Una función CursoLab.log(...) que acepte cualquier cantidad de argumentos, incluidos nil, con select("#", ...) (L04).
  4. Una "foto" del inventario como mapa fullType -> cantidad, y una función que compare dos fotos y devuelva la lista de cambios (L03, L05).
  5. La foto se arma recorriendo el ArrayList de getInventory():getItems() desde 0, guardando textos, nunca los objetos de Java (L06).
  6. Se actualiza con Events.EveryTenMinutes, y el estado se carga en Events.OnCreatePlayer (que recibe el número de jugador; usa getSpecificPlayer) (L08).
  7. Una clase CursoLab.Diario hecha con ISBaseObject:derive, con :new(max), :agregar(texto) (descarta las entradas más viejas si pasa de max) y :ultimas(k, filtro), más un __tostring (L09).
  8. Nombres visibles memoizados, el diario guardado en player:getModData().CursoLab pasado por la limpieza de la sección 6, y CursoLab.verDiario(filtro) que imprima las entradas cuyo texto cumpla el patrón filtro, armando la salida con table.concat (L10).

Listo cuando: recoges y sueltas cosas, esperas 10 minutos de juego, y CursoLab.verDiario("Sopaipilla") muestra los cambios; sales, vuelves a entrar y el diario sigue ahí; y el log no tiene ningún error de CursoLab.

Pista: un esqueleto para empezar

No es la solución completa: faltan la comparación de fotos, el filtro y el guardado. Pero muestra cómo encajan las piezas.

lua
-- client/CursoLab_Diario.lua
CursoLab = CursoLab or {}
local CL = CursoLab
local format, concat = string.format, table.concat

function CL.log(...)
    local partes = {}
    for i = 1, select("#", ...) do
        partes[i] = tostring((select(i, ...)))
    end
    print("[CursoLab] " .. concat(partes, " "))
end

CL.Diario = ISBaseObject:derive("CursoLabDiario")
local Diario = CL.Diario

function Diario:new(max)
    local o = ISBaseObject.new(self)
    o.max = max or 50
    o.entradas = {}
    return o
end

function Diario:agregar(texto)
    local e = self.entradas
    e[#e + 1] = format("%.1f h  %s", getGameTime():getWorldAgeHours(), texto)
    while #e > self.max do table.remove(e, 1) end
end

Diario.__tostring = function(self)
    return format("Diario(%d/%d)", #self.entradas, self.max)
end

local function foto(player)
    local cuenta = {}
    local items = player:getInventory():getItems()
    for i = 0, items:size() - 1 do
        local t = items:get(i):getFullType()
        cuenta[t] = (cuenta[t] or 0) + 1
    end
    return cuenta
end

local estado = { diario = nil, anterior = nil }   -- estado del jugador 0 (pantalla dividida: L08)

local function alCrearJugador(numJugador)
    local player = getSpecificPlayer(numJugador)
    if not player then return end
    estado.diario = Diario:new(100)
    -- TODO: cargar entradas desde player:getModData().CursoLab
    estado.anterior = foto(player)
end

local function cadaDiezMinutos()
    local player = getPlayer()
    if not player or not estado.diario then return end
    local actual = foto(player)
    -- TODO: comparar estado.anterior con actual y agregar los cambios al diario
    estado.anterior = actual
    -- TODO: guardar estado.diario.entradas en player:getModData().CursoLab
end

Events.OnCreatePlayer.Add(alCrearJugador)
Events.EveryTenMinutes.Add(cadaDiezMinutos)

Fíjate en tostring((select(i, ...))): los paréntesis extra dejan solo un valor. Y en Diario.__tostring: como derive pone self.__index = self, la clase hace de metatabla de sus objetos, así que el __tostring definido en la clase funciona en cada diario.


Anterior: L09 · Metatablas y clases · Siguiente: 00 · Entorno de trabajo