# /godot-juice — Game Feel : rendre le jeu satisfaisant

> **Synonymes** : juice, game feel, polish, impact, punch, weight, feedback, satisfying, crunchy, screenshake, hitstop, freeze frame, squash, stretch, trail, damage numbers

Tu es un expert en "juice" et game feel pour Godot 4.x. Ton rôle : transformer un prototype fonctionnel en jeu **satisfaisant** — celui où chaque action a du poids, chaque impact se ressent.

## Sommaire

1. [Principes du Juice](#section-1--principes-du-juice)
2. [Camera Shake](#section-2--camera-shake-screenshake)
3. [Freeze Frame](#section-3--freeze-frame-hitstop)
4. [Squash & Stretch](#section-4--squash--stretch)
5. [Hit Flash](#section-5--hit-flash)
6. [Trails](#section-6--trails-traînées)
7. [Damage Numbers](#section-7--damage-numbers)
8. [Autoload Juice](#section-8--autoload-juice-centralisation)
9. [Recettes complètes](#section-9--recettes-complètes)
10. [Checklist Review](#section-10--checklist-review)
11. [Pièges courants](#section-11--pièges-courants)

---

## Philosophie

- **Feeling first** — le juice sert une émotion, pas un effet technique
- **Dosage** — actions fréquentes = feedback subtil, actions rares = feedback fort
- **Cohérence** — même type d'impact = même intensité de feedback partout
- **Performance** — le juice ne doit jamais causer de lag

---

## Section 1 : Principes du Juice

| Principe | Détail |
|----------|--------|
| **Exagération** | Les mouvements réels sont ennuyeux. Overshoot, squash, stretch, rebond |
| **Anticipation** | Un petit recul avant un dash, une compression avant un saut |
| **Follow-through** | Les choses ne s'arrêtent pas net. Inertie, oscillation, settling |
| **Feedback immédiat** | < 100ms entre l'action et la réponse visuelle/sonore |
| **Layering** | Empiler plusieurs feedbacks : visuel + son + shake + particules |
| **Contraste** | Le calme rend le chaos impactant. Pas de juice constant |

---

## Section 2 : Camera Shake (Screenshake)

Le screenshake donne du poids aux impacts. Mal dosé, il donne la nausée.

### Règles de dosage

| Action | Intensité | Durée | Exemple |
|--------|-----------|-------|---------|
| Tir / attaque légère | 0 ou 0.05 | 50ms | Pew pew |
| Impact / hit reçu | 0.1 - 0.2 | 100ms | Coup de poing |
| Explosion / mort | 0.3 - 0.5 | 150ms | Boom |
| Boss hit / event majeur | 0.5 - 0.8 | 200ms | Climax |

### Pattern — Shake avec Perlin Noise

Le bruit de Perlin est plus organique que `randf()`. Pas de saccades brutales.

```gdscript
## camera_shake.gd — Attacher à Camera2D
extends Camera2D

@export var decay: float = 2.0
@export var max_offset: float = 16.0
@export var max_rotation: float = 0.05  # radians
@export var noise_speed: float = 30.0

var trauma: float = 0.0
var noise := FastNoiseLite.new()
var noise_y: float = 0.0

func _ready():
    # Caméra 2D fixe : utiliser offset, JAMAIS position
    anchor_mode = Camera2D.ANCHOR_MODE_FIXED_TOP_LEFT
    position = Vector2.ZERO

    noise.seed = randi()
    noise.frequency = 0.5

func _process(delta: float):
    if trauma > 0:
        trauma = maxf(0, trauma - delta * decay)
        var shake = trauma * trauma  # Quadratique = plus naturel

        noise_y += delta * noise_speed
        offset.x = noise.get_noise_2d(0, noise_y) * max_offset * shake
        offset.y = noise.get_noise_2d(100, noise_y) * max_offset * shake
        rotation = noise.get_noise_2d(200, noise_y) * max_rotation * shake
    else:
        offset = Vector2.ZERO
        rotation = 0.0

func add_trauma(amount: float):
    trauma = minf(1.0, trauma + amount)

func shake(intensity: float):
    """Shortcut: intensity 0.0-1.0"""
    add_trauma(intensity)
```

> **Important** : Toujours utiliser `offset` pour le shake, JAMAIS `position`. Sinon le cadrage est cassé.

### Look Ahead (Anticipation caméra)

La caméra regarde légèrement vers où le joueur va, pas où il est.

```gdscript
## Dans le script caméra ou un script séparé
@export var look_ahead_factor: float = 0.15
@export var look_ahead_smoothing: float = 5.0

var target_offset: Vector2 = Vector2.ZERO

func _process(delta: float):
    # Calculer le look ahead vers la souris ou la direction de mouvement
    var screen_center = get_viewport_rect().size / 2
    var mouse_offset = (get_viewport().get_mouse_position() - screen_center) * look_ahead_factor

    # Smooth lerp vers la cible
    target_offset = target_offset.lerp(mouse_offset, delta * look_ahead_smoothing)

    # Ajouter au offset existant (après le shake)
    offset += target_offset
```

---

## Section 3 : Freeze Frame (Hitstop)

Une pause de 30-80ms sur les impacts importants. Donne le temps au cerveau de "register" l'action.

### Pattern — Freeze avec Timer ignorant le time_scale

```gdscript
## juice.gd — Autoload singleton
extends Node

func freeze_frame(duration: float = 0.05):
    Engine.time_scale = 0.0
    # Le 4e paramètre (ignore_time_scale) est CRUCIAL
    await get_tree().create_timer(duration, true, false, true).timeout
    Engine.time_scale = 1.0
```

### Règles de dosage

| Action | Durée freeze | Notes |
|--------|--------------|-------|
| Hit léger | 0 ou 20ms | Imperceptible mais présent |
| Hit lourd | 30-50ms | Perceptible, satisfaisant |
| Kill / finisher | 50-80ms | Moment de gloire |
| Parry parfait | 80-120ms | Récompense le timing |

> **Ne pas abuser.** Si chaque hit freeze, le jeu devient saccadé. Réserver aux moments importants.

---

## Section 4 : Squash & Stretch

Déformation temporaire qui donne de l'élasticité aux objets. Principe fondamental de l'animation.

### Pattern — Squash & Stretch procédural

```gdscript
## squash_stretch.gd — Attacher au node visuel (Sprite2D, etc.)
extends Node

@export var squash_amount: float = 0.8  # < 1 = compression
@export var stretch_amount: float = 1.2  # > 1 = étirement
@export var return_speed: float = 12.0

var target: Node2D

func _ready():
    target = get_parent()

func squash():
    """Appelé à l'atterrissage, à l'impact"""
    target.scale = Vector2(1.0 / squash_amount, squash_amount)
    _return_to_normal()

func stretch():
    """Appelé au saut, au dash"""
    target.scale = Vector2(squash_amount, 1.0 / squash_amount)
    _return_to_normal()

func _return_to_normal():
    var tween = create_tween()
    tween.tween_property(target, "scale", Vector2.ONE, 0.15) \
        .set_ease(Tween.EASE_OUT).set_trans(Tween.TRANS_ELASTIC)
```

### Usage typique

```gdscript
## Dans le script du personnage
func _on_landed():
    $Sprite/SquashStretch.squash()
    $Camera.shake(0.1)

func _on_jump():
    $Sprite/SquashStretch.stretch()

func _on_dash():
    $Sprite/SquashStretch.stretch()
```

---

## Section 5 : Hit Flash

Flash blanc (ou coloré) quand une entité prend des dégâts. Feedback visuel immédiat.

### Pattern — Hit Flash avec Shader

Le shader est plus propre que de modifier `modulate` (qui affecte aussi les enfants).

```gdshader
// hit_flash.gdshader
shader_type canvas_item;

uniform float flash_intensity : hint_range(0.0, 1.0) = 0.0;
uniform vec4 flash_color : source_color = vec4(1.0, 1.0, 1.0, 1.0);

void fragment() {
    vec4 tex = texture(TEXTURE, UV);
    COLOR = mix(tex, flash_color, flash_intensity);
    COLOR.a = tex.a;  // Préserver la transparence
}
```

```gdscript
## hit_flash.gd — Attacher au Sprite2D
extends Node

@export var flash_duration: float = 0.08
@export var flash_color: Color = Color.WHITE

var sprite: CanvasItem

func _ready():
    sprite = get_parent()
    # Créer le material si pas déjà présent
    if not sprite.material:
        var mat = ShaderMaterial.new()
        mat.shader = preload("res://shaders/hit_flash.gdshader")
        sprite.material = mat

func flash():
    var mat = sprite.material as ShaderMaterial
    mat.set_shader_parameter("flash_color", flash_color)
    mat.set_shader_parameter("flash_intensity", 1.0)

    var tween = create_tween()
    tween.tween_property(mat, "shader_parameter/flash_intensity", 0.0, flash_duration)
```

### Pattern — Hit Flash simple (sans shader)

Si le shader est overkill :

```gdscript
func flash():
    var original = sprite.modulate
    sprite.modulate = Color.WHITE
    await get_tree().create_timer(flash_duration).timeout
    sprite.modulate = original
```

---

## Section 6 : Trails (Traînées)

Traînée visuelle derrière un objet en mouvement rapide.

> **Piège fréquent** : Si le trail est enfant d'un objet mobile, utiliser `top_level = true` sinon les points sont en coordonnées locales et le trail se décale bizarrement.

### Pattern — Trail avec Line2D

```gdscript
## trail.gd — Attacher à l'objet mobile (en enfant)
extends Line2D

@export var max_points: int = 20
@export var point_interval: float = 0.02

var _timer: float = 0.0
var _target: Node2D

func _ready():
    top_level = true  # CRUCIAL : coordonnées globales
    _target = get_parent()

    width = 4.0
    default_color = Color(1, 1, 1, 0.5)

    # Gradient pour fade progressif
    gradient = Gradient.new()
    gradient.set_color(0, Color(1, 1, 1, 0.6))  # Tête
    gradient.set_color(1, Color(1, 1, 1, 0.0))  # Queue

func _process(delta: float):
    _timer += delta
    if _timer >= point_interval:
        _timer = 0.0
        add_point(_target.global_position)
        if get_point_count() > max_points:
            remove_point(0)
```

### Trail conditionnel (seulement à haute vitesse)

```gdscript
@export var min_speed: float = 200.0

func _process(delta: float):
    var velocity = _target.velocity if _target.has_method("get_velocity") else Vector2.ZERO

    if velocity.length() < min_speed:
        # Fade out progressif
        if get_point_count() > 0:
            remove_point(0)
        return

    # ... reste du code
```

---

## Section 7 : Damage Numbers

Chiffres de dégâts qui pop et disparaissent. Plus complexe qu'il n'y paraît pour un bon rendu.

### Structure de scène

```
DamageNumber (Node2D)
└── Label
    - Pivot Offset = size / 2 (CRUCIAL pour le scale depuis le centre)
    - Font Size grande (ex: 32) + Scale réduit (ex: 0.5) = texte net
```

### Pattern — Damage Number complet

```gdscript
## damage_number.gd
extends Node2D

@onready var label: Label = $Label

func setup(value: int, is_crit: bool = false):
    label.text = str(value)

    if is_crit:
        label.add_theme_color_override("font_color", Color.YELLOW)
        scale = Vector2(1.2, 1.2)

    z_index = 100  # Toujours devant
    _animate()

func _animate():
    var tween = create_tween()
    tween.set_parallel(true)

    # Mouvement vers le haut avec ralentissement
    tween.tween_property(self, "position:y", position.y - 50, 0.5) \
        .set_ease(Tween.EASE_OUT)

    # Léger mouvement horizontal aléatoire
    var x_offset = randf_range(-20, 20)
    tween.tween_property(self, "position:x", position.x + x_offset, 0.5)

    # Pop : petit -> gros -> normal -> disparition
    tween.tween_property(self, "scale", Vector2(1.3, 1.3), 0.1)
    tween.chain().tween_property(self, "scale", Vector2(1.0, 1.0), 0.1)
    tween.chain().tween_property(self, "scale", Vector2.ZERO, 0.2).set_delay(0.3)

    # Fade out
    tween.tween_property(self, "modulate:a", 0.0, 0.3).set_delay(0.4)

    # Cleanup
    tween.chain().tween_callback(queue_free)
```

### Spawner — JAMAIS en enfant de l'ennemi

```gdscript
## Dans le script qui gère les dégâts
func spawn_damage_number(pos: Vector2, damage: int, is_crit: bool = false):
    var dmg_num = preload("res://ui/damage_number.tscn").instantiate()
    dmg_num.global_position = pos + Vector2(0, -20)  # Légèrement au-dessus
    dmg_num.setup(damage, is_crit)

    # CRUCIAL : ajouter à la scène principale, PAS à l'ennemi
    # Sinon le nombre disparaît si l'ennemi meurt
    get_tree().current_scene.add_child(dmg_num)
```

> **Sécurité physique** : Si appelé pendant une collision, utiliser `call_deferred("add_child", dmg_num)`.

---

## Section 8 : Autoload Juice (Centralisation)

Un singleton pour accéder au juice depuis n'importe où.

```gdscript
## juice.gd — Project > Project Settings > Autoload
extends Node

var camera: Camera2D

func _ready():
    await get_tree().process_frame
    camera = get_viewport().get_camera_2d()

# === SHAKE ===
func shake(intensity: float = 0.3):
    if camera and camera.has_method("shake"):
        camera.shake(intensity)

# === FREEZE ===
func freeze_frame(duration: float = 0.05):
    Engine.time_scale = 0.0
    await get_tree().create_timer(duration, true, false, true).timeout
    Engine.time_scale = 1.0

# === COMBO ===
func impact(shake_intensity: float = 0.2, freeze_duration: float = 0.03):
    """Combo shake + freeze pour les impacts"""
    shake(shake_intensity)
    if freeze_duration > 0:
        freeze_frame(freeze_duration)
```

### Usage

```gdscript
func _on_enemy_hit():
    Juice.shake(0.15)
    $Sprite/HitFlash.flash()

func _on_enemy_killed():
    Juice.impact(0.4, 0.05)
    spawn_damage_number(enemy.global_position, damage, true)
```

---

## Section 9 : Recettes complètes

### Collision Pong avec juice

```gdscript
func _on_ball_hit_paddle(paddle: Node2D, velocity: Vector2):
    var speed_ratio = velocity.length() / max_speed

    # 1. Shake proportionnel à la vitesse
    Juice.shake(lerpf(0.05, 0.3, speed_ratio))

    # 2. Freeze sur les hits rapides
    if speed_ratio > 0.7:
        Juice.freeze_frame(0.03)

    # 3. Flash le paddle
    paddle.get_node("HitFlash").flash()

    # 4. Squash la balle
    ball.get_node("SquashStretch").squash()

    # 5. Particules à l'impact
    spawn_particles(ball.global_position, velocity.normalized())
```

### Atterrissage platformer

```gdscript
func _on_landed(fall_speed: float):
    var intensity = clampf(fall_speed / 500.0, 0.0, 1.0)

    # Shake proportionnel à la hauteur de chute
    if intensity > 0.3:
        Juice.shake(intensity * 0.2)

    # Squash
    $Sprite/SquashStretch.squash()

    # Particules de poussière
    if intensity > 0.2:
        $DustParticles.emitting = true
```

---

## Section 10 : Checklist Review

Avant de valider le juice d'une feature :

| Pattern | Question | Fix si non |
|---------|----------|------------|
| **Shake** | Utilise `offset`, pas `position` ? | Refactorer le script caméra |
| **Shake** | Utilise du bruit de Perlin, pas `randf()` ? | Ajouter FastNoiseLite |
| **Shake** | Rotation incluse (pas juste X/Y) ? | Ajouter `max_rotation` |
| **Freeze** | Timer avec `ignore_time_scale = true` ? | 4e param de `create_timer` |
| **Trail** | `top_level = true` si enfant d'objet mobile ? | Ajouter dans `_ready()` |
| **Damage Numbers** | Ajoutés à la scène principale, pas à l'ennemi ? | `get_tree().current_scene.add_child()` |
| **Damage Numbers** | `call_deferred` si spawn pendant collision ? | Sécuriser l'appel |
| **Hit Flash** | Stocke/restaure la valeur originale ? | Sauvegarder avant de modifier |
| **Dosage** | Actions fréquentes = feedback subtil ? | Réduire l'intensité |

---

## Section 11 : Pièges courants

| Piège | Symptôme | Solution |
|-------|----------|----------|
| Shake sur `position` | La caméra se décale définitivement | Utiliser `offset` uniquement |
| Shake `randf()` | Mouvement saccadé, désagréable | Utiliser FastNoiseLite |
| Freeze sans `ignore_time_scale` | Le jeu reste gelé | 4e param du timer = `true` |
| Trail en coordonnées locales | Trail décalé bizarrement | `top_level = true` |
| Damage number enfant de l'ennemi | Disparaît quand l'ennemi meurt | Ajouter à la scène principale |
| Trop de juice | Jeu épileptique | Dosage. Contraste. Réserver aux moments importants |
| Juice sans son | Manque d'impact | Le son est 50% du juice. Ajouter des SFX |

---

## See also

| Skill | Quand l'utiliser |
|-------|-----------------|
| `/godot-vfx` | Particules avancées (sub-emitters, turbulence), shaders (dissolve, outline, sway), WorldEnvironment, lighting |
| `/godot-behavior` | Feedback UI (hover, focus, pressed), tweens UI, états à 3 canaux |
| `/godot-composition` | Structure UI avant d'ajouter du behavior |
| `/godot-debug` | Si le juice cause des bugs (performance, glitches visuels) |
