Objetivo: cambiar lo que hace el juego sin copiar sus archivos, de forma que tu mod sobreviva a los parches y conviva con otros mods. Al final sabes elegir la herramienta según dónde vive el comportamiento: datos, Lua o Java.
1. ¿Dónde vive lo que quieres cambiar?
Con los módulos 07 y 08 ya puedes responder esto antes de escribir una línea:
| Vive en… | Ejemplo | Cómo se cambia | Riesgo |
|---|---|---|---|
Datos (scripts .txt, distribuciones) | peso de un ítem, ingredientes de una receta, loot | Script propio que lo redefine, o Lua que lo modifica al cargar (módulo 13) | Bajo |
| Lua del juego | menús, acciones, UI, gran parte de la lógica de juego | Envolver la función (sección 2) | Bajo si envuelves, alto si copias |
| Java con un gancho | ataque, beber automático, estadísticas | Hook.X.Add (sección 4) o un evento | Medio: reemplazas comportamiento de Java |
| Java sin gancho | la fórmula del loot, el daño base | Indirecto: cambiar los datos que lee, o reaccionar después con un evento | A veces no se puede |
La regla de oro: cambia lo mínimo, en el nivel más alto posible. Si alcanza con datos, no toques Lua; si alcanza con envolver, no copies.
2. Envolver una función de Lua
En Lua las funciones son valores guardados en tablas. ISEatFoodAction.complete es solo el campo complete de la tabla ISEatFoodAction: puedes guardar el valor original y poner otra función en su lugar que llame a la original.
Un mod nuestro sin publicar (DesarmeXP) lo hace así (DesarmeXP/.../shared/DesarmeXP_Hooks.lua):
require "Entity/TimedActions/ISHandcraftAction" -- 1. asegura que el original ya existe
local origPerformRecipe = ISHandcraftAction.performRecipe -- 2. guarda el original
function ISHandcraftAction:performRecipe() -- 3. pon la tuya en su lugar
local eligible = ... -- algo ANTES
origPerformRecipe(self) -- 4. llama al original
if eligible then ... end -- algo DESPUÉS
endEl comentario de cabecera de ese archivo resume la filosofía: "Se envuelve la función original, nunca se copia." Si copiaras la función entera, cuando el juego la cambie en un parche tu mod seguiría ejecutando la versión vieja.
Las cuatro formas de envolver
local orig = Tabla.metodo
-- Antes: preparar o registrar
function Tabla:metodo(...)
CursoLab.log("va a pasar")
return orig(self, ...)
end
-- Después: reaccionar al resultado
function Tabla:metodo(...)
local r = orig(self, ...)
CursoLab.log("pasó y devolvió", r)
return r
end
-- Condicional: solo cambia en tu caso
function Tabla:metodo(a, b)
if not esMiCaso(a) then return orig(self, a, b) end
return miVersion(self, a, b)
end
-- Bloquear: impedir la acción (con cuidado)
function Tabla:metodo(...)
if prohibido(...) then return false end
return orig(self, ...)
endReglas que evitan bugs
requireel archivo original antes de guardar la referencia. Si tu archivo carga antes que el del juego,origesnily lo descubres con un error en la primera llamada (el orden de carga lo viste en el módulo 01).- Devuelve lo que devuelve el original. Muchas funciones del juego devuelven
true/false(por ejemploisValid, ocomplete, que termina conreturn true). Si tu envoltura no hacereturn, devuelvenil, y el juego lo interpreta como "falso". - Pasa todos los argumentos. Usa
...si no necesitas nombrarlos: si un parche agrega un parámetro, tu envoltura lo sigue pasando. - Protégete de la recarga. Si recargas tu archivo con F11, envuelves la función que ya habías envuelto: tu código corre dos veces. Usa una bandera:
if not CursoLab._envuelto_complete then
CursoLab._envuelto_complete = true
local orig = ISEatFoodAction.complete
function ISEatFoodAction:complete() ... end
end- Método de clase o de instancia. Envolver
ISEatFoodAction.completeafecta a todas las acciones de comer. Si solo quieres cambiar un objeto, envuelve el campo de esa instancia, como hace un mod nuestro sin publicar (xpranksCustom) coninst.checkPlayerenXPR_RecordedMediaGuard.lua.
Lo que no se puede envolver: las funciones local
-- client/ISUI/ISInventoryPaneContextMenu.lua:12
local function predicateNotBroken(item) ... endUna función local no está en ninguna tabla: nadie fuera de ese archivo puede alcanzarla. Si el comportamiento que quieres cambiar está en una función local, tienes que envolver la función pública que la llama y corregir su resultado, o reemplazar el archivo entero (último recurso, módulo 01).
Convivir con otros mods
Si dos mods envuelven la misma función, se encadenan solos: el que carga segundo guarda como "original" la envoltura del primero. Esto funciona siempre que todos llamen al original. Las envolturas que no lo llaman (las de "reemplazar") rompen la cadena y desactivan en silencio a los mods que cargaron antes. Por eso "condicional" es mejor que "reemplazar".
3. ¿Dónde envolver una acción? perform vs complete
Las acciones de B42 tienen dos finales. En ISEatFoodAction:
function ISEatFoodAction:perform() -- limpieza visible: sonidos, refrescar el inventario
...
ISBaseTimedAction.perform(self);
end
function ISEatFoodAction:complete() -- el efecto real: el personaje come
self.character:Eat(self.item, self.percentage, self.useUtensil);
return true;
endNinguna línea de Lua llama a complete(): lo llama Java (zombie.characters.CharacterTimedActions.LuaTimedActionNew.complete). Si quieres reaccionar a lo que pasó (el personaje comió), envuelve complete. Si quieres algo visual del cliente, perform. El módulo 11 explica en qué lado (cliente o servidor) corre cada uno en multijugador, que es por lo que están separados.
4. Ganchos de Java: Hook
Además de Events, existe una tabla Hook con 8 ganchos que Java dispara antes de hacer algo. Están en zombie.Lua.LuaHookManager.AddEvents():
AutoDrink, UseItem, Attack, CalculateStats, ContextualAction, WeaponHitCharacter, WeaponSwing, WeaponSwingHitPoint.
Se usan igual que un evento (Hook.AutoDrink.Add(fn), Hook.AutoDrink.Remove(fn)), pero con una diferencia grande, que solo se ve en el bytecode. Esto es IsoGameCharacter.autoDrink():
88: ldc "AutoDrink"
91: aload_0
92: invokestatic LuaHookManager.TriggerHook(String, Object)Z
95: ifeq 99 // si TriggerHook devolvió false, sigue con el autodrink normal
98: return // si devolvió true, Java NO hace nada másY TriggerHook devuelve true si hay al menos una función registrada (lo ves en zombie.Lua.Event.trigger: no mira lo que devuelve tu función). Conclusión:
El juego lo usa justamente así. En ISInventoryPaneContextMenu.lua:4055, mientras transfieres agua de un recipiente, registra un gancho vacío para apagar el beber automático y lo quita cuando termina:
local hookAutoDrink
hookAutoDrink = function(character)
if not ISTimedActionQueue.hasActionType(character, "ISTransferWaterAction") then
Hook.AutoDrink.Remove(hookAutoDrink)
end
end
if getCore():getOptionAutoDrink() then
Hook.AutoDrink.Add(hookAutoDrink)
endY ISReloadWeaponAction.lua:544 registra Hook.Attack siempre: el ataque de Java (IsoLivingCharacter.AttemptAttack) nunca corre, lo maneja Lua. Antes de registrar un gancho, decompila el método de Java que lo dispara y mira qué te estás saltando.
5. Cuando la lógica está en Java y no hay gancho
Pasa seguido (la fórmula del loot del módulo 08 es un caso). Opciones, de mejor a peor:
- Cambiar los datos que lee. La fórmula del loot no se toca, pero las distribuciones y las opciones de sandbox sí.
- Reaccionar después.
OnFillContainercorre al final del llenado: puedes quitar o agregar ítems ahí. - Envolver el Lua que llama a Java. Si la llamada a Java sale de una función de Lua, envuelve esa.
- Aceptar el límite. Un mod de Workshop no reemplaza clases de Java. Hay mods que lo hacen con instalación manual, pero quedan fuera de este curso.
Ejercicios
Ejercicio 9.1 · El evento de la sopaipilla, bien hecho
En el ejercicio 6.3 quizás no encontraste un evento para "comió algo". Ahora sí tienes la herramienta: envuelve ISEatFoodAction:complete para disparar OnCursoLabSopaipilla (ejercicio 6.4) cuando el ítem es una sopaipilla.
Listo cuando:
- el mensaje sale al comer una sopaipilla y no con otra comida,
- comer sigue funcionando igual (la función devuelve lo mismo que el original),
- recargar el archivo con F11 no hace que el mensaje salga dos veces.
Una solución
require "TimedActions/ISEatFoodAction"
if not CursoLab._wrapEat then
CursoLab._wrapEat = true
local orig = ISEatFoodAction.complete
function ISEatFoodAction:complete(...)
local tipo = self.item and self.item:getFullType()
local r = orig(self, ...)
if tipo == "CursoLab.Sopaipilla" then
triggerEvent("OnCursoLabSopaipilla", self.character, self.item)
end
return r
end
endEl tipo se lee antes de llamar al original: después de comer, el ítem puede haber desaparecido.
Ejercicio 9.2 · Dos mods, una función
Crea dos archivos en CursoLab que envuelvan la misma función (por ejemplo ISInventoryPaneContextMenu.createMenu, que está definida con punto y no con dos puntos: no recibe self) y que cada uno escriba en la consola.
- Comprueba que se ejecutan los dos y en qué orden.
- Cambia el primero para que no llame al original. ¿Qué pasa con el segundo? ¿Y si cambias el orden de carga (renombrando los archivos)?
Listo cuando: puedes explicar con el orden de carga por qué un mod "rompe" a otro sin tocarlo.
Ejercicio 9.3 · Una función local
En ISInventoryPaneContextMenu.lua, elige una de las funciones local (hay varias, por ejemplo formatFoodValue en la línea 2052) y cambia su efecto visible sin reemplazar el archivo. Escribe en tus notas qué función pública tuviste que envolver y por qué.
Ejercicio 9.4 · Un gancho que apaga Java
- Registra un
Hook.AutoDrinkque solo escriba en la consola y no haga nada más. - Activa el beber automático, ten sed y una botella en el inventario. ¿Qué pasa?
- Explica el resultado con el bytecode de
IsoGameCharacter.autoDrink(). - Ahora haz que solo se apague el beber automático cuando la botella es de un tipo concreto. (Pista: no puedes "dejar que Java siga", así que vas a tener que registrarte y quitarte en el momento justo, como hace el juego.)
Ejercicio 9.5 · Elegir la herramienta
Para cada cambio, escribe dónde vive el comportamiento (datos, Lua, Java con gancho, Java sin gancho) y qué herramienta usarías. No hace falta implementarlos.
- Las manzanas pesan la mitad.
- Al abrir un refrigerador por primera vez, siempre trae una sopaipilla.
- Las armas de fuego no se pueden usar dentro de edificios.
- Una opción nueva en el menú contextual de los árboles.
- Los zombis hacen el doble de daño.
Desafío 9
Escribe CursoLab.wrap(tabla, nombre, fn): envuelve tabla[nombre] con fn(orig, ...), es idempotente al recargar, guarda el original para poder deshacerlo con CursoLab.unwrap(tabla, nombre) y escribe un aviso en la consola si la función no existe. Reescribe el ejercicio 9.1 con ella.
Anterior: 08b · Bytecode a fondo · Siguiente: 10 · Menús contextuales e interfaz