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:
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 escucharA 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é haces | Qué pasa |
|---|---|
Add(f) | Agrega f al final de la lista. Los handlers corren en el orden en que se registraron |
Add(f) dos veces | f 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 error | El error va al log y los demás handlers del evento siguen corriendo |
| Tu handler devuelve algo | Se 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.
-- server/XpSystem/XpUpdate.lua:9 (registrada en la línea 377 con Events.OnPlayerMove.Add)
xpUpdate.onPlayerMove = function(player)-- 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:
Events.EveryOneMinute.Add(function() print("[CursoLab] tic") end)
Events.EveryOneMinute.Remove(function() print("[CursoLab] tic") end) -- NO quita nadaLa 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.
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)
endSi 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:
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:
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:
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ónde | Ventaja | Cuándo usarlo |
|---|---|---|
local del archivo (upvalue) | Nadie de afuera la toca por error; acceso rápido | Estado 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:
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
endLa 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
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.
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:
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… | Usa | Ejemplo |
|---|---|---|
| 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:
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:
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)
endAquí se juntan las dos ideas del módulo:
- La función es una closure: recuerda
playerObjeisoCampfireObject, que eran parámetros deonDropCorpse. 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 = nily 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.
-- 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 siempreLo 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:
-- 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
endclient/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:
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):
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
endY en las líneas 203 y 204:
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:
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):
local function pname(_player)
return _player and (_player:getUsername() or _player:getDisplayName());
endfunction 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:
-- 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:
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
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
- Escribe un handler de
OnTickcon 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. - Comprueba en la consola que sigue imprimiendo después de los 120.
- 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
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
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
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
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