Voltar para o Blog
Quest Log

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

Interface de jogo moderna mostrando HUD minimalista, menus intuitivos e elementos de UI responsivos

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.

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.

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.

Próximo nível
Quer aprender isso na prática?

No CursoGame.Dev você sai dos tutoriais soltos e constrói jogos publicáveis, com trilha progressiva, quests práticas e feedback real.

Conhecer a plataforma
+500 alunos4.9/5Garantia 7 dias

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:

  1. 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.
  2. 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:

  1. Coloque feedback (visual e som) em todo botão. É a melhoria de maior retorno pelo menor esforço.
  2. Troque qualquer UI que você atualiza no _process por atualização via sinal.
  3. Adicione pelo menos duas opções de acessibilidade: tamanho de fonte e forma além de cor.
  4. Teste em duas resoluções bem diferentes, incluindo retrato se for pra celular.
  5. Sente do lado de um playtester e anote onde ele trava, sem ajudar.