WENOPAL

06 · Lua en Project Zomboid (Kahlua)

Qué dialecto de Lua usa Project Zomboid, qué le falta, y cómo organizar un mod con namespace, clases, eventos y archivos.

Curso técnico · Build 42 · Actualizado el

Objetivo: escribir Lua para PZ sabiendo qué dialecto es, qué falta respecto del Lua "normal", cómo se organiza un mod de verdad (namespace, clases, eventos) y qué patrones usa el propio juego. Ya sabes programar: aquí solo va lo que cambia.

1. Qué Lua es este

PZ no usa el intérprete oficial de Lua. Usa Kahlua: una implementación de Lua 5.1 escrita en Java que corre dentro de la JVM del juego. Consecuencias:

TemaEn PZPor qué importa
VersiónLua 5.1No hay goto (el juego tiene un comentario quejándose: server/metazones/metazoneHandler.lua:92), ni //, ni operadores de bits nativos, ni table.unpack (es unpack)
NúmerosTodos son double de Java3 / 2 == 1.5; para enteros usa math.floor. Los IDs grandes pierden precisión
os, ioNo existen (ninguna línea del juego los usa)Para archivos: getFileWriter / getFileReader (sección 5)
loadstring, dofileNo se usanEl código se carga solo con require y la carga del mod
VelocidadInterpretado sobre la JVMBarato llamar a Java; caro hacer bucles grandes en Lua cada tick (módulo 14)
ErroresLos atrapa Kahlua y los muestra en el contador rojo (debug) y en console.txtUn error dentro de un evento no detiene el juego, pero corta tu función a la mitad

Lo que sí está y usa el juego: string.format (65 archivos), setmetatable (451), unpack (18), select("#", ...), pcall, closures, varargs.

2. Funciones globales que vienen de Java

Muchas funciones "globales" no están definidas en ningún .lua: las pone Java en el entorno de Lua. Las que vas a usar desde el primer día:

FunciónQué hace
getPlayer() / getSpecificPlayer(n)El jugador local (o el número n en pantalla dividida)
getText("Clave")Traducción
ZombRand(n), ZombRandFloat(a, b)Aleatorios del juego (480 usos). Úsalos en vez de math.random
getTimestampMs()Reloj en milisegundos
isClient(), isServer()En qué lado corre el código (882 usos; módulo 12)
instanceof(obj, "IsoPlayer")Pregunta la clase Java de un objeto (221 usos)
getTexture(nombre), getCore(), getGameTime(), getCell(), getWorld()Acceso al motor
triggerEvent("OnX", ...)Dispara un evento a mano

En el módulo 07 aprendes a sacar la lista completa de estas funciones del .jar.

3. Organizar un mod: el patrón de nuestros mods

Todos nuestros mods con varios archivos siguen el mismo patrón, y es el correcto:

lua
-- shared/CursoLab_Core.lua
CursoLab = CursoLab or {}         -- una sola tabla global por mod, creada de forma idempotente
local CL = CursoLab               -- alias local: más corto y más rápido

CL.CONFIG = { maxSopaipillas = 4 }

local function helperPrivado(x)   -- todo lo que no es API va como local
    return x * 2
end

function CL.api(x)
    return helperPrivado(x)
end
  • Una tabla global con el nombre del mod. Nunca variables globales sueltas: el entorno global es compartido con el juego y con todos los mods.
  • X = X or {} permite que varios archivos del mod agreguen cosas a la misma tabla sin importar el orden, y que recargar con F11 no borre el estado.
  • local para todo lo demás: en Kahlua una variable local es más rápida que una global (que es una búsqueda en tabla).

4. Clases: derive y :new

El juego no tiene clases: tiene un patrón con metatablas en shared/ISBaseObject.lua, que es corto y vale la pena leer completo:

lua
function ISBaseObject:derive(type)
    local o = {}
    setmetatable(o, self)
    self.__index = self
    o.Type = type
    o.SuperType = self
    return o
end

Todo lo que es "clase" en el Lua del juego (ISPanel, ISBaseTimedAction, ISContextMenu) sale de aquí. Una clase tuya:

lua
CursoLab.Contador = ISBaseObject:derive("CursoLabContador")
local Contador = CursoLab.Contador

function Contador:new(inicio)
    local o = ISBaseObject.new(self)
    o.n = inicio or 0
    return o
end

function Contador:sumar(k)
    self.n = self.n + (k or 1)
    return self.n
end

Para llamar al método del padre se hace explícito: ISPanel.render(self). No hay super.

Dos instanceof distintos: instanceof(obj, "IsoZombie") (global, para objetos Java) y ISBaseObject:instanceof(obj, "ISPanel") (para tablas Lua con campo Type). Confundirlos es un clásico.

5. Eventos

El 90 % de un mod es reaccionar a eventos:

lua
local function onEveryTen()
    CursoLab.log("diez minutos de juego")
end
Events.EveryTenMinutes.Add(onEveryTen)
-- y para sacarlo: Events.EveryTenMinutes.Remove(onEveryTen)
  • Cada evento pasa argumentos distintos. No los adivines: busca en el juego un Events.X.Add del mismo evento y mira la firma. La referencia del sitio (/referencia/eventos.html) lista dónde los usa el juego.
  • Eventos propios: el juego los crea así (server/Foraging/forageServer.lua:497):
lua
LuaEventManager.AddEvent("OnForageSpot")
-- ...
triggerEvent("OnForageSpot", arg1, arg2)

Así otros mods (o tus propios archivos) se enganchan sin depender de tus funciones internas.

  • Protege tus handlers con pcall si hacen algo arriesgado. Wanaco, uno de nuestros mods, lo hace en cada comando de servidor: un error en un comando no tumba el listener.

6. Archivos: getFileWriter / getFileReader

Como no hay io, el juego usa (en shared/Foraging/forageSystem.lua:681):

lua
local w = getFileWriter("forageMissingItems.log", true, false)   -- (nombre, crear si no existe, agregar al final)
w:write("texto\n")
w:close()

Los archivos quedan en ~/Zomboid/Lua/. Sirven para logs propios, exportar datos o configuración local. Para datos que tienen que viajar con la partida o sincronizarse en multijugador se usa ModData (módulos 12 y 14).

7. La librería del juego que puedes usar

No reinventes: el juego trae utilidades en Lua.

  • shared/luautils.lua: luautils.split, luautils.round, luautils.tableContains, luautils.walkAdj (caminar al lado de algo antes de una acción)...
  • shared/Util/: LuaTableUtil, StringReplacer, AdjacentFreeTileFinder...

Úsalas con require "luautils" o require "Util/LuaTableUtil" si las necesitas antes de que carguen.


Ejercicios

Ejercicio 6.1 · El dialecto

En la consola Lua, comprueba y anota el resultado de:

  1. print(7 / 2, math.floor(7 / 2), 2^53 + 1)
  2. print(type(getPlayer()), tostring(getPlayer()))
  3. print(coroutine, os, io, bit)
  4. print(pcall(function() error("boom") end))

Listo cuando: puedes explicar por qué type(getPlayer()) no dice "table".

Pista para la 2

getPlayer() devuelve un objeto Java (IsoPlayer) envuelto para Lua: Kahlua debería reportarlo como userdata (compruébalo). No es una tabla: no puedes ponerle campos (getPlayer().x = 1 falla). Para guardar datos en un objeto Java se usa obj:getModData(), que sí devuelve una tabla.

Ejercicio 6.2 · Una clase propia

Escribe CursoLab.Bitacora, una clase derivada de ISBaseObject que guarde los últimos N eventos (con hora de juego: getGameTime():getWorldAgeHours()) y que tenga :agregar(texto), :ultimos(k) y :volcar() (este último escribe todo a ~/Zomboid/Lua/CursoLab_bitacora.log con getFileWriter).

Listo cuando: después de jugar 10 minutos tienes un log con lo que pasó.

Ejercicio 6.3 · Escuchar el juego

Usando la Bitácora, registra:

  • cuando el jugador come algo (pista: busca en ISEatFoodAction.lua qué pasa al terminar; ¿hay un evento o tienes que engancharte a la acción?),
  • cuando sube de nivel una habilidad (busca un evento con Level o Perk en la referencia),
  • cuando mata un zombi.

Listo cuando: los tres aparecen en la bitácora. Para el primero quizás no haya evento: anota cómo lo resolviste (el módulo 09 te da la herramienta limpia).

Ejercicio 6.4 · Un evento propio

Crea el evento OnCursoLabSopaipilla que se dispara cuando alguien come una sopaipilla, y engánchate a él desde otro archivo de CursoLab para mostrar un mensaje sobre la cabeza del jugador (getPlayer():Say("...")).

Listo cuando: el archivo que escucha no llama a ninguna función interna del que dispara.

Ejercicio 6.5 · Leer Lua del juego

Lee completo shared/TimedActions/ISEatFoodAction.lua (o el que te tocó en el 6.3) y escribe, en 10 líneas, qué hace cada función y en qué orden las llama el juego.

Desafío 6

Escribe un "inspector" para la consola: CursoLab.dump(valor, profundidad) que imprima tablas de forma legible (con ciclos detectados) y, si el valor es un objeto Java, imprima su clase. Lo vas a usar en todo el resto del curso. (El juego tiene uno en client/DebugUIs/ObjectViewer.lua: mira cómo lista los campos de un objeto Java con getNumClassFields y getClassFieldVal.)


Anterior: 05 · Modelos 3D · Siguiente: 07 · La frontera Lua ↔ Java