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.
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 100Como guarda la variable y no el valor, si la variable cambia después, la closure ve el cambio:
local x = 1
local function verX() return x end
x = 2
print(verX()) -- 2, no 1La 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:
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 4Si 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.
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;
endY 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é:
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 noUn memoizador genérico
Con closures puedes envolver cualquier función de un argumento:
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] = valorno da error en Kahlua (en Lua estándar sí): la asignación se pierde en silencio. Lo probamos. Por eso el memoizador revisax == nila 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: elfullType, 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:
local function porcentaje(valor)
if type(valor) ~= "number" then
error("porcentaje: se esperaba un número, llegó " .. type(valor))
end
return valor * 100
endfunction logTo01(value)
if value < 0.0 or value > 1.0 then
error("only [0,1] accepted!");
endAtrapar 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:
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ándar | En PZ (Kahlua) |
|---|---|
Un error atrapado con pcall es silencioso | Se 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 mensaje | El mensaje queda tal cual: "x" |
error("x", 2) cambia el nivel que se reporta | El segundo argumento no es un nivel: no lo uses |
pcall devuelve false, mensaje | Devuelve 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 Lua | El 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
OnTickni 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":
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) endUn despachador de avisos con pcall en el lugar correcto:
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
endCompá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):
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.resumesobre una corutina terminada lanza un error ("Can not resume coroutine that is in status: dead") en vez de devolverfalse. Revisacoroutine.status(co) == "suspended"antes.- No puedes hacer
yielddentro de unpcall: 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:
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
end5. 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
| Pieza | Significa |
|---|---|
. | cualquier carácter |
%a %d %l %u | letra, dígito, minúscula, mayúscula |
%s %w %p %x | espacio, 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 final | anclas: 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] |
%1 | lo 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
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
endgsub 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:
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 2Si 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:
| Caso | Lua estándar | Kahlua (PZ) |
|---|---|---|
| Patrón con varias capturas y una función | La función recibe todas las capturas | Recibe solo la primera (las demás llegan como nil) |
La función o la tabla devuelve nil | Deja el texto encontrado completo | Pone la primera captura (o el texto completo si no hay capturas) |
La función devuelve false | Deja el texto encontrado completo | Escribe null |
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.
luautils.trim = function(s)
return (s:gsub("^%s*(.-)%s*$", "%1"))
endY 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:
local function lastIndexOf(_string, _needle)
local i = _string:match(".*" .. _needle .. "()")Dos bugs del juego que enseñan más que la teoría
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%$".
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:
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 / "" / cPara 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
-- "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 guarda | No se guarda (se descarta sin aviso) |
|---|---|
| Claves de texto o número | Claves booleanas, claves que son tablas u objetos |
| Valores de texto, número, booleano o tabla | Funciones, 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.aymd.bapuntan a la misma tabla, al guardar se escribe dos veces y al cargar son dos tablas distintas. - Un ciclo no tiene freno.
savese 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.
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, ",") .. "}"
endPara 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.
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
endProbado en Kahlua, ida y vuelta:
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 8Las 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
getFileWritery léelo congetFileReadery:readLine()(el juego lo hace enmedia/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:
local floor, min, max = math.floor, math.min, math.max
local insert, concat = table.insert, table.concat
local format = string.formatlocal insert, concat, format, print = table.insert, table.concat, string.format, printY 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
-- 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:
-- 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écnica | Cuándo importa |
|---|---|
| Locales en vez de globales | Funciones que corren cada tick o en bucles grandes |
Reutilizar tablas (table.wipe) | Eventos frecuentes (OnTick, OnPlayerUpdate) |
| Funciones definidas afuera | Igual: nada de function dentro del evento caliente |
table.concat | Textos armados en bucles |
| Memoizar | Llamadas a Java repetidas con los mismos argumentos |
Sin pcall en eventos calientes | Siempre 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.
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
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))
endk % 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:
CursoLab.esFullType(s):truesistiene formaModulo.Item(letras, dígitos o_a cada lado de un solo punto).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
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
endEn 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
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))
endLos $ 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:
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.
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):
- Un archivo
client/CursoLab_Diario.luaque agrega todo a la tabla globalCursoLab, sin globales sueltas y con el nivel superior mínimo: solo definiciones y registros de eventos (L01, L07). - Textos armados con
string.formatytostring, sin errores con booleanos (L02, L10). - Una función
CursoLab.log(...)que acepte cualquier cantidad de argumentos, incluidosnil, conselect("#", ...)(L04). - Una "foto" del inventario como mapa
fullType -> cantidad, y una función que compare dos fotos y devuelva la lista de cambios (L03, L05). - La foto se arma recorriendo el
ArrayListdegetInventory():getItems()desde 0, guardando textos, nunca los objetos de Java (L06). - Se actualiza con
Events.EveryTenMinutes, y el estado se carga enEvents.OnCreatePlayer(que recibe el número de jugador; usagetSpecificPlayer) (L08). - Una clase
CursoLab.Diariohecha conISBaseObject:derive, con:new(max),:agregar(texto)(descarta las entradas más viejas si pasa demax) y:ultimas(k, filtro), más un__tostring(L09). - Nombres visibles memoizados, el diario guardado en
player:getModData().CursoLabpasado por la limpieza de la sección 6, yCursoLab.verDiario(filtro)que imprima las entradas cuyo texto cumpla el patrónfiltro, armando la salida contable.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.
-- 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