WENOPAL

L08 · Eventos y estado

Engancharte a los eventos del juego, quitar handlers sin errores, guardar estado con closures, medir tiempo y separar el estado de cada jugador.

Curso técnico · Build 42 · Actualizado el

Objetivo: usar Events.X.Add y Events.X.Remove sabiendo cómo funcionan por dentro, guardar estado entre una llamada y otra con closures, no hacer trabajo en cada tick y llevar datos separados por jugador.

1. Qué es un evento

Un mod casi nunca "corre" solo. El juego avisa cuando pasa algo (pasó un minuto, murió un zombi, se creó el jugador) y llama a las funciones que registraste para ese aviso. Cada aviso es un evento, y la tabla global Events tiene una entrada por evento:

lua
local function alMorirZombi(zombi)
    print("[CursoLab] murió un zombi en", zombi:getX(), zombi:getY())
end

Events.OnZombieDead.Add(alMorirZombi)      -- registrar
-- ...
Events.OnZombieDead.Remove(alMorirZombi)   -- dejar de escuchar

A la función que registras se le llama handler (o callback). Fíjate que pasas la función sin paréntesis: alMorirZombi es el valor función; alMorirZombi() la llamaría en ese momento y pasaría lo que devuelve (probablemente nil).

Cómo funciona por dentro

Events.OnZombieDead es una tabla con dos funciones de Java, Add y Remove. Lo puedes leer en la clase zombie.Lua.Event del .jar: cada evento guarda una lista (ArrayList) de funciones de Lua. De ese código salen reglas que conviene saber de memoria:

Qué hacesQué pasa
Add(f)Agrega f al final de la lista. Los handlers corren en el orden en que se registraron
Add(f) dos vecesf queda dos veces en la lista y corre dos veces. No hay control de duplicados
Add(nil) o Add("texto")No pasa nada y no hay error: solo se aceptan funciones de Lua
Remove(f)Quita la primera aparición de esa misma función (se compara por identidad, no por contenido)
Remove(g) con g que no estáNo pasa nada, sin error
Tu handler lanza un errorEl error va al log y los demás handlers del evento siguen corriendo
Tu handler devuelve algoSe ignora

Cada evento pasa argumentos distintos

OnZombieDead pasa el zombi; OnPlayerMove pasa el jugador; EveryOneMinute no pasa nada; OnCreatePlayer pasa el número del jugador, no el objeto. No los adivines: busca en el Lua del juego un Events.X.Add del mismo evento y mira con qué parámetros está escrita esa función.

lua
-- server/XpSystem/XpUpdate.lua:9 (registrada en la línea 377 con Events.OnPlayerMove.Add)
xpUpdate.onPlayerMove = function(player)
lua
-- shared/Logs/ISPerkLog.lua:57 (registrada con Events.OnCreatePlayer.Add)
function ISPerkLog.logCreatePlayer(_player)
    if getServerOptions():getBoolean("PerkLogs") then
        local character = getSpecificPlayer(_player);

En el segundo, el parámetro se llama _player pero es un número: por eso lo convierten con getSpecificPlayer.

2. Quitar un handler: la misma función

Remove necesita la misma función que pasaste a Add. Dos funciones con el mismo código son dos valores distintos:

lua
Events.EveryOneMinute.Add(function() print("[CursoLab] tic") end)
Events.EveryOneMinute.Remove(function() print("[CursoLab] tic") end)   -- NO quita nada

La función anónima del Add no la guardaste en ningún lado: ya no la puedes quitar nunca. Regla práctica: si algún día vas a quitar un handler, guárdalo en una variable o en un campo de tu tabla.

lua
CursoLab = CursoLab or {}

function CursoLab.onMinuto()
    print("[CursoLab] pasó un minuto de juego")
end

function CursoLab.activar()
    Events.EveryOneMinute.Add(CursoLab.onMinuto)
end

function CursoLab.desactivar()
    Events.EveryOneMinute.Remove(CursoLab.onMinuto)
end

Si el archivo se ejecuta dos veces

Cada vez que se ejecuta un local function f() ... end, Lua crea una función nueva. Si tu archivo vuelve a correr (por ejemplo al recargarlo mientras pruebas), registras una función nueva y la vieja sigue en la lista: ahora tu código corre dos veces. Como Remove compara por identidad, la nueva no sirve para quitar la vieja. Si guardas el handler en tu tabla global, puedes quitar el anterior antes de registrar el nuevo:

lua
CursoLab = CursoLab or {}

if CursoLab.onMinuto then
    Events.EveryOneMinute.Remove(CursoLab.onMinuto)   -- el de la ejecución anterior
end

function CursoLab.onMinuto()
    print("[CursoLab] pasó un minuto de juego")
end

Events.EveryOneMinute.Add(CursoLab.onMinuto)

Remove con una función que no está no da error, así que esto es seguro también la primera vez.

3. Closures: funciones que recuerdan

Un handler muchas veces necesita recordar algo entre una llamada y la siguiente: cuántas veces corrió, cuándo fue la última vez, qué valor tenía algo antes. La forma más simple es una variable local del archivo:

lua
local muertes = 0

local function contarMuerte(zombi)
    muertes = muertes + 1
    print("[CursoLab] zombis muertos esta sesión:", muertes)
end

Events.OnZombieDead.Add(contarMuerte)

contarMuerte usa muertes, que no es un parámetro ni una local suya: es una variable de afuera que la función "captura". Una función que captura variables de afuera se llama closure, y las variables capturadas se llaman upvalues. La variable sigue viva mientras la función exista, aunque el archivo ya haya terminado de cargar.

Fábricas de handlers

Si una función crea y devuelve otra función, cada llamada crea una closure nueva con su propia copia de las variables locales:

lua
local function crearContador(nombre)
    local n = 0                       -- cada llamada a crearContador tiene su propio n
    return function()
        n = n + 1
        print("[CursoLab]", nombre, n)
    end
end

local contarMinutos = crearContador("minutos")
local contarHoras   = crearContador("horas")

Events.EveryOneMinute.Add(contarMinutos)
Events.EveryHours.Add(contarHoras)

contarMinutos y contarHoras tienen cada una su n: no se pisan. Y como las guardaste en variables, las puedes quitar con Remove.

Estado en la tabla del mod o en una local

DóndeVentajaCuándo usarlo
local del archivo (upvalue)Nadie de afuera la toca por error; acceso rápidoEstado interno de un solo archivo
Campo de tu tabla global (CursoLab.muertes)Otros archivos del mod la ven; sobrevive si el archivo se vuelve a ejecutar con CursoLab = CursoLab or {}Estado compartido entre archivos, o que quieres inspeccionar desde la consola

Ninguna de las dos sobrevive a cerrar el juego. Para eso está ModData (módulo 14 · Datos persistentes y rendimiento).

4. No hacer todo en cada tick

OnTick corre una vez por frame: a 60 FPS son 60 llamadas por segundo. Casi nada necesita revisarse tan seguido. El truco más viejo es un contador: el handler corre en cada tick, pero sale enseguida casi siempre.

Míralo en el juego: un contador hacia atrás

client/Vehicles/ISUI/ISVehicleDashboard.lua:658, registrado con Events.OnTick.Add(ISVehicleDashboard.damageChecker) en la línea 722:

lua
ISVehicleDashboard.lastVehicleDamageTimer = 0
function ISVehicleDashboard.damageChecker()
    local character = getPlayer()
    if character == nil then return end
    if character:getVehicle() == nil then
        ISVehicleDashboard.onExitVehicle(character)
    end

    if ISVehicleDashboard.lastVehicleDamageTimer <= 0 then
        local vehicle = character:getVehicle()
        if vehicle ~= nil then
            if ISVehicleDashboard.lastVehicleDamage == nil then
                ISVehicleDashboard.lastVehicleDamage = ISVehicleDashboard.getVehicleCondition(vehicle)
            else
                local condition = ISVehicleDashboard.getVehicleCondition(vehicle)
                if comparePartsDamage(ISVehicleDashboard.lastVehicleDamage, condition) then
                    ISVehicleDashboard.damageFlick(character)
                end
                ISVehicleDashboard.lastVehicleDamage = condition
            end
        else
            ISVehicleDashboard.lastVehicleDamage = nil
        end
        ISVehicleDashboard.lastVehicleDamageTimer = 10
    else
        ISVehicleDashboard.lastVehicleDamageTimer = ISVehicleDashboard.lastVehicleDamageTimer - 1
    end
end

La comparación cara (getVehicleCondition arma una tabla con el estado de cada parte del auto) se hace una vez cada 11 ticks; los otros 10 solo restan 1. El estado (lastVehicleDamageTimer y lastVehicleDamage) vive en la tabla del módulo, entre llamada y llamada.

Tu versión con una closure

lua
local CADA = 30          -- ticks
local cuenta = 0

local function revisarJugador()
    cuenta = cuenta + 1
    if cuenta < CADA then return end
    cuenta = 0
    local jugador = getPlayer()
    if not jugador then return end
    -- trabajo de verdad, ~2 veces por segundo a 60 FPS
end

Events.OnTick.Add(revisarJugador)

Fíjate en que la cuenta vuelve a 0 en vez de crecer para siempre y usar %. Las dos formas funcionan, pero recuerda que en Kahlua % con números negativos no da lo mismo que en Lua estándar (lo viste en L02 · Números, texto y booleanos); un contador que vuelve a 0 te ahorra pensar en eso.

Antes de usar OnTick, pregúntate si hay un evento más lento que sirva. EveryOneMinute, EveryTenMinutes, EveryHours y EveryDays corren con el reloj del juego, y casi siempre alcanzan. El módulo 14 tiene la tabla de cuánto corre cada evento.

5. Temporizadores con getTimestampMs()

Contar ticks tiene un problema: los ticks dependen de los FPS. A 30 FPS, "cada 60 ticks" son 2 segundos; a 120 FPS, medio segundo. Si lo que te importa es tiempo real, mide tiempo real: getTimestampMs() devuelve el reloj en milisegundos.

lua
local INTERVALO = 2000           -- ms
local proximo = 0

local function cadaDosSegundos()
    local ahora = getTimestampMs()
    if ahora < proximo then return end
    proximo = ahora + INTERVALO
    print("[CursoLab] pasaron ~2 segundos reales")
end

Events.OnTick.Add(cadaDosSegundos)

Guardar "cuándo toca la próxima vez" (proximo) es más simple que guardar "cuándo fue la última" y restar en cada tick, aunque las dos formas sirven.

Míralo en el juego: un temporizador real

server/Foraging/forageServer.lua:455, registrado con Events.OnTick.Add(forageServer.relevanceTick) en la línea 502:

lua
function forageServer.relevanceTick()
    local now = getTimestampMs();
    if now < forageServer._nextRelevanceMs then
        return;
    end;
    forageServer._nextRelevanceMs = now + forageServer.relevanceMs;

Con forageServer.relevanceMs = 2000 (línea 18), el resto de la función corre una vez cada 2 segundos reales, sin importar los FPS del servidor.

Tiempo real o tiempo de juego

Quieres medir…UsaEjemplo
Tiempo real (segundos de reloj)getTimestampMs()Limitar cuántas veces por segundo se envía algo, refrescar una ventana
Tiempo de juego (minutos, horas del mundo)Eventos EveryOneMinute, EveryHours..., o getGameTime():getWorldAgeHours()"La comida tarda 3 horas de juego en cocinarse"

Si el jugador acelera el tiempo, el tiempo de juego corre más rápido; el real, no. Elegir mal hace que tu mecánica funcione distinto según la velocidad del juego.

6. Un handler que se quita a sí mismo

Muchas veces quieres algo que corra una sola vez, o hasta que pase algo. El handler se registra, hace lo suyo y se quita.

Correr una sola vez

shared/Logs/ISPerkLog.lua:104:

lua
function ISPerkLog.init()
    Events.EveryOneMinute.Remove(ISPerkLog.init)
    --
    local character = getSpecificPlayer(0);
    ISPerkLog.logLogin(character);
    ISPerkLog.logAllPerks(character);

Y en la línea 118: if isClient() then Events.EveryOneMinute.Add(ISPerkLog.init); end;. En un cliente de multijugador, el primer minuto de juego después de cargar, init corre, se quita y ya no vuelve a correr. Es una forma de "esperar a que el mundo esté listo".

Esperar hasta que se cumpla algo

shared/Camping/ISCampingMenu.lua:444. El jugador tiene que terminar de girar hacia la fogata antes de soltar el cadáver:

lua
function ISCampingMenu.onDropCorpse(worldobjects, playerObj, isoCampfireObject, campfire)
	playerObj:faceThisObject(isoCampfireObject)
	local func = nil
	func = function()
		playerObj:faceThisObject(isoCampfireObject)
		if not playerObj:shouldBeTurning() then
			playerObj:getGrapplingTarget():setCurrent(isoCampfireObject:getSquare())
			playerObj:setDoGrappleLetGo();
			Events.OnTick.Remove(func)
		end
	end
	Events.OnTick.Add(func)
end

Aquí se juntan las dos ideas del módulo:

  • La función es una closure: recuerda playerObj e isoCampfireObject, que eran parámetros de onDropCorpse. Cada vez que el jugador elige la opción se crea una closure nueva con sus valores.
  • Se quita a sí misma cuando termina. Para poder nombrarse adentro, primero se declara local func = nil y después se le asigna la función.

La trampa del local f = function

¿Por qué no escribir directamente local func = function() ... end? Porque en Lua la variable local empieza a existir después de la línea que la declara. Dentro de la función, func todavía no es la local: es una global func, que vale nil.

lua
-- MAL: adentro, func es la global (nil)
local func = function()
    if listo() then
        Events.OnTick.Remove(func)    -- Remove(nil): no hace nada y no da error
    end
end
Events.OnTick.Add(func)               -- corre para siempre

Lo peor es que no hay error: Remove(nil) simplemente no hace nada (sección 1), y tu handler queda corriendo en cada tick para siempre. Dos formas correctas:

lua
-- Bien: declarar primero, asignar después (como el juego)
local func
func = function()
    if listo() then Events.OnTick.Remove(func) end
end

-- Bien: local function (es azúcar para lo mismo)
local function func2()
    if listo() then Events.OnTick.Remove(func2) end
end

client/ISUI/ISButtonPrompt.lua:234 usa la segunda forma (local function waitForCancelRelease(), que se quita en la línea 241).

¿Es seguro quitarse durante el evento?

Sí. En zombie.Lua.Event.trigger el juego recorre la lista por índice y, después de llamar a cada función, revisa si esa función sigue en la lista; si no, ajusta el índice para no saltarse la siguiente. Dos detalles del mismo código:

  • Si un handler quita a otro handler que ya corrió en esta vuelta, la lista se corre y el siguiente se puede saltar una vez. Quítate solo a ti mismo.
  • Si un handler agrega otro durante el evento, el nuevo queda al final y corre en esa misma vuelta.

7. Estado por jugador

En pantalla dividida hay hasta 4 jugadores en la misma máquina, y en un servidor muchos más. Una sola variable para "el jugador" no alcanza.

El error: una variable para todos

server/XpSystem/XpUpdate.lua guarda la última posición en dos campos del módulo:

lua
xpUpdate.lastX = 0;
xpUpdate.lastY = 0;
-- ... y al final de xpUpdate.onPlayerMove(player):
	xpUpdate.lastX = x;-- these values are used to prevent AFKing to grind nimble
	xpUpdate.lastY = y;

Con un jugador funciona. Si OnPlayerMove llega para dos jugadores distintos, cada uno pisa los valores del otro, y la comparación "¿se movió desde la última vez?" mezcla las posiciones de los dos.

En el cliente: por número de jugador

Del lado del cliente, cada jugador local tiene un número (0 a 3) que te da jugador:getPlayerNum(). El juego guarda los datos de la interfaz de cada jugador así (client/ISUI/PlayerData/ISPlayerData.lua):

lua
ISPlayerData = {}

function getPlayerData(id)
    return ISPlayerData[id+1];
end

-- ...
function destroyPlayerData(playerObj)
    local id = playerObj:getPlayerNum()
    if ISPlayerData[id+1] then
        removeInventoryUI(id)
        ISPlayerData[id+1] = nil
    end
end

Y en las líneas 203 y 204:

lua
Events.OnCreatePlayer.Add(createPlayerData)
Events.OnPlayerDeath.Add(destroyPlayerData)

Fíjate en el id+1: los números de jugador empiezan en 0 y las listas de Lua en 1. Se crea el estado cuando aparece el jugador y se borra cuando muere. Tu versión:

lua
CursoLab = CursoLab or {}
CursoLab.porJugador = CursoLab.porJugador or {}

local function datosDe(jugador)
    local num = jugador:getPlayerNum()
    local d = CursoLab.porJugador[num]
    if not d then
        d = { pasos = 0, ultimaX = jugador:getX(), ultimaY = jugador:getY() }
        CursoLab.porJugador[num] = d
    end
    return d
end

local function alMoverse(jugador)
    local d = datosDe(jugador)
    local x, y = jugador:getX(), jugador:getY()
    if x ~= d.ultimaX or y ~= d.ultimaY then
        d.pasos = d.pasos + 1
        d.ultimaX, d.ultimaY = x, y
    end
end

local function alMorir(jugador)
    CursoLab.porJugador[jugador:getPlayerNum()] = nil
end

Events.OnPlayerMove.Add(alMoverse)
Events.OnPlayerDeath.Add(alMorir)

Aquí la clave es el número y no hace falta el +1, porque es un mapa (lo usamos como clave, no como posición de una lista).

En el servidor: por nombre de usuario

El número de jugador es de pantalla dividida (de 0 a 3 en cada máquina), así que no sirve para distinguir a jugadores conectados desde máquinas distintas. En el servidor, el juego usa el nombre de usuario (server/Foraging/forageServer.lua):

lua
local function pname(_player)
    return _player and (_player:getUsername() or _player:getDisplayName());
end
lua
function forageServer.onRequestZone(_player, _focus)
    if not _player then
        return;
    end;
    local user = pname(_player);
    if not user then
        return;
    end;
    local now = getTimestampMs();
    local last = forageServer.lastReqMs[user];
    if last and (now - last) < forageServer.minReqMs then
        return;
    end;
    forageServer.lastReqMs[user] = now;

Es un temporizador por jugador: cada usuario puede pedir datos como máximo una vez cada 250 ms (forageServer.minReqMs = 250). Y en relevanceTick (línea 455) el servidor revisa cada 2 segundos quién sigue conectado y llama a forageServer.dropPlayer(user) para borrar el estado de los que se fueron. Sin esa limpieza, la tabla crece con cada jugador que alguna vez entró.

No uses el objeto jugador como clave

Una tabla puede tener cualquier valor como clave, incluido un objeto de Java como el IsoPlayer. Funciona, pero ese objeto queda guardado mientras la clave exista, aunque el jugador haya muerto o se haya desconectado (lo viste en L06 · Lua y objetos de Java). Y como en Kahlua no hay tablas débiles (__mode no hace nada), la tabla no se limpia sola. Usa el número o el nombre de usuario, y borra la entrada cuando el jugador se va.

8. CursoLab: un solo OnTick para todos tus temporizadores

Cuando un mod crece, aparecen diez handlers de OnTick, cada uno con su contador. Es más ordenado tener uno que revise una lista de tareas:

lua
-- shared/CursoLab_Timers.lua
CursoLab = CursoLab or {}
CursoLab.tareas = CursoLab.tareas or {}

-- Corre fn una vez, dentro de ms milisegundos reales.
function CursoLab.despues(ms, fn)
    local t = { cuando = getTimestampMs() + ms, fn = fn }
    table.insert(CursoLab.tareas, t)
    return t
end

-- Corre fn cada ms milisegundos reales hasta que la canceles.
function CursoLab.cada(ms, fn)
    local t = { cuando = getTimestampMs() + ms, fn = fn, repetir = ms }
    table.insert(CursoLab.tareas, t)
    return t
end

function CursoLab.cancelar(t)
    t.cancelada = true
end

local function revisarTareas()
    local lista = CursoLab.tareas
    if #lista == 0 then return end
    local ahora = getTimestampMs()
    -- de atrás hacia adelante, para poder borrar sin saltarse ninguna
    for i = #lista, 1, -1 do
        local t = lista[i]
        if t.cancelada then
            table.remove(lista, i)
        elseif ahora >= t.cuando then
            t.fn()
            if t.repetir and not t.cancelada then
                t.cuando = ahora + t.repetir
            else
                table.remove(lista, i)
            end
        end
    end
end

if CursoLab._revisarTareas then
    Events.OnTick.Remove(CursoLab._revisarTareas)
end
CursoLab._revisarTareas = revisarTareas
Events.OnTick.Add(revisarTareas)

Uso:

lua
CursoLab.despues(3000, function()
    getPlayer():Say("Pasaron 3 segundos")
end)

local reloj = CursoLab.cada(1000, function()
    print("[CursoLab] tic")
end)
-- más tarde:
CursoLab.cancelar(reloj)

Dos decisiones de diseño:

  • Cada tarea es una tabla que devuelves. Así se puede cancelar sin buscarla por la función (que podría estar dos veces).
  • Si no hay tareas, el handler sale en la primera línea: el costo por tick es casi nada.

Ejercicios

Ejercicio L8.1 · Contar y dejar de contar

Haz que CursoLab cuente los zombis que mata el jugador en esta sesión y lo diga sobre su cabeza (getPlayer():Say(...)) cada 5 muertes. Al llegar a 20, el handler se quita a sí mismo y dice "Suficiente por hoy".

Listo cuando: después de 20 muertes no vuelve a decir nada, y en la consola no hay errores.

Solución
lua
local muertes = 0

local function contar(zombi)
    muertes = muertes + 1
    local jugador = getPlayer()
    if not jugador then return end
    if muertes >= 20 then
        jugador:Say("Suficiente por hoy")
        Events.OnZombieDead.Remove(contar)
    elseif muertes % 5 == 0 then
        jugador:Say("Van " .. muertes)
    end
end

Events.OnZombieDead.Add(contar)

local function contar hace que contar exista dentro de la propia función, así que el Remove funciona. Ojo: OnZombieDead avisa de cualquier zombi que muere, no solo de los que mató tu jugador. Para el ejercicio alcanza; en un mod de verdad tendrías que revisar quién lo mató.

Ejercicio L8.2 · La trampa, a propósito

  1. Escribe un handler de OnTick con la forma mala (local f = function() ... Events.OnTick.Remove(f) ... end) que debería quitarse después de 120 ticks y que imprime en cada tick.
  2. Comprueba en la consola que sigue imprimiendo después de los 120.
  3. Arréglalo de las dos formas correctas de la sección 6.

Listo cuando: puedes explicar, sin mirar, por qué la versión mala no da ningún error.

Solución
lua
local n = 0
local f
f = function()
    n = n + 1
    print("[CursoLab] tick", n)
    if n >= 120 then Events.OnTick.Remove(f) end
end
Events.OnTick.Add(f)

En la versión mala, dentro de la función f es la variable global f (la local todavía no existía cuando se escribió la función). Vale nil, y Events.OnTick.Remove(nil) no hace nada: zombie.Lua.Event.Remove solo actúa si recibe una función de Lua.

Ejercicio L8.3 · Ticks contra milisegundos

Registra dos handlers de OnTick: uno que imprime cada 60 ticks y otro que imprime cada 1000 ms con getTimestampMs(). Juega un minuto con el límite de FPS del juego en un valor y después en otro (opciones de pantalla).

Listo cuando: puedes decir cuál de los dos cambió de ritmo y por qué.

Solución
lua
local cuenta = 0
local function porTicks()
    cuenta = cuenta + 1
    if cuenta < 60 then return end
    cuenta = 0
    print("[CursoLab] 60 ticks")
end

local proximo = 0
local function porReloj()
    local ahora = getTimestampMs()
    if ahora < proximo then return end
    proximo = ahora + 1000
    print("[CursoLab] 1000 ms")
end

Events.OnTick.Add(porTicks)
Events.OnTick.Add(porReloj)

El de ticks cambia con los FPS (más FPS, más seguido); el de milisegundos no.

Ejercicio L8.4 · Pasos por jugador

Usando el ejemplo de la sección 7, muestra los pasos de cada jugador local cada 30 segundos reales con jugador:Say(...). Usa CursoLab.cada de la sección 8. Pruébalo en pantalla dividida si tienes un mando; si no, con un jugador.

Listo cuando: cada jugador ve sus propios pasos, y al morir y volver a crear el personaje la cuenta empieza en 0.

Solución
lua
CursoLab.cada(30000, function()
    for num, d in pairs(CursoLab.porJugador) do
        local jugador = getSpecificPlayer(num)
        if jugador and not jugador:isDead() then
            jugador:Say("Pasos: " .. d.pasos)
        end
    end
end)

Guardamos el número, no el objeto, y pedimos el jugador con getSpecificPlayer(num) en el momento: si ya no está, nos salteamos esa entrada.

Ejercicio L8.5 · Registrarse una sola vez

Tu archivo de CursoLab registra CursoLab.onMinuto en EveryOneMinute. Haz que, aunque el archivo se ejecute tres veces seguidas (simúlalo copiando el bloque de registro tres veces en el archivo), el mensaje salga una sola vez por minuto.

Listo cuando: el mensaje sale una vez por minuto de juego con el bloque repetido.

Solución
lua
CursoLab = CursoLab or {}

if CursoLab.onMinuto then
    Events.EveryOneMinute.Remove(CursoLab.onMinuto)
end
function CursoLab.onMinuto()
    print("[CursoLab] minuto")
end
Events.EveryOneMinute.Add(CursoLab.onMinuto)

Cada repetición quita la función que registró la anterior y registra la suya: al final queda una sola en la lista.

Desafío L8

Agrega a CursoLab.despues/CursoLab.cada una tercera función, CursoLab.cuando(condicion, fn, maxMs): revisa condicion() en cada tick y, la primera vez que devuelve true, llama a fn() y se termina. Si pasan maxMs milisegundos sin que se cumpla, se cancela sola y escribe un aviso con el prefijo [CursoLab]. Úsala para que, al comer cualquier cosa, el jugador diga "Rico" recién cuando su hambre baje de cierto valor (pista: getPlayer():getStats(); busca en el Lua del juego cómo se lee el hambre en B42). Revisa que tu sistema siga usando un solo handler de OnTick.


Anterior: L07 · Organizar un mod · Siguiente: L09 · Metatablas y clases