| name | game-ui |
| description | Interface utilisateur pour jeux vidéo — uGUI, UMG, Godot Control, Dear ImGui, HUD, inventaire, menus, world space UI, UI animation, responsive, accessibilité, minimap, localisation, optimisation UI. |
| tags | ["game-ui","ugui","umg","godot-control","dear-imgui","hud","inventory","menu","world-space-ui","ui-animation","accessibility","minimap","localization"] |
Game UI — Guide Complet
Ce skill couvre la conception et le développement d'interfaces utilisateur pour jeux vidéo. À charger pour toute tâche impliquant HUD, menus, inventaire, UI world-space, ou systèmes UI dans Unity/Unreal/Godot.
1. Frameworks UI par Moteur
| Moteur | Framework | Usage | Points Forts |
|---|
| Unity | uGUI (Canvas) | Standard | Ancres, Canvas scaler, EventSystem |
| Unity | UI Toolkit | Éditeur/UI complexe | USS/UXML, data binding |
| Unreal | UMG/Slate | Tout Unreal | Widget Blueprint, animation |
| Godot | Control Nodes | Tout Godot | Theme, StyleBox, containers |
| Custom | Dear ImGui | Outils, debug | Immédiat mode, zéro setup |
| Web | Coherent GT | UI HTML | Design web, full JS |
2. Architecture UI — Patterns
MVC (Model-View-Controller)
public class PlayerStats : MonoBehaviour
{
public int Health { get; private set; } = 100;
public int MaxHealth = 100;
public int Score { get; private set; }
public event System.Action<int, int> OnHealthChanged;
public event System.Action<int> OnScoreChanged;
public void TakeDamage(int damage)
{
Health = Mathf.Max(0, Health - damage);
OnHealthChanged?.Invoke(Health, MaxHealth);
}
public void AddScore(int points)
{
Score += points;
OnScoreChanged?.Invoke(Score);
}
}
public class HealthBarView : MonoBehaviour
{
[SerializeField] private Image _fillImage;
[SerializeField] private Text _healthText;
public void ()
{
_fillImage.fillAmount = ()current / max;
_healthText.text = ;
}
}
:
{
[] PlayerStats _stats;
[] HealthBarView _healthBar;
{
_stats.OnHealthChanged += _healthBar.UpdateHealth;
_stats.OnScoreChanged += UpdateScoreDisplay;
_healthBar.UpdateHealth(_stats.Health, _stats.MaxHealth);
}
}
Data Binding (UI Toolkit)
<ui:UXML xmlns:ui="UnityEngine.UIElements">
<ui:Label text="Score: {score}" binding-path="Score" />
<ui:ProgressBar low-value="0" high-value="100"
binding-path="Health" />
</ui:UXML>
Event-Driven UI
public static class GameEvents
{
public static event System.Action<int> OnScoreChanged;
public static event System.Action<float> OnHealthChanged;
public static event System.Action<Item> OnItemCollected;
public static event System.Action<GameState> OnGameStateChanged;
public static void ScoreChanged(int score) => OnScoreChanged?.Invoke(score);
public static void HealthChanged(float health) => OnHealthChanged?.Invoke(health);
public static void ItemCollected(Item item) => OnItemCollected?.Invoke(item);
public static void GameStateChanged(GameState state) => OnGameStateChanged?.Invoke(state);
}
public class ScorePickup : MonoBehaviour
{
public int points = ;
=> GameEvents.ScoreChanged(points);
}
:
{
[] Text _scoreText;
=> GameEvents.OnScoreChanged += UpdateScore;
=> GameEvents.OnScoreChanged -= UpdateScore;
{
_scoreText.text = score.ToString();
StartCoroutine(AnimateScorePop());
}
}
3. Canvas et Layout (Unity uGUI)
Canvas Scalers (Résolution Indépendante)
void SetupCanvas(CanvasScaler scaler)
{
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
scaler.referenceResolution = new Vector2(1920, 1080);
scaler.screenMatchMode = CanvasScaler.ScreenMatchMode.MatchWidthOrHeight;
scaler.matchWidthOrHeight = 0.5f;
}
Anchors et RectTransform
Anchors: points d'attache du UI element par rapport au parent
7 positions d'ancrage courantes:
- Top-Left: coins = (0,1, 0,1) → menus, minimap
- Top-Center: coins = (0.5,1, 0.5,1) → title
- Top-Right: coins = (1,1, 1,1) → score, ammo
- Middle: coins = (0.5,0.5, 0.5,0.5) → popup, HP bar
- Bottom: coins = (0,0, 1,0) → health bar, hotbar
- Stretch: coins = (0,0, 1,1) → overlay, fullscreen
- Custom: coins = (0.2,0.3, 0.8,0.7) → zone spécifique
RectTransform:
- anchoredPosition: position relative aux ancres
- sizeDelta: taille
- offsetMin/offsetMax: décalage par rapport aux ancres
Layout Groups
Unity UI — Optimisation du Canvas
PROBLÈME: chaque modification du Canvas déclenche un rebuild
→ Impact performance si changé chaque frame
RÈGLES:
1. Canvas static = pas de rebuild
2. Pas de layout groups changeant chaque frame
3. Parent = même canvas pour les éléments qui changent ensemble
4. Éviter les Raycast Target inutiles (désactiver sur les images décoratives)
5. Utiliser des Canvas enfants séparés pour les éléments qui changent souvent
Canvas Rebuild Profiler:
- Window → Analysis → Profiler → UI
- Voir le nombre de rebuilds par frame
- Cible: < 5 rebuilds/frame
4. UMG (Unreal Motion Graphics)
Widget Blueprint Architecture
Widget Blueprint (HealthBar_Widget):
├── Canvas Panel (Root)
│ ├── ProgressBar "HealthBar"
│ │ └── Fill: Image
│ ├── TextBlock "HealthText"
│ └── Image "Background"
Bindings:
- HealthBar.Percent → GetPlayerHealth() / MaxHealth
- HealthText.Text → Format("{0}/{1}", GetPlayerHealth(), GetMaxHealth())
UMG C++
UCLASS()
class UHealthBarWidget : public UUserWidget
{
GENERATED_BODY()
public:
UPROPERTY(meta = (BindWidget))
class UProgressBar* HealthBar;
UPROPERTY(meta = (BindWidget))
class UTextBlock* HealthText;
UFUNCTION(BlueprintCallable)
void SetHealth(float Current, float Max);
UPROPERTY(Transient, meta = (BindWidgetAnim))
class UWidgetAnimation* DamageAnimation;
};
void UHealthBarWidget::SetHealth(float Current, float Max)
{
HealthBar->SetPercent(Current / Max);
HealthText->SetText(FText::FromString(
FString::Printf(TEXT("%.0f/%.0f"), Current, Max)));
if (Current < PreviousHealth)
PlayAnimation(DamageAnimation);
PreviousHealth = Current;
}
Slate (C++ pur, UI Framework d'Unreal)
class SHealthBar : public SCompoundWidget
{
SLATE_BEGIN_ARGS(SHealthBar)
: _Percent(1.0f)
{}
SLATE_ATTRIBUTE(float, Percent)
SLATE_ATTRIBUTE(FText, Label)
SLATE_END_ARGS()
void Construct(const FArguments& InArgs)
{
ChildSlot
[
SNew(SOverlay)
+ SOverlay::Slot()
[
SNew(SImage)
.ColorAndOpacity(FLinearColor::Red)
.Image(FCoreStyle::Get().GetBrush("WhiteTexture"))
]
+ SOverlay::Slot()
.Padding(2.0f)
[
SNew(SImage)
.ColorAndOpacity(this, &SHealthBar::GetBarColor)
.Image(FCoreStyle::Get().GetBrush("WhiteTexture"))
.RenderTransform(this, &SHealthBar::GetBarTransform)
]
+ SOverlay::Slot()
.HAlign(HAlign_Center)
.VAlign(VAlign_Center)
[
SNew(STextBlock)
.Text(InArgs._Label)
.Font(FCoreStyle::Get().GetFontStyle())
]
];
}
};
5. Godot Control Nodes
# Godot — HUD Scene
# Control root avec Anchor = Full Rect
extends Control
@onready var health_bar := $VBoxContainer/HealthBar as TextureProgressBar
@onready var health_label := $VBoxContainer/HealthBar/Label as Label
@onready var score_label := $VBoxContainer/ScoreLabel as Label
@onready var inventory_grid := $InventoryGrid as GridContainer
func _ready():
# Connecter les signaux
PlayerManager.player_health_changed.connect(_on_health_changed)
PlayerManager.score_changed.connect(_on_score_changed)
func _on_health_changed(current: float, max: float):
health_bar.value = current / max * 100.0
health_label.text = "%d/%d" % [current, max]
# Animation de dégâts
var tween = create_tween()
tween.tween_property(health_bar, "modulate", Color.RED, 0.1)
tween.tween_property(health_bar, "modulate", Color.WHITE, 0.3)
func _on_score_changed(score: int):
var tween = create_tween()
score_label.text = str(score)
score_label.scale = Vector2(1.2, 1.2)
tween.tween_property(score_label, "scale", Vector2.ONE, 0.3)
# Godot Theme
# Créer un fichier .tres (Theme) pour centraliser:
# - Default font, font sizes
# - Colors, styles
# - Button styles, Panel styles, Label styles
# Puis assigner au Control root pour propagation
Godot Containers
# Godot container types:
# HBoxContainer → horizontal
# VBoxContainer → vertical
# GridContainer → grille
# CenterContainer → centré
# MarginContainer → marges
# AspectRatioContainer → ratio fixe
# PanelContainer → avec style
# Exemple: inventaire en grille
func setup_inventory_grid(rows: int, cols: int):
inventory_grid.columns = cols
for i in range(rows * cols):
var slot = preload("res://ui/InventorySlot.tscn").instantiate()
inventory_grid.add_child(slot)
6. HUD Design — Éléments Clés
Health Bar — Implémentation Complète
public class HealthBar : MonoBehaviour
{
[SerializeField] private Image _fillImage;
[SerializeField] private Image _damageImage;
[SerializeField] private Image _delayedBar;
[SerializeField] private Text _healthText;
private float _currentHealth;
private float _maxHealth;
private float _displayHealth;
private float _delayedHealth;
private Coroutine _damageRoutine;
public void Initialize(float current, float max)
{
_currentHealth = current;
_maxHealth = max;
_displayHealth = current;
_delayedHealth = current;
UpdateDisplay();
}
public void SetHealth(float current)
{
_currentHealth = current;
StopAllCoroutines();
StartCoroutine(AnimateHealth());
if (_damageRoutine != null) StopCoroutine(_damageRoutine);
_damageRoutine = StartCoroutine(DamageFlash());
}
private System.Collections.IEnumerator ()
{
(Mathf.Abs(_displayHealth - _currentHealth) > )
{
_displayHealth = Mathf.Lerp(_displayHealth, _currentHealth, Time.deltaTime * );
UpdateDisplay();
;
}
_displayHealth = _currentHealth;
UpdateDisplay();
(_currentHealth < _delayedHealth)
{
startDelay = _delayedHealth;
elapsed = ;
(elapsed < )
{
elapsed += Time.deltaTime;
_delayedHealth = Mathf.Lerp(startDelay, _currentHealth, elapsed / );
UpdateDelayedBar();
;
}
_delayedHealth = _currentHealth;
UpdateDelayedBar();
}
}
System.Collections.
{
_damageImage.enabled = ;
_damageImage.color = Color.red;
;
_damageImage.enabled = ;
}
{
_fillImage.fillAmount = _displayHealth / _maxHealth;
_healthText.text = ;
}
{
_delayedBar.fillAmount = _delayedHealth / _maxHealth;
}
}
Crosshair — Système Complet
public class Crosshair : MonoBehaviour
{
[SerializeField] private RectTransform _top, _bottom, _left, _right;
[SerializeField] private float _baseSpread = 20f;
[SerializeField] private float _maxSpread = 100f;
[SerializeField] private float _spreadRecovery = 10f;
private float _currentSpread;
private float _targetSpread;
void Update()
{
transform.position = new Vector3(Screen.width / 2f, Screen.height / 2f, 0);
_currentSpread = Mathf.Lerp(_currentSpread, _targetSpread, Time.deltaTime * _spreadRecovery);
float s = _baseSpread + _currentSpread;
_top.anchoredPosition = new Vector2(0, s);
_bottom.anchoredPosition = new Vector2(0, -s);
_left.anchoredPosition = new Vector2(-s, 0);
_right.anchoredPosition = new Vector2(s, 0);
_targetSpread = Mathf.Lerp(_targetSpread, 0, Time.deltaTime * 2f);
}
public void () => _targetSpread += amount;
=> AddSpread();
=> AddSpread();
}
Minimap — Système Complet
public class Minimap : MonoBehaviour
{
[SerializeField] private Camera _minimapCamera;
[SerializeField] private RectTransform _minimapRect;
[SerializeField] private GameObject _iconPrefab;
[SerializeField] private float _worldSize = 500f;
[SerializeField] private float _iconScale = 1f;
private Transform _player;
private Dictionary<Transform, RectTransform> _icons = new();
void Start()
{
_player = GameObject.FindGameObjectWithTag("Player").transform;
_minimapCamera.transform.SetParent(_player);
_minimapCamera.transform.localPosition = Vector3.up * 200f;
_minimapCamera.transform.localRotation = Quaternion.Euler(90, 0, 0);
}
public void RegisterIcon(Transform target, Color color, Sprite icon = null)
{
var iconObj = Instantiate(_iconPrefab, _minimapRect);
var iconRect = iconObj.GetComponent<RectTransform>();
var img = iconObj.GetComponent<Image>();
img.color = color;
if (icon) img.sprite = icon;
_icons[target] = iconRect;
}
void LateUpdate()
{
( kvp _icons)
{
(kvp.Key == ) ;
Vector3 worldPos = kvp.Key.position;
Vector3 relativePos = worldPos - _player.position;
x = relativePos.x / _worldSize;
z = relativePos.z / _worldSize;
(Mathf.Abs(x) <= && Mathf.Abs(z) <= )
{
kvp.Value.gameObject.SetActive();
kvp.Value.anchoredPosition = Vector2(x * _minimapRect.sizeDelta.x * ,
z * _minimapRect.sizeDelta.y * );
kvp.Value.localRotation = Quaternion.Euler(, , -kvp.Key.eulerAngles.y);
}
{
kvp.Value.gameObject.SetActive();
}
}
}
}
7. UI Animation
DOTween (Unity — Recommandé)
using DG.Tweening;
public class UIAnimations : MonoBehaviour
{
public RectTransform panel;
public Image healthBar;
public Text damageText;
void Start()
{
panel.DOAnchorPosY(0, 0.5f).From(new Vector2(0, -100))
.SetEase(Ease.OutBack);
healthBar.DOFillAmount(0.5f, 0.3f)
.SetEase(Ease.InOutQuad);
damageText.DOAnchorPosY(50, 1f).From()
.SetEase(Ease.OutQuad);
damageText.DOFade(0, 1f).From(1);
panel.DOPunchPosition(Vector2.right * 10f, 0.3f, 10, 1);
Sequence seq = DOTween.Sequence();
seq.Append(panel.DOScale(0, 0));
seq.Append(panel.DOScale(1.1f, 0.2f).SetEase(Ease.OutQuad));
seq.Append(panel.DOScale(1f, 0.1f));
}
}
LeanTween (Alternative Légère)
LeanTween.moveX(panel, 0, 0.5f).setEase(LeanTweenType.easeOutBack);
LeanTween.value(healthBar.gameObject, UpdateFill, 0f, 1f, 0.3f);
LeanTween.alpha(damageText, 0, 1f).setFrom(1);
LeanTween.scale(panel, Vector3.one, 0.3f).setEase(LeanTweenType.easeOutElastic);
Godot Tween
# Godot Tween (intégré)
func show_damage(damage: int):
var label = preload("res://ui/DamagePopup.tscn").instantiate()
add_child(label)
label.text = str(damage)
var tween = create_tween().set_parallel()
tween.tween_property(label, "position", label.position + Vector2.UP * 50, 0.8)
tween.tween_property(label, "modulate", Color.TRANSPARENT, 0.8)
tween.tween_property(label, "scale", Vector2(1.5, 1.5), 0.2)
8. Inventaire — Drag & Drop
public class InventorySlot : MonoBehaviour, IDropHandler, IBeginDragHandler, IDragHandler, IEndDragHandler
{
[SerializeField] private Image _itemIcon;
[SerializeField] private Text _stackCount;
private Item _item;
private Canvas _canvas;
private RectTransform _rectTransform;
private CanvasGroup _canvasGroup;
void Awake()
{
_rectTransform = GetComponent<RectTransform>();
_canvasGroup = GetComponent<CanvasGroup>();
_canvas = GetComponentInParent<Canvas>();
}
public void SetItem(Item item, int count)
{
_item = item;
_itemIcon.sprite = item.Icon;
_stackCount.text = count > 1 ? count.ToString() : "";
_itemIcon.enabled = item != null;
}
public void OnBeginDrag(PointerEventData eventData)
{
if (_item == null) return;
_canvasGroup.alpha = 0.6f;
_canvasGroup.blocksRaycasts = false;
}
public void OnDrag(PointerEventData eventData)
{
_rectTransform.anchoredPosition += eventData.delta / _canvas.scaleFactor;
}
public ()
{
_canvasGroup.alpha = ;
_canvasGroup.blocksRaycasts = ;
_rectTransform.anchoredPosition = Vector2.zero;
}
{
otherSlot = eventData.pointerDrag.GetComponent<InventorySlot>();
(otherSlot != )
{
InventoryManager.Instance.SwapItems(, otherSlot);
}
}
}
9. Localisation et Accessibilité
Localisation (String Tables)
[CreateAssetMenu(fileName = "StringTable", menuName = "Localization/StringTable")]
public class StringTable : ScriptableObject
{
[System.Serializable]
public struct LocalizedString
{
public string Key;
public string English;
public string French;
public string German;
public string Spanish;
public string Japanese;
public string Korean;
public string ChineseSimplified;
}
public LocalizedString[] Strings;
private Dictionary<string, LocalizedString> _lookup;
public string Get(string key, SystemLanguage language)
{
if (_lookup == null)
{
_lookup = new Dictionary<string, LocalizedString>();
foreach (var s in Strings)
_lookup[s.Key] = s;
}
if (!_lookup.TryGetValue(key, out var entry))
return $"[{key}]";
language
{
SystemLanguage.French => entry.French,
SystemLanguage.German => entry.German,
SystemLanguage.Japanese => entry.Japanese,
SystemLanguage.Korean => entry.Korean,
SystemLanguage.ChineseSimplified => entry.ChineseSimplified,
_ => entry.English
};
}
}
:
{
[] _key;
[] StringTable _table;
Text _text;
=> _text = GetComponent<Text>();
=> Refresh();
{
(_text != )
_text.text = _table.Get(_key, LocalizationManager.CurrentLanguage);
}
}
Accessibilité
public class AccessibilityOptions : MonoBehaviour
{
[Header("Text Scaling")]
[SerializeField] private float _defaultScale = 1f;
[SerializeField] private float _largeScale = 1.5f;
[Header("Color Blind")]
[SerializeField] private Material _deuteranopia;
[SerializeField] private Material _protanopia;
[SerializeField] private Material _tritanopia;
[Header("Audio")]
[SerializeField] private AudioSource _narrationSource;
public enum ColorBlindMode { None, Deuteranopia, Protanopia, Tritanopia }
public void SetTextScale(float scale)
{
foreach (var text in Resources.FindObjectsOfTypeAll<Text>())
text.fontSize = Mathf.RoundToInt(text.fontSize * scale / _defaultScale);
}
public void SetColorBlindMode(ColorBlindMode mode)
{
var mat = mode switch
{
ColorBlindMode.Deuteranopia => _deuteranopia,
ColorBlindMode.Protanopia => _protanopia,
ColorBlindMode.Tritanopia => _tritanopia,
_ =>
};
Camera.main.SetReplacementShader(mat?.shader, );
}
{
SubtitleManager.Instance.Enabled = enabled;
}
{
uiCanvas = GameObject.Find().GetComponent<Canvas>();
(enabled)
uiCanvas.sortingOrder = ;
}
}
10. Pièges Courants
- Canvas rebuild chaque frame → UI dynamique qui change de layout à chaque Update. Utiliser un Canvas séparé pour les éléments statiques.
- Raycast Target sur tout → Chaque Image/Text a Raycast Target = true → bloque les clics. Désactiver sur les éléments décoratifs.
- Pas de pool UI → Instancier/détruire des UI elements (messages de dégâts, notifications) → GC spikes. Pool d'UI elements.
- Anchors mal configurées → UI qui sort de l'écran sur 16:9 vs 21:9. Toujours tester avec plusieurs ratios.
- Pas de Safe Area → iPhone notch / Dynamic Island qui cache le HUD. Utiliser Screen.safeArea.
- World Space UI sans billboarding → UI qui reste dans le sol quand on tourne la tête. Toujours regarder la caméra.
- Localisation oubliée → Textes en dur dans le code. Utiliser des string tables partout.
- Input bloqué par UI → Cliquer sur un bouton = tir aussi. Utiliser EventSystem.current.IsPointerOverGameObject().
- Font trop petite → Illisible sur mobile. Minimum 24px pour le texte principal.
- Pas de test accessibilité → Daltoniens, malvoyants, daltoniens ne peuvent pas jouer. Toujours tester.