← Volver al listado de tecnologías

GUI y Menús en Defold

Por: Artiko
defoldguimenusuiinterfaceresponsive

GUI y Menús en Defold

El sistema GUI de Defold está optimizado para crear interfaces de usuario de alto rendimiento en juegos. A diferencia de otros motores, Defold renderiza la GUI usando su propio pipeline especializado, lo que garantiza 60 FPS consistentes incluso en dispositivos móviles de gama baja.

Fundamentos del Sistema GUI

Diferencias entre GUI y Game Objects

AspectoGame ObjectsGUI
RenderizadoPipeline 3DPipeline 2D especializado
PerformanceModeradoUltra optimizado
CoordenadasEspacio mundial 3DEspacio de pantalla 2D
Uso típicoGameplay, efectosMenús, HUD, interfaces
InteracciónFísica, colisionesInput directo

Tipos de Nodos GUI

-- Nodos básicos disponibles
gui.NODE_TYPE_BOX        -- Rectángulo con textura/color
gui.NODE_TYPE_TEXT       -- Texto renderizado
gui.NODE_TYPE_PIE        -- Gráfico circular (health bars)
gui.NODE_TYPE_TEMPLATE   -- Instancia de otro GUI
gui.NODE_TYPE_SPINE      -- Animaciones Spine

Proyecto Completo: Sistema de Menús

Vamos a crear un sistema completo de menús para un juego, incluyendo menú principal, opciones, pausa y game over.

Estructura del Proyecto

menu_system/
├── menus/
│   ├── main_menu.gui
│   ├── main_menu.gui_script
│   ├── options_menu.gui
│   ├── options_menu.gui_script
│   ├── pause_menu.gui
│   ├── pause_menu.gui_script
│   └── game_over.gui
├── hud/
│   ├── game_hud.gui
│   └── game_hud.gui_script
├── common/
│   ├── button_style.lua
│   └── gui_utils.lua
└── assets/
    ├── ui_atlas.atlas
    └── fonts/

Parte 1: Menú Principal

Crear el Main Menu GUI

  1. New → Guimenus/main_menu.gui
  2. Configurar resolución de referencia: 1920x1080

Diseño del Menú Principal

-- Estructura visual en main_menu.gui
main_menu/
├── background (Box)
│   ├── logo (Box)
│   ├── button_container (Box)
│   │   ├── play_button (Template)
│   │   ├── options_button (Template)
│   │   ├── credits_button (Template)
│   │   └── quit_button (Template)
│   └── version_text (Text)

Script del Menú Principal

-- menus/main_menu.gui_script
local button_utils = require "common.button_style"
local gui_utils = require "common.gui_utils"

function init(self)
    -- Referencias a nodos importantes
    self.buttons = {
        play = gui.get_node("play_button"),
        options = gui.get_node("options_button"),
        credits = gui.get_node("credits_button"),
        quit = gui.get_node("quit_button")
    }

    self.background = gui.get_node("background")
    self.logo = gui.get_node("logo")

    -- Configurar estado inicial
    setup_initial_state(self)

    -- Animación de entrada
    animate_menu_entrance(self)

    print("Menú principal inicializado")
end

function setup_initial_state(self)
    -- Configurar botones
    for name, node in pairs(self.buttons) do
        button_utils.setup_button(node, name)
        gui.set_enabled(node, true)
    end

    -- Configurar logo
    gui.set_scale(self.logo, vmath.vector3(0.8, 0.8, 1))

    -- Configurar texto de versión
    local version_node = gui.get_node("version_text")
    gui.set_text(version_node, "v1.0.0")
end

function animate_menu_entrance(self)
    -- Animar logo desde arriba
    local logo_start_pos = gui.get_position(self.logo)
    logo_start_pos.y = logo_start_pos.y + 200
    gui.set_position(self.logo, logo_start_pos)

    gui.animate(self.logo, gui.PROP_POSITION, gui.get_position(self.logo) - vmath.vector3(0, 200, 0),
               gui.EASING_OUTBOUNCE, 1.0)

    -- Animar botones con delay escalonado
    for i, button in ipairs({self.buttons.play, self.buttons.options, self.buttons.credits, self.buttons.quit}) do
        local start_pos = gui.get_position(button)
        start_pos.x = start_pos.x - 300

        gui.set_position(button, start_pos)

        timer.delay(0.1 * i, false, function()
            gui.animate(button, gui.PROP_POSITION, gui.get_position(button) + vmath.vector3(300, 0, 0),
                       gui.EASING_OUTQUAD, 0.3)
        end)
    end
end

function on_input(self, action_id, action)
    if action_id == hash("touch") and action.pressed then
        local touch_pos = vmath.vector3(action.x, action.y, 0)

        for name, button in pairs(self.buttons) do
            if gui.pick_node(button, touch_pos.x, touch_pos.y) then
                handle_button_press(self, name)
                return true
            end
        end
    end

    return false
end

function handle_button_press(self, button_name)
    -- Animación de feedback
    local button = self.buttons[button_name]
    button_utils.animate_button_press(button)

    -- Lógica de navegación
    if button_name == "play" then
        start_game(self)
    elseif button_name == "options" then
        open_options_menu(self)
    elseif button_name == "credits" then
        show_credits(self)
    elseif button_name == "quit" then
        quit_game(self)
    end
end

function start_game(self)
    print("Iniciando juego...")

    -- Animación de salida
    animate_menu_exit(self, function()
        msg.post("/main", "start_game")
    end)
end

function open_options_menu(self)
    print("Abriendo opciones...")

    animate_menu_exit(self, function()
        msg.post("/menu_manager", "show_options")
    end)
end

function animate_menu_exit(self, callback)
    -- Animar todos los elementos hacia fuera
    gui.animate(self.logo, gui.PROP_POSITION, gui.get_position(self.logo) + vmath.vector3(0, 200, 0),
               gui.EASING_INBACK, 0.3)

    for i, button in ipairs({self.buttons.play, self.buttons.options, self.buttons.credits, self.buttons.quit}) do
        timer.delay(0.05 * i, false, function()
            gui.animate(button, gui.PROP_POSITION, gui.get_position(button) - vmath.vector3(300, 0, 0),
                       gui.EASING_INBACK, 0.3)
        end)
    end

    -- Callback después de la animación
    timer.delay(0.5, false, callback)
end

Utilidades para Botones

-- common/button_style.lua
local M = {}

function M.setup_button(node, button_type)
    -- Configuración base para todos los botones
    gui.set_color(node, vmath.vector4(0.2, 0.6, 1.0, 1.0))

    -- Estilos específicos por tipo
    if button_type == "play" then
        gui.set_color(node, vmath.vector4(0.2, 0.8, 0.2, 1.0))  -- Verde
    elseif button_type == "quit" then
        gui.set_color(node, vmath.vector4(0.8, 0.2, 0.2, 1.0))  -- Rojo
    end
end

function M.animate_button_press(node)
    -- Efecto de presión
    local original_scale = gui.get_scale(node)

    gui.animate(node, gui.PROP_SCALE, original_scale * 0.9,
               gui.EASING_OUTQUAD, 0.1, 0, function()
                   gui.animate(node, gui.PROP_SCALE, original_scale,
                              gui.EASING_OUTBACK, 0.2)
               end)

    -- Efecto de brillo
    local original_color = gui.get_color(node)
    local bright_color = original_color * 1.3
    bright_color.w = original_color.w  -- Mantener alfa

    gui.animate(node, gui.PROP_COLOR, bright_color,
               gui.EASING_OUTQUAD, 0.1, 0, function()
                   gui.animate(node, gui.PROP_COLOR, original_color,
                              gui.EASING_OUTQUAD, 0.2)
               end)
end

function M.set_button_enabled(node, enabled)
    if enabled then
        gui.set_color(node, vmath.vector4(1, 1, 1, 1))
        gui.set_enabled(node, true)
    else
        gui.set_color(node, vmath.vector4(0.5, 0.5, 0.5, 0.5))
        gui.set_enabled(node, false)
    end
end

return M

Parte 2: HUD de Juego

Game HUD con Información Dinámica

-- hud/game_hud.gui_script
local gui_utils = require "common.gui_utils"

function init(self)
    -- Referencias a elementos del HUD
    self.health_bar = gui.get_node("health_bar")
    self.health_fill = gui.get_node("health_fill")
    self.score_text = gui.get_node("score_text")
    self.timer_text = gui.get_node("timer_text")
    self.combo_text = gui.get_node("combo_text")
    self.pause_button = gui.get_node("pause_button")

    -- Estado del HUD
    self.current_health = 100
    self.max_health = 100
    self.current_score = 0
    self.game_time = 0
    self.combo_count = 0
    self.combo_timer = 0

    -- Configuración inicial
    setup_hud_elements(self)

    print("HUD de juego inicializado")
end

function setup_hud_elements(self)
    -- Posicionar elementos según el diseño
    update_health_display(self)
    update_score_display(self)
    update_timer_display(self)
    update_combo_display(self)

    -- Configurar botón de pausa
    gui.set_position(self.pause_button, vmath.vector3(50, 50, 0))
end

function update(self, dt)
    -- Actualizar timer de juego
    self.game_time = self.game_time + dt
    update_timer_display(self)

    -- Actualizar combo timer
    if self.combo_timer > 0 then
        self.combo_timer = self.combo_timer - dt
        if self.combo_timer <= 0 then
            reset_combo(self)
        end
    end
end

function update_health_display(self)
    local health_percentage = self.current_health / self.max_health
    local fill_scale = gui.get_scale(self.health_fill)
    fill_scale.x = health_percentage
    gui.set_scale(self.health_fill, fill_scale)

    -- Cambiar color según salud
    local health_color
    if health_percentage > 0.6 then
        health_color = vmath.vector4(0.2, 0.8, 0.2, 1)  -- Verde
    elseif health_percentage > 0.3 then
        health_color = vmath.vector4(0.8, 0.8, 0.2, 1)  -- Amarillo
    else
        health_color = vmath.vector4(0.8, 0.2, 0.2, 1)  -- Rojo
        -- Efecto de parpadeo cuando salud baja
        animate_low_health_warning(self)
    end

    gui.set_color(self.health_fill, health_color)
end

function animate_low_health_warning(self)
    gui.animate(self.health_fill, gui.PROP_COLOR, vmath.vector4(1, 0.5, 0.5, 1),
               gui.EASING_INOUTQUAD, 0.5, 0, function()
                   gui.animate(self.health_fill, gui.PROP_COLOR, vmath.vector4(0.8, 0.2, 0.2, 1),
                              gui.EASING_INOUTQUAD, 0.5)
               end, gui.PLAYBACK_LOOP_PINGPONG)
end

function update_score_display(self)
    gui.set_text(self.score_text, "Score: " .. format_number(self.current_score))

    -- Animación cuando el score aumenta
    if self.score_animation_active then
        return  -- Ya hay una animación en curso
    end

    self.score_animation_active = true
    local original_scale = gui.get_scale(self.score_text)

    gui.animate(self.score_text, gui.PROP_SCALE, original_scale * 1.2,
               gui.EASING_OUTQUAD, 0.1, 0, function()
                   gui.animate(self.score_text, gui.PROP_SCALE, original_scale,
                              gui.EASING_OUTQUAD, 0.1, 0, function()
                                  self.score_animation_active = false
                              end)
               end)
end

function update_timer_display(self)
    local minutes = math.floor(self.game_time / 60)
    local seconds = math.floor(self.game_time % 60)
    local timer_text = string.format("%02d:%02d", minutes, seconds)
    gui.set_text(self.timer_text, timer_text)
end

function update_combo_display(self)
    if self.combo_count > 1 then
        gui.set_text(self.combo_text, "COMBO x" .. self.combo_count)
        gui.set_enabled(self.combo_text, true)

        -- Efecto visual del combo
        local combo_color = vmath.vector4(1, 1, 0.2, 1)  -- Amarillo brillante
        if self.combo_count >= 5 then
            combo_color = vmath.vector4(1, 0.2, 0.2, 1)  -- Rojo para combos altos
        end
        gui.set_color(self.combo_text, combo_color)
    else
        gui.set_enabled(self.combo_text, false)
    end
end

function reset_combo(self)
    self.combo_count = 0
    self.combo_timer = 0
    update_combo_display(self)
end

function on_message(self, message_id, message, sender)
    if message_id == hash("update_health") then
        self.current_health = math.max(0, math.min(self.max_health, message.health))
        update_health_display(self)

    elseif message_id == hash("add_score") then
        self.current_score = self.current_score + message.points
        update_score_display(self)

        -- Manejar combo
        if message.is_combo then
            self.combo_count = self.combo_count + 1
            self.combo_timer = 3.0  -- 3 segundos para mantener combo
            update_combo_display(self)
        else
            reset_combo(self)
        end

    elseif message_id == hash("game_paused") then
        show_pause_overlay(self)

    elseif message_id == hash("game_resumed") then
        hide_pause_overlay(self)
    end
end

function on_input(self, action_id, action)
    if action_id == hash("touch") and action.pressed then
        if gui.pick_node(self.pause_button, action.x, action.y) then
            msg.post("/main", "pause_game")
            return true
        end
    end

    return false
end

function format_number(number)
    -- Formatear números grandes con separadores
    local formatted = tostring(number)
    local k, m, g = 1000, 1000000, 1000000000

    if number >= g then
        return string.format("%.1fG", number / g)
    elseif number >= m then
        return string.format("%.1fM", number / m)
    elseif number >= k then
        return string.format("%.1fK", number / k)
    else
        return formatted
    end
end

Parte 3: Menú de Opciones

Sistema de Configuración

-- menus/options_menu.gui_script
local gui_utils = require "common.gui_utils"

function init(self)
    -- Elementos de configuración
    self.sliders = {
        master_volume = gui.get_node("master_volume_slider"),
        music_volume = gui.get_node("music_volume_slider"),
        sfx_volume = gui.get_node("sfx_volume_slider")
    }

    self.checkboxes = {
        fullscreen = gui.get_node("fullscreen_checkbox"),
        vsync = gui.get_node("vsync_checkbox"),
        show_fps = gui.get_node("show_fps_checkbox")
    }

    self.buttons = {
        back = gui.get_node("back_button"),
        apply = gui.get_node("apply_button"),
        reset = gui.get_node("reset_button")
    }

    -- Cargar configuración actual
    load_current_settings(self)

    print("Menú de opciones inicializado")
end

function load_current_settings(self)
    -- Cargar desde archivo de configuración o valores por defecto
    self.settings = {
        master_volume = 0.8,
        music_volume = 0.6,
        sfx_volume = 0.8,
        fullscreen = false,
        vsync = true,
        show_fps = false
    }

    apply_settings_to_ui(self)
end

function apply_settings_to_ui(self)
    -- Configurar sliders
    for setting, value in pairs(self.settings) do
        if self.sliders[setting] then
            update_slider_value(self.sliders[setting], value)
        end
    end

    -- Configurar checkboxes
    for setting, value in pairs(self.settings) do
        if self.checkboxes[setting] then
            update_checkbox_value(self.checkboxes[setting], value)
        end
    end
end

function update_slider_value(slider_node, value)
    -- Actualizar posición del handle del slider
    local slider_bg = gui.get_node(gui.get_id(slider_node) .. "_bg")
    local slider_handle = gui.get_node(gui.get_id(slider_node) .. "_handle")

    local bg_size = gui.get_size(slider_bg)
    local handle_pos = gui.get_position(slider_handle)
    handle_pos.x = (value - 0.5) * bg_size.x
    gui.set_position(slider_handle, handle_pos)

    -- Actualizar texto de valor
    local value_text = gui.get_node(gui.get_id(slider_node) .. "_value")
    gui.set_text(value_text, string.format("%.0f%%", value * 100))
end

function update_checkbox_value(checkbox_node, value)
    local checkmark = gui.get_node(gui.get_id(checkbox_node) .. "_checkmark")
    gui.set_enabled(checkmark, value)

    if value then
        gui.set_color(checkbox_node, vmath.vector4(0.2, 0.8, 0.2, 1))
    else
        gui.set_color(checkbox_node, vmath.vector4(0.8, 0.8, 0.8, 1))
    end
end

function on_input(self, action_id, action)
    if action_id == hash("touch") then
        local touch_pos = vmath.vector3(action.x, action.y, 0)

        -- Manejar botones
        for name, button in pairs(self.buttons) do
            if gui.pick_node(button, touch_pos.x, touch_pos.y) then
                handle_button_press(self, name)
                return true
            end
        end

        -- Manejar sliders
        for name, slider in pairs(self.sliders) do
            if handle_slider_input(self, slider, name, touch_pos, action) then
                return true
            end
        end

        -- Manejar checkboxes
        for name, checkbox in pairs(self.checkboxes) do
            if gui.pick_node(checkbox, touch_pos.x, touch_pos.y) and action.pressed then
                toggle_checkbox(self, name)
                return true
            end
        end
    end

    return false
end

function handle_slider_input(self, slider_node, setting_name, touch_pos, action)
    local slider_bg = gui.get_node(gui.get_id(slider_node) .. "_bg")

    if gui.pick_node(slider_bg, touch_pos.x, touch_pos.y) then
        if action.pressed or action.repeated then
            local bg_pos = gui.get_position(slider_bg)
            local bg_size = gui.get_size(slider_bg)

            -- Calcular valor basado en posición del toque
            local relative_x = touch_pos.x - bg_pos.x
            local value = math.max(0, math.min(1, (relative_x + bg_size.x/2) / bg_size.x))

            self.settings[setting_name] = value
            update_slider_value(slider_node, value)

            -- Aplicar configuración inmediatamente para feedback
            apply_setting_immediately(self, setting_name, value)
        end
        return true
    end

    return false
end

function toggle_checkbox(self, setting_name)
    self.settings[setting_name] = not self.settings[setting_name]
    update_checkbox_value(self.checkboxes[setting_name], self.settings[setting_name])

    -- Aplicar configuración inmediatamente
    apply_setting_immediately(self, setting_name, self.settings[setting_name])
end

function apply_setting_immediately(self, setting_name, value)
    -- Aplicar configuraciones que tienen efecto inmediato
    if setting_name == "master_volume" then
        sound.set_group_gain("master", value)
    elseif setting_name == "music_volume" then
        sound.set_group_gain("music", value)
    elseif setting_name == "sfx_volume" then
        sound.set_group_gain("sfx", value)
    end
end

function handle_button_press(self, button_name)
    if button_name == "back" then
        msg.post("/menu_manager", "show_main_menu")
    elseif button_name == "apply" then
        save_settings(self)
        show_confirmation_message(self, "Configuración guardada")
    elseif button_name == "reset" then
        reset_to_defaults(self)
    end
end

function save_settings(self)
    -- Guardar configuración en archivo
    local save_data = {
        settings = self.settings
    }

    local save_string = json.encode(save_data)
    local file = io.open(sys.get_save_file("settings", "config.json"), "w")
    if file then
        file:write(save_string)
        file:close()
        print("Configuración guardada")
    end
end

Parte 4: Responsive Design

Adaptación a Diferentes Resoluciones

-- common/gui_utils.lua
local M = {}

function M.setup_responsive_layout(gui_script)
    local screen_width, screen_height = window.get_size()
    local design_width, design_height = 1920, 1080  -- Resolución de diseño

    local scale_x = screen_width / design_width
    local scale_y = screen_height / design_height

    -- Usar el menor scale para mantener aspect ratio
    local uniform_scale = math.min(scale_x, scale_y)

    return {
        screen_width = screen_width,
        screen_height = screen_height,
        scale_x = scale_x,
        scale_y = scale_y,
        uniform_scale = uniform_scale
    }
end

function M.scale_node_for_screen(node, layout_info)
    local current_scale = gui.get_scale(node)
    local new_scale = current_scale * layout_info.uniform_scale
    gui.set_scale(node, new_scale)
end

function M.position_node_for_screen(node, anchor_x, anchor_y, offset_x, offset_y, layout_info)
    -- anchor_x/y: 0-1 (0 = left/bottom, 1 = right/top)
    -- offset_x/y: píxeles desde el anchor
    local screen_x = anchor_x * layout_info.screen_width + offset_x
    local screen_y = anchor_y * layout_info.screen_height + offset_y

    gui.set_position(node, vmath.vector3(screen_x, screen_y, 0))
end

return M

Orientación Móvil

-- Detectar orientación y ajustar layout
function handle_orientation_change(self)
    local screen_width, screen_height = window.get_size()
    local is_landscape = screen_width > screen_height

    if is_landscape then
        apply_landscape_layout(self)
    else
        apply_portrait_layout(self)
    end
end

function apply_landscape_layout(self)
    -- Layout horizontal para landscape
    local button_container = gui.get_node("button_container")
    gui.set_position(button_container, vmath.vector3(960, 300, 0))

    -- Organizar botones horizontalmente
    local button_spacing = 200
    for i, button in ipairs(self.button_list) do
        local pos = gui.get_position(button)
        pos.x = (i - 2) * button_spacing
        gui.set_position(button, pos)
    end
end

function apply_portrait_layout(self)
    -- Layout vertical para portrait
    local button_container = gui.get_node("button_container")
    gui.set_position(button_container, vmath.vector3(540, 400, 0))

    -- Organizar botones verticalmente
    local button_spacing = 100
    for i, button in ipairs(self.button_list) do
        local pos = gui.get_position(button)
        pos.y = -(i - 1) * button_spacing
        gui.set_position(button, pos)
    end
end

Parte 5: Animaciones Avanzadas

Sistema de Transiciones

-- Transiciones suaves entre menús
function transition_to_menu(self, target_menu, transition_type)
    if transition_type == "slide_left" then
        slide_transition(self, -1920, 0, target_menu)
    elseif transition_type == "slide_right" then
        slide_transition(self, 1920, 0, target_menu)
    elseif transition_type == "fade" then
        fade_transition(self, target_menu)
    elseif transition_type == "scale" then
        scale_transition(self, target_menu)
    end
end

function slide_transition(self, offset_x, offset_y, target_menu)
    local root_node = gui.get_node("root")
    local target_pos = gui.get_position(root_node) + vmath.vector3(offset_x, offset_y, 0)

    gui.animate(root_node, gui.PROP_POSITION, target_pos,
               gui.EASING_INOUTQUAD, 0.3, 0, function()
                   msg.post("/menu_manager", "load_menu", {menu = target_menu})
               end)
end

function fade_transition(self, target_menu)
    local root_node = gui.get_node("root")

    gui.animate(root_node, gui.PROP_COLOR, vmath.vector4(1, 1, 1, 0),
               gui.EASING_OUTQUAD, 0.2, 0, function()
                   msg.post("/menu_manager", "load_menu", {menu = target_menu})
               end)
end

Efectos de Hover y Focus

-- Sistema de hover para botones (para versiones de escritorio)
function setup_button_hover_effects(self)
    for name, button in pairs(self.buttons) do
        -- Estado inicial
        button_utils.set_button_state(button, "normal")
    end
end

function handle_mouse_movement(self, x, y)
    for name, button in pairs(self.buttons) do
        if gui.pick_node(button, x, y) then
            if not self.hovered_button or self.hovered_button ~= button then
                -- Nuevo hover
                if self.hovered_button then
                    button_utils.set_button_state(self.hovered_button, "normal")
                end

                self.hovered_button = button
                button_utils.set_button_state(button, "hover")
                play_hover_sound()
            end
            return
        end
    end

    -- No hay hover en ningún botón
    if self.hovered_button then
        button_utils.set_button_state(self.hovered_button, "normal")
        self.hovered_button = nil
    end
end

Ejercicios Prácticos

Ejercicio 1: Menu Circular

Crea un menú circular donde los botones se organizan en círculo alrededor del centro:

Ejercicio 2: Inventario Dinámico

Implementa un sistema de inventario con:

Ejercicio 3: Diálogos Narrativos

Crea un sistema de diálogos para RPG:

Próximos Pasos

En la siguiente lección crearemos un juego de plataformas completo, aplicando tanto los conceptos de física como de GUI en un proyecto integral.


⬅️ Anterior: Física y Colisiones | Siguiente: Juego de Plataformas ➡️

¡Perfecto! Ahora dominas el sistema GUI de Defold y puedes crear interfaces profesionales para cualquier tipo de juego.