UI/UX Design para Jogos: Guia Completo de Interface e Experiência do Usuário

Aprenda UI/UX design para jogos: princípios de usabilidade, HUD design, menus intuitivos e implementação em Godot. Tutorial completo com exemplos.
Eu já joguei coisa com mecânica sensacional que abandonei no menu de configurações, porque a interface me fez sentir burro. E joguei coisa tecnicamente simples que segurou minha atenção por horas, porque tudo respondia na hora certa. Em mais de 20 anos fazendo jogo, aprendi que UI boa é a que ninguém percebe: o jogador faz o que quer sem parar pra pensar. UI ruim é fricção pura, e fricção espanta gente.
Esse guia é prático. Princípio, motivo do princípio, e código de Godot que realmente roda. Sem teoria de manual.
A diferença entre UI e UX (que muita gente embola)
UI (User Interface) é o que aparece na tela e o jogador toca:
- HUD (vida, munição, minimapa)
- Menus (pause, inventário, configurações)
- Prompts e tooltips
- Barras de progresso e indicadores
UX (User Experience) é como a pessoa se sente usando isso:
- Fluxo de navegação
- Feedback tátil e visual
- Tempo de resposta
- Curva de aprendizado
Dá pra ter uma UI linda e uma UX horrível. Botão bonito que demora 300 ms pra responder ao toque é UI boa com UX ruim. As duas coisas andam juntas, mas não são a mesma coisa.
Princípios que eu uso em todo projeto
1. Clareza vem antes de bonito
Informação crítica tem que ser entendida num relance, sem o jogador interpretar. A vida é o melhor exemplo: ninguém quer ler número quando está apanhando. Cor já resolve. Verde, laranja, vermelho, e o cérebro entende antes do consciente.
# Barra de vida que muda de cor e pulsa quando a coisa aperta
extends TextureProgressBar
@export var low_health_threshold: float = 0.3
@export var critical_health_threshold: float = 0.15
@onready var animation_player: AnimationPlayer = $AnimationPlayer
func _ready() -> void:
# value_changed dispara sempre que "value" muda no inspector ou em runtime
value_changed.connect(_on_health_changed)
func _on_health_changed(new_value: float) -> void:
var health_percent := new_value / max_value
if health_percent <= critical_health_threshold:
modulate = Color.RED
animation_player.play("pulse_critical")
elif health_percent <= low_health_threshold:
modulate = Color.ORANGE
animation_player.play("pulse_low")
else:
modulate = Color.WHITE
animation_player.stop()
func update_health(current: float, maximum: float) -> void:
max_value = maximum
value = current
A sacada aqui é separar a lógica de cor da lógica de jogo. O jogo só chama update_health(). Quem decide cor e animação é a barra. Isso te poupa de espalhar if vida < 30 por todo lado.
2. Consistência você define uma vez, não em todo botão
Se cada tela tem sua própria cor de azul e seu próprio tamanho de fonte, o jogo parece feito por três pessoas que não se falaram. Centralize. Em Godot, um Resource de tema é o jeito mais limpo de fazer isso: você muda a cor primária num lugar e ela troca no jogo inteiro.
# Tema central de UI, salvo como .tres
class_name UITheme
extends Resource
@export_group("Colors")
@export var primary_color: Color = Color.hex(0x4A90E2FF)
@export var secondary_color: Color = Color.hex(0x50E3C2FF)
@export var danger_color: Color = Color.RED
@export var success_color: Color = Color.GREEN
@export var text_color: Color = Color.WHITE
@export var bg_color: Color = Color(0, 0, 0, 0.7)
@export_group("Fonts")
@export var body_font: Font
@export var title_size: int = 32
@export var body_size: int = 16
@export_group("Spacing")
@export var padding: int = 16
@export var button_height: int = 48
func apply_to_button(button: Button) -> void:
button.add_theme_color_override("font_color", text_color)
button.add_theme_color_override("font_hover_color", primary_color)
if body_font:
button.add_theme_font_override("font", body_font)
button.add_theme_font_size_override("font_size", body_size)
button.custom_minimum_size.y = button_height
Vale dizer: o sistema de Theme nativo da Godot (aquele recurso .theme que você edita no editor) já faz boa parte disso e é o caminho recomendado pra estilizar Control em larga escala. Esse UITheme aqui é útil quando você precisa de valores extras (espaçamentos, thresholds, cores semânticas tipo "danger") que o jogo lê em runtime. Use os dois juntos.
3. Toda ação precisa de resposta
Apertou e nada aconteceu? O jogador aperta de novo. E de novo. Acha que travou. Feedback imediato (visual ou sonoro) é o que diz "ó, eu te ouvi". Um botão que cresce um tiquinho no hover e afunda no clique já muda completamente a sensação.
# Botão com hover, press e som
extends Button
@export var hover_scale: float = 1.05
@export var press_scale: float = 0.95
@export var anim_duration: float = 0.15
@onready var hover_sound: AudioStreamPlayer = $HoverSound
@onready var click_sound: AudioStreamPlayer = $ClickSound
func _ready() -> void:
# pivot no centro pra escala não puxar pra um canto
pivot_offset = size / 2.0
mouse_entered.connect(_on_hover)
mouse_exited.connect(_on_unhover)
button_down.connect(_on_press)
pressed.connect(func(): click_sound.play())
func _on_hover() -> void:
hover_sound.play()
_scale_to(hover_scale)
func _on_unhover() -> void:
_scale_to(1.0)
func _on_press() -> void:
_scale_to(press_scale, anim_duration * 0.5)
func _scale_to(target: float, duration: float = anim_duration) -> void:
var tween := create_tween()
tween.set_ease(Tween.EASE_OUT).set_trans(Tween.TRANS_BACK)
tween.tween_property(self, "scale", Vector2.ONE * target, duration)
Detalhe que quase todo mundo erra na primeira vez: sem ajustar o pivot_offset, a escala cresce a partir do canto superior esquerdo e o botão "foge" pro lado. Centralizar o pivô resolve.
4. Hierarquia visual: nem tudo grita ao mesmo tempo
Se tudo na tela tem o mesmo peso, nada tem destaque. Vida é crítico, então é grande e fica num canto fixo. Pontuação é informativo, então é pequeno e discreto. O jogador não precisa que você explique isso, ele lê pelo tamanho e pela opacidade.
# HUD organizado por importância
extends CanvasLayer
@onready var health_bar: Control = $HealthBar
@onready var ammo_counter: Control = $AmmoCounter
@onready var score: Control = $Score
@onready var notifications: Control = $Notifications
func _ready() -> void:
# Vida: crítica, grande e cheia
health_bar.scale = Vector2(1.2, 1.2)
# Munição: importante, mas um pouco mais discreta
ammo_counter.modulate.a = 0.9
# Score: informativo, fica no fundo da hierarquia
score.modulate.a = 0.7
score.scale = Vector2(0.8, 0.8)
func show_notification(text: String, duration: float = 3.0) -> void:
var label := Label.new()
label.text = text
label.add_theme_font_size_override("font_size", 24)
label.modulate.a = 0.0
notifications.add_child(label)
var tween := create_tween()
tween.tween_property(label, "modulate:a", 1.0, 0.3)
tween.tween_interval(duration)
tween.tween_property(label, "modulate:a", 0.0, 0.3)
tween.tween_callback(label.queue_free)
Repara que eu encadeei o fade in, a espera e o fade out num único tween. Em vez de espalhar await timer no meio do código, o tween cuida da sequência inteira e ainda chama queue_free no fim. Menos linha, menos bug.
HUD: mostre o necessário, esconda o resto
Minimalismo contextual
O melhor HUD é o que some quando não precisa aparecer. Prompt de "E interagir" só faz sentido quando tem algo perto pra interagir. Fora disso, é poluição na tela.
# HUD que mostra o prompt só quando há algo interagível por perto
extends Control
@onready var interaction_prompt: Label = $InteractionPrompt
@onready var objective_marker: Label = $ObjectiveMarker
var nearby_interactable: Node = null
func _ready() -> void:
interaction_prompt.hide()
objective_marker.hide()
# Chamado pelo Area2D de detecção quando o jogador entra/sai do alcance
func set_nearby_interactable(interactable: Node) -> void:
nearby_interactable = interactable
interaction_prompt.text = "[E] " + interactable.interaction_text
interaction_prompt.show()
func clear_nearby_interactable() -> void:
nearby_interactable = null
interaction_prompt.hide()
Aqui eu troquei a checagem no _process por chamadas event-driven. Em vez de o HUD perguntar "tem algo perto?" 60 vezes por segundo, é o Area2D que avisa quando entra e quando sai. Mostrar/esconder UI dentro do _process é um clássico desperdício: você roda lógica todo frame pra um estado que muda uma vez a cada vários segundos.
UI diegética: vida no próprio personagem
UI diegética é a que vive dentro do mundo do jogo, sem overlay. Em vez de barra de HP no canto, o personagem pisca vermelho, solta partícula e a tela escurece nas bordas quando você está quase morrendo. Dead Space é o exemplo clássico: a vida fica numa barra na coluna do personagem, dentro da ficção. Custa mais trabalho, mas a imersão sobe absurdamente.
# Dano comunicado pelo próprio personagem, sem barra de HP
extends CharacterBody2D
@onready var sprite: Sprite2D = $Sprite2D
@onready var blood_particles: GPUParticles2D = $BloodParticles
@export var max_health: float = 100.0
var current_health: float = 100.0
# Emite um sinal pra um shader/overlay de vinheta reagir
signal health_ratio_changed(ratio: float)
func take_damage(amount: float) -> void:
current_health = max(current_health - amount, 0.0)
# Flash vermelho no sprite
sprite.modulate = Color.RED
var tween := create_tween()
tween.tween_property(sprite, "modulate", Color.WHITE, 0.3)
blood_particles.emitting = true
health_ratio_changed.emit(current_health / max_health)
if current_health <= 0.0:
die()
func die() -> void:
# sua lógica de morte aqui
pass
Eu deixei o efeito de vinheta como um sinal (health_ratio_changed) em vez de o personagem ir buscar um nó por caminho absoluto tipo /root/MainScene/VignetteEffect. Caminho hardcoded quebra na primeira vez que você renomeia uma cena. Sinal não. O overlay de vinheta se conecta ao personagem e reage sozinho.
UI espacial (3D) que olha pra câmera
Em jogos 3D e VR, parte da UI fica no espaço: nome do inimigo, barra de vida flutuando acima da cabeça. O truque é o billboard, fazer o elemento sempre encarar a câmera, senão você lê o nome de lado.
A Godot já tem billboard nativo via BaseMaterial3D.BILLBOARD_ENABLED no Sprite3D, e é o jeito mais barato. Mas se você precisa de um SubViewport com Control real dentro (barra de vida com nó TextureProgressBar, por exemplo), aí você gira o nó na mão:
# UI 3D que sempre encara a câmera
extends Node3D
func _process(_delta: float) -> void:
var camera := get_viewport().get_camera_3d()
if camera:
# olha na direção da câmera, mantendo o "pra cima" do mundo
look_at(camera.global_position, Vector3.UP)
Cuidado com um detalhe: look_at quebra se a posição do nó coincidir exatamente com a da câmera (vetor de comprimento zero). Em produção, vale checar a distância antes de chamar. Pra barra acima de inimigo isso raramente acontece, mas pra UI colada na câmera, acontece.
Menus que não irritam
Menu principal
Menu é a primeira coisa que o jogador vê. Animação de entrada escalonada (botões que aparecem um atrás do outro) deixa tudo mais vivo sem custar quase nada.
extends Control
@onready var buttons_container: VBoxContainer = $VBoxContainer
@onready var title: Control = $Title
var button_scene: PackedScene = preload("res://ui/menu_button.tscn")
func _ready() -> void:
create_menu_buttons()
animate_title()
func create_menu_buttons() -> void:
var entries := [
{"text": "Novo Jogo", "action": start_new_game},
{"text": "Continuar", "action": continue_game},
{"text": "Configurações", "action": open_settings},
{"text": "Sair", "action": quit_game},
]
for i in entries.size():
var button: Button = button_scene.instantiate()
button.text = entries[i].text
button.pressed.connect(entries[i].action)
button.modulate.a = 0.0
buttons_container.add_child(button)
# delay crescente: cada botão entra um pouco depois do anterior
var tween := create_tween().set_parallel(true)
tween.tween_property(button, "modulate:a", 1.0, 0.3).set_delay(i * 0.08)
tween.tween_property(button, "position:x", 0.0, 0.3) \
.from(-100.0).set_trans(Tween.TRANS_BACK).set_delay(i * 0.08)
func animate_title() -> void:
title.scale = Vector2.ZERO
var tween := create_tween()
tween.tween_property(title, "scale", Vector2.ONE, 0.5).set_trans(Tween.TRANS_ELASTIC)
func start_new_game() -> void:
get_tree().change_scene_to_file("res://scenes/game.tscn")
func continue_game() -> void:
SaveSystem.load_game()
func open_settings() -> void:
get_tree().change_scene_to_file("res://ui/settings_menu.tscn")
func quit_game() -> void:
get_tree().quit()
Mudei uma coisa que tinha ali antes: em vez de dar await timer no meio do loop pra escalonar os botões, eu uso set_delay(i * 0.08). Com await dentro do _ready, se o jogador clica rápido em "Sair" antes de todos os botões aparecerem, você pode pegar erro de nó já liberado. Com delay no tween, o loop termina na hora e os botões aparecem sozinhos.
Menu de pausa
O segredo do menu de pausa em Godot são duas linhas: get_tree().paused congela o jogo, e process_mode = PROCESS_MODE_ALWAYS garante que o menu continue rodando enquanto todo o resto está congelado. Se você esquecer o segundo, o próprio menu congela junto e o jogador não consegue despausar.
extends Control
var is_paused: bool = false
func _ready() -> void:
hide()
# roda mesmo com a árvore pausada, senão o menu congela junto
process_mode = Node.PROCESS_MODE_ALWAYS
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("ui_cancel"):
toggle_pause()
func toggle_pause() -> void:
is_paused = not is_paused
get_tree().paused = is_paused
visible = is_paused
if is_paused:
animate_in()
else:
animate_out()
func animate_in() -> void:
scale = Vector2(0.8, 0.8)
modulate.a = 0.0
var tween := create_tween().set_parallel(true)
tween.tween_property(self, "scale", Vector2.ONE, 0.3).set_trans(Tween.TRANS_BACK)
tween.tween_property(self, "modulate:a", 1.0, 0.2)
func animate_out() -> void:
var tween := create_tween()
tween.tween_property(self, "modulate:a", 0.0, 0.2)
await tween.finished
hide()
func _on_quit_pressed() -> void:
get_tree().paused = false # despausa antes de trocar de cena
get_tree().change_scene_to_file("res://ui/main_menu.tscn")
Outra pegadinha: lembre de setar paused = false antes de voltar pro menu principal. Se você trocar de cena com o jogo ainda pausado, o menu principal nasce congelado e parece que travou.
Acessibilidade: não é firula, é alcance
Vou ser direto: acessibilidade não é "extra pra quando sobrar tempo". É gente que vai ou não conseguir jogar o que você fez. Tamanho de fonte ajustável e modo daltônico são o mínimo, e custam pouco se você pensar neles desde o começo. Deixar pro fim é o que dói.
Tamanho de texto ajustável
A ideia: cada label guarda seu tamanho base, e um multiplicador global escala todos de uma vez. Use grupos pra alcançar todos os labels sem precisar referenciar um por um.
class_name AccessibilitySettings
extends Node
enum TextSize { SMALL, MEDIUM, LARGE, EXTRA_LARGE }
const MULTIPLIERS := {
TextSize.SMALL: 0.8,
TextSize.MEDIUM: 1.0,
TextSize.LARGE: 1.3,
TextSize.EXTRA_LARGE: 1.6,
}
var current_size: TextSize = TextSize.MEDIUM
func set_text_size(size: TextSize) -> void:
current_size = size
var multiplier: float = MULTIPLIERS[size]
# todo label que deve escalar entra no grupo "scalable_text"
for label in get_tree().get_nodes_in_group("scalable_text"):
if label is Label or label is RichTextLabel:
var base_size: int = label.get_meta("base_font_size", 16)
label.add_theme_font_size_override("font_size", int(base_size * multiplier))
O ponto fino aqui é o base_font_size no get_meta. Se você multiplicar em cima do tamanho atual, em vez do tamanho base, a fonte cresce e nunca volta direito quando o jogador troca de "grande" pra "pequeno". Sempre escale a partir do valor original.
Modo de alto contraste
Pra alto contraste, o caminho honesto é trocar o tema por uma variante de alto contraste, não modular cor por cor na mão. A Godot deixa você trocar o theme de qualquer Control em runtime, e isso propaga pros filhos. É menos código e bem menos bug do que sair caçando cada elemento.
class_name ContrastMode
extends Node
@export var normal_theme: Theme
@export var high_contrast_theme: Theme
@export var ui_root: Control # raiz da sua árvore de UI
var enabled: bool = false
func toggle() -> void:
enabled = not enabled
ui_root.theme = high_contrast_theme if enabled else normal_theme
RenderingServer.set_default_clear_color(
Color.BLACK if enabled else Color(0.1, 0.1, 0.1)
)
Você cria dois recursos de Theme no editor (um normal, um de alto contraste com cores fortes e bordas grossas) e troca o ponteiro. Quem desenha cada Control é o tema, então a troca é instantânea e cobre o jogo inteiro.
Modos para daltonismo
Daltonismo merece atenção porque muito jogo usa cor como ÚNICA forma de passar informação (verde = aliado, vermelho = inimigo). Pra quem tem deuteranopia, isso vira a mesma cor. Duas frentes resolvem:
- Nunca dependa só de cor. Adicione forma ou ícone junto. Aliado com contorno redondo, inimigo com contorno em ponta. Essa é a correção de verdade, e funciona pra todo mundo.
- Filtro de tela que remapeia as cores via shader, pra quem prefere.
O filtro se faz com um shader aplicado a uma ColorRect em tela cheia, num CanvasLayer que fica por cima de tudo. Você liga e desliga trocando o material:
# ColorRect em tela cheia, num CanvasLayer acima do jogo
extends ColorRect
enum ColorblindType { NONE, PROTANOPIA, DEUTERANOPIA, TRITANOPIA }
const SHADERS := {
ColorblindType.PROTANOPIA: "res://shaders/protanopia.gdshader",
ColorblindType.DEUTERANOPIA: "res://shaders/deuteranopia.gdshader",
ColorblindType.TRITANOPIA: "res://shaders/tritanopia.gdshader",
}
func apply_filter(type: ColorblindType) -> void:
if type == ColorblindType.NONE:
material = null
hide()
return
var mat := ShaderMaterial.new()
mat.shader = load(SHADERS[type])
material = mat
show()
Pra esse shader ler o que está renderizado embaixo dele, ele precisa amostrar a tela via hint_screen_texture no .gdshader. Os algoritmos de simulação/correção de daltonismo (matrizes de Brettel e companhia) são públicos, mas isso já é assunto de um artigo só sobre shaders. O que importa do lado da UI é: o filtro é uma camada por cima, ligada ou desligada por uma opção, e nunca substitui a regra número 1 (forma além da cor).
Animações e transições
Transição entre telas
Trocar de cena seco é áspero. Um fade de meio segundo já dá ritmo. O padrão em Godot é um CanvasLayer que fica sempre por cima, escurece, troca a cena, e clareia.
class_name SceneTransition
extends CanvasLayer
@onready var color_rect: ColorRect = $ColorRect
func _ready() -> void:
color_rect.modulate.a = 0.0
func change_scene(target_scene: String) -> void:
# escurece
var tween := create_tween()
tween.tween_property(color_rect, "modulate:a", 1.0, 0.3)
await tween.finished
get_tree().change_scene_to_file(target_scene)
# clareia
tween = create_tween()
tween.tween_property(color_rect, "modulate:a", 0.0, 0.3)
Pra esse CanvasLayer sobreviver à troca de cena, coloque ele como autoload (singleton). Senão ele morre junto com a cena antiga no meio da transição e o efeito buga.
Micro-animações
São os detalhes que dão vida: a moeda que pula um pouco quando você pega, o número que sobe contando em vez de saltar direto pro valor final. Custa pouco e a percepção de qualidade sobe muito.
# Moeda que pula e número que conta subindo
extends TextureRect
@onready var coin_label: Label = $CoinLabel
@onready var coin_particles: GPUParticles2D = $CoinParticles
var displayed_total: int = 0
func add_coins(amount: int) -> void:
pivot_offset = size / 2.0
var bounce := create_tween()
bounce.tween_property(self, "scale", Vector2(1.3, 1.3), 0.1)
bounce.tween_property(self, "scale", Vector2.ONE, 0.2).set_trans(Tween.TRANS_ELASTIC)
coin_particles.emitting = true
count_up(displayed_total + amount)
func count_up(target: int) -> void:
# tween anima a propriedade "displayed_total", e _process só lê e mostra
var tween := create_tween()
tween.tween_property(self, "displayed_total", target, 0.5)
tween.tween_callback(func(): coin_label.text = str(target))
func _process(_delta: float) -> void:
coin_label.text = str(displayed_total)
Aqui eu deixei o tween animar a variável displayed_total direto. Bem mais limpo do que o loop com vários await timer que eu já vi por aí pra contar número: você deixa o tween fazer a interpolação e só lê o valor. Menos código e o resultado fica mais suave.
Layout responsivo
Use os containers e anchors da engine antes de calcular posição na mão
Esse é o erro mais comum: gente posicionando tudo com coordenada fixa em pixel e depois sofrendo quando muda a resolução. Na Godot, 90% do trabalho de layout responsivo é feito pelos Container (VBoxContainer, HBoxContainer, MarginContainer) e pelos anchors. Ancorou no canto, ele fica no canto em qualquer resolução, sem você escrever uma linha.
Configure também o Stretch Mode em Project Settings > Display > Window. O modo canvas_items com aspect expand resolve a maior parte dos casos de escalar UI entre telas diferentes.
Só caia pro código quando o layout muda de estrutura, não só de tamanho. O caso clássico: trocar de horizontal (desktop) pra vertical (celular em pé).
# Troca a estrutura do layout entre paisagem e retrato
extends Control
func _ready() -> void:
get_viewport().size_changed.connect(_on_viewport_resized)
_on_viewport_resized()
func _on_viewport_resized() -> void:
var vp := get_viewport_rect().size
if vp.x < vp.y:
_apply_portrait_layout()
else:
_apply_landscape_layout()
func _apply_portrait_layout() -> void:
# celular em pé: empilha na vertical
$HealthBar.position = Vector2(20, 20)
$HealthBar.size = Vector2(200, 30)
$AmmoCounter.position = Vector2(20, 70)
func _apply_landscape_layout() -> void:
# desktop: espalha pelos cantos
$HealthBar.position = Vector2(20, 20)
$HealthBar.size = Vector2(300, 40)
var vp := get_viewport_rect().size
$AmmoCounter.position = Vector2(vp.x - 170, vp.y - 60)
Safe area no celular
Celular com notch ou ilha dinâmica come um pedaço da tela. UI importante encostada na borda some atrás do recorte. A Godot te dá a área segura via DisplayServer.get_display_safe_area(). O jeito limpo é colocar sua UI dentro de um MarginContainer e aplicar as margens da safe area nele, porque são as margens que são propriedade de MarginContainer, não de qualquer Control.
# Aplica a safe area do dispositivo a um MarginContainer raiz
extends MarginContainer
func _ready() -> void:
var safe := DisplayServer.get_display_safe_area()
var screen := DisplayServer.screen_get_size()
add_theme_constant_override("margin_left", safe.position.x)
add_theme_constant_override("margin_top", safe.position.y)
add_theme_constant_override("margin_right", screen.x - safe.end.x)
add_theme_constant_override("margin_bottom", screen.y - safe.end.y)
Importante: margin_left, margin_top etc. são constantes de tema do MarginContainer, então esse script precisa estar num nó MarginContainer de verdade. Aplicar isso num Control genérico não faz nada (foi um erro que já me custou uma tarde antes de eu sacar).
Tooltips e tutoriais
Tooltips que não saem da tela
Tooltip é simples até o jogador passar o mouse num botão no canto direito e o tooltip vazar pra fora da janela. A correção é checar se ele cabe e, se não couber, jogar pro outro lado.
class_name Tooltip
extends PanelContainer
@onready var label: Label = $Label
@export var offset: Vector2 = Vector2(10, 10)
func _ready() -> void:
hide()
func show_for(text: String, anchor: Control) -> void:
label.text = text
show()
# espera um frame pra o PanelContainer recalcular o tamanho com o texto novo
await get_tree().process_frame
_reposition(anchor)
func _reposition(anchor: Control) -> void:
var pos := anchor.global_position + offset
var screen := get_viewport_rect().size
# se vazar pela direita, joga pra esquerda do alvo
if pos.x + size.x > screen.x:
pos.x = anchor.global_position.x - size.x - offset.x
# se vazar por baixo, joga pra cima do alvo
if pos.y + size.y > screen.y:
pos.y = anchor.global_position.y - size.y - offset.y
global_position = pos
O detalhe que faz funcionar é o await get_tree().process_frame antes de reposicionar. Quando você troca o texto, a Godot só recalcula o tamanho do painel no frame seguinte. Se você ler size na mesma hora, lê o tamanho velho e o reposicionamento sai torto.
Tutorial passo a passo
Tutorial bom não é uma parede de texto no começo. É contextual: aparece no momento certo, destaca o que importa, e espera a pessoa fazer a ação antes de seguir.
# Tutorial em passos, cada um espera uma ação do jogador
class_name TutorialSystem
extends Node
signal tutorial_completed
var steps: Array = []
var current_step: int = 0
func start(tutorial_steps: Array) -> void:
steps = tutorial_steps
current_step = 0
_show_step()
func _show_step() -> void:
if current_step >= steps.size():
tutorial_completed.emit()
return
var step: Dictionary = steps[current_step]
_show_message(step.get("message", ""))
if step.has("highlight"):
_highlight(step.highlight)
# espera o jogador disparar o sinal que esse passo exige
if step.has("wait_signal"):
await step.wait_signal
else:
await get_tree().create_timer(step.get("duration", 3.0)).timeout
current_step += 1
_show_step()
func _show_message(text: String) -> void:
# plugue aqui seu painel de mensagem de tutorial
pass
func _highlight(target: Control) -> void:
# ex.: overlay escuro com um furo no retângulo do alvo
pass
O pulo do gato é o await step.wait_signal. Cada passo carrega o sinal que ele espera (por exemplo, player.jumped no passo "aprenda a pular"). O tutorial trava ali até o jogador realmente pular. Você não está cronometrando, está esperando a competência. Isso muda tudo na sensação de tutorial.
::blog-cta{title="Transforme Jogadores em Fãs com UI/UX Excepcional" description="Aprenda a criar interfaces profissionais que engajam e retêm jogadores. Curso completo de game design com foco em UX." buttonText="Candidate-se Agora" icon="fas fa-palette" variant="highlight"}::
Performance de UI: o que realmente importa
UI mal feita derruba framerate, e quase sempre por dois motivos. Vou direto neles, sem encheção:
Não rode lógica de UI todo frame se o estado muda raramente. Aquele exemplo lá em cima do prompt de interação event-driven em vez de _process? É isso. Atualize a UI quando o dado muda, não 60 vezes por segundo. Use sinais.
Carregue menus sob demanda. Não precisa instanciar a tela de créditos no boot. Carregue na primeira vez que ela for aberta e guarde a referência pra reusar.
# Carrega cada menu só na primeira vez que ele é pedido
class_name MenuManager
extends Node
var loaded: Dictionary = {}
func show_menu(menu_path: String) -> void:
if not loaded.has(menu_path):
var menu: Node = load(menu_path).instantiate()
add_child(menu)
loaded[menu_path] = menu
for path in loaded:
loaded[path].visible = (path == menu_path)
Sobre "reduzir draw calls de UI": na Godot 4, Control já é desenhado de forma eficiente, e otimização prematura aqui costuma ser tempo jogado fora. Se você de fato medir gargalo de draw call (no Monitor de performance do editor, não no chute), aí sim vale olhar CanvasGroup pra mesclar um grupo de elementos com transparência. Mas mede antes. Otimizar UI que não é o gargalo é o jeito mais fácil de perder uma tarde sem ganhar fps.
Fechando
Se você levar uma coisa só desse texto, leve esta: UI boa some. O jogador faz o que quer, sente que respondeu na hora, e nunca para pra decifrar a sua interface. Clareza, consistência, feedback e acessibilidade não são quatro itens de checklist soltos, são quatro jeitos de tirar fricção do caminho.
E tem uma parte que nenhum código resolve: testar com gente de verdade. O que é óbvio pra você, que construiu o jogo, pode ser um enigma pra quem abriu agora. Eu já achei que tinha feito um menu cristalino e vi playtester travar nele em dez segundos. Senta do lado, fica de boca fechada, e observa onde a pessoa hesita. É ali que está o trabalho.
Por onde começar no seu projeto:
- Coloque feedback (visual e som) em todo botão. É a melhoria de maior retorno pelo menor esforço.
- Troque qualquer UI que você atualiza no
_processpor atualização via sinal. - Adicione pelo menos duas opções de acessibilidade: tamanho de fonte e forma além de cor.
- Teste em duas resoluções bem diferentes, incluindo retrato se for pra celular.
- Sente do lado de um playtester e anote onde ele trava, sem ajudar.


