# Card Feel & Card Shaders — CCG/TCG 2D

> **Purpose**: Recette "marinade" pour le game feel d'un jeu de cartes (CCG/TCG/deckbuilder) en 2D Godot 4. Valeurs testees issues de Balatro, Slay the Spire, Inscryption, Hearthstone, Marvel Snap. Shaders complets, timings precis, combinaisons non-evidentes.

---

## 1. Hand Layout — la courbe en eventail

La main de cartes n'est PAS une HBoxContainer. C'est une courbe parametrique avec rotation et arc parabolique.

### Layout algorithm

```gdscript
# hand_layout.gd — attacher au Node2D qui contient les cartes
extends Node2D

## --- Tuning (valeurs testees Slay the Spire style) ---
@export var max_card_spacing := -60.0   # Overlap entre cartes (negatif = overlap)
@export var arc_height := 40.0          # Hauteur de l'arc (20-60 range utile)
@export var rot_max := 0.2              # Rotation max en radians (~11 deg)
@export var hover_scale := 1.5          # Scale on hover (1.2 = subtil, 2.0 = dramatique)
@export var scatter_offset := 120.0     # Distance d'ecartement des voisins au hover
@export var hover_lift := -40.0         # Deplacement Y au hover (negatif = monte)

## --- Tween tuning ---
@export var tween_duration := 0.15      # Duree tween layout (0.1 = snappy, 0.2 = smooth)
@export var hover_tween_duration := 0.12

var _cards: Array[Node2D] = []
var _hovered_index: int = -1

func layout() -> void:
    var count := _cards.size()
    if count == 0:
        return

    var total_width := (count - 1) * max_card_spacing
    var start_x := -total_width / 2.0

    for i in count:
        var card := _cards[i]

        # Position normalisee [-1, 1]
        var t := 0.0
        if count > 1:
            t = (float(i) / float(count - 1)) * 2.0 - 1.0

        # Position de base
        var target_x := start_x + i * max_card_spacing
        var target_y := -arc_height * (1.0 - t * t)  # Parabole inversee
        var target_rot := t * rot_max
        var target_scale := Vector2.ONE

        # Hover : ecarter les voisins, lift + scale la carte hovered
        if _hovered_index >= 0:
            if i == _hovered_index:
                target_y += hover_lift
                target_scale = Vector2.ONE * hover_scale
                target_rot = 0.0  # La carte hovered est droite
            elif i < _hovered_index:
                target_x -= scatter_offset * (1.0 - float(i) / float(_hovered_index))
            elif i > _hovered_index:
                var dist_from_hover := float(i - _hovered_index)
                var remaining := float(count - 1 - _hovered_index)
                if remaining > 0.0:
                    target_x += scatter_offset * (1.0 - dist_from_hover / remaining)

        # Tween vers la cible
        var tw := create_tween().set_parallel(true)
        var dur := hover_tween_duration if _hovered_index >= 0 else tween_duration
        tw.tween_property(card, "position", Vector2(target_x, target_y), dur)\
            .set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_OUT)
        tw.tween_property(card, "rotation", target_rot, dur)\
            .set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_OUT)
        tw.tween_property(card, "scale", target_scale, dur)\
            .set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)  # Back = overshoot satisfaisant

func set_hover(index: int) -> void:
    _hovered_index = index
    layout()
    # Z-index : la carte hovered passe au-dessus
    for i in _cards.size():
        _cards[i].z_index = 10 if i == _hovered_index else i
```

### Valeurs par jeu de reference

| Param | Slay the Spire | Balatro | Inscryption |
|-------|---------------|---------|-------------|
| `arc_height` | 40-50 | 20-30 (plus plat) | 10-15 (quasi droit) |
| `rot_max` | 0.2 rad | 0.15 rad | 0.05 rad |
| `hover_scale` | 1.3-1.5 | 1.2 | 1.4 |
| `hover_lift` | -40px | -30px | -50px |
| `scatter_offset` | 120px | 80px | 60px |
| `tween_duration` | 0.15s | 0.12s | 0.18s |

### Piege : z-index vs input order

Godot separe l'ordre de rendu (`z_index`) et l'ordre de reception des inputs (ordre du scene tree). Quand tu montes le `z_index` d'une carte hovered, elle s'affiche au-dessus visuellement, mais les inputs restent dans l'ordre du tree.

**Fix** : ne PAS utiliser `Control` nodes pour les cartes. Utiliser `Area2D` + collision shapes + raycast custom pour la detection hover. Ou `Node2D` avec `_input` custom et comparaison de distance souris.

---

## 2. Card Hover — le "lift"

Le hover n'est pas juste un scale. C'est une combinaison de 5 micro-effets simultaness :

### Les 5 couches du hover

```
1. Scale up       → 1.0 → 1.3 (TRANS_BACK / EASE_OUT, 0.12s)
2. Y offset       → 0 → -40px (TRANS_CUBIC / EASE_OUT, 0.12s)
3. Rotation reset → current → 0.0 (TRANS_CUBIC / EASE_OUT, 0.1s)
4. Shadow grow    → scale shadow sprite 1.0 → 1.2, opacity 0.3 → 0.5, offset Y +5px
5. Z-index jump   → index normal → 10 (instantane)
```

**Le secret de Balatro** : `TRANS_BACK` pour le scale. Ca cree un leger overshoot (la carte grossit un poil trop puis revient) qui donne un feeling "rebond" extremement satisfaisant. L'overshoot est subtil (~5-8% au-dessus de la cible) mais le cerveau le detecte comme "physique".

### Tween easing cheatsheet pour les cartes

| Action | Trans | Ease | Duration | Pourquoi |
|--------|-------|------|----------|----------|
| Hover enter | `TRANS_BACK` | `EASE_OUT` | 0.12s | Overshoot = satisfaisant |
| Hover exit | `TRANS_CUBIC` | `EASE_OUT` | 0.15s | Retour doux, pas d'overshoot |
| Card play (hand → board) | `TRANS_CUBIC` | `EASE_IN_OUT` | 0.2-0.3s | Arc fluide |
| Card draw (deck → hand) | `TRANS_BACK` | `EASE_OUT` | 0.25s | Apparition dynamique |
| Snap back (cancel drag) | `TRANS_BACK` | `EASE_OUT` | 0.2s | Elastique, rubber band |
| Card destroy | `TRANS_CUBIC` | `EASE_IN` | 0.15s | Acceleration vers la disparition |
| Hand re-sort | `TRANS_CUBIC` | `EASE_OUT` | 0.15s | Reorganisation fluide |

### Piege : hover flicker sur cartes qui se chevauchent

Quand les cartes se chevauchent, entrer dans la zone de la carte B = sortir de la zone de la carte A. Si le hover exit de A trigger un layout qui deplace B, le hover entre en boucle flicker.

**Fix** : debounce le hover. Ne pas reagir immediatement au mouse_exit. Attendre 1 frame (`await get_tree().process_frame`) et re-verifier la position souris avant de trigger le unhover. Alternative : un seul raycast depuis le hand manager, pas de detection individuelle par carte.

---

## 3. Card Tilt — perspective 3D en 2D

L'effet "tilt" de Balatro : la carte semble pivoter en 3D selon la position de la souris dessus. C'est un vertex shader qui fake la perspective.

### Shader perspective 2D

```gdshader
shader_type canvas_item;

uniform float fov : hint_range(1, 179) = 90.0;
uniform float rot_y : hint_range(-60, 60) = 0.0;  // Tilt horizontal (degres)
uniform float rot_x : hint_range(-60, 60) = 0.0;  // Tilt vertical (degres)
uniform float inset : hint_range(0, 1) = 0.0;
uniform bool cull_back = true;

varying vec3 world_pos;

void vertex() {
    float ry = radians(rot_y);
    float rx = radians(rot_x);
    float sy = sin(ry); float cy = cos(ry);
    float sx = sin(rx); float cx = cos(rx);

    mat3 rot_mat;
    rot_mat[0] = vec3(cy, 0.0, -sy);
    rot_mat[1] = vec3(sy * sx, cx, cy * sx);
    rot_mat[2] = vec3(sy * cx, -sx, cy * cx);

    float persp = tan(radians(fov) * 0.5);
    world_pos = rot_mat * vec3(UV - 0.5, 0.5 / persp);

    float depth_scale = (0.5 / persp) + 0.5;
    world_pos.xy *= depth_scale * rot_mat[2].z;

    VERTEX += (UV - 0.5) / TEXTURE_PIXEL_SIZE * persp * (1.0 - inset);
}

void fragment() {
    if (cull_back && world_pos.z <= 0.0) discard;

    vec2 proj_uv = (world_pos.xy / world_pos.z) + 0.5;
    if (proj_uv.x < 0.0 || proj_uv.x > 1.0 || proj_uv.y < 0.0 || proj_uv.y > 1.0)
        discard;

    COLOR = texture(TEXTURE, proj_uv);
}
```

### Controle GDScript — souris → rotation

```gdscript
# card_tilt.gd — attacher a chaque carte
extends Sprite2D

@export var max_tilt := 15.0  # Degres max de tilt (Balatro ~12-15)
@export var tilt_speed := 8.0  # Lerp speed (plus haut = plus reactif)

var _target_rot := Vector2.ZERO
var _current_rot := Vector2.ZERO
var _is_hovered := false

func _process(delta: float) -> void:
    if _is_hovered:
        _current_rot = _current_rot.lerp(_target_rot, tilt_speed * delta)
    else:
        _current_rot = _current_rot.lerp(Vector2.ZERO, tilt_speed * delta)

    material.set_shader_parameter("rot_y", _current_rot.x)
    material.set_shader_parameter("rot_x", _current_rot.y)

func on_mouse_moved(local_pos: Vector2) -> void:
    # local_pos = position souris relative au centre de la carte
    var card_size := texture.get_size() * scale
    var normalized := local_pos / (card_size * 0.5)  # [-1, 1]
    normalized = normalized.clamp(Vector2(-1, -1), Vector2(1, 1))

    _target_rot.x = normalized.x * max_tilt   # Souris a droite = tilt droite
    _target_rot.y = -normalized.y * max_tilt   # Souris en haut = tilt vers l'avant
```

### Valeurs de reference

- **Balatro** : `max_tilt` ~12-15 deg, `fov` 90, lerp speed ~8-10. Le tilt est subtil mais constant — meme au repos les cartes ont un micro-sway
- **Pokemon TCG Pocket** : `max_tilt` ~20 deg, plus agressif, plus "physique"
- **Marvel Snap** : parallax plutot que tilt (layers qui bougent independamment)

### Piege : tilt + scale = deformation

Quand une carte est scalee (hover), le shader perspective recoit des VERTEX deja modifies. Si le `inset` n'est pas ajuste, la carte deborde de sa zone ou se deforme. **Fix** : `inset` = `1.0 - (1.0 / hover_scale)` quand la carte est hovered. Ou appliquer le tilt AVANT le scale dans le pipeline.

---

## 4. Balatro Idle Sway — les cartes respirent

La signature de Balatro : tout bouge en permanence. Les cartes oscillent legerement meme au repos. Ca donne un feeling "vivant" sans aucune interaction.

### Pattern idle sway

```gdscript
# card_idle_sway.gd — composant ajoute a chaque carte
extends Node

@export var owner_card: Node2D
@export var sway_amplitude := Vector2(2.0, 1.5)  # Pixels de mouvement X, Y
@export var sway_speed := Vector2(1.3, 1.7)       # Vitesse oscillation (desynchro X/Y)
@export var rot_amplitude := 0.02                  # Radians de rotation (~1 deg)
@export var rot_speed := 0.8

var _time_offset := 0.0  # Offset unique par carte pour desynchroniser
var _base_position := Vector2.ZERO
var _base_rotation := 0.0

func _ready() -> void:
    # Offset aleatoire pour que toutes les cartes ne bougent pas en sync
    _time_offset = randf() * TAU
    _base_position = owner_card.position
    _base_rotation = owner_card.rotation

func _process(delta: float) -> void:
    var t := Time.get_ticks_msec() / 1000.0 + _time_offset

    var offset := Vector2(
        sin(t * sway_speed.x) * sway_amplitude.x,
        cos(t * sway_speed.y) * sway_amplitude.y
    )
    var rot_offset := sin(t * rot_speed) * rot_amplitude

    owner_card.position = _base_position + offset
    owner_card.rotation = _base_rotation + rot_offset
```

**Valeurs Balatro** : l'amplitude est tres faible (~2-3px) mais constante. L'oeil ne le voit pas consciemment mais l'absence se remarque immediatement. L'effet est sur TOUT : cartes, boutons, UI elements, meme le background. C'est ce qui rend le jeu "tactile".

**Piege** : le sway + le tween de layout = conflit. Quand le layout tween modifie `position`, le sway la re-modifie par-dessus. **Fix** : le sway modifie un offset separe (`_sway_offset`), pas directement `position`. Le layout set `_base_position`, le sway ajoute `_sway_offset` dans `_process`.

---

## 5. Card Play — l'arc de la main au plateau

La carte ne va pas en ligne droite de la main au board. Elle suit un arc (courbe de Bezier quadratique) avec acceleration.

### Pattern arc play

```gdscript
# card_play_animation.gd
extends Node

## Jouer une carte de sa position actuelle vers la cible avec un arc
func play_card(card: Node2D, target: Vector2, duration := 0.3) -> void:
    var start := card.position
    var control := Vector2(
        (start.x + target.x) / 2.0,               # Milieu en X
        min(start.y, target.y) - 80.0              # Point de controle AU-DESSUS
    )

    # Phase 1 : arc + shrink (0 → 0.7 de la duree)
    var tween := create_tween()
    tween.tween_method(
        func(t: float):
            card.position = _quadratic_bezier(start, control, target, t)
            # Slight scale pulse pendant l'arc
            var scale_curve := 1.0 + 0.1 * sin(t * PI)  # Grossit au milieu de l'arc
            card.scale = Vector2.ONE * scale_curve,
        0.0, 1.0, duration * 0.7
    ).set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_IN)

    # Phase 2 : impact — micro-scale bump + settle
    tween.tween_property(card, "scale", Vector2.ONE * 1.15, 0.05)\
        .set_trans(Tween.TRANS_CUBIC)
    tween.tween_property(card, "scale", Vector2.ONE, 0.1)\
        .set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)

func _quadratic_bezier(p0: Vector2, p1: Vector2, p2: Vector2, t: float) -> Vector2:
    var q0 := p0.lerp(p1, t)
    var q1 := p1.lerp(p2, t)
    return q0.lerp(q1, t)
```

### Impact juice (au moment ou la carte atterrit)

```
1. Screenshake   → amplitude 3-5px, duree 0.1s, decay rapide
2. Scale bump    → 1.0 → 1.15 → 1.0 en 0.15s (TRANS_BACK)
3. Particles     → burst 8-12 particules en ring, velocity outward 80-120px/s, lifetime 0.3s
4. Flash blanc   → la carte flash blanc 1 frame (shader uniform flash_amount = 1.0 → 0.0 en 0.08s)
5. Sound         → "card slam" avec pitch variation +-0.05 pour pas de repetition mecanique
```

**Slay the Spire** ajoute des "card streaks" — des trainees de mouvement quand la carte se deplace vite. C'est un trail shader ou des sprites fantomes qui followent avec opacite decroissante.

**Marvel Snap** : impact beaucoup plus dramatique. La carte "slam" avec screenshake important (~8-10px), burst de particules, et reaction visuelle de la zone (le board ripple/pulse).

### Piege : play arc + camera shake = nausee

Si la camera shake est trop forte ou trop longue, ca devient fatigant. Regle de frequence : **frequent = subtil, rare = dramatique**. Une carte jouee toutes les 3-5s → shake leger (3px, 0.08s). Un combo ou finisher → shake fort (8px, 0.15s).

---

## 6. Card Flip — faux 3D en 2D

Le flip de carte (reveal, retourner) utilise le shader perspective de la section 3, anime avec un tween sur `rot_y`.

### Pattern flip

```gdscript
# card_flip.gd
func flip_card(card: Sprite2D, back_texture: Texture2D, front_texture: Texture2D, duration := 0.4) -> void:
    var half := duration / 2.0

    # Phase 1 : rotation 0 → 90 (la carte devient invisible / tranche)
    var tween := create_tween()
    tween.tween_method(
        func(angle: float):
            card.material.set_shader_parameter("rot_y", angle),
        0.0, 90.0, half
    ).set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_IN)

    # Swap texture a exactement 90 deg (invisible)
    tween.tween_callback(func():
        card.texture = front_texture
        # Flip le cull pour voir l'autre face
        card.material.set_shader_parameter("cull_back", false)
    )

    # Phase 2 : rotation 90 → 0 (la carte se retourne)
    tween.tween_method(
        func(angle: float):
            card.material.set_shader_parameter("rot_y", angle),
        90.0, 0.0, half
    ).set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_OUT)

    # Reset cull
    tween.tween_callback(func():
        card.material.set_shader_parameter("cull_back", true)
    )
```

### Timing du suspense

- **Flip rapide** (reveal en jeu) : 0.3-0.4s total. Le joueur veut voir, pas attendre
- **Flip dramatique** (reveal pack, boss card) : 0.6-0.8s avec un pause de 0.1-0.2s au milieu (quand la carte est de profil). Cette pause micro-seconde cree du suspense
- **Inscryption** : le flip est lent (~0.8s) et accompagne d'un son grave. Le jeu est sur la tension, pas la vitesse

### Piege : flip + text = illisible

Le shader perspective deforme les UV. Si la carte contient du texte rendu par Godot (Label, RichTextLabel), le texte va se deformer avec le shader et devenir illisible a mi-rotation. **Fix** : renderer la carte entiere (texte inclus) en SubViewport, puis appliquer le shader sur le SubViewport. Ou utiliser une texture pre-rendered de la carte.

---

## 7. Card Shaders — holographic, foil, rarity

### 7a. Foil Balatro (iridescence animee)

Le shader de foil de Balatro utilise des layers de sin/cos superposes pour creer un shimmer qui bouge dans le temps. L'effet manipule les canaux RGB independamment — il amplifie le bleu tout en reduisant rouge/vert.

```gdshader
shader_type canvas_item;

uniform vec2 offset;
uniform float speed : hint_range(0, 1) = 0.4;
uniform float intensity : hint_range(0, 1) = 0.5;

void fragment() {
    vec4 tex = texture(TEXTURE, UV);
    vec2 adjusted_uv = UV - vec2(0.5);

    // Luminance de base pour le masque
    float low = min(tex.r, min(tex.g, tex.b));
    float high = max(tex.r, max(tex.g, tex.b));
    float delta = min(high, max(0.5, 1.0 - low));

    vec2 foil = vec2(TIME / (1.0 / speed) + offset.x, offset.y);

    // Layer 1 : ondes concentriques
    float fac = max(min(
        2.0 * sin(length(90.0 * adjusted_uv) + foil.x * 2.0
        + 3.0 * (1.0 + 0.8 * cos(length(113.0 * adjusted_uv) - foil.x * 3.12)))
        - 1.0 - max(5.0 - length(90.0 * adjusted_uv), 0.0),
    1.0), 0.0);

    // Layer 2 : rotation angulaire
    vec2 rotater = vec2(cos(foil.x * 0.12), sin(foil.x * 0.35));
    float angle = dot(rotater, adjusted_uv) / max(length(rotater) * length(adjusted_uv), 0.001);
    float fac2 = max(min(
        5.0 * cos(foil.y * 0.3 + angle * PI * (2.2 + 0.9 * sin(foil.x * 1.65)))
        - 4.0 - max(2.0 - length(20.0 * adjusted_uv), 0.0),
    1.0), 0.0);

    // Layer 3-4 : bandes horizontales et verticales
    float fac3 = 0.3 * clamp(2.0 * sin(foil.x * 5.0 + UV.x * 3.0
        + 3.0 * (1.0 + 0.5 * cos(foil.x * 7.0))) - 1.0, -1.0, 1.0);
    float fac4 = 0.3 * clamp(2.0 * sin(foil.x * 6.66 + UV.y * 3.8
        + 3.0 * (1.0 + 0.5 * cos(foil.x * 3.41))) - 1.0, -1.0, 1.0);

    float maxfac = max(max(fac, max(fac2, max(fac3, max(fac4, 0.0))))
        + 2.2 * (fac + fac2 + fac3 + fac4), 0.0);

    // Application : boost bleu, attenue rouge/vert
    tex.r = tex.r - delta + delta * maxfac * 0.3 * intensity;
    tex.g = tex.g - delta + delta * maxfac * 0.3 * intensity;
    tex.b = tex.b + delta * maxfac * 1.9 * intensity;
    tex.a = min(tex.a, 0.3 * tex.a + 0.9 * min(0.5, maxfac * 0.1));

    COLOR = tex;
}
```

**Usage** : appliquer sur un Sprite2D enfant de la carte, positionne par-dessus l'artwork. `intensity` controle la force. A 0.3-0.5, c'est subtil et classe. A 1.0, c'est flashy Pokemon-style.

### 7b. Holographic card (parallax + rainbow gradient)

L'holographic est un effet multi-couche : gradient rainbow + noise metalique + reponse au tilt.

```gdshader
shader_type canvas_item;

uniform sampler2D foil_mask : source_color;   // Blanc = foil, noir = pas foil
uniform sampler2D gradient : source_color;     // Gradient rainbow horizontal
uniform sampler2D noise : source_color;        // Noise metallique
uniform sampler2D normal_map : hint_normal;    // Texture rainures/grooves

uniform float scroll : hint_range(0, 3) = 1.0;
uniform float period : hint_range(0.1, 5.0) = 1.0;
uniform float normal_strength : hint_range(0, 2) = 0.5;
uniform float effect_alpha : hint_range(0, 1) = 0.7;
uniform vec2 tilt;  // Mis a jour par le script selon position souris

void fragment() {
    vec4 tex = texture(TEXTURE, UV);
    float mask = texture(foil_mask, UV).r;

    if (mask < 0.01) {
        COLOR = tex;
        return;
    }

    // Normal map pour grooves
    vec3 norm = texture(normal_map, UV).rgb * 2.0 - 1.0;

    // Gradient UV = tilt + scroll + UV + normal perturbation
    float grad_uv = (tilt.x * 0.3 + tilt.y * 0.3)
        + UV.x / period
        + norm.x * normal_strength
        + TIME * scroll * 0.1;

    vec4 rainbow = texture(gradient, vec2(fract(grad_uv), 0.5));
    vec4 sparkle = texture(noise, UV + vec2(TIME * 0.02, TIME * 0.015));

    // Composite : art + (rainbow * sparkle * mask)
    vec3 foil_color = rainbow.rgb * (sparkle.rgb * 1.5 + 0.5);
    COLOR.rgb = mix(tex.rgb, foil_color, mask * effect_alpha);
    COLOR.a = tex.a;
}
```

**Textures a preparer** :
- `gradient` : image 256x1 avec un gradient rainbow HSV complet, mode repeat
- `noise` : seamless metallic noise (Perlin ou Worley), 256x256
- `normal_map` : lignes diagonales fines (simule les grooves d'un hologramme), 256x256
- `foil_mask` : mask noir/blanc sur l'artwork (foil uniquement sur certaines zones)

### 7c. Hearthstone Golden Card (UV distortion + flow)

Hearthstone anime l'artwork des golden cards avec de la distortion UV — l'image bouge comme de l'eau ou du feu. Le truc : c'est PAS un shader procedural, c'est un tileable noise qui deplace les UV.

```gdshader
shader_type canvas_item;

uniform sampler2D flow_map : hint_normal;     // Noise tileable (oriente le deplacement)
uniform sampler2D effect_mask;                 // RGBA: 4 zones d'effet independantes
uniform float distortion_amount : hint_range(0, 0.05) = 0.015;
uniform float flow_speed : hint_range(0, 2) = 0.5;
uniform vec2 flow_direction_1 = vec2(0.0, -1.0);  // Haut (feu, fumee)
uniform vec2 flow_direction_2 = vec2(0.0, 1.0);   // Bas (eau, cascade)

void fragment() {
    // Flow map sample — deux layers decales pour eviter le "swimming" artifact
    vec2 flow_uv_1 = UV + flow_direction_1 * TIME * flow_speed * 0.1;
    vec2 flow_uv_2 = UV + flow_direction_2 * TIME * flow_speed * 0.07;
    vec2 distort_1 = (texture(flow_map, flow_uv_1).rg - 0.5) * 2.0 * distortion_amount;
    vec2 distort_2 = (texture(flow_map, flow_uv_2).rg - 0.5) * 2.0 * distortion_amount;

    // Mask par canal : R = zone 1, G = zone 2, B = zone 3, A = zone 4
    vec4 mask = texture(effect_mask, UV);

    // Distortion composite par zone
    vec2 final_uv = UV;
    final_uv += distort_1 * mask.r;  // Zone 1 : flow direction 1
    final_uv += distort_2 * mask.g;  // Zone 2 : flow direction 2
    // B et A pour d'autres effets (glow pulse, color shift...)

    COLOR = texture(TEXTURE, final_uv);
    COLOR.a = texture(TEXTURE, UV).a;  // Alpha de l'original pour pas deformer les bords
}
```

**Process Blizzard** (reconstitue) :
1. L'artiste peint l'artwork statique
2. Un mask RGBA separe definit les zones (max 4 effets par carte)
3. Le shader applique distortion UV + effets par zone
4. `distortion_amount` ~0.01-0.02 : subtil mais visible. Au-dessus de 0.03 = eau, en dessous = chaleur

### 7d. Dissolve / Burn (destruction de carte)

```gdshader
shader_type canvas_item;

uniform sampler2D dissolve_noise : source_color;
uniform float progress : hint_range(0, 1) = 0.0;   // 0 = intact, 1 = detruit
uniform float burn_width : hint_range(0, 0.15) = 0.05;
uniform vec4 burn_color : source_color = vec4(1.0, 0.4, 0.0, 1.0);  // Orange feu
uniform vec4 ember_color : source_color = vec4(1.0, 0.8, 0.2, 1.0); // Jaune braise

void fragment() {
    vec4 tex = texture(TEXTURE, UV);
    float noise_val = texture(dissolve_noise, UV).r;

    // Seuil de dissolution
    float burn_edge = smoothstep(noise_val - burn_width, noise_val, progress);
    float inner_edge = smoothstep(noise_val, noise_val + burn_width, progress);

    // Couleur du bord en feu
    vec3 edge_color = mix(ember_color.rgb, burn_color.rgb, inner_edge);

    COLOR.rgb = mix(edge_color, tex.rgb, inner_edge);
    COLOR.a = tex.a * burn_edge;

    // Emission glow sur le bord (si WorldEnvironment glow actif)
    // Multiplier edge_color * 2.0 pour activer le bloom HDR
}
```

**Timing pour destruction** : `progress` de 0 a 1 en 0.6-0.8s avec `TRANS_CUBIC / EASE_IN`. La carte accelere sa destruction — lent au debut, rapide a la fin. Accompagner de particules (embers) et d'un sound de papier qui brule.

### 7e. Rarity Glow (outline lumineux)

Un outline colore autour de la carte selon sa rarete.

```gdshader
shader_type canvas_item;

uniform vec4 glow_color : source_color = vec4(1.0, 0.84, 0.0, 1.0);  // Gold
uniform float glow_width : hint_range(0, 10) = 3.0;
uniform float pulse_speed : hint_range(0, 5) = 2.0;
uniform float pulse_amount : hint_range(0, 1) = 0.3;

void fragment() {
    vec4 tex = texture(TEXTURE, UV);
    vec2 pixel_size = TEXTURE_PIXEL_SIZE;

    // Detection outline : sampler les voisins
    float outline = 0.0;
    for (float x = -glow_width; x <= glow_width; x += 1.0) {
        for (float y = -glow_width; y <= glow_width; y += 1.0) {
            vec2 offset = vec2(x, y) * pixel_size;
            float neighbor_alpha = texture(TEXTURE, UV + offset).a;
            outline = max(outline, neighbor_alpha);
        }
    }

    // Pulse
    float pulse = 1.0 + pulse_amount * sin(TIME * pulse_speed);

    // Si le pixel est transparent mais un voisin ne l'est pas → outline
    if (tex.a < 0.1 && outline > 0.5) {
        COLOR = glow_color * pulse;
    } else {
        COLOR = tex;
    }
}
```

| Rarete | `glow_color` | `pulse_speed` | `glow_width` |
|--------|-------------|---------------|--------------|
| Common | Pas de glow | - | - |
| Uncommon | `(0.3, 0.7, 1.0)` bleu pale | 1.5 | 2 |
| Rare | `(0.6, 0.2, 0.9)` violet | 2.0 | 3 |
| Epic | `(1.0, 0.5, 0.0)` orange | 2.5 | 3 |
| Legendary | `(1.0, 0.84, 0.0)` gold | 3.0 | 4 |

**Piege** : la boucle double for dans le shader est couteuse (`glow_width` 4 = 81 samples). Pour une main de 10 cartes, ca peut devenir lourd. **Fix** : pre-render l'outline dans un SubViewport a resolution reduite, ou utiliser un SDF (Signed Distance Field) pour le calcul d'outline.

---

## 8. Drag & Drop — le feeling "physique"

### Pattern drag

```gdscript
# card_drag.gd — state machine minimaliste
extends Node2D

enum DragState { IDLE, HOVER, DRAGGING, RETURNING }

var state := DragState.IDLE
var _drag_offset := Vector2.ZERO
var _origin_position := Vector2.ZERO
var _drag_velocity := Vector2.ZERO  # Pour le "throw" feeling

@export var follow_speed := 25.0       # Lerp du drag (pas 1:1, slight lag = physique)
@export var rotation_from_velocity := 0.003  # La carte penche dans la direction du drag
@export var max_drag_rotation := 0.15  # Radians max pendant le drag

func _process(delta: float) -> void:
    match state:
        DragState.DRAGGING:
            var mouse := get_global_mouse_position()
            var target := mouse + _drag_offset
            var old_pos := global_position

            # Smooth follow — PAS snap direct. Le leger lag = feeling physique
            global_position = global_position.lerp(target, follow_speed * delta)

            # Velocity pour la rotation
            _drag_velocity = (global_position - old_pos) / delta

            # Rotation basee sur la velocity horizontale
            var target_rot := clamp(_drag_velocity.x * rotation_from_velocity,
                -max_drag_rotation, max_drag_rotation)
            rotation = lerp(rotation, target_rot, 10.0 * delta)

func start_drag() -> void:
    state = DragState.DRAGGING
    _origin_position = position
    _drag_offset = global_position - get_global_mouse_position()
    z_index = 100  # Au-dessus de tout

func cancel_drag() -> void:
    state = DragState.RETURNING
    z_index = 0
    # Snap back avec TRANS_BACK = rubber band
    var tw := create_tween().set_parallel(true)
    tw.tween_property(self, "position", _origin_position, 0.2)\
        .set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)
    tw.tween_property(self, "rotation", 0.0, 0.15)\
        .set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_OUT)
    tw.finished.connect(func(): state = DragState.IDLE)
```

**Le secret** : `follow_speed` de 20-30 (pas Infinity). La carte suit la souris avec un micro-lag. Ce lag cree l'illusion de masse/poids. A 25, ca se sent "carte en carton". A 15, ca se sent "carte lourde/epique". A 40+, c'est colle a la souris, zero feeling physique.

### Cancel drag = rubber band

Le `TRANS_BACK` sur le retour cree un overshoot : la carte depasse sa position d'origine puis revient. Ca simule un elastique. Duree 0.2s = snappy. 0.3s+ = mou.

### Piege : drag + hand layout = conflit

Pendant le drag, le hand layout continue de tourner. Si un timer re-layout la main, la carte dragged va sauter a sa position de layout. **Fix** : exclure la carte dragged du layout tant que `state == DRAGGING`. La remettre dans le layout quand `cancel_drag()` ou `play_card()`.

---

## 9. Hand Re-Sort — reorganiser apres retrait

Quand une carte est jouee/retiree de la main, les cartes restantes doivent se re-distribuer. Le timing est crucial.

### Pattern

```
1. Carte jouee → animation play_card (0.3s)
2. Attendre 0.1s (le cerveau a besoin de voir le depart)
3. Re-layout les cartes restantes (0.15s, TRANS_CUBIC / EASE_OUT)
4. Si nouvelle carte piochee → insert a droite avec TRANS_BACK (0.25s)
```

**Slay the Spire** : les cartes se resserrent immediatement apres le play. Pas d'attente.
**Balatro** : leger delai (~0.1s) avant le re-sort. Plus delibere.
**Inscryption** : les cartes glissent lentement (~0.3s). Le jeu est lent et delibere.

### Piege : re-sort pendant hover = flicker

Si le joueur hovere une carte pendant que la main se re-sort, le hover trigger un layout, qui trigger un hover exit (la carte a bouge), qui trigger un nouveau layout... boucle. **Fix** : bloquer le hover pendant le re-sort. Flag `_is_animating` a `true` pendant le tween de layout, ignorer les hover events.

---

## 10. Combinaisons non-evidentes

### Tilt + Foil = holographic interactif

Passer la position souris (normalisee [-1,1]) a la fois au shader perspective (tilt) ET au shader foil (via `offset`). Le foil reagit au tilt, comme un vrai hologramme physique. C'est le combo qui rend Balatro et Pokemon TCG Pocket si satisfaisants.

```gdscript
func on_mouse_moved(local_pos: Vector2) -> void:
    var normalized := local_pos / (card_size * 0.5)
    # Tilt shader
    tilt_material.set_shader_parameter("rot_y", normalized.x * max_tilt)
    tilt_material.set_shader_parameter("rot_x", -normalized.y * max_tilt)
    # Foil shader
    foil_material.set_shader_parameter("offset", normalized * 0.5)
```

### Idle sway + tilt = cartes vivantes

Le sway modifie la position, le tilt modifie la perspective. Ensemble, la carte semble flotter dans l'espace 3D, respirant legerement. C'est plus que la somme des parties.

### Impact play + screen flash + screenshake = poids

Au moment du play :
1. Screenshake (3-5px, 0.08s)
2. Screen flash blanc (ColorRect alpha 0.0 → 0.1 → 0.0 en 0.06s)
3. La carte elle-meme scale 1.0 → 1.15 → 1.0

Les trois ensemble = "slam" satisfaisant. Un seul manquant = feeling creux.

### Dissolve + particles = destruction memorable

Le shader dissolve seul = plat. Ajouter des GPUParticles2D qui emettent des embers aux positions du bord en feu. Les particules montent (gravity -100) avec une lifetime de 0.5s. La combinaison = la carte brule pour de vrai.

---

## Pieges nommes

| Piege | Symptome | Fix |
|-------|----------|-----|
| Scale hover + tilt shader | Carte deformee | Ajuster `inset` ou appliquer tilt avant scale |
| Sway + layout tween | Carte saute | Sway modifie un offset, pas position directement |
| Hover flicker sur overlap | Carte clignote | Debounce hover (1 frame) ou raycast unique depuis le hand |
| Drag + hand layout | Carte dragged teleportee | Exclure la carte du layout pendant drag |
| Re-sort + hover | Boucle de relayout | Flag `_is_animating`, bloquer hover |
| Glow shader N^2 loop | Lag avec 10+ cartes | Pre-render outline ou utiliser SDF |
| Flip + texte Label | Texte illisible a mi-flip | SubViewport pour pre-render la carte |
| Follow speed trop haut | Zero feeling physique au drag | 20-30 pour "carte", 12-18 pour "lourd" |
| Tilt sans lerp | Mouvement saccade | Toujours lerp la rotation, jamais set direct |
| Flash blanc sur ColorRect | Flash plein ecran au load | `color = Color(0,0,0,0)` dans `_ready()` |

---

## Stack combine

```
1. Hand layout parabolique     → arc + rotation + spacing
2. Hover 5 couches             → scale + lift + rot reset + shadow + z-index
3. Tilt perspective shader     → souris → rotation fake 3D
4. Idle sway                   → sin/cos offset, desynchronise par carte
5. Play arc bezier             → courbe + impact juice (shake + flash + particles)
6. Flip perspective            → rot_y 0→90→0, swap texture a mi-chemin
7. Foil/holo shader            → iridescence animee, reponse au tilt
8. Drag smooth follow          → lerp 20-30, rotation par velocity
9. Cancel rubber band          → TRANS_BACK overshoot
10. Dissolve + embers          → destruction en combinant shader + particles
```

Voir aussi : `lights_and_shadows.md` pour l'eclairage des cartes/plateau, `state_machines.md` pour la FSM de la carte (idle/hover/drag/play/discard).
