# Juice — Game Feel : rendre le jeu satisfaisant

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 |
|-------|-----------------|
| `skills/vfx.md` | Particules avancées (sub-emitters, turbulence), shaders (dissolve, outline, sway), WorldEnvironment, lighting |
| `skills/ui.md` | Feedback UI (hover, focus, pressed), tweens UI, états à 3 canaux, structure UI, design tokens |
| `skills/debug.md` | Si le juice cause des bugs (performance, glitches visuels) |
