# /godot-behavior — Le système nerveux de l'UI : états, transitions, feedback

> **Synonymes** : behavior, behaviour, UI animation, tween, tweens, hover, focus, pressed, états, states, feedback UI, réactivité, interactivité, transitions, sound design UI, son UI

## Sommaire

1. [Principes de Behavior](#section-1--principes-de-behavior)
2. [Protocole — 3 canaux d'états](#section-2--protocole--3-canaux-détats)
3. [Événements et transitions](#section-3--événements-et-transitions)
4. [Canalisation par propriété](#section-4--canalisation-par-propriété)
5. [Implémentation GDScript](#section-5--implémentation-gdscript)
6. [Conductor pattern](#section-6--conductor-pattern)
7. [Sound design](#section-7--sound-design)
8. [Fallback performance](#section-8--fallback-performance-3-tiers)
9. [Process](#section-9--process)
10. [Accessibilité Behavior](#section-10--accessibilité-behavior)
11. [Game Feel / Juice → voir /godot-juice](#section-11--game-feel--juice)
12. [Review checklist](#section-12--review-checklist-ui-behavior)

---

Tu es un UI behavior designer spécialisé Godot 4.x. Ton rôle : donner **vie** à une UI existante — réactivité, feedback, animations, son. Tu interviens APRÈS que la composition soit stable (`/godot-composition`).

## Entrée

Avant d'ajouter du behavior, vérifier :

| Artefact | Action |
|----------|--------|
| Rôles metadata sur les nodes UI | **Obligatoire.** Lancer `godot_execute_script` pour vérifier que les nodes cibles ont des `meta("role")`. Si aucun rôle → exécuter `/godot-composition` d'abord. |
| `theme.tres` | Si présent → les StyleBox gèrent déjà hover/focus/pressed au niveau theme. Le behavior ajoute les tweens/FX par-dessus, il ne redéfinit pas les couleurs. |
| `core/event_bus.gd` | Si présent → les changements de GameState passent par l'EventBus, pas par des appels directs. |

## Philosophie

- **La composition d'abord** — ne pas animer un squelette bancal
- **Feeling first** — chaque transition sert une émotion (satisfaction, urgence, clarté)
- **Interruptible** — toute animation doit pouvoir être coupée proprement
- **Dégradation gracieuse** — le behavior scale de "full FX" à "snap immédiat"
- **Shippable sans behavior** — le behavior est du polish, pas un prérequis

---

## Section 1 : Principes de Behavior

| # | Principe | Définition |
|---|----------|------------|
| 1 | **Réactivité** | Feedback immédiat à chaque action du joueur (< 100ms perçu) |
| 2 | **Navigabilité** | Le focus est toujours visible, la navigation clavier/manette est fluide |
| 3 | **Temporalité** | Les durées de transition sont proportionnelles à l'importance de l'action |
| 4 | **Expressivité** | Les animations ont du caractère (overshoot, squash, ease) pas juste du linéaire |
| 5 | **Cohérence** | Même type d'action = même type de feedback partout |
| 6 | **Interruptibilité** | Changement d'état mid-animation = transition propre depuis la valeur actuelle |
| 7 | **Canalisation** | Chaque propriété visuelle est contrôlée par UN canal d'état |
| 8 | **Performance** | Les FX se dégradent en 3 tiers, jamais de lag perceptible |
| 9 | **Continuité** | Même en mode dégradé, les transitions ponctuelles gardent un fade minimal |

---

## Section 2 : Protocole — 3 canaux d'états

Le modèle d'états est **orthogonal** : 3 canaux indépendants combinés en un triplet.

### SystemState (priorité maximale)
| État | Signification |
|------|--------------|
| `enabled` | Normal, interactif |
| `disabled` | Grisé, non-interactif |
| `hidden` | Invisible, retiré du layout |

### GameState
| État | Signification |
|------|--------------|
| `locked` | Verrouillé (pas encore débloqué) |
| `unlockable` | Condition proche d'être remplie |
| `active` | Disponible, utilisable |
| `equipped` | Sélectionné/équipé |
| `cooldown` | En recharge |

### InteractionState
| État | Signification |
|------|--------------|
| `idle` | Repos |
| `hover` | Souris dessus |
| `focus` | Focus clavier/manette |
| `pressed` | En cours d'appui |

### Triplet d'état
Un composant est toujours dans un triplet `(System, Game, Interaction)`.

Exemples :
- `(enabled, active, hover)` → bouton survolé, dispo
- `(enabled, cooldown, idle)` → skill en recharge, pas d'interaction
- `(disabled, locked, idle)` → grisé et verrouillé
- `(enabled, equipped, focus)` → item équipé, focus manette

### Règle de dominance
**System > tout.** Si `SystemState = disabled`, les canaux Game et Interaction sont visuellement écrasés (grisé). Si `SystemState = hidden`, le node est invisible.

---

## Section 3 : Événements et transitions

### Graphe InteractionState
```
idle → hover    (mouse_enter)
hover → idle    (mouse_exit)
idle → focus    (focus_entered, gamepad)
focus → idle    (focus_exited)
hover → pressed (mouse_down)
focus → pressed (accept_pressed)
pressed → hover (mouse_up, si souris toujours dessus)
pressed → idle  (mouse_up, si souris sortie)
pressed → focus (accept_released, gamepad)
```

### Graphe GameState
```
locked → unlockable    (condition_progress)
unlockable → active    (condition_met)
active → equipped      (equip_action)
equipped → active      (unequip_action)
active → cooldown      (use_action)
cooldown → active      (cooldown_end)
```

### Graphe SystemState
```
enabled → disabled    (system_disable)
disabled → enabled    (system_enable)
enabled → hidden      (system_hide)
hidden → enabled      (system_show)
```

### Loi d'Unicité
Toute transition passe par `emit_event(channel, event)`. **Jamais** de modification directe d'état.

---

## Section 4 : Canalisation par propriété

Quelle propriété visuelle est contrôlée par quel canal :

| Propriété | Canal primaire | Canal secondaire | Notes |
|-----------|---------------|-----------------|-------|
| **Scale** | InteractionState | — | Hover: 1.05, Press: 0.95 squash+overshoot |
| **Outline** | InteractionState | — | Focus: border accent visible |
| **Emission/Glow** | GameState (base) | InteractionState (bonus additif) | Equipped: glow, Hover: +glow |
| **Tint/Couleur** | GameState | — | Locked: gris, Cooldown: désaturé |
| **Masque radial** | GameState | — | Cooldown: fill progressif |
| **Particules** | GameState | InteractionState (additif) | Equipped: aura, Hover: +sparks |
| **Son** | InteractionState (trigger) | GameState (contenu) | Hover→son, mais le son dépend du GameState |
| **Opacité** | SystemState | — | Disabled: 0.5, Hidden: 0.0 |

**Règle** : un canal ne touche JAMAIS la propriété d'un autre canal sauf en mode additif explicite.

---

## Section 5 : Implémentation GDScript

### API minimale

```gdscript
## Base class pour tout composant UI avec behavior
extends Control
class_name UIComponent

signal state_changed(channel: String, old_state: String, new_state: String)

var _system_state := "enabled"
var _game_state := "active"
var _interaction_state := "idle"
var _roles := {}
var _active_tweens: Array[Tween] = []

func _ready():
    _cache_roles(self)
    _apply_all_states()

func _cache_roles(node: Node):
    if node.has_meta("role"):
        _roles[node.get_meta("role")] = node
    for child in node.get_children():
        _cache_roles(child)

func get_role(role: String) -> Node:
    return _roles.get(role)

func get_state(channel: String) -> String:
    match channel:
        "system": return _system_state
        "game": return _game_state
        "interaction": return _interaction_state
    return ""

func emit_event(channel: String, event: String):
    var old_state := get_state(channel)
    var new_state := _resolve_transition(channel, old_state, event)
    if new_state == old_state:
        return
    _set_channel(channel, new_state)
    _cleanup_tweens()
    _apply_state(channel, old_state, new_state)
    state_changed.emit(channel, old_state, new_state)

func _set_channel(channel: String, state: String):
    match channel:
        "system": _system_state = state
        "game": _game_state = state
        "interaction": _interaction_state = state

func _resolve_transition(channel: String, current: String, event: String) -> String:
    # Override dans les sous-classes pour définir le graphe
    return current

func _apply_all_states():
    _apply_state("system", "", _system_state)
    _apply_state("game", "", _game_state)
    _apply_state("interaction", "", _interaction_state)

func _apply_state(_channel: String, _old: String, _new: String):
    # Override dans les sous-classes
    pass

func _cleanup_tweens():
    for tween in _active_tweens:
        if tween.is_valid():
            tween.kill()
    _active_tweens.clear()

func _create_tween() -> Tween:
    var tween = create_tween()
    _active_tweens.append(tween)
    return tween
```

### Pattern tween — hover scale

```gdscript
func _apply_interaction_hover():
    var tween = _create_tween()
    tween.tween_property(self, "scale", Vector2(1.05, 1.05), 0.12).set_ease(Tween.EASE_OUT).set_trans(Tween.TRANS_BACK)

func _apply_interaction_idle():
    var tween = _create_tween()
    tween.tween_property(self, "scale", Vector2.ONE, 0.1).set_ease(Tween.EASE_OUT)
```

### Pattern tween — press squash + overshoot

```gdscript
func _apply_interaction_pressed():
    var tween = _create_tween()
    # Squash
    tween.tween_property(self, "scale", Vector2(0.92, 0.92), 0.06).set_ease(Tween.EASE_IN)

func _apply_interaction_released():
    var tween = _create_tween()
    # Overshoot bounce
    tween.tween_property(self, "scale", Vector2(1.08, 1.08), 0.1).set_ease(Tween.EASE_OUT).set_trans(Tween.TRANS_BACK)
    tween.tween_property(self, "scale", Vector2.ONE, 0.08).set_ease(Tween.EASE_IN_OUT)
```

### Pattern tween — focus pulsation

```gdscript
func _apply_interaction_focus():
    # Outline visible
    var focus_node = get_role("focus_outline")
    if focus_node:
        focus_node.visible = true

    # Subtle pulse (loop)
    var tween = _create_tween().set_loops()
    tween.tween_property(self, "modulate:a", 0.85, 0.6).set_ease(Tween.EASE_IN_OUT)
    tween.tween_property(self, "modulate:a", 1.0, 0.6).set_ease(Tween.EASE_IN_OUT)
```

### Pattern cooldown — masque radial

```gdscript
func _apply_game_cooldown(duration: float):
    var overlay = get_role("skill_cooldown") as TextureProgressBar
    if not overlay:
        return
    overlay.visible = true
    overlay.value = 100
    overlay.fill_mode = TextureProgressBar.FILL_CLOCKWISE

    var tween = _create_tween()
    tween.tween_property(overlay, "value", 0.0, duration)
    tween.tween_callback(func(): emit_event("game", "cooldown_end"))
```

### Écrire via MCP

```
godot_write_script(path="res://scripts/ui_component.gd", content="...")
```

Puis attacher :
```
godot_attach_script(node_path="NodeName", script_path="res://scripts/ui_component.gd")
```

---

## Section 6 : Conductor pattern

Un **Conductor** orchestre les comportements d'un contexte UI (un écran, un panel).

### Principes
- **Un Conductor par contexte** — pas de God Object qui gère toute l'UI
- **Table de routage déclarative** — events → actions sur les composants
- **Composant absent = skip silencieux** — pas de crash si un node n'existe pas
- **Bus autoload pour inter-contextes** — le Conductor d'un écran peut envoyer des events au Conductor d'un autre

### Template

```gdscript
extends Node
class_name UIConductor

## Routing table: {event_name: [{target_role, channel, event}]}
var _routes := {
    "item_selected": [
        {"role": "item_info", "channel": "system", "event": "show"},
        {"role": "equip_button", "channel": "system", "event": "enable"},
    ],
    "item_equipped": [
        {"role": "equipped_icon", "channel": "game", "event": "equip"},
        {"role": "equip_button", "channel": "game", "event": "equipped"},
    ],
}

var _components := {}

func _ready():
    _discover_components(get_parent())

func _discover_components(node: Node):
    if node.has_meta("role"):
        _components[node.get_meta("role")] = node
    for child in node.get_children():
        _discover_components(child)

func dispatch(event_name: String):
    if event_name not in _routes:
        return
    for action in _routes[event_name]:
        var comp = _components.get(action["role"])
        if comp and comp.has_method("emit_event"):
            comp.emit_event(action["channel"], action["event"])
```

---

## Section 7 : Sound design

### Catégories
| Catégorie | Rôle | Priorité |
|-----------|------|----------|
| **Action** | Confirmer, équiper, utiliser | 1 (haute) |
| **Feedback** | Succès, erreur, cooldown fini | 2 |
| **Navigation** | Hover, focus, scroll | 3 |
| **Ambiance** | Musique, ambiance UI | 4 (basse) |

### Table sonore par événement
| Événement | Son type | Durée | Notes |
|-----------|----------|-------|-------|
| hover | tick léger, soft click | < 50ms | Throttle 120ms entre deux triggers |
| focus | note basse, woosh court | < 80ms | Throttle 80ms |
| pressed | click net, impact | < 100ms | Immédiat |
| equip | satisfying click, ring | 200-400ms | Feedback clair |
| cooldown_start | descente tonale | 100-200ms | Signale l'indisponibilité |
| cooldown_end | ding, chime | 100-200ms | Signale le retour |
| error | buzz, dissonance | 100-200ms | Pas agressif |
| success | chime ascendant | 200-300ms | Récompense |

### Throttle anti-fatigue
- `hover` : 120ms minimum entre deux triggers
- `focus` : 80ms minimum
- Max **4 voix simultanées** — si dépassé, la voix la plus ancienne est coupée
- **Priorité** : action > feedback > navigation > ambiance

### Implémentation
```gdscript
var _last_sound_time := {}
var _throttle := {"hover": 0.12, "focus": 0.08}

func play_sound(event: String, stream: AudioStream):
    var now = Time.get_ticks_msec() / 1000.0
    var min_interval = _throttle.get(event, 0.0)
    if event in _last_sound_time and now - _last_sound_time[event] < min_interval:
        return
    _last_sound_time[event] = now
    # Play via AudioStreamPlayer
    var player = AudioStreamPlayer.new()
    player.stream = stream
    player.bus = "UI"
    add_child(player)
    player.play()
    player.finished.connect(player.queue_free)
```

---

## Section 8 : Fallback performance (3 tiers)

| Aspect | Tier High | Tier Medium | Tier Low |
|--------|-----------|-------------|----------|
| **Tweens** | Complets (ease, overshoot, bounce) | Simplifiés (ease out, pas de bounce) | Snap immédiat (spammable) ou fade 60ms (ponctuel) |
| **Particules** | Tous les FX | FX ÷ 2 | Aucun FX |
| **Shaders** | Outline + mask + glow | Outline + mask | Outline seul |
| **Lights** | Toutes | 1 light max | Aucune |
| **Sons** | Tous | Action + Feedback | Muet |

### Principe de continuité
Même en **Tier Low**, les transitions **ponctuelles** (show, hide, scene change) conservent un **fade minimal de 60ms**. Seules les transitions **spammables** (hover, focus) sont en snap.

### Implémentation
```gdscript
enum PerformanceTier { HIGH, MEDIUM, LOW }
var current_tier := PerformanceTier.HIGH

func tween_property_tiered(node: Node, property: String, value: Variant, duration_high: float):
    match current_tier:
        PerformanceTier.HIGH:
            var tween = _create_tween()
            tween.tween_property(node, property, value, duration_high).set_ease(Tween.EASE_OUT).set_trans(Tween.TRANS_BACK)
        PerformanceTier.MEDIUM:
            var tween = _create_tween()
            tween.tween_property(node, property, value, duration_high * 0.6).set_ease(Tween.EASE_OUT)
        PerformanceTier.LOW:
            node.set(property, value)
```

---

## Section 9 : Process

1. **Vérifier la Composition** — la structure UI doit être stable. Si non, exécuter `/godot-composition` d'abord
2. **Identifier les composants** qui ont besoin de Behavior (interactifs, animés, feedbacks)
3. **Définir les canaux d'états** par composant — quel triplet (System, Game, Interaction) ?
4. **Écrire les graphes de transitions** — quels événements, quelles transitions autorisées ?
5. **Implémenter le script de base** via `godot_write_script` (UIComponent ou dérivé)
6. **Ajouter les tweens/FX/son** — hover, focus, pressed, transitions d'état
7. **Tester les interactions** — hover, focus, pressed, transitions rapides
8. **Tester l'interruptibilité** — changer d'état mid-animation, vérifier pas d'empilement
9. **Tester les 3 tiers** — vérifier que Tier Low reste utilisable

---

## Section 10 : Accessibilité Behavior

| Règle | Détail |
|-------|--------|
| **Motion réduite** | Toutes transitions = snap, pas de pulsation, pas de loop. Respecter la préférence OS |
| **Son coupé** | Tout feedback sonore a un équivalent visuel (flash, outline, changement de couleur) |
| **Focus manette** | Le focus est TOUJOURS visible — outline franche, pas juste un glow subtil |
| **Pas de clignotement** | Aucune animation > 3 flash/seconde (risque épilepsie) |
| **Timing** | Les animations ne bloquent jamais l'interaction — le joueur peut agir pendant un tween |

---

## Section 11 : Game Feel / Juice

> **Ce skill se concentre sur le behavior UI.** Pour le game feel non-UI (screenshake, freeze frame, squash & stretch, trails, damage numbers, etc.), voir **`/godot-juice`**.

Le skill `/godot-juice` couvre :
- Screenshake avec Perlin noise
- Freeze frames / hitstop
- Squash & stretch procédural
- Hit flash (shader ou modulate)
- Trails (Line2D avec top_level)
- Damage numbers
- Autoload Juice.gd

Pour les particules avancées, shaders, et lighting, voir **`/godot-vfx`**.

---

## Section 12 : Review checklist (UI Behavior)

Quand tu reviews du code UI behavior, vérifie systématiquement :

| Pattern | Question | Fix si non |
|---------|----------|------------|
| **Tweens** | Cleanup des tweens précédents avant d'en créer un nouveau ? | Appeler `_cleanup_tweens()` ou `tween.kill()` |
| **États** | Les 3 canaux (System/Game/Interaction) sont définis ? | Déclarer les états par canal |
| **Canalisation** | Chaque propriété a UN canal propriétaire ? | Vérifier la table de canalisation |
| **Interruptibilité** | Changement d'état mid-animation = transition propre ? | Tester les transitions rapides |
| **Idempotence** | Appliquer le même état 2x = pas d'empilement ? | Vérifier que les tweens ne s'accumulent pas |
| **Focus** | Outline visible pour navigation manette ? | Ajouter outline_width > 0 sur focus |
| **Son** | Throttle en place pour hover/focus ? | 80-120ms entre deux triggers |

Pour la checklist game feel (screenshake, trails, etc.), voir `/godot-juice` Section 10.
