WENOPAL

L07 · Organizar un mod

Dónde va cada archivo (client, server, shared), en qué orden carga, cómo usar require y módulos, y cómo convivir con el juego y otros mods sin pisarse.

Curso técnico · Build 42 · Actualizado el

Objetivo: repartir tu mod en varios archivos que carguen en el orden correcto, se usen entre sí con require y no choquen con el juego ni con otros mods. Un mod ordenado es más fácil de arreglar, y un error en un archivo no tumba todo lo demás.

1. Tres carpetas: shared, client, server

Todo el Lua de tu mod va dentro de media/lua/ (en Build 42, 42/media/lua/ de tu mod), repartido en tres carpetas:

CarpetaQué va ahíEjemplos
shared/Lo que necesitan todos: la tabla del mod, configuración, utilidades, definiciones, clases de accionesCursoLab_Core.lua, tablas de datos
client/Lo que ve y toca un jugador: menús contextuales, ventanas, teclas, sonidosCursoLab_Menu.lua
server/Lo que decide el mundo: loot, respuestas a comandos de los clientesCursoLab_Server.lua

Cuándo carga cada una (esto sale de zombie.Lua.LuaManager y sus llamadas en Java):

  1. Al arrancar el juego, antes del menú principal: primero shared/, después client/.
  2. Al cargar una partida: server/.
  3. En un servidor dedicado: shared/ y server/. De client/ solo se calcula la suma de verificación (para comprobar que los clientes tienen los mismos archivos), pero no se ejecuta.

Ojo con un detalle: la carga de server/ al entrar a una partida ocurre también en los clientes de multijugador (GameLoadingState.enter() la pide sin preguntar si eres cliente). Por eso los archivos de server/ del juego que solo deben correr en el servidor empiezan cortando la ejecución:

lua
if isClient() then return end

Un return en el nivel superior de un archivo termina ese archivo ahí mismo: lo que viene después no se define. Es la forma más simple de decir "este archivo no es para esta máquina". El juego hace lo mismo al revés en media/lua/client/Foraging/forageClient.lua:

lua
if isServer() then return; end;

El chequeo al inicio de un archivo de server/ funciona porque esa carpeta carga cuando ya se sabe si eres cliente. Para todo lo demás, pregunta isClient() / isServer() dentro de tus funciones, cuando se ejecutan: es lo seguro en cualquier carpeta. El detalle de quién corre qué en multijugador está en 12 · Multijugador.

2. El orden de carga dentro de una carpeta

Dentro de cada carpeta (shared/, client/ o server/), LuaManager.LoadDirBase arma la lista así:

  1. Primero todos los archivos del juego de esa carpeta.
  2. Después los de cada mod, en el orden de carga de los mods.
  3. Dentro del juego y dentro de cada mod, por orden alfabético de la ruta completa, sin distinguir mayúsculas (Java pasa las rutas a minúsculas y las ordena con CASE_INSENSITIVE_ORDER). Las subcarpetas cuentan como parte de la ruta.

Consecuencia útil: cuando carga cualquier archivo client/ de tu mod, todo el client/ del juego ya cargó (y todo shared/). Puedes usar ISPanel o ISContextMenu sin miedo.

Consecuencia peligrosa: el orden alfabético tiene sorpresas. Mira estos cuatro archivos de client/:

client/CursoLab_Menu.lua
client/CursoLab/Ventana.lua
client/cursolab_Atajos.lua
client/CursoLab_Core.lua

Cargan en este orden:

client/CursoLab/Ventana.lua      ← "/" va antes que "_" en la tabla de caracteres
client/cursolab_Atajos.lua       ← las mayúsculas no importan: "atajos" < "core"
client/CursoLab_Core.lua
client/CursoLab_Menu.lua

Si Ventana.lua usa algo de CursoLab_Core.lua al cargar, falla. No dependas del orden alfabético para nada importante: para eso existe require.

3. require: "carga este archivo antes de seguir"

lua
require "CursoLab/Util"          -- sin .lua; relativo a shared/, client/ o server/
require "ISUI/ISPanel"           -- funciona igual con archivos del juego

Lo que hace require en PZ (es LuaManager.GlobalObject.require y LuaManager.RunLuaInternal, en Java):

  • Agrega .lua y busca el archivo en las carpetas que ya se cargaron o se están cargando, en orden: shared/, client/, server/.
  • Si el archivo ya se cargó, no lo vuelve a ejecutar: devuelve lo mismo que devolvió la primera vez. Da igual cuántos archivos lo pidan.
  • Si no se cargó todavía, lo ejecuta ahora, y después sigue con tu archivo.

Y sus trampas, que no avisan con un error:

SituaciónQué pasa
Escribes mal la ruta (require "CursoLab/Utill")Solo una advertencia en console.txt: require("CursoLab/Utill") failed. Devuelve nil y tu archivo sigue. El error sale después, donde uses lo que no cargó
Desde shared/ pides un archivo de client/Mismo resultado: cuando carga shared/, la carpeta client/ todavía no está en la lista de búsqueda. Lo mismo desde client/ hacia server/ al arrancar
A pide B y B pide ALa segunda vuelta no se ejecuta: advertencia recursive require() y nil
El archivo pedido falla al ejecutarse (un error en su nivel superior)Queda marcado como cargado igual. Los siguientes require de ese archivo devuelven nil sin volver a ejecutarlo

Regla práctica: shared/ no puede pedir nada de client/ ni de server/. Si algo lo necesitan dos lados, va en shared/.

4. El patrón módulo: un archivo que devuelve una tabla

Un archivo Lua puede terminar con return valor, y ese valor es lo que te entrega require. Así se arma un módulo: un archivo con funciones que no ensucia el entorno global.

lua
-- shared/CursoLab/Util.lua
local Util = {}

local function limitar(x, min, max)       -- privada: nadie fuera de este archivo la ve
    return math.max(min, math.min(max, x))
end

function Util.porcentaje(parte, total)
    if total == 0 then return 0 end
    return limitar(math.floor(parte / total * 100 + 0.5), 0, 100)
end

function Util.plural(n, uno, varios)
    if n == 1 then return n .. " " .. uno end
    return n .. " " .. varios
end

return Util
lua
-- client/CursoLab_Menu.lua
local Util = require "CursoLab/Util"

local function avisarManzanas(n)
    getPlayer():Say("Tengo " .. Util.plural(n, "manzana", "manzanas"))
end

Como require guarda lo que devolvió la primera vez, todos los archivos que piden el módulo reciben la misma tabla: si uno le cambia algo, los demás lo ven. Y si olvidas el return Util al final, require te devuelve nil.

lua
return {
    Version = 6,
    Zombies = 4,
    Distribution = 1,
lua
SandboxVars = require "Sandbox/Apocalypse"

5. Una sola tabla global por mod

El entorno global de Lua es uno solo, compartido por el juego y por todos los mods activos. Cada global que creas puede chocar con la de otro.

La regla del curso (la misma de 06 · Lua en PZ): una sola global, con el nombre del mod, y todo lo demás dentro de ella o como local.

lua
-- shared/CursoLab_Core.lua
CursoLab = CursoLab or {}

CursoLab.VERSION = "0.2.0"
CursoLab.config = { maxSopaipillas = 4 }

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

¿Por qué CursoLab = CursoLab or {} y no CursoLab = {}? Porque así cualquier archivo del mod puede ser el primero: el que llegue primero crea la tabla y los demás le agregan cosas. Con = {}, un archivo que cargue después borraría lo que pusieron los anteriores. El juego usa este mismo patrón en sus propios archivos, por ejemplo la primera línea de media/lua/client/Hotbar/ISHotbarAttachDefinition.lua:

lua
ISHotbarAttachDefinition = ISHotbarAttachDefinition or {};

¿Global o módulo? Las dos cosas conviven bien:

UsaPara
La tabla global CursoLabLo que otros archivos o otros mods tienen que poder usar (tu API), y lo que inspeccionas desde la consola
Un módulo con requireUtilidades internas que solo usa tu mod
local en el archivoTodo lo que solo usa ese archivo

Las globales sin querer

La causa más común de una global no es decidirlo: es olvidar el local.

lua
function CursoLab.contar()
    total = 0                     -- sin local: acabas de crear la global "total"
    for i = 1, 10 do total = total + i end
    return total
end

Si otro mod también usa una global total sin querer, los dos se pisan y el bug aparece "al azar", según qué se ejecutó último. Cada variable nueva dentro de una función: local. Cada función auxiliar de un archivo: local function.

6. No pises las globales del juego

El juego define cientos de globales: todas las clases de interfaz (ISPanel, ISContextMenu...), las de acciones, utilidades como luautils, y también funciones con nombres muy genéricos. En media/lua/shared/env.lua:

lua
function round(num, idp)
  local mult = 10^(idp or 0)
  return math.floor(num * mult + 0.5) / mult
end

round se usa en casi 190 líneas del Lua del juego. Si tu mod define su propia función global round (por ejemplo, una que redondea siempre hacia abajo), cambias el comportamiento de todo el juego y de todos los mods que la usan. Lo mismo con xpairs o strsplit (también en env.lua), o con luautils, que el juego crea con luautils = {}; en media/lua/shared/luautils.lua: un mod que escriba luautils = {} borra todas sus funciones.

Antes de inventar un nombre global, pregúntale a la consola si ya existe:

lua
print(round)        -- si imprime algo distinto de nil, el nombre ya está tomado
print(CursoLab)

Y si de verdad quieres cambiar una función del juego, hay una forma correcta que no rompe a nadie (guardar la original y llamarla desde la tuya): la ves en 09 · Sobrescribir comportamiento.

7. Poco código en el nivel superior: lo demás, en funciones

El nivel superior de un archivo es lo que no está dentro de ninguna función: se ejecuta en el momento en que el archivo carga. Para shared/ y client/ ese momento es al arrancar el juego, cuando no hay partida: no hay jugador ni mundo.

lua
-- client/CursoLab_Mal.lua
local inv = getPlayer():getInventory()        -- ERROR al arrancar: getPlayer() es nil
CursoLab.config.dificil = SandboxVars.CursoLab.Dificil   -- al arrancar todavía no son los valores de la partida

Peor que el error en sí es lo que deja: un error al cargar un archivo deja el intérprete de Lua en mal estado, y después pueden fallar require y archivos del propio juego que no tienen nada que ver con tu mod. Un error dentro de una función que corre en un evento, en cambio, solo corta esa función.

La regla: en el nivel superior deja solo definiciones (la tabla del mod, constantes, local function, require) y registros de eventos. Todo lo que haga algo, dentro de funciones:

lua
-- client/CursoLab_Bien.lua
require "CursoLab_Core"

local function alEntrar(playerNum, player)
    CursoLab.log("jugador listo:", player:getUsername())
end

local function alEmpezar()
    CursoLab.config.dificil = SandboxVars.CursoLab and SandboxVars.CursoLab.Dificil or false
end

Events.OnCreatePlayer.Add(alEntrar)
Events.OnGameStart.Add(alEmpezar)
lua
if isServer() then return; end;

forageClient = {};
forageData = {};

function forageClient.init()
	forageData = ModData.getOrCreate("forageData");
end
Events.OnInitGlobalModData.Add(forageClient.init)

(Fíjate también en lo que no te conviene copiar: forageData es una segunda global suelta. En tu mod iría dentro de la tabla del mod.)

8. La forma de CursoLab desde ahora

Con todo lo anterior, esta es la estructura que vamos a usar en el resto del curso:

42/media/lua/
├── shared/
│   ├── CursoLab_Core.lua        ← CursoLab = CursoLab or {}, log, config
│   └── CursoLab/
│       └── Util.lua             ← módulo: return Util
├── client/
│   ├── CursoLab_Menu.lua        ← require "CursoLab_Core"; menús, eventos de cliente
│   └── CursoLab_Ventana.lua
└── server/
    └── CursoLab_Server.lua      ← if isClient() then return end; comandos y loot
  • Los nombres de archivo llevan el nombre del mod (CursoLab_... o la carpeta CursoLab/), para no reemplazar sin querer un archivo de otro mod con la misma ruta (lo viste en 01 · Anatomía).
  • Cada archivo que usa algo de otro lo pide con require, aunque "ya debería estar cargado".
  • La única global es CursoLab.

Ejercicios

Ejercicio L7.1 · Adivina el orden

En la carpeta client/ de CursoLab crea estos archivos, cada uno con una sola línea print("[CursoLab] cargó " .. "NOMBRE") (con su nombre):

client/CursoLab_Zeta.lua
client/cursolab_alfa.lua
client/CursoLab/Medio.lua
client/CursoLab_beta.lua

Antes de abrir el juego, escribe en qué orden crees que van a salir. Después ábrelo y compara con console.txt.

Listo cuando: tu predicción coincide, y puedes explicar por qué CursoLab/Medio.lua no sale al medio.

Solución

Las rutas se comparan en minúsculas: cursolab/medio.lua, cursolab_alfa.lua, cursolab_beta.lua, cursolab_zeta.lua. El primer carácter distinto entre cursolab/ y cursolab_ es / contra _, y en la tabla de caracteres / (47) va antes que _ (95). Por eso todo lo que está en la subcarpeta CursoLab/ carga antes que cualquier CursoLab_*.lua de la misma carpeta.

[CursoLab] cargó Medio
[CursoLab] cargó alfa
[CursoLab] cargó beta
[CursoLab] cargó Zeta

Y todos salen después de los archivos client/ del juego, que cargan primero.

Ejercicio L7.2 · Tu primer módulo

  1. Crea shared/CursoLab/Util.lua con las funciones porcentaje y plural de la sección 4, más una tercera: Util.redondear(x, decimales).
  2. Pídelo con require desde dos archivos distintos de client/, y en cada uno guarda la tabla en CursoLab._utilA y CursoLab._utilB.
  3. En la consola, compara CursoLab._utilA == CursoLab._utilB.
  4. Comenta el return Util del final, reinicia el juego y mira qué pasa.

Listo cuando: puedes explicar por qué el paso 3 da true y qué error ves en el paso 4 (y en qué línea aparece: no es en Util.lua).

Solución
lua
-- shared/CursoLab/Util.lua
local Util = {}

function Util.porcentaje(parte, total)
    if total == 0 then return 0 end
    return math.floor(parte / total * 100 + 0.5)
end

function Util.plural(n, uno, varios)
    if n == 1 then return n .. " " .. uno end
    return n .. " " .. varios
end

function Util.redondear(x, decimales)
    local m = 10 ^ (decimales or 0)
    return math.floor(x * m + 0.5) / m
end

return Util

El paso 3 da true porque require ejecuta Util.lua una sola vez y entrega la misma tabla a todos los que la piden. Sin el return, require devuelve nil: el error aparece en el archivo de client/ que intenta usar Util.plural(...) (algo como "attempted index: plural of non-table: null"), no en Util.lua, que cargó sin problemas.

Ejercicio L7.3 · Caza de globales

Este archivo funciona, pero tiene cuatro problemas de organización. Encuéntralos y corrígelos.

lua
-- client/CursoLab_Contador.lua
CursoLab = {}

contador = 0

function round(x)
    return math.floor(x)
end

function sumar()
    contador = contador + 1
    getPlayer():Say("Van " .. round(contador / 2))
end

getPlayer():Say("CursoLab listo")
Events.OnKeyPressed.Add(sumar)

Listo cuando: tu versión no crea ninguna global salvo CursoLab, no pisa nada del juego y no hace nada en el nivel superior salvo definir y registrar.

Solución
  1. CursoLab = {} borra lo que hayan puesto los archivos que cargaron antes (por ejemplo CursoLab_Core.lua en shared/). Es CursoLab = CursoLab or {}.
  2. contador y sumar son globales sueltas: pueden chocar con otro mod.
  3. function round(x) reemplaza la global round del juego (shared/env.lua) por una que no redondea: rompe todo lo que la usa.
  4. getPlayer():Say(...) en el nivel superior se ejecuta al arrancar el juego, cuando no hay jugador: error al cargar.
lua
-- client/CursoLab_Contador.lua
require "CursoLab_Core"
CursoLab = CursoLab or {}

local contador = 0

local function sumar(tecla)
    contador = contador + 1
    local p = getPlayer()
    if p then
        p:Say("Van " .. math.floor(contador / 2))
    end
end

local function alEmpezar()
    getPlayer():Say("CursoLab listo")
end

Events.OnKeyPressed.Add(sumar)
Events.OnGameStart.Add(alEmpezar)

Ejercicio L7.4 · El archivo de servidor

Crea server/CursoLab_Server.lua con la guarda if isClient() then return end en la primera línea, y debajo un CursoLab.log("server cargó") y un Events.OnGameStart.Add(...) que también escriba un mensaje.

  1. Entra a una partida de un jugador. ¿Salen los dos mensajes? ¿Antes o después de los de client/?
  2. Sin abrir un servidor: si un amigo se conecta a tu servidor dedicado con este mod, ¿qué mensajes salen en su console.txt y cuáles en el server-console.txt? Escribe tu respuesta con el porqué.

Listo cuando: el paso 1 coincide con lo que predijiste y el paso 2 está justificado con la sección 1.

Solución
  1. En un jugador isClient() es false, así que el archivo corre entero: salen los dos mensajes. server cargó sale al cargar la partida, mucho después de los mensajes de client/, que salieron al arrancar el juego.
  2. El cliente de tu amigo también carga server/ al entrar a la partida, pero ahí isClient() es true y el archivo se corta en la primera línea: en su console.txt no sale nada de este archivo. En el servidor dedicado sí corre: los dos mensajes van a server-console.txt.

Desafío L7

Reorganiza todo lo que llevas de CursoLab con la estructura de la sección 8:

  • Una sola global, CursoLab, creada con CursoLab = CursoLab or {} en shared/CursoLab_Core.lua.
  • Al menos un módulo en shared/CursoLab/ que se use con require desde client/.
  • Ningún archivo hace nada en el nivel superior salvo require, definiciones y Events.X.Add.
  • server/ tiene su guarda.

Para comprobar que no se te escapó ninguna global, agrega a CursoLab_Core.lua una función que se ejecute en OnGameStart y recorra _G con pairs, comparando contra una foto de las claves de _G que tomaste al principio de ese archivo (CursoLab_Core.lua carga en shared/, después de todo el shared/ del juego). Que imprima todas las globales nuevas. ¿Cuáles son de tu mod y cuáles del juego o de otros mods? (Pista: las de otros mods y las que crea el juego después de shared/ también van a aparecer; filtra las que se crearon en archivos tuyos buscándolas con rg en tu carpeta.)


Anterior: L06 · Lua y objetos de Java · Siguiente: L08 · Eventos y estado