# Workflow Patterns — Multi-Agent, Architecture & Scaffolding

> Patterns opérationnels compilés à partir des sessions Column Raid.
> Pour les pièges GDScript, voir `gdscript_pitfalls.md`.

---

## 1. Orchestration multi-agents

### 1.1 Le fichier goulot : identifier avant de lancer

Avant de paralléliser, lister les fichiers modifiés par chaque livrable. Conflit = même fichier modifié par 2+ agents.

**Fichiers goulots typiques** :

| Fichier | Touché par quasi tout | Stratégie |
|---------|----------------------|-----------|
| `zone.tscn` | gameplay livrables | 1 seul agent à la fois |
| `zone.gd` | gameplay livrables | 1 seul agent à la fois |
| `event_bus.gd` | tout ce qui ajoute des signaux | Éditions ciblées par section OK |
| `hud.gd` | tout ce qui affiche des infos | 1 seul agent à la fois |
| `project.godot` | autoloads, settings | Éditions ciblées OK (lignes distinctes) |

### 1.2 Règles de parallélisation

```
SAFE en parallèle :
  - Agents qui créent des fichiers différents ET modifient des fichiers différents
  - event_bus.gd : OK si chaque agent ajoute dans une section distincte (commentée)
  - project.godot : OK si chaque agent touche une ligne différente (autoload distinct)

INTERDIT en parallèle :
  - 2 agents qui modifient le même .gd (hors event_bus)
  - 2 agents qui modifient le même .tscn
  - 2 agents qui refactorent la même logique
```

### 1.3 Instructions aux agents

Chaque agent reçoit :
1. **FILES YOU OWN** — liste exhaustive des fichiers à créer/modifier
2. **DO NOT MODIFY** — liste des fichiers interdits (autres agents les touchent)
3. Contexte complet (deliverable spec + fichiers à lire)

**Pattern d'instruction** :
```
## FILES YOU OWN
Create: weapon_manager.gd, spread_bullet.gd, ...
Modify: weapon_system.gd, event_bus.gd, hud.gd

## DO NOT MODIFY
- zone.gd (being modified by another agent)
- loot_item.gd, loot_manager.gd (other agent)
```

### 1.4 Combos testés qui marchent

| Combo | Conflit | Verdict |
|-------|---------|---------|
| 15 Level-ups + 24 Audio + 25 Visual | project.godot (OK, lignes distinctes) | **OK** |
| 16 Weapons + 14 Loot | Quasi aucun (fichiers distincts) | **OK** |
| 12 Combat + 13 Elites | zone.tscn, base_enemy.gd | **NON** |
| 24 Audio + 25 Visual | project.godot (OK) | **OK** |

### 1.5 Vérification post-merge

Après que tous les agents terminent, **toujours vérifier** :
1. `event_bus.gd` — pas de signaux dupliqués ou écrasés
2. `project.godot` — autoloads tous présents, main_scene correcte
3. `zone.tscn` — pas de nodes en conflit ou d'ext_resources cassées

### 1.6 Workflow recommandé

```
1. Lister les livrables candidats à la parallélisation
2. Mapper fichiers modifiés par chaque livrable
3. Identifier les conflits -> grouper en "safe combos"
4. Rédiger les instructions agent avec FILES YOU OWN / DO NOT MODIFY
5. Lancer les agents en parallèle
6. À la completion : vérifier event_bus.gd, project.godot, .tscn partagés
7. Mettre à jour CHECKPOINT.md
```

---

## 2. Patterns architecturaux Godot

### 2.1 Metadata injection (découplage projectiles)

```gdscript
# Producteur (weapon_manager.gd)
var b = bullet_scene.instantiate()
b.set_meta("damage", weapon.damage + _global_bonus)
b.set_meta("speed", weapon.bullet_speed)
container.add_child(b)

# Consommateur (spread_bullet.gd)
func _ready():
    var damage = get_meta("damage", 10)  # Fallback si pas de meta
    var speed = get_meta("speed", 300)
```

Zéro coupling. Le projectile ne connaît pas WeaponManager.

### 2.2 Per-weapon timers (pas de tick global)

```gdscript
func add_weapon(weapon_data):
    var timer = Timer.new()
    timer.wait_time = weapon_data.fire_rate
    timer.timeout.connect(_on_weapon_fire.bind(weapon_data))
    add_child(timer)
    timer.start()
```

Chaque arme a son propre rythme = plus organique.

### 2.3 Backward compat via self-destruct

```gdscript
func _ready():
    var manager = get_tree().root.find_child("WeaponManager", true, false)
    if manager:
        _disabled = true  # Nouveau système prend le relais
        return
```

L'ancien système détecte le nouveau et se désactive. Pas de suppression, pas de breaking change.

### 2.4 Per-column tracking avec auto-cleanup

```gdscript
var _column_items: Array[Array] = [[], [], [], [], [], []]

func _track_item(item: Node, column: int):
    _column_items[column].append(item)
    item.tree_exiting.connect(_untrack_item.bind(item, column))

func _untrack_item(item: Node, column: int):
    _column_items[column].erase(item)
```

Pas de stale references. `tree_exiting` se déclenche automatiquement sur `queue_free()`.

### 2.5 SFX pool (round-robin)

```gdscript
const POOL_SIZE = 8
var _sfx_pool: Array[AudioStreamPlayer] = []
var _sfx_index: int = 0

func _ready():
    for i in POOL_SIZE:
        var player = AudioStreamPlayer.new()
        player.bus = "SFX"
        add_child(player)
        _sfx_pool.append(player)

func play_sfx(stream: AudioStream, pitch: float = 1.0):
    var p = _sfx_pool[_sfx_index]
    _sfx_index = (_sfx_index + 1) % POOL_SIZE
    p.stream = stream
    p.pitch_scale = pitch
    p.play()
```

8 players suffisent pour un bullet hell. Sons qui se chevauchent sans créer/détruire de nodes.

### 2.6 Ascending pitch pour rapid pickups

```gdscript
var _pickup_pitch: float = 1.0
var _pitch_timer: float = 0.0
const PITCH_RESET_DELAY = 0.4
const PITCH_INCREMENT = 0.08
const PITCH_MAX = 1.8

func _on_loot_collected():
    play_sfx("pickup", _pickup_pitch)
    _pickup_pitch = min(_pickup_pitch + PITCH_INCREMENT, PITCH_MAX)
    _pitch_timer = PITCH_RESET_DELAY

func _process(delta):
    if _pitch_timer > 0:
        _pitch_timer -= delta
        if _pitch_timer <= 0:
            _pickup_pitch = 1.0
```

Ramasser du loot rapidement = son qui monte en pitch = satisfaction croissante.

---

## 3. Édition .tscn sans MCP

### Quand c'est acceptable

| Situation | Éditer .tscn ? |
|-----------|---------------|
| MCP connecté | **NON** — utiliser godot_add_node |
| MCP absent, node statique simple | **OUI** — ajouter ext_resource + node |
| MCP absent, UI complexe | **NON** — créer programmatiquement en _ready() |
| Nouveau .tscn standalone | **OUI** — écrire le fichier complet |

### Format

```
[ext_resource type="Script" path="res://mechanics/xp_system.gd" id="15_xp"]

[node name="XPSystem" type="Node" parent="."]
script = ExtResource("15_xp")
```

Règles : `id` unique, **pas de `uid=`** (Godot l'assigne), `parent="."` = enfant du root.

### Supprimer un node
1. Supprimer le bloc `[node name="X" ...]`
2. L'`[ext_resource]` correspondant peut rester (Godot tolère les orphelins)
3. Si on veut être propre, supprimer aussi l'ext_resource

### Remplacer un node (ex: OldSystem → NewSystem)
```
# 1. Remplacer l'ext_resource
AVANT: [ext_resource type="Script" path="res://old.gd" id="37_old"]
APRÈS: [ext_resource type="Script" path="res://new.gd" id="39_new"]

# 2. Supprimer le vieux node bloc
# 3. Ajouter le nouveau (avec le bon ext_resource id)
[node name="NewSystem" type="Node" parent="."]
script = ExtResource("39_new")
```

### Piège : les `uid=` dans les ext_resources
- Les fichiers `.gd` existants ont un `.gd.uid` companion → UID connu
- Les NOUVEAUX fichiers `.gd` (pas encore ouverts dans Godot) n'ont PAS de `.gd.uid`
- **Solution** : ajouter l'ext_resource SANS `uid=` → Godot assignera le uid au prochain load

---

## 4. Scaffolding & graceful fallbacks

### Audio scaffolding

```gdscript
const SFX_PATHS = {
    "shoot": "res://Assets/audio/sfx/FX_Shoot01.wav",
    "player_hit": "",     # placeholder, asset manquant
}

func _load_audio(path: String) -> AudioStream:
    if path.is_empty():
        return null
    if not ResourceLoader.exists(path):
        push_warning("AudioManager: missing asset: " + path)
        return null
    return load(path)
```

Path vide = feature scaffoldée. Signal connecté, code prêt, asset manquant.

### UI theme fallback

```gdscript
func _load_stylebox_texture(path: String) -> StyleBox:
    var full_path = "res://Assets/ui/" + path
    if ResourceLoader.exists(full_path):
        var tex = load(full_path)
        var sb = StyleBoxTexture.new()
        sb.texture = tex
        return sb
    var sb = StyleBoxFlat.new()
    sb.bg_color = Color(0.2, 0.2, 0.25)
    sb.set_corner_radius_all(4)
    return sb
```

### Signaux connectés mais handler vide

Handler branché, SFX null -> silently skipped, aucun crash. Prêt pour l'asset.

---

## 5. Automated test loop (simulate + verify)

### 5.1 Le pattern

```
play_scene
  └─► simulate_input(sequence)
        └─► get_logs (incrémental — nouvelles lignes seulement)
              └─► analyser les logs
                    ├─ OK → fin ou prochaine séquence
                    └─ KO → ajuster le code → recommencer
```

Claude peut tester un jeu **sans intervention humaine** : envoyer des inputs, lire le résultat dans les logs, itérer.

### 5.2 Prérequis côté jeu

Le jeu doit exposer son état via `GolemCapture.log()`. Sans ça, Claude envoie des inputs dans le vide.

```gdscript
# Dans le script du joueur, de l'ennemi, du score, etc.
func _on_hit(damage: int) -> void:
    health -= damage
    GolemCapture.log("player_hit health=%d damage=%d" % [health, damage])

func _on_score_changed(new_score: int) -> void:
    GolemCapture.log("score=%d" % new_score)
```

**Convention de log** : `key=value` parseable. Pas de prose, pas de format variable.

### 5.3 Séquences d'input typiques

```python
# Mouvement simple — tester le déplacement
[{"action": "move_right", "duration": 1.0}]

# Combo — mouvement + action
[
    {"action": "move_right", "pressed": true},
    {"wait": 0.5},
    {"action": "jump", "duration": 0.15},
    {"wait": 1.0},
    {"action": "move_right", "pressed": false}
]

# Stress test — input rapide en boucle
[
    {"action": "attack", "duration": 0.1},
    {"wait": 0.05}
]  # loop=20

# Test d'inactivité — vérifier que rien ne crash sans input
[{"wait": 3.0}]
```

### 5.4 Boucle complète en pratique

```
1. play_scene()
2. get_logs()                       → vider le buffer initial
3. simulate_input(sequence)         → exécuter la séquence
4. get_logs()                       → lire SEULEMENT les nouvelles lignes
5. Analyser : chercher les clés attendues (health=, score=, error=)
6. Si KO :
   a. stop_scene()
   b. Modifier le script
   c. Retour à 1
7. Si OK :
   a. Prochaine séquence ou stop_scene()
```

### 5.5 Limites

| Limite | Contournement |
|--------|--------------|
| Pas de mouse/keyboard brut | Uniquement les actions Input Map nommées |
| Pas de lecture directe de l'état du jeu | `GolemCapture.log()` + conventions key=value |
| `time_scale` bas ralentit la séquence | Le timeout est en game-time, le deferred en real-time — marge de 5s |
| MCP déconnecte souvent après play | `get_logs` d'abord (fiable), retry screenshot 1-2 fois max |
| GolemCapture doit être l'autoload | Vérifier dans Project Settings > Autoload |

### 5.6 Anti-patterns

- **Boucler 10 fois sans changer le code** — si le résultat est le même, c'est le code qui est faux, pas l'input
- **Séquences de 50+ steps** — diviser en sous-séquences, vérifier entre chaque
- **Se fier au visuel** — `get_game_screenshot` est fragile après play. Les logs sont plus fiables
- **Oublier le `get_logs()` de purge** — le premier appel après `play_scene` contient parfois des logs de la session précédente si `clear_logs` n'a pas été appelé

---

## 6. Checklist pré-livraison

```
[ ] Tous les scripts ont `extends`
[ ] Pas de `print()` debug oubliés
[ ] Pas de constantes/vars inutilisées
[ ] Pas de `:=` dangereux (grep `var \w+ :=`)
[ ] event_bus.gd : signaux dans la bonne section, commentés
[ ] Nouveaux nodes dans .tscn : ext_resource id unique, pas de uid=
[ ] .tscn partagés : pas de conflits nodes/ext_resources
[ ] project.godot : autoloads dans l'ordre correct
[ ] Graceful fallback sur assets manquants
[ ] queue_free() sur tous les FX (pas de nodes orphelins)
[ ] Deliverable .md mis à jour
[ ] Chaque action joueur échouée a un feedback (signal ou visuel)
[ ] Popups : process_mode = ALWAYS + layer distinct
[ ] Tweens de popup : TWEEN_PAUSE_PROCESS
[ ] Données passées à l'UI : copies (.duplicate(true))
[ ] Engine.time_scale compensé dans player._physics_process
[ ] Effets audio : index stockés au _ready(), guard clauses bus_idx == -1
[ ] is_instance_valid() sur tout node optionnel dans les callbacks
[ ] ResourceLoader.exists() avant tout load() dynamique
[ ] is_instance_valid() après chaque await sur tous les refs
[ ] add_child() dans _ready() parent → call_deferred si nécessaire
[ ] Propriétés particules sur le Material, pas le node
[ ] Array[T] = source retourne bien Array[T] ? Sinon Array brut
[ ] queue_redraw() dans _process → throttlé ?
[ ] get_node() dans hot paths → caché en var ?
```

---

## 7. Popups programmatiques — CanvasLayer + set_script()

### Quand l'utiliser

UI temporaire (level-up, weapon swap, confirmation) qui pause le jeu. Pas de .tscn — tout en code.

### Pattern

```gdscript
# zone.gd — instanciation
var popup_script := preload("res://scenes/ui/levelup_popup.gd")
_levelup_popup = CanvasLayer.new()
_levelup_popup.set_script(popup_script)
add_child(_levelup_popup)  # @onready + _ready() fonctionnent
```

```gdscript
# levelup_popup.gd
extends CanvasLayer

func _ready() -> void:
    layer = 5
    process_mode = Node.PROCESS_MODE_ALWAYS
    visible = false
    _build_ui()

func show_choices(data: Array) -> void:
    _populate_cards(data)
    visible = true
    get_tree().paused = true
    # Animation d'entrée
    var tw := create_tween()
    tw.set_pause_mode(Tween.TWEEN_PAUSE_PROCESS)
    tw.tween_property(_panel, "scale", Vector2.ONE, 0.2) \
        .set_ease(Tween.EASE_OUT).set_trans(Tween.TRANS_BACK)

func _close() -> void:
    get_tree().paused = false
    visible = false
```

### Anatomie type

```
CanvasLayer (layer=5, process_mode=ALWAYS, visible=false)
  └─ ColorRect (overlay sombre, PRESET_FULL_RECT, mouse_filter=STOP)
      └─ CenterContainer (PRESET_FULL_RECT)
          └─ PanelContainer (StyleBoxFlat arrondi)
              └─ VBoxContainer
                  ├─ Label (titre)
                  ├─ HBoxContainer (cartes dynamiques)
                  └─ Label (hint clavier)
```

### Avantages
- Un seul fichier .gd = une source de vérité
- Pas de .tscn à maintenir / conflits multi-agent
- Layout dynamique (nombre de cartes variable)
- `set_script()` avant `add_child()` = `@onready` OK

---

## 8. Signal-driven UI flow — ne jamais `return false` silencieux

### Anti-pattern

```gdscript
func add_weapon(weapon_id: String) -> bool:
    if weapons.size() >= MAX_WEAPONS:
        return false  # Le joueur ne sait rien → bug UX
```

### Pattern

```gdscript
func add_weapon(weapon_id: String) -> bool:
    if weapons.size() >= MAX_WEAPONS:
        EventBus.weapon_replacement_needed.emit(weapon_id)
        return false

# zone.gd connecte le signal → ouvre un popup
# Le popup communique le choix via EventBus.weapon_replacement_chosen
# WeaponManager écoute et agit
```

### Flow complet

```
action → système logique détecte échec → signal
  → orchestrateur (zone.gd) ouvre l'UI appropriée
    → UI affiche, attend input (pause)
      → joueur choisit → signal résultat
        → système logique exécute
```

### Règles
- Le **système logique** n'a JAMAIS de logique UI — il émet des signaux
- L'**UI** ne modifie JAMAIS les données source — elle les affiche (copies) et communique via signal
- L'**orchestrateur** (zone.gd) connecte les deux mondes

---

## 9. CanvasLayer ordering — convention

| Layer | Contenu | Raison |
|-------|---------|--------|
| 0 | Jeu (défaut) | Entities, enemies, loot |
| 1 | HUD | CanvasLayer par défaut |
| 5 | Popup gameplay (level-up) | Au-dessus du jeu, sous le pause |
| 6 | Popup secondaire (weapon replace) | Au-dessus du premier popup |
| 10 | Pause menu | Au-dessus de tout |

**Règle** : chaque popup reçoit un layer distinct. Même si deux popups ne devraient jamais coexister, un layer dédié évite le z-fighting si un bug les ouvre simultanément.

---

## 10. Audio effects runtime — AudioServer manipulation

### Setup au _ready()

```gdscript
var _sfx_bus_idx: int = -1
var _sfx_lowpass_idx: int = -1

func _setup_audio_buses() -> void:
    _sfx_bus_idx = AudioServer.get_bus_index("SFX")
    if _sfx_bus_idx == -1:
        return
    var lowpass := AudioEffectLowPassFilter.new()
    lowpass.cutoff_hz = 20500.0  # ~max audible = transparent
    AudioServer.add_bus_effect(_sfx_bus_idx, lowpass)
    _sfx_lowpass_idx = AudioServer.get_bus_effect_count(_sfx_bus_idx) - 1
```

### Toggle avec tween

```gdscript
func _on_bullet_time_started(_time_scale: float) -> void:
    if _sfx_bus_idx == -1 or _sfx_lowpass_idx == -1:
        return
    var effect: AudioEffectLowPassFilter = AudioServer.get_bus_effect(_sfx_bus_idx, _sfx_lowpass_idx)
    if not effect:
        return
    create_tween().tween_property(effect, "cutoff_hz", 800.0, 0.15)
```

### Valeurs cutoff utiles

| Cutoff | Feeling |
|--------|---------|
| 20500 Hz | Normal (transparent) |
| 5000 Hz | Légèrement voilé |
| 2000 Hz | Étouffé (mur) |
| 800 Hz | Très étouffé (bullet time, rêve) |
| 300 Hz | Presque muet (seuls les graves) |

### Pièges
- `get_bus_index("SFX")` retourne `-1` si le bus n'existe pas → **toujours guard check**
- Stocker les index au `_ready()`, ne jamais recalculer
- `get_bus_effect()` retourne `AudioEffect` (base) → typer la variable
- Le tween survit même si le son se termine — l'effet est sur le **bus**

---

## 11. Programmatic vs .tscn — matrice de décision

| Critère | .tscn | Programmatic |
|---------|-------|-------------|
| Structure fixe (mêmes nodes toujours) | **OUI** | Non |
| Layout dynamique (nb de cartes variable) | Non | **OUI** |
| Preview éditeur utile | **OUI** | Non |
| Popup/overlay temporaire | Non | **OUI** |
| Dev solo, itération rapide | OK | **OUI** |
| Scène partagée multi-agent | **OUI** | Non |

### Convention

- **Scènes permanentes** (zone, main_menu, game_over) : .tscn
- **Popups/overlays** (levelup, weapon replace, pause) : programmatic
- **Entities** (enemies, projectiles, loot) : .tscn (preview utile)
- **VFX** (score popup, impact, pickup) : programmatic ou mini .tscn

### Combinaison bullet time — 4 systèmes indépendants

```
1. Vignette overlay   → tween color:a sur ColorRect
2. Desaturation       → tween modulate Color(0.7,0.7,0.7) sur _entities
3. Audio low-pass     → tween cutoff_hz sur bus effect
4. Player speed       → delta / Engine.time_scale dans _physics_process
```

Chaque système connecté via EventBus. Aucun ne connaît les autres. `modulate` Color(0.7,0.7,0.7) = fake desaturation sans shader — suffisant visuellement.

---

## 12. Dynamic spawn — set_script + call_deferred + ready.connect

### Le pattern complet

```gdscript
func _spawn_entity() -> void:
    var script := preload("res://path/to/script.gd")
    var node := Area2D.new()
    node.set_script(script)

    var column := randi_range(0, 5)
    container.call_deferred("add_child", node)

    # setup() dans ready.connect, pas avant add_child
    node.ready.connect(func() -> void:
        node.setup(column)
    , CONNECT_ONE_SHOT)
```

### Pourquoi pas setup() avant add_child ?
- `setup()` accède souvent à `get_tree().current_scene` ou des `@onready` vars
- Avant `add_child`, le node n'est pas dans le tree → crash ou null
- **Exception** : si setup() ne fait QUE setter des vars internes (pas d'accès tree), c'est OK

### call_deferred + ready.connect
Le `ready` signal fire après que le node entre dans le tree via le deferred call. `CONNECT_ONE_SHOT` évite les reconnexions parasites si on re-add le node.

---

## 13. Extrapolation linéaire au-delà des courbes

### Le pattern (danger, spawn_rate, hp_scale, etc.)

```gdscript
var _curve_points := [
    [0, 0], [100, 50], [300, 100], [600, 200],
]

func _interpolate(time: float) -> float:
    if time <= 0.0:
        return 0.0
    # Au-delà du dernier point : extrapoler linéairement
    var last_t: float = _curve_points[-1][0]
    var last_v: float = _curve_points[-1][1]
    if time >= last_t:
        var prev_t: float = _curve_points[-2][0]
        var prev_v: float = _curve_points[-2][1]
        var slope := (last_v - prev_v) / (last_t - prev_t)
        return last_v + slope * (time - last_t)
    # Interpolation standard entre les points...
```

### Pourquoi extrapoler ?
Retirer le clamp sans extrapolation = valeur plate au dernier point. L'extrapolation linéaire donne une courbe cohérente qui continue à monter.

### Floor de sécurité
Pour les intervalles : `maxf(0.05, result)` pour éviter des valeurs négatives.

### Piège `:=`
Les éléments du tableau `_curve_points` sont `Variant` → `var last: Array = _curve[-1]`, jamais `:=`.

---

## 14. VFX programmatiques — gibs, damage numbers, particules

### Gibs (débris) — Node2D + _draw(), zéro .tscn

```gdscript
# Chaque gib = dict {pos, vel, size, rot, rot_speed, color}
# Gravité dans _process, rendu dans _draw
# Auto-destroy via timer interne (_time >= LIFETIME)
var _gibs: Array[Dictionary] = []

func _process(delta: float) -> void:
    for gib in _gibs:
        gib["vel"].y += GRAVITY * delta
        gib["pos"] += gib["vel"] * delta
        gib["rot"] += gib["rot_speed"] * delta
    if Engine.get_process_frames() % 3 == 0:
        queue_redraw()  # Throttle ~20fps

func _draw() -> void:
    for gib in _gibs:
        draw_colored_polygon(points, gib["color"])
```

Avantage : zéro dépendance .tscn, instanciation ultra légère, pas de scene tree overhead.

### Damage Numbers — Node2D + Label

```
setup_number(value, color, crit, direction) AVANT add_child
Entry pop via tween scale (TRANS_BACK pour overshoot)
Crit : plus gros + shake les 0.2 premières secondes
Fade out dans les 30% finaux du lifetime
Drift latéral aléatoire pour éviter les overlaps
Auto-destroy via compteur _time dans _process (pas create_timer)
```

### GPU Particles programmatiques

```gdscript
var particles := GPUParticles2D.new()
particles.amount = 12
particles.one_shot = true
particles.lifetime = 0.35
particles.explosiveness = 1.0

var mat := ParticleProcessMaterial.new()
mat.spread = 180.0
mat.initial_velocity_min = 100.0
mat.initial_velocity_max = 250.0

# IMPORTANT : color_ramp est sur le MATERIAL, pas sur GPUParticles2D
var gradient := Gradient.new()
gradient.colors = PackedColorArray([Color.WHITE, Color(1, 0.4, 0.1, 0)])
var grad_tex := GradientTexture1D.new()
grad_tex.gradient = gradient
mat.color_ramp = grad_tex  # PAS particles.color_ramp

particles.process_material = mat

# Additive blend
var canvas_mat := CanvasItemMaterial.new()
canvas_mat.blend_mode = CanvasItemMaterial.BLEND_MODE_ADD
particles.material = canvas_mat
```

### Limites perf
- Gibs Node2D avec `_draw()` = très légers, pas de texture
- GPU Particles en one_shot ne persistent pas
- Auto-destroy : `_time >= LIFETIME → queue_free()`
- Max ~20 gibs par frame (throttle queue_redraw)

---

## 15. UI bottom-aligned — VBox spacer pattern

### Ce qui ne fonctionne PAS
- Control enfant de CanvasLayer avec anchors → résolution incohérente
- Position absolue sur CanvasLayer → invisible
- `anchor_bottom = 1.0` + offsets négatifs → fragile

### Ce qui fonctionne : HUD full-rect + spacer expand_fill

```gdscript
# .tscn : MarginContainer anchors_preset = 15 (FULL_RECT)
# Code : spacer pousse le contenu en bas
var spacer := Control.new()
spacer.size_flags_vertical = Control.SIZE_EXPAND_FILL
vbox.call_deferred("add_child", spacer)

var stats_row := HBoxContainer.new()
vbox.call_deferred("add_child", stats_row)
```

Le spacer mange tout l'espace vertical entre le top du HUD et le bas de l'écran. Les éléments après le spacer sont collés en bas.

### Attention
`call_deferred` = les enfants ne sont pas disponibles dans le même frame. Ajouter les children du row directement (il n'est pas dans le tree), puis le row en deferred.

---

## 16. Temporary nodes vs repositioning — pattern VFX/UI

### Problème
Réutiliser un node unique (ColorRect, marker) pour afficher N instances simultanées : seule la dernière position est visible.

### Fix : nodes temporaires avec auto-destroy

```gdscript
func _on_warning(column: int) -> void:
    var rect := ColorRect.new()
    rect.position = Vector2(col_x, 0)
    rect.size = Vector2(COLUMN_WIDTH, 1280.0)
    rect.color = Color(1.0, 0.1, 0.05, 0.25)
    grid.add_child(rect)

    var tween := create_tween()
    tween.tween_property(rect, "color:a", 0.0, 0.5)
    tween.tween_callback(func() -> void:
        if is_instance_valid(rect):
            rect.queue_free()
    )
```

### Règle
Quand un effet doit afficher N instances simultanées (warnings, highlights, markers), **créer des nodes temporaires** plutôt que repositionner un node unique. Tween + `queue_free` = zero cleanup.

---

## 17. Signal refactoring à grande échelle — stratégie safe

### Le problème
Supprimer un signal utilisé par plusieurs systèmes casse silencieusement les `.connect()` dans des fichiers non modifiés.

### Stratégie

```
1. GARDER la déclaration du signal dans event_bus.gd
2. SUPPRIMER les .emit() (plus personne ne l'émet)
3. Les .connect() deviennent des no-ops silencieux = safe
4. Grep exhaustif : "signal_name" sur tout le projet
5. Corriger les fichiers critiques
6. Les fichiers secondaires restent connectés = safe
```

### Backward compat — garder les signatures
Si un signal prend N args et qu'on supprime un concept, garder la signature et passer 0/null :
```gdscript
# AVANT
EventBus.score_changed.emit(score, target_score)
# APRÈS (target supprimé, signature gardée)
EventBus.score_changed.emit(score, 0)
```

### Sections commentées dans event_bus.gd
```gdscript
# Extraction & Multi-Zone
signal extraction_activated()

# Enemy Projectiles
signal enemy_projectile_warning(column: int)
```
Les sections commentées permettent le travail parallèle multi-agent sur event_bus.gd.

---

## 18. Viewport origin & coordinate space — Godot 2D

### Le piège
En Godot 2D, `(0, 0)` = **coin haut-gauche** du viewport, pas le centre. Le centre d'un viewport 1920x1080 = `(960, 540)`.

### Conséquences
- Player placé à `(0, 0)` = apparaît dans le coin
- Éléments d'arène avec coordonnées négatives = travail contre le système
- Camera2D zoom 1.0 sur viewport 1920x1080 avec sprites 16px = invisibles

### Règles
1. **Positionner l'élément principal au centre du viewport** — `(960, 540)` pour 1080p
2. **Positionner les éléments enfants en coordonnées positives** relatives au centre
3. **Pour pixel art (16px base), cibler ~640x360 de zone visible** — zoom ~3.0. Formule : `zoom = viewport_width / target_visible_width`
4. **Détecter l'origine** de chaque scène avant de placer les enfants

---

## 13. Deliverable structure — verbe joueur, pas composant technique

### Le problème

Découper par composant technique :
```
01 Player Movement
05 Marked Enemies + Dash
06 Dash Chains
10 VFX Core
11 SFX Core
```
= on teste des mécaniques sèches. Le dash sans implosion visuelle et crépitement sonore n'est pas un dash — c'est un déplacement technique. Les VFX/SFX dans un livrable séparé = du polish, pas du gamefeel.

### La règle

Découper par **verbe joueur** (ce que le joueur FAIT) :
```
01 Déplacement + Glitch-Step ciblé (mouvement + dash + chains + VFX départ/arrivée/trail + SFX crépitement/implosion/crescendo)
02 Contre + Bullet Time (counter + perfect + BT + VFX flash/désaturation + SFX impact/résonance)
```

### Modules réutilisables

Chaque deliverable produit des **modules portables** en plus du câblage spécifique au jeu :

```
Deliverable "Arena + Movement + Sable"
├── Modules (portables) :
│   ├── MovementComponent — 8-dir, speed modifiers, lock/unlock
│   ├── ReactiveGround — sillons, particules aux impacts, scintillement
│   └── SmoothCamera — follow, limites, look-ahead
└── Câblage (spécifique au jeu) :
    └── player.gd connecte les modules entre eux
```

Un module = script autonome, @export pour la config, signaux pour communiquer, zéro référence au jeu.

### Checklist

- [ ] Chaque livrable correspond à un verbe joueur, pas à un système
- [ ] Chaque livrable embarque le juice (VFX/SFX/screenshake) lié à la mécanique
- [ ] Chaque livrable liste ses "Modules produits (réutilisables)"
- [ ] Chaque module est un script autonome (@export, signaux, pas de dépendance jeu)
- [ ] Pas de livrable "VFX Core" ou "SFX Core" sauf si le GDD le définit
- [ ] Chaque livrable est testable avec son feeling — si on ne peut pas RESSENTIR quelque chose, le découpage est mauvais
- [ ] Si un GDD existe, ses livrables Phase 1 sont repris 1:1
