GUI y Menús en Defold
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
| Aspecto | Game Objects | GUI |
|---|---|---|
| Renderizado | Pipeline 3D | Pipeline 2D especializado |
| Performance | Moderado | Ultra optimizado |
| Coordenadas | Espacio mundial 3D | Espacio de pantalla 2D |
| Uso típico | Gameplay, efectos | Menús, HUD, interfaces |
| Interacción | Física, colisiones | Input 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
- New → Gui →
menus/main_menu.gui - 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:
- Animación de aparición radial
- Rotación suave al navegar
- Efecto de zoom en selección
Ejercicio 2: Inventario Dinámico
Implementa un sistema de inventario con:
- Grid de slots arrastrables
- Tooltips informativos
- Categorías filtrablesB
- Animaciones de transferencia
Ejercicio 3: Diálogos Narrativos
Crea un sistema de diálogos para RPG:
- Texto con efecto typewriter
- Opciones de respuesta
- Retratos de personajes animados
- Sistema de branching
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.