WENOPAL

10 · Menús contextuales e interfaz

Agregar opciones al clic derecho sin pelear con otros mods y construir ventanas propias entendiendo cómo se conecta Lua con la interfaz de Java.

Curso técnico · Build 42 · Actualizado el

Objetivo: agregar opciones al clic derecho sin pelear con otros mods, y construir ventanas propias entendiendo cómo se conecta una tabla de Lua con un elemento de interfaz de Java.

1. El menú contextual: engancharse, no reemplazar

El juego arma el menú del clic derecho en dos archivos enormes (client/ISUI/ISInventoryPaneContextMenu.lua y ISWorldObjectContextMenu.lua), y al final dispara un evento para que los mods agreguen lo suyo:

Clic derecho sobre…EventoArgumentosDónde se dispara
ítems del inventarioOnFillInventoryObjectContextMenuplayerNum, context, itemsISInventoryPaneContextMenu.lua:935
el mundoOnFillWorldObjectContextMenuplayerNum, context, worldobjects, testISWorldObjectContextMenu.lua:213

El comentario del propio juego junto al segundo lo dice: "use the event (as you would 'OnTick' etc) to add items to context menu without mod conflicts". No envuelvas createMenu para esto: usa el evento.

Ejemplo real: uno de nuestros mods

En Pumpkin Z DIY, uno de nuestros mods (PumpkinZ/.../client/PumpkinZ_CarveUI.lua):

lua
local function onFillInventoryMenu(playerNum, context, items)
    local player = getSpecificPlayer(playerNum)
    if not player or player:isDead() then return end
    local pumpkin
    for _, v in ipairs(items) do
        local item = v
        if type(v) == "table" and v.items then item = v.items[1] end   -- ← ojo con esto
        if item and item.getFullType and item:getFullType() == PUMPKIN then
            pumpkin = item
            break
        end
    end
    if not pumpkin then return end
    local knife = sharpKnife(player)
    local option = context:addOption(getText("IGUI_PumpkinZ_CarveMenu"), player, PumpkinZ.openCarveUI, pumpkin, knife)
    if not knife then
        option.notAvailable = true                                -- se ve en gris
        local tip = ISInventoryPaneContextMenu.addToolTip()
        tip.description = getText("IGUI_PumpkinZ_NeedKnife")      -- y explica por qué
        option.toolTip = tip
    end
end
Events.OnFillInventoryObjectContextMenu.Add(onFillInventoryMenu)

Tres cosas que este código hace bien y que vas a copiar siempre:

  1. items mezcla dos formas. Si el jugador hace clic en una pila del inventario, el elemento es una tabla { items = {...} } (la pila) y no el ítem. Si hace clic en un ítem suelto, es el objeto Java. Siempre normaliza.
  2. Opción deshabilitada con explicación en vez de no mostrarla. El jugador aprende qué le falta. El juego usa notAvailable = true 153 veces.
  3. playerNum es un número, no el jugador: getSpecificPlayer(playerNum) (pantalla dividida).

addOption y sus parámetros

lua
context:addOption(texto, target, funcion, arg1, arg2, ...)   -- hasta 10 argumentos

Al elegir la opción, el juego llama funcion(target, arg1, arg2, ...). Si funcion es un método (MiClase.metodo), target hace de self. Otras funciones útiles de ISContextMenu: addOptionOnTop, insertOptionAfter(nombreAnterior, ...), removeOptionByName, getNew(context) + addSubMenu(opcion, submenu) para submenús, y addDebugOption (solo en debug).

El parámetro test del menú del mundo

El menú del mundo a veces se arma solo para preguntar si habría opciones (por ejemplo para el mando), con test = true. El juego tiene 35 líneas del estilo if test then return true end. Si tu función abre ventanas o hace algo caro, respeta test.

2. Cómo funciona una ventana

Cada elemento de interfaz tiene dos mitades:

Lua: tabla PumpkinZCarveUI (derivada de ISPanel)           Java: zombie.ui.UIElement
  .x .y .width .height                         ◄──────►      posición, tamaño, foco, dibujo
  :prerender() :render() :onMouseDown() ...     ◄──llama──    por nombre, cada frame / evento
  .javaObject ──────────────────────────────────────────────►
  • ISUIElement:instantiate() crea la mitad Java: self.javaObject = UIElement.new(self). Le pasa la tabla de Lua.
  • Desde ahí, Java llama a tus funciones por nombre: en zombie.ui.UIElement están las cadenas prerender, render, update, onMouseDown, onMouseUp, onMouseMove, onMouseWheel, onRightMouseDown, onKeyPress, onResize... Si tu tabla tiene una función con ese nombre, se llama. Si no, no pasa nada.
  • addToUIManager() llama a instantiate() si hace falta y registra el elemento en UIManager. addChild(otro) hace lo mismo con un hijo.

El ciclo de vida

lua
MiVentana = ISPanel:derive("MiVentana")

function MiVentana:new(x, y, w, h)          -- 1. crear la tabla: solo datos, nada de Java todavía
    local o = ISPanel.new(self, x, y, w, h)
    o.backgroundColor = { r = 0, g = 0, b = 0, a = 0.8 }
    o.moveWithMouse = true
    return o
end

function MiVentana:initialise()             -- 2. crear hijos (botones, listas)
    ISPanel.initialise(self)
    self.boton = ISButton:new(10, 10, 100, 25, "OK", self, MiVentana.onOK)
    self.boton:initialise()
    self:addChild(self.boton)
end

function MiVentana:prerender()              -- 3. cada frame: el fondo
    ISPanel.prerender(self)
end

function MiVentana:render()                 -- 4. cada frame: lo que va encima
    self:drawText("Hola", 10, 50, 1, 1, 1, 1, UIFont.Small)
end

function MiVentana:close()                  -- 5. salir
    self:setVisible(false)
    self:removeFromUIManager()
end

-- abrirla:
local v = MiVentana:new(100, 100, 300, 200)
v:initialise()
v:addToUIManager()

Pumpkin Z DIY sigue exactamente este patrón y agrega uno más: una sola instancia (if PumpkinZCarveUI.instance then PumpkinZCarveUI.instance:close() end). Sin eso, cada clic abre otra ventana encima.

Algunos elementos (las ventanas ISCollapsableWindow, por ejemplo) crean sus hijos en createChildren() en vez de initialise(): lo llama el juego al instanciar. Mira cuál usa la clase de la que derivas.

Los ladrillos

En client/ISUI/: ISPanel (base), ISCollapsableWindow (ventana con barra de título, se puede mover y cerrar), ISButton, ISLabel, ISTickBox, ISComboBox, ISTextEntryBox, ISScrollingListBox (listas largas), ISRichTextPanel (texto con formato), ISModalDialog (sí/no). Antes de construir algo, busca una ventana del juego que se parezca y lee cómo está hecha.

3. Tamaños: fuentes y pantallas

PZ deja que el jugador elija el tamaño de letra. Una interfaz con alturas fijas en píxeles se rompe con letra grande. El juego mide siempre:

lua
local FONT_HGT_SMALL = getTextManager():getFontHeight(UIFont.Small)      -- al inicio del archivo
local ancho = getTextManager():MeasureStringX(UIFont.Small, texto)       -- 733 usos en el juego

Reglas:

  • Altura de botones y filas = altura de fuente + margen, no un número fijo.
  • Ancho de botones = texto medido + margen (los textos traducidos al portugués suelen ser más largos que en inglés).
  • Posición centrada: getCore():getScreenWidth(), getCore():getScreenHeight() (como Pumpkin Z DIY).

4. Dibujar

Dentro de prerender/render, coordenadas relativas al elemento:

FunciónPara
drawRect(x, y, w, h, a, r, g, b) / drawRectBorder(...)Rectángulos (ojo: alfa primero)
drawText(txt, x, y, r, g, b, a, fuente), drawTextCentre, drawTextRightTexto (aquí alfa va al final)
drawTexture(tex, x, y, a, r, g, b), drawTextureScaled(tex, x, y, w, h, a, r, g, b)Imágenes (getTexture(...) del módulo 04)

El orden distinto de los colores entre drawRect y drawText es un tropiezo clásico: si tu texto sale invisible, revisa ese orden.


Ejercicios

Ejercicio 10.1 · Opción con condiciones

Agrega al clic derecho de un ítem de comida la opción "Ver datos (CursoLab)", que solo aparece en comida. Si el ítem está podrido, la opción sale deshabilitada con un tooltip que dice por qué. Al elegirla, imprime en la consola hambre, días frescos y estado.

Listo cuando: funciona con un ítem suelto y con una pila de 5, y no aparece con herramientas.

Ejercicio 10.2 · Submenú en el mundo

En el clic derecho sobre el mundo, agrega un submenú "CursoLab" con tres opciones: "Coordenadas aquí", "Tipo de suelo" y "¿Hay zombis cerca?". Usa worldobjects para saber el cuadro (pista: cada objeto tiene getSquare()).

Listo cuando: el submenú funciona y respetas test.

Ejercicio 10.3 · Tu primera ventana

Haz CursoLab.VentanaBitacora, una ISCollapsableWindow que muestre las últimas 20 entradas de la bitácora (ejercicio 6.2) en una ISScrollingListBox, con un botón "Volcar a archivo" y un botón "Limpiar". Ábrela con una tecla (pista: Events.OnKeyPressed y Keyboard.KEY_..., módulo 07).

Listo cuando: se abre, se mueve, se cierra, no se abre dos veces y se ve bien con la letra más grande (opciones del juego → Pantalla → tamaño de letra).

Ejercicio 10.4 · Leer una ventana del juego

Elige una ventana del juego que te guste (busca en client/ISUI/), y dibuja en papel su árbol de elementos: qué hijos crea, en qué función, y qué funciones de render usa. Encuentra una técnica que no conocías.

Ejercicio 10.5 · Medir antes de dibujar

Cambia tu ventana del 10.3 para que ningún tamaño sea un número fijo: todo calculado desde getFontHeight y MeasureStringX. Pruébala en español, inglés y con letra grande.

Desafío 10

Una mini-HUD: un panel pequeño y transparente en una esquina que muestre en vivo la hora del juego, la temperatura y el peso cargado. Que se pueda mover con el mouse, que recuerde su posición entre partidas (pista: getFileWriter, módulo 06, o ModData, módulo 14) y que no consuma rendimiento (actualiza el texto cada segundo, no cada frame).


Anterior: 09 · Sobrescribir comportamiento · Siguiente: 11 · Timed actions