← Volver al listado de tecnologías
UI Responsive y Adaptación a Diferentes Pantallas
UI Responsive y Adaptación a Diferentes Pantallas
Crear interfaces que se adapten perfectamente a cualquier dispositivo móvil es fundamental para el éxito de tu juego. En esta guía aprenderás a implementar UI responsive en Defold.
Conceptos Fundamentales
Resoluciones y Densidades de Pantalla
Resoluciones Comunes
-- screen_resolutions.lua
local M = {}
M.COMMON_RESOLUTIONS = {
-- iPhone
{name = "iPhone 5", width = 320, height = 568, ratio = 16/9},
{name = "iPhone 6/7/8", width = 375, height = 667, ratio = 16/9},
{name = "iPhone 6+/7+/8+", width = 414, height = 736, ratio = 16/9},
{name = "iPhone X/XS", width = 375, height = 812, ratio = 19.5/9},
{name = "iPhone XR", width = 414, height = 896, ratio = 19.5/9},
{name = "iPhone 12/13/14", width = 390, height = 844, ratio = 19.5/9},
{name = "iPhone 12/13/14 Pro Max", width = 428, height = 926, ratio = 19.5/9},
-- Android
{name = "Android Small", width = 320, height = 480, ratio = 3/2},
{name = "Android Medium", width = 360, height = 640, ratio = 16/9},
{name = "Android Large", width = 411, height = 731, ratio = 16/9},
{name = "Android XL", width = 412, height = 892, ratio = 18.5/9},
-- Tablets
{name = "iPad", width = 768, height = 1024, ratio = 4/3},
{name = "iPad Pro", width = 834, height = 1194, ratio = 4/3},
{name = "Android Tablet", width = 800, height = 1280, ratio = 16/10}
}
function M.get_device_info()
local width = tonumber(sys.get_config("display.width"))
local height = tonumber(sys.get_config("display.height"))
local ratio = width / height
return {
width = width,
height = height,
ratio = ratio,
is_tablet = width > 600 or height > 600
}
end
return M
Sistema de Anchors y Pivots
Configuración de Anchors
-- anchor_system.script
local M = {}
-- Tipos de anchor predefinidos
M.ANCHOR_TYPES = {
TOP_LEFT = {anchor = gui.ANCHOR_NONE, pivot = gui.PIVOT_NW},
TOP_CENTER = {anchor = gui.ANCHOR_TOP, pivot = gui.PIVOT_N},
TOP_RIGHT = {anchor = gui.ANCHOR_NONE, pivot = gui.PIVOT_NE},
CENTER_LEFT = {anchor = gui.ANCHOR_LEFT, pivot = gui.PIVOT_W},
CENTER = {anchor = gui.ANCHOR_CENTER, pivot = gui.PIVOT_CENTER},
CENTER_RIGHT = {anchor = gui.ANCHOR_RIGHT, pivot = gui.PIVOT_E},
BOTTOM_LEFT = {anchor = gui.ANCHOR_NONE, pivot = gui.PIVOT_SW},
BOTTOM_CENTER = {anchor = gui.ANCHOR_BOTTOM, pivot = gui.PIVOT_S},
BOTTOM_RIGHT = {anchor = gui.ANCHOR_NONE, pivot = gui.PIVOT_SE}
}
function M.set_anchor(node, anchor_type)
local config = M.ANCHOR_TYPES[anchor_type]
if config then
gui.set_anchor(node, config.anchor)
gui.set_pivot(node, config.pivot)
end
end
function M.position_at_anchor(node, anchor_type, offset_x, offset_y)
M.set_anchor(node, anchor_type)
local screen_width = tonumber(sys.get_config("display.width"))
local screen_height = tonumber(sys.get_config("display.height"))
local x, y = 0, 0
if anchor_type == "TOP_LEFT" then
x, y = offset_x, screen_height - offset_y
elseif anchor_type == "TOP_RIGHT" then
x, y = screen_width - offset_x, screen_height - offset_y
elseif anchor_type == "BOTTOM_LEFT" then
x, y = offset_x, offset_y
elseif anchor_type == "BOTTOM_RIGHT" then
x, y = screen_width - offset_x, offset_y
end
gui.set_position(node, vmath.vector3(x, y, 0))
end
return M
Layout Responsivo
1. Sistema de Grid Flexible
Grid Layout Manager
-- grid_layout.lua
local M = {}
function M.create_grid(parent_node, rows, cols, padding, spacing)
local parent_size = gui.get_size(parent_node)
local available_width = parent_size.x - (padding.left + padding.right) - (spacing.x * (cols - 1))
local available_height = parent_size.y - (padding.top + padding.bottom) - (spacing.y * (rows - 1))
local cell_width = available_width / cols
local cell_height = available_height / rows
local grid = {
parent = parent_node,
rows = rows,
cols = cols,
cell_width = cell_width,
cell_height = cell_height,
padding = padding,
spacing = spacing,
cells = {}
}
return grid
end
function M.add_to_grid(grid, node, row, col, span_rows, span_cols)
span_rows = span_rows or 1
span_cols = span_cols or 1
local start_x = grid.padding.left + (col - 1) * (grid.cell_width + grid.spacing.x)
local start_y = grid.padding.top + (row - 1) * (grid.cell_height + grid.spacing.y)
local width = grid.cell_width * span_cols + grid.spacing.x * (span_cols - 1)
local height = grid.cell_height * span_rows + grid.spacing.y * (span_rows - 1)
gui.set_position(node, vmath.vector3(start_x, start_y, 0))
gui.set_size(node, vmath.vector3(width, height, 0))
grid.cells[row .. "_" .. col] = {
node = node,
row = row,
col = col,
span_rows = span_rows,
span_cols = span_cols
}
end
function M.update_grid(grid)
local parent_size = gui.get_size(grid.parent)
local available_width = parent_size.x - (grid.padding.left + grid.padding.right) - (grid.spacing.x * (grid.cols - 1))
local available_height = parent_size.y - (grid.padding.top + grid.padding.bottom) - (grid.spacing.y * (grid.rows - 1))
grid.cell_width = available_width / grid.cols
grid.cell_height = available_height / grid.rows
-- Reposicionar todos los elementos
for key, cell in pairs(grid.cells) do
M.add_to_grid(grid, cell.node, cell.row, cell.col, cell.span_rows, cell.span_cols)
end
end
return M
2. Safe Area y Notch Support
Safe Area Manager
-- safe_area.script
local M = {}
function M.get_safe_area()
local screen_width = tonumber(sys.get_config("display.width"))
local screen_height = tonumber(sys.get_config("display.height"))
-- Valores por defecto
local safe_area = {
top = 0,
bottom = 0,
left = 0,
right = 0
}
-- Detectar iPhone X y modelos posteriores
local sys_info = sys.get_sys_info()
if sys_info.system_name == "iPhone OS" then
local device_model = sys_info.device_model
-- iPhone X, XS, XR, 11, 12, 13, 14 series
if string.find(device_model, "iPhone1[0-9],") or
string.find(device_model, "iPhone11,") or
string.find(device_model, "iPhone12,") or
string.find(device_model, "iPhone13,") or
string.find(device_model, "iPhone14,") then
if screen_height > screen_width then
-- Portrait
safe_area.top = 44
safe_area.bottom = 34
else
-- Landscape
safe_area.left = 44
safe_area.right = 44
end
end
end
return safe_area
end
function M.apply_safe_area_to_node(node, margin_top, margin_bottom, margin_left, margin_right)
local safe_area = M.get_safe_area()
local screen_width = tonumber(sys.get_config("display.width"))
local screen_height = tonumber(sys.get_config("display.height"))
margin_top = margin_top or 0
margin_bottom = margin_bottom or 0
margin_left = margin_left or 0
margin_right = margin_right or 0
local new_width = screen_width - safe_area.left - safe_area.right - margin_left - margin_right
local new_height = screen_height - safe_area.top - safe_area.bottom - margin_top - margin_bottom
local new_x = safe_area.left + margin_left
local new_y = safe_area.bottom + margin_bottom
gui.set_position(node, vmath.vector3(new_x, new_y, 0))
gui.set_size(node, vmath.vector3(new_width, new_height, 0))
end
return M
Adaptación por Orientación
1. Detector de Orientación
Orientation Manager
-- orientation_manager.script
local M = {}
M.ORIENTATION = {
PORTRAIT = "portrait",
LANDSCAPE = "landscape",
PORTRAIT_UPSIDE_DOWN = "portrait_upside_down",
LANDSCAPE_LEFT = "landscape_left",
LANDSCAPE_RIGHT = "landscape_right"
}
function init(self)
self.current_orientation = M.get_orientation()
self.orientation_callbacks = {}
-- Suscribirse a cambios de ventana
window.set_listener(function(self, event, data)
if event == window.WINDOW_EVENT_RESIZED then
M.check_orientation_change(self)
end
end)
end
function M.get_orientation()
local width = tonumber(sys.get_config("display.width"))
local height = tonumber(sys.get_config("display.height"))
if height > width then
return M.ORIENTATION.PORTRAIT
else
return M.ORIENTATION.LANDSCAPE
end
end
function M.check_orientation_change(self)
local new_orientation = M.get_orientation()
if new_orientation ~= self.current_orientation then
local old_orientation = self.current_orientation
self.current_orientation = new_orientation
-- Notificar a todos los callbacks registrados
for _, callback in ipairs(self.orientation_callbacks) do
callback(new_orientation, old_orientation)
end
msg.post(".", "orientation_changed", {
new_orientation = new_orientation,
old_orientation = old_orientation
})
end
end
function M.register_callback(callback)
table.insert(self.orientation_callbacks, callback)
end
function M.is_portrait()
return M.get_orientation() == M.ORIENTATION.PORTRAIT
end
function M.is_landscape()
return M.get_orientation() == M.ORIENTATION.LANDSCAPE
end
return M
2. Layout Adaptive por Orientación
Adaptive Layout
-- adaptive_layout.gui_script
local orientation_manager = require "main.orientation_manager"
local grid_layout = require "main.grid_layout"
function init(self)
self.portrait_layout = {}
self.landscape_layout = {}
-- Configurar layouts por orientación
self:setup_portrait_layout()
self:setup_landscape_layout()
-- Aplicar layout inicial
self:apply_current_layout()
-- Registrar callback para cambios de orientación
orientation_manager.register_callback(function(new_orientation, old_orientation)
self:apply_current_layout()
end)
end
function setup_portrait_layout(self)
-- Layout vertical para portrait
self.portrait_layout = {
header = {
anchor = "TOP_CENTER",
size = {width = "100%", height = 120},
position = {x = 0, y = -20}
},
main_content = {
anchor = "CENTER",
size = {width = "90%", height = "60%"},
position = {x = 0, y = -40}
},
bottom_bar = {
anchor = "BOTTOM_CENTER",
size = {width = "100%", height = 100},
position = {x = 0, y = 20}
}
}
end
function setup_landscape_layout(self)
-- Layout horizontal para landscape
self.landscape_layout = {
header = {
anchor = "TOP_CENTER",
size = {width = "100%", height = 80},
position = {x = 0, y = -10}
},
main_content = {
anchor = "CENTER",
size = {width = "70%", height = "70%"},
position = {x = 0, y = 0}
},
side_panel = {
anchor = "CENTER_RIGHT",
size = {width = "25%", height = "70%"},
position = {x = -20, y = 0}
},
bottom_bar = {
anchor = "BOTTOM_CENTER",
size = {width = "100%", height = 60},
position = {x = 0, y = 10}
}
}
end
function apply_current_layout(self)
local layout = orientation_manager.is_portrait() and self.portrait_layout or self.landscape_layout
for element_name, config in pairs(layout) do
local node = gui.get_node(element_name)
if node then
self:apply_layout_config(node, config)
end
end
-- Animación suave de transición
gui.animate(gui.get_node("root"), gui.PROP_COLOR, vmath.vector4(1,1,1,1), gui.EASING_OUTQUAD, 0.3)
end
function apply_layout_config(self, node, config)
local screen_width = tonumber(sys.get_config("display.width"))
local screen_height = tonumber(sys.get_config("display.height"))
-- Calcular tamaño
local width = config.size.width
local height = config.size.height
if type(width) == "string" and string.find(width, "%%") then
width = screen_width * (tonumber(string.sub(width, 1, -2)) / 100)
end
if type(height) == "string" and string.find(height, "%%") then
height = screen_height * (tonumber(string.sub(height, 1, -2)) / 100)
end
gui.set_size(node, vmath.vector3(width, height, 0))
-- Aplicar anchor y posición
if config.anchor then
anchor_system.set_anchor(node, config.anchor)
end
if config.position then
local pos = gui.get_position(node)
pos.x = pos.x + config.position.x
pos.y = pos.y + config.position.y
gui.set_position(node, pos)
end
end
Escalado de UI
1. Escalado Inteligente
Scale Manager
-- scale_manager.lua
local M = {}
M.SCALE_MODES = {
FIT = "fit", -- Ajustar manteniendo proporción
FILL = "fill", -- Llenar toda la pantalla
STRETCH = "stretch", -- Estirar sin mantener proporción
LETTERBOX = "letterbox" -- Barras negras para mantener proporción
}
function M.calculate_scale(target_width, target_height, scale_mode)
local screen_width = tonumber(sys.get_config("display.width"))
local screen_height = tonumber(sys.get_config("display.height"))
local scale_x = screen_width / target_width
local scale_y = screen_height / target_height
local scale = 1
local offset_x, offset_y = 0, 0
if scale_mode == M.SCALE_MODES.FIT then
scale = math.min(scale_x, scale_y)
elseif scale_mode == M.SCALE_MODES.FILL then
scale = math.max(scale_x, scale_y)
elseif scale_mode == M.SCALE_MODES.STRETCH then
-- No usar escala uniforme, aplicar diferentes escalas por eje
return {
scale_x = scale_x,
scale_y = scale_y,
offset_x = 0,
offset_y = 0
}
elseif scale_mode == M.SCALE_MODES.LETTERBOX then
scale = math.min(scale_x, scale_y)
offset_x = (screen_width - target_width * scale) / 2
offset_y = (screen_height - target_height * scale) / 2
end
return {
scale = scale,
offset_x = offset_x,
offset_y = offset_y
}
end
function M.apply_scale_to_node(node, scale_info)
if scale_info.scale then
gui.set_scale(node, vmath.vector3(scale_info.scale, scale_info.scale, 1))
else
gui.set_scale(node, vmath.vector3(scale_info.scale_x, scale_info.scale_y, 1))
end
local pos = gui.get_position(node)
pos.x = pos.x + scale_info.offset_x
pos.y = pos.y + scale_info.offset_y
gui.set_position(node, pos)
end
return M
2. Escalado de Texto Dinámico
Dynamic Text Scaler
-- text_scaler.lua
local M = {}
function M.auto_fit_text(text_node, max_width, max_height, min_font_size, max_font_size)
min_font_size = min_font_size or 12
max_font_size = max_font_size or 48
local text = gui.get_text(text_node)
local font = gui.get_font(text_node)
-- Búsqueda binaria para encontrar el tamaño óptimo
local low = min_font_size
local high = max_font_size
local best_size = min_font_size
while low <= high do
local mid = math.floor((low + high) / 2)
gui.set_font_size(text_node, mid)
local metrics = gui.get_text_metrics(font, text, max_width, true, mid)
if metrics.width <= max_width and metrics.height <= max_height then
best_size = mid
low = mid + 1
else
high = mid - 1
end
end
gui.set_font_size(text_node, best_size)
return best_size
end
function M.scale_text_for_device(text_node, base_size)
local screen_width = tonumber(sys.get_config("display.width"))
local base_width = 375 -- iPhone 6/7/8 como referencia
local scale_factor = screen_width / base_width
local new_size = math.floor(base_size * scale_factor)
-- Limitar el rango de tamaños
new_size = math.max(12, math.min(48, new_size))
gui.set_font_size(text_node, new_size)
return new_size
end
return M
Componentes UI Responsivos
1. Botón Responsivo
Responsive Button
-- responsive_button.gui_script
local scale_manager = require "main.scale_manager"
local text_scaler = require "main.text_scaler"
function init(self)
self.button_node = gui.get_node("button")
self.label_node = gui.get_node("button_label")
self.icon_node = gui.get_node("button_icon")
-- Configuración base
self.base_size = gui.get_size(self.button_node)
self.base_font_size = gui.get_font_size(self.label_node)
self:setup_responsive_behavior()
end
function setup_responsive_behavior(self)
-- Adaptar según el tamaño de pantalla
local screen_width = tonumber(sys.get_config("display.width"))
local device_scale = screen_width / 375 -- Base iPhone 6/7/8
-- Escalar botón
local new_width = self.base_size.x * device_scale
local new_height = self.base_size.y * device_scale
gui.set_size(self.button_node, vmath.vector3(new_width, new_height, 0))
-- Escalar texto
text_scaler.scale_text_for_device(self.label_node, self.base_font_size)
-- Escalar icono si existe
if self.icon_node then
local icon_size = gui.get_size(self.icon_node)
gui.set_size(self.icon_node, vmath.vector3(
icon_size.x * device_scale,
icon_size.y * device_scale,
0
))
end
end
function on_input(self, action_id, action)
if action_id == hash("touch") and gui.pick_node(self.button_node, action.x, action.y) then
if action.pressed then
-- Efecto visual de presión
gui.animate(self.button_node, gui.PROP_SCALE, vmath.vector3(0.95, 0.95, 1),
gui.EASING_OUTQUAD, 0.1)
elseif action.released then
-- Volver al tamaño normal
gui.animate(self.button_node, gui.PROP_SCALE, vmath.vector3(1, 1, 1),
gui.EASING_OUTBACK, 0.2)
-- Ejecutar acción del botón
msg.post("main:/controller", "button_pressed", {button_id = "responsive_button"})
end
return true
end
return false
end
2. Lista Responsiva
Responsive List
-- responsive_list.gui_script
local grid_layout = require "main.grid_layout"
function init(self)
self.list_container = gui.get_node("list_container")
self.item_template = gui.get_node("item_template")
self.items = {}
-- Ocultar template
gui.set_enabled(self.item_template, false)
self:setup_responsive_list()
end
function setup_responsive_list(self)
local screen_width = tonumber(sys.get_config("display.width"))
local container_size = gui.get_size(self.list_container)
-- Calcular cuántos elementos pueden caber por fila
local item_width = 150
local spacing = 20
local cols = math.floor((container_size.x - spacing) / (item_width + spacing))
cols = math.max(1, cols) -- Mínimo 1 columna
-- Ajustar el ancho de los elementos para llenar el espacio disponible
local available_width = container_size.x - (spacing * (cols + 1))
local adjusted_item_width = available_width / cols
self.grid_config = {
cols = cols,
item_width = adjusted_item_width,
item_height = 80,
spacing_x = spacing,
spacing_y = 20
}
print("Responsive list setup: " .. cols .. " columns, item width: " .. adjusted_item_width)
end
function add_item(self, item_data)
local item_node = gui.clone(self.item_template)
gui.set_enabled(item_node, true)
gui.set_parent(item_node, self.list_container)
-- Configurar contenido del item
local label_node = gui.get_node("label", item_node)
if label_node then
gui.set_text(label_node, item_data.text or "Item")
end
-- Calcular posición en grid
local item_index = #self.items
local row = math.floor(item_index / self.grid_config.cols)
local col = item_index % self.grid_config.cols
local x = self.grid_config.spacing_x + col * (self.grid_config.item_width + self.grid_config.spacing_x)
local y = -self.grid_config.spacing_y - row * (self.grid_config.item_height + self.grid_config.spacing_y)
gui.set_position(item_node, vmath.vector3(x, y, 0))
gui.set_size(item_node, vmath.vector3(self.grid_config.item_width, self.grid_config.item_height, 0))
table.insert(self.items, {
node = item_node,
data = item_data,
row = row,
col = col
})
return item_node
end
function on_message(self, message_id, message, sender)
if message_id == hash("orientation_changed") then
self:setup_responsive_list()
self:relayout_items()
end
end
function relayout_items(self)
for i, item in ipairs(self.items) do
local row = math.floor((i-1) / self.grid_config.cols)
local col = (i-1) % self.grid_config.cols
local x = self.grid_config.spacing_x + col * (self.grid_config.item_width + self.grid_config.spacing_x)
local y = -self.grid_config.spacing_y - row * (self.grid_config.item_height + self.grid_config.spacing_y)
gui.animate(item.node, gui.PROP_POSITION, vmath.vector3(x, y, 0), gui.EASING_OUTQUAD, 0.3)
gui.animate(item.node, gui.PROP_SIZE,
vmath.vector3(self.grid_config.item_width, self.grid_config.item_height, 0),
gui.EASING_OUTQUAD, 0.3)
end
end
Testing y Debug
1. Debug de Resoluciones
Resolution Debugger
-- resolution_debugger.gui_script
function init(self)
self.debug_enabled = sys.get_config("project.debug", "0") == "1"
if self.debug_enabled then
self:create_debug_overlay()
end
end
function create_debug_overlay(self)
-- Crear overlay de información
local overlay = gui.new_box_node(vmath.vector3(0, 0, 0), vmath.vector3(300, 200, 0))
gui.set_color(overlay, vmath.vector4(0, 0, 0, 0.7))
gui.set_anchor(overlay, gui.ANCHOR_TOP)
gui.set_pivot(overlay, gui.PIVOT_NW)
-- Texto de información
local info_text = gui.new_text_node(vmath.vector3(10, -10, 0), "")
gui.set_parent(info_text, overlay)
gui.set_anchor(info_text, gui.ANCHOR_NONE)
gui.set_pivot(info_text, gui.PIVOT_NW)
gui.set_color(info_text, vmath.vector4(1, 1, 1, 1))
self.debug_overlay = overlay
self.debug_text = info_text
self:update_debug_info()
end
function update_debug_info(self)
if not self.debug_enabled then return end
local screen_width = tonumber(sys.get_config("display.width"))
local screen_height = tonumber(sys.get_config("display.height"))
local ratio = screen_width / screen_height
local sys_info = sys.get_sys_info()
local info = string.format(
"Resolución: %dx%d\n" ..
"Ratio: %.2f:1\n" ..
"Plataforma: %s\n" ..
"Dispositivo: %s\n" ..
"DPI: %d\n" ..
"Orientación: %s",
screen_width, screen_height,
ratio,
sys_info.system_name,
sys_info.device_model,
sys_info.dpi or 0,
orientation_manager.get_orientation()
)
gui.set_text(self.debug_text, info)
end
function update(self, dt)
if self.debug_enabled then
self:update_debug_info()
end
end
2. Test de Responsive
Responsive Tester
-- responsive_tester.script
function init(self)
self.test_resolutions = {
{name = "iPhone 5", width = 320, height = 568},
{name = "iPhone 8", width = 375, height = 667},
{name = "iPhone X", width = 375, height = 812},
{name = "Pixel 3", width = 393, height = 786},
{name = "iPad", width = 768, height = 1024}
}
self.current_test = 1
end
function test_resolution(self, resolution)
print("Testing resolution: " .. resolution.name .. " (" .. resolution.width .. "x" .. resolution.height .. ")")
-- Simular cambio de resolución (solo para testing)
msg.post("@render:", "change_resolution", {
width = resolution.width,
height = resolution.height
})
-- Notificar a todos los GUI scripts sobre el cambio
msg.post("@system:", "test_resolution", resolution)
end
function on_input(self, action_id, action)
if action.pressed then
if action_id == hash("test_next") then
self.current_test = self.current_test + 1
if self.current_test > #self.test_resolutions then
self.current_test = 1
end
self:test_resolution(self.test_resolutions[self.current_test])
end
end
end
Mejores Prácticas
1. Checklist de UI Responsiva
- ✅ Usar anchors y pivots apropiados
- ✅ Implementar safe area para dispositivos con notch
- ✅ Probar en múltiples resoluciones
- ✅ Optimizar para ambas orientaciones
- ✅ Escalar texto dinámicamente
- ✅ Usar layouts flexibles en lugar de posiciones fijas
2. Optimizaciones de Rendimiento
- ✅ Reutilizar nodos GUI cuando sea posible
- ✅ Usar object pooling para listas dinámicas
- ✅ Minimizar animaciones simultáneas
- ✅ Optimizar atlas de texturas para diferentes densidades
3. Testing en Dispositivos Reales
- ✅ Probar en dispositivos con diferentes tamaños
- ✅ Verificar legibilidad de texto en pantallas pequeñas
- ✅ Comprobar áreas táctiles mínimas (44pt en iOS)
- ✅ Validar comportamiento en orientación landscape
Esta guía te proporciona las herramientas necesarias para crear interfaces verdaderamente responsivas que se adapten perfectamente a cualquier dispositivo móvil.