# GDScript 4.x — Pièges rencontrés

> Référence définitive. Chaque section = un piège avec symptôme, cause et fix.
> Compilé à partir des sessions Column Raid (multi-agent & solo).

---

## Quick-scan checklist (vérifier AVANT de coder)

> TL;DR — si tu ne lis qu'une chose dans ce fichier, lis ça.

- [ ] `var X :=` — source non-triviale ? → `: Type =`
- [ ] `global_position =` — après `add_child()` ?
- [ ] `add_child` dans callback Area2D/Body2D → `call_deferred`
- [ ] Ordre d'émission signaux quand un flag est impliqué
- [ ] `get_theme_stylebox()` sans `.duplicate()` → ref partagée
- [ ] `create_tween()` dans popup pausé → `TWEEN_PAUSE_PROCESS` ?
- [ ] `Engine.time_scale` dans `_physics_process` → compensé player ?
- [ ] Callback signal sur node optionnel → `is_instance_valid()` ?
- [ ] Données passées à l'UI → `duplicate(true)` ?
- [ ] `load()` dynamique (path variable) → `ResourceLoader.exists()` avant ?
- [ ] `await` dans la fonction → `is_instance_valid()` sur TOUS les refs après ?
- [ ] `add_child()` dans `_ready()` parent → `call_deferred` nécessaire ?
- [ ] Propriété GPU particles → sur le Material, pas le node ?
- [ ] `Array[T] = method()` → la source retourne bien `Array[T]` ?
- [ ] `queue_redraw()` dans `_process()` → throttlé ? `get_node()` → caché ?
- [ ] `@export var X` dans extends NativeClass → nom ne masque pas un membre natif ?
- [ ] `set_deferred("prop", val)` sur donnée lue dans `_ready()` → assigner directement ?
- [ ] Node placé à `(0,0)` → c'est vraiment le coin haut-gauche qu'on veut ?
- [ ] `collision_mask = N` → c'est un bitmask, pas un numéro de layer ?
- [ ] `collision_mask` inclut le ground layer (souvent layer 3 ou 4) ? Entités traversent le sol sinon
- [ ] `position` vs `global_position` → local au parent ou coordonnée écran ?
- [ ] MeshInstance3D skinné → `skeleton` path non-vide ? (`".."` vers le Skeleton3D parent)
- [ ] Mesh skinné en T-pose malgré skeleton OK → `AnimationPlayer.is_playing()` ? Anim chargée et jouant ?
- [ ] AnimationPlayer `root_node` → pointe vers le bon parent (pas `"../.."` hérité d'un import) ?
- [ ] Animation tracks avec `%` prefix → remap nécessaire si chargement dynamique ?
- [ ] `load()` après reimport massif → cache stable ? Redémarrer si crash silencieux
- [ ] `update_property` + `save_scene` sur node FBX/GLB → grep .tscn pour vérifier persistance
- [ ] `Camera3D.current = true` dans `_ready()` si scène instanciée dynamiquement ?
- [ ] Suppression éditeur (@tool / execute_script) : `.free()` (pas `queue_free()`) avant `save_scene` ?
- [ ] CharacterBody3D bloque sur escaliers/débris FBX → `step_height` natif suffit pas, `body_test_motion()` ?
- [ ] `CapsuleShape3D.margin` réduit à `<= 0.01` ? (défaut 0.04 accroche sur arêtes)
- [ ] CharacterBody3D instancié comme enfant d'un autre CharacterBody3D → collision du child désactivée (`layer=0`, `mask=0`) ?

---

## 1. `:=` — L'inférence de type qui casse tout

**Le plus gros piège rencontré.** 29 fixes sur 12 fichiers en une session.

### Symptôme
```
Parser Error: Cannot infer the type of "X" variable because the value doesn't have a set type.
```
Le jeu **ne démarre pas**. Pas un warning — un Parser Error bloquant.

### Cause
`:=` demande au parser d'inférer le type à la compilation. Si l'expression retourne `Variant` (type dynamique), le parser refuse.

### Expressions qui retournent Variant

| Expression | Retourne | Exemple |
|-----------|----------|---------|
| `dict[key]` | `Variant` | `var cfg := enemies["boss"]` |
| `dict.get(key, default)` | `Variant` | `var hp := data.get("hp", 0)` |
| `.duplicate()` | `Variant` | `var copy := original.duplicate()` |
| `.instantiate()` | `Node` non typé | `var b := scene.instantiate()` |
| `get_node_or_null()` | `Node` nullable | `var p := get_node_or_null("Player")` |
| `load()` sans cast | `Resource` | `var tex := load(path)` |
| `==`, `!=`, `<`, `>` | `Variant` (bool) | `var ok := a == b` |
| `x if cond else y` | `Variant` | `var s := A if flag else B` |
| `get_nodes_in_group()` | `Array` untyped | `var e := get_tree().get_nodes_in_group("x")` |
| `{}` ou `[]` littéral | `Variant` | `var cache := {}` |
| `array[-1]`, `array[i]` | `Variant` (si Array non-typé) | `var last := curve[-1]` |
| `node.property` via Node générique | `Variant` | `var val := generic_node.speed` |
| Arithmétique sur propriétés dynamiques | `Variant` | `var diff := sys.a - sys.b` |

### Expressions SAFE avec `:=`

| Expression | Pourquoi | Exemple |
|-----------|----------|---------|
| Littéraux primitifs | Type évident | `var x := 5`, `var s := "text"` |
| Constructeurs typés | Retour explicite | `var v := Vector2(1, 0)` |
| `SomeClass.new()` | Type connu | `var t := Timer.new()` |
| `preload()` | Retour typé | `var sc := preload("res://x.tscn")` |
| `create_tween()` | Retourne `Tween` | `var tw := create_tween()` |
| `randf_range()`, `randi_range()` | Retournent `float`/`int` | `var r := randf_range(0, 1)` |
| `str()`, `int()`, `float()` | Casts explicites | `var n := int(x)` |
| `minf()`, `maxf()`, `clampf()` | Retournent `float` | `var c := clampf(v, 0, 1)` |
| `absi()`, `absf()` | Retournent `int`/`float` | `var d := absi(a - b)` |
| Accès à un member typé | Annotation dans la classe | `var s := GameState.current_score` |
| `as Type` cast | Force le type | `var sb := bar.get("x") as StyleBoxFlat` |
| `const` declarations | Toujours OK | `const SPEED := 300.0` |

### Fix
```gdscript
# MAUVAIS
var choices := pool.get_random_choices(level)
var config := _enemy_types[type].duplicate()

# BON
var choices: Array = pool.get_random_choices(level)
var config: Dictionary = _enemy_types[type].duplicate()
```

**Règle** : si la source n'est pas un littéral, un constructeur typé ou une fonction à retour garanti → `: Type =` au lieu de `:=`.

### Piège bonus : `:=` via Node générique (cas 3)
```gdscript
var sys: Node = scene.get_node_or_null("ComboSystem")
# MAUVAIS — sys est Node, .combo_window est résolu dynamiquement
var bonus := sys.combo_window - sys.BASE_COMBO_WINDOW

# BON — type explicite
var bonus: float = sys.combo_window - sys.BASE_COMBO_WINDOW
```
Le pattern `var sys: Node = get_node(...)` est fréquent (on ne veut pas preload le script pour typer). Toute opération sur `sys.property` est dynamique → `:=` impossible.

### Piège bonus : int vs float
`var speed := 200` infère `int`. `speed = 200.5` → erreur de type silencieuse. Toujours écrire `200.0` pour les floats.

---

## 2. Ternaire + `:=` — double peine

### Symptôme
```
Parser Error: Cannot infer the type of "speed" variable
```

### Cause
L'expression ternaire `A if condition else B` retourne `Variant` **même si A et B sont du même type**.

```gdscript
# MAUVAIS — ternaire retourne Variant
var speed := SAME_COL_SPEED if same_col else ADJ_SPEED
var sfx := "shoot_alt" if rapid else "shoot"
var color := Color.RED if hp < 25 else Color.GREEN

# BON
var speed: float = SAME_COL_SPEED if same_col else ADJ_SPEED
var sfx: String = "shoot_alt" if rapid else "shoot"
var color: Color = Color.RED if hp < 25 else Color.GREEN
```

Piégeux parce que les deux branches sont souvent du même type — on s'attend à ce que GDScript infère. Mais non.

---

## 3. `global_position` avant `add_child()` — assignation ignorée

### Symptôme
Le node spawne à `(0, 0)` au lieu de la position voulue.

### Cause
`global_position` dépend du parent dans l'arbre. Avant `add_child()`, le node n'a pas de parent → l'assignation est silencieusement ignorée ou écrasée.

### Fix — deux stratégies

**Stratégie A : assigner APRÈS add_child()**
```gdscript
var fx: Node = scene.instantiate()
container.add_child(fx)
fx.global_position = target_pos  # Maintenant il a un parent
```

**Stratégie B : stocker et appliquer dans _ready()**
```gdscript
# Producteur
var fx := Node2D.new()
fx.set_script(preload("res://pickup_fx.gd"))
fx.setup(target_pos, color)
container.add_child(fx)

# pickup_fx.gd
var _spawn_pos := Vector2.ZERO

func setup(pos: Vector2, color: Color) -> void:
    _spawn_pos = pos

func _ready() -> void:
    global_position = _spawn_pos
```

**Attention** : `position` (locale) fonctionne avant `add_child()` si le parent est à `(0,0)`. Seul `global_position` a ce piège.

---

## 4. `add_child()` pendant le physics flush — crash

### Symptôme
```
ERROR: Can't change this state while flushing queries.
```

### Cause
Godot interdit de modifier l'arbre pendant que le moteur physique itère (collisions, `body_entered`/`area_entered`).

### Fix
```gdscript
# MAUVAIS — dans un callback physique
func _on_body_entered(body):
    var fx = fx_scene.instantiate()
    container.add_child(fx)  # CRASH

# BON
func _on_body_entered(body):
    var fx = fx_scene.instantiate()
    container.call_deferred("add_child", fx)
```

### Quand utiliser `call_deferred`
- `add_child()` dans un callback Area2D/Body2D
- `queue_free()` dans un callback physique
- Toute modification de l'arbre dans `_physics_process` si déclenchée indirectement par le moteur

---

## 5. Signaux synchrones — ordre d'émission = comportement

### Symptôme
Les handlers de signaux ne font pas ce qu'on attend — un flag n'est pas encore set, ou est déjà reset.

### Cause
Les signaux GDScript sont **synchrones**. `emit()` exécute tous les handlers immédiatement, dans l'ordre de connexion, **avant** de retourner.

```gdscript
print("before")
EventBus.loot_collected.emit("xp", 10)  # Tous les handlers ICI
print("after")  # APRÈS tous les handlers
```

Si deux signaux sont émis l'un après l'autre, et qu'un handler du premier set un flag vérifié par un handler du second — **l'ordre d'émission détermine le comportement**.

### Fix — suppress flag avec deferred reset
```gdscript
func _on_pile_collected(column: int, items: Array) -> void:
    _suppress_individual_fx = true
    _spawn_pickup_fx(dominant_cat, true)
    call_deferred("_reset_suppress_fx")  # Reset APRÈS le frame

func _on_loot_collected(category: String, _value: int) -> void:
    if _suppress_individual_fx:
        return
    _spawn_pickup_fx(category, false)

func _reset_suppress_fx() -> void:
    _suppress_individual_fx = false
```

---

## 6. Tween sur un node freed — silent fail ou crash

### Symptôme
Le tween ne s'exécute pas, ou crash avec `ObjectDB::instance not valid`.

### Cause
`create_tween()` est lié au node qui l'a créé. Si le node est `queue_free()` avant la fin du tween, le tween est invalidé.

### Fix
```gdscript
# Tween qui survit au node — lié à l'arbre, pas au node
func _die() -> void:
    var tw := get_tree().create_tween()
    tw.tween_property(self, "modulate:a", 0.0, 0.3)
    tw.tween_callback(queue_free)  # Free APRÈS le tween

# OU — kill le tween manuellement
func _exit_tree() -> void:
    if _active_tween and _active_tween.is_valid():
        _active_tween.kill()
```

### Piège subtil
Tween `scale → 1.3 → 1.0` sur un slot HUD, changement de scène pendant l'animation → crash silencieux. Pas bloquant mais les logs s'accumulent.

---

## 7. `queue_free()` n'est pas immédiat

### Symptôme
Un node est `queue_free()` mais il répond encore à un signal, une collision, ou apparaît dans `get_children()`.

### Cause
`queue_free()` marque le node pour suppression **à la fin du frame**. Pendant le frame en cours, le node existe et reçoit signaux/callbacks.

### Fix
```gdscript
# MAUVAIS — le node freed peut trigger ça
func _on_area_entered(area):
    take_damage(10)

# BON — vérifier la validité
func _on_area_entered(area):
    if not is_instance_valid(area) or area.is_queued_for_deletion():
        return
    take_damage(10)
```

---

## 8. Accès membre sur Variant — warning cascade

### Symptôme
```
W 0:00:01 SCRIPT WARNING: The member "current_column" is not declared in the current class
```
Le code fonctionne, mais les warnings polluent la console.

### Cause
Node typé `Node` ou `Node2D` au lieu de son vrai type → GDScript ne connaît pas les membres custom.

### Fix
```gdscript
# Option 1 : class_name disponible
var player: Player = get_node("Player") as Player

# Option 2 : sans class_name
if player.has_method("get_current_column"):
    var col: int = player.get_current_column()

# Option 3 : accepter le Variant (pragmatique pour prototypage)
var player = get_node("Player")  # Pas de type = pas de warning
```

---

## 9. `set_script()` avant `add_child()` = `@onready` OK

Le script attaché via `set_script()` AVANT `add_child()` fonctionne normalement : `@onready` s'initialise, `_ready()` est appelé après.

```gdscript
var popup = CanvasLayer.new()
popup.set_script(preload("res://ui/levelup_popup.gd"))
add_child(popup)  # @onready + _ready() fonctionnent
```

Utile pour les variants de projectiles qui partagent la même structure visuelle (même .tscn, script différent).

---

## 10. StyleBoxFlat — référence partagée silencieuse

### Symptôme
Modifier le style d'une carte/bouton modifie **toutes** les cartes/boutons en même temps.

### Cause
`get_theme_stylebox("panel")` retourne une **référence** à l'instance partagée, pas une copie.

```gdscript
# MAUVAIS — modifie le style de TOUS les PanelContainer
var style: StyleBoxFlat = card.get_theme_stylebox("panel")
style.border_color = Color.GOLD  # Toutes les cartes deviennent gold

# BON — copie avant modification
var style: StyleBoxFlat = card.get_theme_stylebox("panel").duplicate()
style.border_color = Color.GOLD
card.add_theme_stylebox_override("panel", style)
```

### Règle
**Toujours `.duplicate()` avant de modifier un StyleBox récupéré par `get_theme_stylebox()`.** Même si tu n'as qu'un seul node — le style est partagé avec le Theme global.

---

## 11. Tween pendant pause — deux conditions requises

### Symptôme
Le popup pause le jeu, mais l'animation d'entrée ne joue pas.

### Cause
Un tween pendant `get_tree().paused = true` exige **les deux conditions** :
1. Le **node créateur** a `process_mode = PROCESS_MODE_ALWAYS`
2. Le **tween** a `set_pause_mode(Tween.TWEEN_PAUSE_PROCESS)`

Si l'un des deux manque, le tween est gelé.

```gdscript
# MAUVAIS — manque le pause mode sur le tween
var tw := create_tween()
tw.tween_property(_panel, "scale", Vector2.ONE, 0.2)

# BON
var tw := create_tween()
tw.set_pause_mode(Tween.TWEEN_PAUSE_PROCESS)
tw.tween_property(_panel, "scale", Vector2.ONE, 0.2)
```

### Piège subtil
`process_mode = ALWAYS` sur un CanvasLayer propage aux enfants. Mais si un enfant a un `process_mode` explicite différent → le tween suit le mode de son créateur, pas celui du parent.

---

## 12. Time scale compensation — bullet time player

### Symptôme
`Engine.time_scale = 0.2` ralentit le joueur alors qu'il devrait bouger à vitesse normale.

### Cause
Godot passe `delta * Engine.time_scale` dans `_physics_process`. Le player reçoit un delta réduit.

### Fix
```gdscript
func _physics_process(delta: float) -> void:
    var real_delta := delta / Engine.time_scale if Engine.time_scale > 0.0 else delta
    # Utiliser real_delta pour TOUT le mouvement et le juice du player
    position.x += _speed_x * real_delta
```

### Ce qu'il NE faut PAS compenser
- Ennemis, projectiles, loot : ils DOIVENT être ralentis (c'est le but)
- Tweens (`create_tween()`) : respectent déjà le time_scale
- Timer nodes : respectent déjà le time_scale

### Guard clause
`Engine.time_scale > 0.0` — si time_scale est 0 (freeze total), division par zéro. Utiliser delta tel quel.

---

## 13. `is_instance_valid()` dans les callbacks signal

### Symptôme
Crash `ObjectDB::instance not valid` quand un signal arrive après `queue_free()` d'un node référencé.

### Cause
Les signaux s'exécutent synchroniquement. Un callback peut référencer un node optionnel (overlay, vignette, FX) qui a été freed entre-temps.

```gdscript
# MAUVAIS — crash si _bt_vignette est freed
func _on_bullet_time_started(_ts: float) -> void:
    var tw := create_tween()
    tw.tween_property(_bt_vignette, "color:a", 0.35, 0.15)

# BON
func _on_bullet_time_started(_ts: float) -> void:
    if not is_instance_valid(_bt_vignette):
        return
    var tw := create_tween()
    tw.tween_property(_bt_vignette, "color:a", 0.35, 0.15)
```

### Règle
**Tout callback de signal qui manipule un node optionnel → `is_instance_valid()` en premier.** Les nodes UI, FX, overlays sont particulièrement à risque (scene change, queue_free asynchrone).

---

## 14. Données snapshot vs référence — `duplicate(true)` pour l'UI

### Symptôme
Le popup modifie accidentellement les données du système logique (WeaponManager, Inventory, etc.).

### Cause
Passer un `Array[Dictionary]` à l'UI donne une **référence**. `duplicate()` (shallow) copie l'array mais les dicts internes restent partagés.

```gdscript
# MAUVAIS — shallow copy, les dicts sont des refs
var snapshot: Array = weapons.duplicate()
popup.show(snapshot)
# popup modifie snapshot[0]["level"] → change weapons[0]["level"] aussi

# BON — deep copy
func get_weapons_snapshot() -> Array[Dictionary]:
    return weapons.duplicate(true)
```

### Règle
**Toute donnée passée du système logique à l'UI = `.duplicate(true)` (deep copy).** L'UI est read-only. Elle affiche et communique le résultat via signal, jamais de modification directe.

---

## 15. `ResourceLoader.exists()` avant `load()` dynamique

### Symptôme
```
Invalid resource path: res://entities/enemies/explosive_01_frames.tres
```
Crash en cascade : load échoue → variable null → méthode sur null.

### Cause
`load()` avec un path construit dynamiquement (`"res://x/%s.tres" % type`) crashe si le fichier n'existe pas. `preload()` est vérifié au compile time, mais `load()` dynamique ne l'est pas.

### Fix
```gdscript
var frames_path := "res://entities/enemies/%s_frames.tres" % enemy_type
if not ResourceLoader.exists(frames_path):
    frames_path = "res://entities/enemies/default_frames.tres"
sprite.sprite_frames = load(frames_path)
```

### Règle
**Toujours `ResourceLoader.exists()` avant `load()` quand le path est variable.** Pour les paths statiques, préférer `preload()`.

---

## 16. `await` + objet freed — guard après chaque await

### Symptôme
```
Invalid access to property on a previously freed object.
```

### Cause
Après un `await`, n'importe quel node référencé peut avoir été `queue_free()` pendant l'attente. Pas seulement `self` — la source, la cible, le container.

### Fix
```gdscript
await get_tree().create_timer(0.08).timeout

if not is_instance_valid(self):
    return
if not is_instance_valid(from_enemy):
    queue_free()
    return
```

### Checklist post-await
1. `is_instance_valid(self)` — le node courant existe encore ?
2. `is_instance_valid(target)` — la cible existe encore ?
3. `is_instance_valid(container)` — le parent/container existe encore ?

**Règle** : après CHAQUE `await`, vérifier `is_instance_valid()` sur TOUS les objets référencés avant le await.

---

## 17. `add_child()` pendant `_ready()` du parent — call_deferred

### Symptôme
```
Parent node is busy setting up children, `add_child()` failed. Consider using `add_child.call_deferred(child)` instead.
```

### Cause
Quand `_ready()` d'un node s'exécute, le parent est encore en train de setup ses enfants définis dans le .tscn. `add_child()` direct échoue.

### Fix
```gdscript
func _ready() -> void:
    var lbl := Label.new()
    # MAUVAIS — si le VBox parent est en cours de setup
    vbox.add_child(lbl)

    # BON
    vbox.call_deferred("add_child", lbl)
```

### Attention
`call_deferred` = les enfants ne seront pas disponibles dans le même frame. Si tu dois accéder aux enfants immédiatement, utilise `add_child()` direct sur un node pas encore dans le tree (ex: children d'un row pas encore ajouté).

---

## 18. GPUParticles2D vs ParticleProcessMaterial — propriétés confondues

### Symptôme
```
Invalid assignment of property or key 'color_ramp' with value of type 'GradientTexture1D' on a base object of type 'GPUParticles2D'.
```

### Cause
Dans les fichiers `.tscn`, `color_ramp` apparaît comme propriété du node GPUParticles2D. Mais en GDScript, c'est une propriété de `ParticleProcessMaterial`, pas du node.

```gdscript
# MAUVAIS
particles.color_ramp = grad_tex

# BON
mat.color_ramp = grad_tex
particles.process_material = mat
```

### Table de confusion courante

| Propriété | Vit sur | PAS sur |
|-----------|---------|---------|
| `color_ramp` | `ParticleProcessMaterial` | `GPUParticles2D` |
| `color` | `ParticleProcessMaterial` | `GPUParticles2D` |
| `scale_min/max` | `ParticleProcessMaterial` | `GPUParticles2D` |
| `amount` | `GPUParticles2D` | `ParticleProcessMaterial` |
| `lifetime` | `GPUParticles2D` | `ParticleProcessMaterial` |
| `one_shot` | `GPUParticles2D` | `ParticleProcessMaterial` |
| `explosiveness` | `GPUParticles2D` | `ParticleProcessMaterial` |

---

## 19. `Array[T]` vs `Array` — assignation incompatible

### Symptôme
```
Trying to assign an array of type "Array" to a variable of type "Array[Node]"
```

### Cause
Beaucoup de méthodes Godot retournent `Array` non-typé, pas `Array[T]`. GDScript refuse l'assignation implicite.

```gdscript
# MAUVAIS
var enemies: Array[Node] = get_tree().get_nodes_in_group("enemies")

# BON — type non-contraint
var enemies: Array = get_tree().get_nodes_in_group("enemies")

# BON — conversion explicite
var enemies: Array[Node] = []
enemies.assign(get_tree().get_nodes_in_group("enemies"))
```

### Méthodes retournant Array non-typé (piège)

| Méthode | Retourne | PAS |
|---------|----------|-----|
| `get_nodes_in_group()` | `Array` | `Array[Node]` |
| `get_connections()` | `Array` | `Array[Dictionary]` |
| `get_groups()` | `Array` | `Array[StringName]` |
| `get_children()` | `Array[Node]` | — (celui-là EST typé) |

---

## 20. Performance — anti-patterns courants en GDScript

### `queue_redraw()` chaque frame (HIGH)
```gdscript
# MAUVAIS — 60 draw calls/sec par node avec _draw()
func _process(delta: float) -> void:
    _time += delta
    queue_redraw()

# BON — throttle ~20fps, visuellement identique
func _process(delta: float) -> void:
    _time += delta
    if Engine.get_process_frames() % 3 == 0:
        queue_redraw()
```
Alternative : remplacer `_draw()` par un Sprite + tween = zéro draw calls.

### `create_timer()` pour auto-destruction (HIGH)
```gdscript
# MAUVAIS — alloue un Timer par node (dizaines en même temps)
get_tree().create_timer(LIFETIME).timeout.connect(queue_free)

# BON — utiliser le compteur déjà dans _process
func _process(delta: float) -> void:
    _time += delta
    if _time >= LIFETIME:
        queue_free()
        return
```

### `get_node()` non-caché dans les hot paths (MEDIUM)
```gdscript
# MAUVAIS — lookup à chaque tir (3x/sec × 4 armes = 12 lookups/sec)
func _shoot() -> void:
    var sprite: AnimatedSprite2D = _player.get_node_or_null("Sprite")

# BON — caché dans _ready()
var _player_sprite: AnimatedSprite2D

func _ready() -> void:
    _player_sprite = _player.get_node_or_null("Sprite") as AnimatedSprite2D
```

### Checklist perf rapide

| Anti-pattern | Sévérité | Fix |
|-------------|----------|-----|
| `queue_redraw()` dans `_process()` | HIGH | Throttle `% 3` ou Sprite+tween |
| `create_timer()` pour self-destruct | HIGH | Compteur `_time` dans _process |
| `get_node()` dans callbacks fréquents | MEDIUM | Cacher en var dans `_ready()` |
| `get_children()` dans _process | MEDIUM | Cacher, invalider sur child changed |
| Tween recréé chaque frame | MEDIUM-HIGH | Garder ref, kill+recréer si état change |
| `load()` non-preload dans hot path | HIGH | `preload()` ou cacher le résultat |

---

## 21. Redéfinition de membre natif — `@export var offset` dans Camera2D

### Symptôme
```
Parser Error: Member "offset" redefined (original in native class 'Camera2D')
```

### Cause
GDScript interdit de déclarer une variable qui porte le même nom qu'une propriété native de la classe parente. Pas un warning — un Parser Error bloquant.

### Fix
```gdscript
# MAUVAIS
extends Camera2D
@export var offset: Vector2 = Vector2.ZERO

# BON
extends Camera2D
@export var follow_offset: Vector2 = Vector2.ZERO
```

### Membres natifs fréquemment piégeux

| Classe | Membres à ne pas redéfinir |
|--------|---------------------------|
| Camera2D | `offset`, `zoom`, `position`, `enabled`, `limit_left/right/top/bottom` |
| Node2D | `position`, `rotation`, `scale`, `visible`, `modulate` |
| Control | `size`, `position`, `offset`, `visible`, `modulate` |
| CharacterBody2D | `velocity`, `position`, `motion_mode` |
| Sprite2D | `texture`, `offset`, `flip_h`, `flip_v` |

### Règle
**Avant de nommer un `@export var` dans un script qui extends une classe Godot, vérifier que le nom n'entre pas en conflit avec une propriété native.** En cas de doute, préfixer : `follow_offset`, `shake_offset`, `camera_zoom`.

---

## 22. `set_deferred` sur données vs `call_deferred` sur add_child

### Symptôme
Enemy spawn sans archetype data. `_ready()` lit `enemy.data` = null.

### Cause
`set_deferred("data", enemy_data)` retarde l'assignation au frame suivant. Par le temps `_ready()` fire (juste après `add_child`), la propriété est encore null.

### Fix
```gdscript
# MAUVAIS — set_deferred retarde la donnée
enemy.set_deferred("data", _data_boss)
container.call_deferred("add_child", enemy)
# _ready() fire → enemy.data == null

# BON — assignation synchrone AVANT le deferred add_child
enemy.data = _data_boss  # Immédiat, safe (pas dans le tree)
container.call_deferred("add_child", enemy)
# _ready() fire → enemy.data contient les bonnes données
```

### Règle
**Propriétés nécessaires dans `_ready()` → assigner directement AVANT `add_child()`.** Seul le `add_child` lui-même a besoin du deferred (pour éviter le physics flush crash). Les variables internes, metadata, et propriétés custom sont safe en synchrone tant que le node n'est pas encore dans l'arbre.

---

## 23. Viewport origin (0,0) = top-left, pas le centre

### Symptôme
Le player spawne dans le coin haut-gauche au lieu du centre de l'écran.

### Cause
En Godot 2D, `(0, 0)` = coin haut-gauche, axe Y vers le bas. C'est l'inverse de la convention mathématique. Quand on écrit `position = Vector2.ZERO`, on place au coin, pas au centre.

### Fix
```gdscript
# Centrer un node dans le viewport
position = get_viewport_rect().size / 2.0

# Via MCP : utiliser les valeurs viewport (ex: 1920x1080)
# godot_update_property(path="Player", property="position", value=[960, 540])
```

### Table de placement

| Élément | Position recommandée | Pourquoi |
|---------|---------------------|----------|
| Player spawn | `viewport_size / 2` (centre) | Le joueur s'attend à apparaître au milieu |
| HUD | Dans un `CanvasLayer` | Indépendant de la caméra |
| Background | `(0, 0)` + taille = viewport | Couvre tout l'écran depuis le coin |
| Enemies spawn | Bords du viewport ou hors-écran | Apparition naturelle |

### Règle
**Quand tu places un node à `(0, 0)`, demande-toi : est-ce que le coin haut-gauche est vraiment la position voulue ?** Pour les entités gameplay (Player, Enemy), le centre du viewport est presque toujours le bon choix initial.

---

## 24. Collision layers — layer ≠ mask, bitmask ≠ numéro

### Symptôme
`collision_mask = 4` mais rien ne collide — 4 en bitmask = layer 3 (bit 3), pas layer 4.

### Cause
Godot utilise des **bitmasks** pour les collision layers. Layer 1 = bit 1 (valeur 1), Layer 2 = bit 2 (valeur 2), Layer 3 = bit 3 (valeur 4), Layer 4 = bit 4 (valeur 8). Écrire `collision_mask = 4` active le layer 3, pas le layer 4.

De plus, `collision_layer` ("je suis sur") ≠ `collision_mask` ("je détecte") :
- Un ennemi sur layer 2 avec mask 1 = "je suis un ennemi, je détecte le player"
- Le player sur layer 1 avec mask 2 = "je suis le player, je détecte les ennemis"

### Convention recommandée

| Layer | Usage |
|-------|-------|
| 1 | Player |
| 2 | Enemies |
| 3 | World (murs, sol) |
| 4 | Projectiles |
| 5 | Pickups |
| 6 | Triggers (zones) |

### Fix
```gdscript
# MAUVAIS — bitmask brut, source d'erreur
collision_layer = 2
collision_mask = 5  # = layers 1+3 (bits 1+3), PAS layers 2+5

# BON — setters explicites par numéro de layer
set_collision_layer_value(2, true)   # Je suis un ennemi
set_collision_mask_value(1, true)    # Je détecte le player
set_collision_mask_value(3, true)    # Je détecte le monde
```

### Règle
**Toujours utiliser `set_collision_layer_value(N, true)` / `set_collision_mask_value(N, true)` au lieu de valeurs bitmask brutes.** La conversion mentale bitmask → numéro de layer est une source d'erreur garantie.

### Pattern courant — ground layer oubliée dans mask
```
player=layer 1, enemies=layer 2, ground=layer 3.
collision_mask = 3 (bitmask layers 1+2) → entités traversent le sol.
Fix : set_collision_mask_value(3, true) pour ajouter le ground.
```
Symptôme typique : ennemis spawnés tombent dans le vide. Le mask inclut player+enemies mais oublie le ground layer. Toujours vérifier que **chaque CharacterBody3D/2D a le ground dans son mask**.

---

## 25. Transform inheritance — position locale vs globale

### Symptôme
Un node enfant apparaît à la mauvaise position après avoir été ajouté à un parent déplacé.

### Cause
`position` est **locale au parent**. Si le parent est à `(100, 200)`, un enfant à `position = (50, 0)` apparaît à l'écran en `(150, 200)`. `global_position` donne la position écran réelle.

Le `scale` du parent affecte aussi les enfants : un parent avec `scale = (2, 2)` double les positions et tailles de tous ses enfants.

### Fix
```gdscript
# Position locale (relative au parent)
child.position = Vector2(50, 0)  # 50px à droite du parent

# Position écran (absolue)
child.global_position = Vector2(960, 540)  # Centre de l'écran, peu importe le parent

# Attention au scale hérité
# Parent scale (2,2) + enfant position (10, 10) = écran (20, 20) relatif au parent
```

### Quand utiliser quoi

| Situation | Utiliser | Pourquoi |
|-----------|----------|----------|
| Placer un node dans le monde | `global_position` | Position écran exacte |
| Positionner un enfant relatif à son parent | `position` | Suit les mouvements du parent |
| Spawn d'entité après `add_child()` | `global_position` (après add_child) | Position absolue fiable |
| Offset visuel (sprite, FX) | `position` | Reste attaché au parent |

### Règle
**`position` = relatif au parent, `global_position` = coordonnée écran.** En cas de doute, utiliser `global_position` pour les entités gameplay et `position` pour les offsets visuels.

---

## Bilan — règles à appliquer systématiquement

### Grep checks avant chaque session
```
[ ] var \w+ :=     — vérifier chaque source non-triviale
[ ] global_position =  — vérifier que c'est après add_child
[ ] add_child dans callbacks Area2D/Body2D — utiliser call_deferred
[ ] Ordre d'émission des signaux quand un flag est impliqué
[ ] get_theme_stylebox sans .duplicate() — référence partagée
[ ] create_tween() dans un popup pausé — TWEEN_PAUSE_PROCESS ?
[ ] Engine.time_scale dans _physics_process — compensé pour le player ?
[ ] Callbacks signal sur nodes optionnels — is_instance_valid() ?
[ ] Données passées à l'UI — duplicate(true) ?
[ ] Node gameplay placé à (0,0) — c'est le coin, pas le centre
[ ] collision_mask = N — c'est un bitmask, pas un numéro de layer
[ ] position vs global_position — local au parent ou coordonnée écran ?
```

### Table récap

| Situation | Faire | Ne pas faire |
|-----------|-------|-------------|
| Assigner depuis dict/duplicate/instantiate | `: Type =` | `:=` |
| Comparer avec `==` et stocker | `: bool =` | `:=` |
| Ternaire | `: Type =` | `:=` |
| `global_position` sur un node frais | Après `add_child()` ou dans `_ready()` | Avant `add_child()` |
| `add_child` dans callback physique | `call_deferred("add_child", node)` | `add_child(node)` |
| Tween sur un node qui va mourir | `get_tree().create_tween()` | `create_tween()` |
| Utiliser un node après `queue_free()` | `is_instance_valid()` check | Accès direct |
| Signaux synchrones + flag | `call_deferred` pour reset | Reset immédiat |
| StyleBox depuis theme | `.duplicate()` avant modification | Modifier la référence directe |
| Tween pendant pause | `TWEEN_PAUSE_PROCESS` + `PROCESS_MODE_ALWAYS` | `create_tween()` seul |
| Player speed pendant bullet time | `delta / Engine.time_scale` | `delta` brut |
| Node optionnel dans callback signal | `is_instance_valid()` check | Accès direct |
| Données passées à l'UI | `.duplicate(true)` (deep copy) | Référence directe |
| `load()` avec path variable | `ResourceLoader.exists()` avant | `load()` direct |
| Code après `await` | `is_instance_valid()` sur TOUS les refs | Accès direct |
| `add_child()` dans `_ready()` parent | `call_deferred("add_child", node)` | `add_child(node)` |
| Propriété particules (color_ramp, scale) | Sur `ParticleProcessMaterial` | Sur `GPUParticles2D` |
| `Array[T] = untyped_method()` | `Array` brut ou `.assign()` | Assignation directe |
| `:=` via `Node` générique | `: Type =` explicite | `:=` (Variant dynamique) |
| `queue_redraw()` dans `_process` | Throttle `% 3` ou Sprite | Chaque frame |
| `get_node()` fréquent | Cacher en var `_ready()` | Lookup répété |
| `@export var offset` dans Camera2D | Préfixer : `follow_offset` | Même nom que propriété native |
| `set_deferred("data", val)` + `call_deferred("add_child")` | Assigner data directement, seul add_child en deferred | `set_deferred` pour les données |
| Node gameplay placé à `(0,0)` | `get_viewport_rect().size / 2.0` pour le centre | Position par défaut = coin |
| `collision_mask = 4` pour layer 4 | `set_collision_mask_value(4, true)` | Valeur bitmask brute |
| Entités traversent le sol | Ajouter ground layer au mask (`set_collision_mask_value(3, true)`) | Oublier le ground dans le mask |
| Enfant mal positionné après add_child | `global_position` pour position écran | `position` sans vérifier le parent |
| MeshInstance3D en T-pose (skeleton) | Vérifier `skeleton` path non-vide (`".."`) | Debug visuel seul |
| MeshInstance3D en T-pose (anim) | Vérifier `AnimationPlayer.is_playing()` — rest pose Synty = T-pose | Assumer skeleton seul suffit |
| `update_property` + `save_scene` sur node FBX/GLB | `grep property .tscn` puis `_ready()` patching | Se fier à `inspect_node` (faux positif) |
| Animation tracks avec `%` prefix | Remap `%NodeName:Bone` → `NodeName:Bone` | Utiliser les paths tels quels |
| Animation API : lecture keyframes | `track_get_key_value()` (Variant générique) | `rotation_track_get_value()` (n'existe pas) |
| FBX retargeting runtime | Pipeline d'import avec BoneMap | `load()` + lecture quaternions (rest poses) |
| `load()` après reimport massif | Redémarrer Godot ou load lazy | `load()` immédiat dans `_ready()` |
| Camera3D en sous-scène instanciée dynamiquement | `cam.current = true` dans `_ready()` | Compter sur le current automatique |
| Suppression node en contexte éditeur | `.free()` (immédiat) avant `save_scene` | `queue_free()` (fin de frame, risque save avant suppression) |
| CharacterBody3D sur escaliers/débris FBX | `body_test_motion()` custom (algo Quake) | `step_height` natif seul (insuffisant) |
| CapsuleShape3D accroche sur arêtes | `margin <= 0.01` | Défaut `0.04` (accrochages fréquents) |
| CharacterBody3D prefab enfant d'un autre CharacterBody3D | Disable collision child (`layer=0`, `mask=0`, shapes disabled) | Laisser les deux bodies actifs (double collision) |

---

## 26. MeshInstance3D.skeleton path vide — T-pose silencieuse (3D)

### Symptôme
Le personnage reste en T-pose. Les animations jouent (AnimationPlayer actif), le Skin a 49+ binds, tout semble correct. Aucune erreur, aucun warning.

### Cause
Les FBX importés (notamment packs Synty) peuvent avoir `skeleton = ""` sur les MeshInstance3D enfants du Skeleton3D. Sans ce path, le mesh ignore complètement le squelette — il ne réagit à aucune transformation de bone, même manuelles.

**Attention — T-pose ≠ toujours skeleton path vide.** Un mesh skinné avec `skeleton = NodePath("..")` correct sera quand même en T-pose si aucune animation ne joue. Les rest poses des rigs Synty = T-pose. Toujours vérifier DEUX choses : (1) skeleton path non-vide, (2) AnimationPlayer joue effectivement une animation.

### Fix
```gdscript
# Vérifier
print(mesh_instance.skeleton)  # "" = problème
print(anim_player.is_playing())  # false = pas d'animation active

# Corriger skeleton path — le MeshInstance3D est enfant direct du Skeleton3D
mesh_instance.skeleton = NodePath("..")

# Corriger animation — charger et jouer
anim_player.play("idle")

# Via MCP
# godot_update_property(path="Character/Skeleton3D/MeshInstance3D", property="skeleton", value="..")
# godot_save_scene()
```

### Diagnostic rapide T-pose
0. **AnimationPlayer actif ?** → `is_playing() == false` ? → charger et jouer une anim (`play("idle")`)
1. **Rotation manuelle d'un bone** → toujours T-pose ? → skeleton path vide
2. **Skeleton path OK** → AnimationPlayer `root_node` correct ?
3. **root_node OK** → track paths résolus ? (pas de `%` prefix)
4. **Tracks OK** → valeurs d'animation ≠ rest poses ?
5. **Valeurs == rest** → source FBX corrompue ou retargeting nécessaire

---

## 27. `%UniqueNode:` dans les track paths AnimationPlayer (3D)

### Symptôme
Animations packées dans le .tscn fonctionnent. Animations chargées dynamiquement via `load()` + ajoutées à l'AnimationLibrary → T-pose ou pas d'effet.

### Cause
L'import avec BoneMap génère des track paths comme `%GeneralSkeleton:Hips`. Le `%` (unique name) se résout correctement pour les animations embarquées dans la scène, mais **PAS** pour les animations ajoutées au runtime via code.

### Fix
```gdscript
# Remap les tracks paths avant d'ajouter l'animation
func _remap_tracks(anim: Animation, prefix: String = "%GeneralSkeleton:") -> void:
    var clean_prefix: String = prefix.substr(1)  # "GeneralSkeleton:"
    for i in range(anim.get_track_count()):
        var path: String = str(anim.track_get_path(i))
        if path.begins_with(prefix):
            var new_path: String = clean_prefix + path.substr(prefix.length())
            anim.track_set_path(i, NodePath(new_path))
```

### Règle
**Toujours remap les `%` prefix dans les track paths quand on charge des animations dynamiquement.** Utiliser `path.substr(prefix.length())` — jamais hardcoder la longueur du prefix (source de bugs off-by-one).

---

## 28. Animation API — asymétrie lecture/écriture

### Symptôme
```
Invalid call. Nonexistent function 'rotation_track_get_value' in base 'Animation'.
```

### Cause
L'API Animation de Godot 4 a une asymétrie :
- **Écriture typée** : `rotation_track_insert_key()`, `position_track_insert_key()`, `scale_track_insert_key()` existent
- **Lecture typée** : `rotation_track_get_value()`, `position_track_get_value()` **n'existent PAS**

Seul `track_get_key_value(track_idx, key_idx)` existe pour la lecture — il retourne un `Variant` qu'il faut caster selon le type de track.

### Fix
```gdscript
# MAUVAIS
var rot: Quaternion = anim.rotation_track_get_value(track, key)

# BON
var rot: Quaternion = anim.track_get_key_value(track_idx, key_idx)
# Le cast implicite fonctionne si le track est bien de type ROTATION_3D
```

### Table des types par track
| Track type | `track_get_key_value()` retourne | Insert typé |
|-----------|----------------------------------|-------------|
| TYPE_POSITION_3D | `Vector3` | `position_track_insert_key()` |
| TYPE_ROTATION_3D | `Quaternion` | `rotation_track_insert_key()` |
| TYPE_SCALE_3D | `Vector3` | `scale_track_insert_key()` |
| TYPE_VALUE | Variant | `track_insert_key()` |

---

## 29. FBX `load()` retourne les rest poses — pas les vraies animations

### Symptôme
On charge un FBX avec `load()`, on lit les keyframes des tracks de rotation/position → les valeurs sont **identiques** aux rest poses du squelette source. Le retargeting runtime produit un résultat statique.

### Cause
Le vrai traitement des données d'animation FBX se fait dans le **pipeline d'import** de Godot, pas au `load()`. Quand on `load()` un FBX directement, les données d'animation brutes ne sont pas correctement décompressées/transformées — on obtient les rest poses au lieu des vraies valeurs animées.

### Fix
Le retargeting doit se faire **à l'import**, pas au runtime :

1. Configurer le fichier `.import` avec un BoneMap dans `_subresources`
2. Déclencher le reimport via `EditorInterface.get_resource_filesystem().reimport_files([path])`
3. Charger l'animation résultante (elle est maintenant correctement retargetée)
4. Remap les `%` track paths si nécessaire (voir piège 27)

### Règle
**Jamais de retargeting runtime en GDScript via lecture directe de FBX.** Toujours passer par le pipeline d'import avec BoneMap + SkeletonProfileHumanoid.

---

## 30. Reimport massif + `load()` immédiat — crash silencieux

### Symptôme
Le jeu freeze au démarrage. Aucun log (`GolemCapture.game_log` silencieux), aucune erreur dans `godot_get_errors`, aucun screenshot possible. Le process tourne mais ne produit rien.

### Cause
Après un `reimport_files()` massif (100+ FBX), le cache `.godot/imported/` n'est pas nécessairement stable immédiatement. Un `load()` de ces mêmes fichiers dans `_ready()` peut lire un cache incohérent → freeze sans trace.

Ce n'est PAS un bug GDScript — les outils de debug MCP ne voient rien parce qu'il n'y a pas d'erreur GDScript à capturer.

### Fix
```gdscript
# Après un reimport massif, ne pas load() immédiatement
# Option 1 : redémarrer le projet Godot (cache reconstruit proprement)
# Option 2 : réduire le scope de chargement pour tester
# Option 3 : load() lazy (à la demande, pas tout dans _ready)
```

### Diagnostic "crash sans trace"
1. `godot_get_logs(source="engine")` — vérifier les logs moteur (pas juste les game logs)
2. Reimport récent ? → probable cache instable. Redémarrer Godot
3. Réduire le scope de chargement (2 anims au lieu de 5)
4. Tester incrémentalement : d'abord les anims, puis l'input, puis le reste

### Règle
**Après un reimport massif, toujours redémarrer le projet ou attendre un cycle complet avant de `load()` les ressources reimportées.** En cas de crash sans trace, penser cache/import avant de chercher un bug GDScript.

---

## 31. FBX/GLB imported scene — overrides non persistants via save_scene

### Symptôme
`godot_update_property` retourne OK → `godot_save_scene` retourne OK → `godot_inspect_node` confirme la nouvelle valeur → au **runtime**, c'est l'ancienne valeur. Des heures de debug sur "pourquoi le fix ne prend pas" alors que le diagnostic MCP dit que tout est bon.

### Cause
Godot refuse d'écrire les overrides de propriétés sur des nodes hérités d'une **scene importée** (FBX, GLB) dans le `.tscn`. L'éditeur garde la valeur en mémoire (d'où `inspect_node` qui confirme), mais `save_scene` ne la sérialise pas sur disque. Au prochain chargement ou au runtime, c'est la valeur originale de l'import qui est utilisée.

Ce piège est **distinct** de "Instance overrides dans parent = ignorées" (piège sur les scènes Godot instanciées). Ici c'est spécifique aux scènes importées depuis des formats 3D.

### Diagnostic
```bash
# Vérifier si l'override est réellement écrit sur disque
grep "skeleton" character.tscn
grep "root_node" character.tscn
# Si la propriété n'apparaît pas → elle ne sera pas au runtime
```

**3 niveaux de vérité** :
| Niveau | Outil | Fiabilité |
|--------|-------|-----------|
| Mémoire éditeur | `godot_inspect_node` | Peut mentir (faux positif) |
| Disque | `grep property file.tscn` | Vérité — ce que Godot charge |
| Runtime | `GolemCapture.game_log()` | Preuve finale |

### Fix — `_ready()` patching
```gdscript
# Setter les propriétés qui ne persistent pas dans _ready()
func _ready() -> void:
    # Skeleton path — FBX imports laissent souvent skeleton = ""
    $Skeleton3D/MeshInstance3D.skeleton = NodePath("..")

    # AnimationPlayer root_node — hérité de l'import, souvent "../.."
    $AnimationPlayer.root_node = NodePath("..")
```

### Propriétés typiquement non-persistantes sur imports FBX/GLB

| Propriété | Valeur import | Valeur voulue |
|-----------|--------------|---------------|
| `MeshInstance3D.skeleton` | `""` (vide) | `NodePath("..")` |
| `AnimationPlayer.root_node` | `"../.."` | `NodePath("..")` |

### Règle
**Après un `update_property` + `save_scene` sur un node provenant d'un FBX/GLB, toujours vérifier avec `grep` sur le .tscn, jamais se fier à `inspect_node`.** Si l'override n'est pas sur disque, patcher dans `_ready()`.

---

## 32. Camera3D.current pas automatiquement true à runtime

### Symptôme
La caméra ne suit pas le player. La vue reste bloquée à `(0, 0, 0)` ou depuis la position éditeur.

### Cause
Avec une seule Camera3D dans une scène, Godot est censé la rendre `current = true` automatiquement. Mais quand la scène contient des sous-scènes instanciées dynamiquement (via `instantiate()` au runtime ou via script éditeur), `current` n'est pas garanti `true`. Le mécanisme auto-current ne se déclenche pas toujours dans ces cas.

### Fix
```gdscript
# Toujours forcer current dans le script de la caméra
func _ready() -> void:
    current = true
```

### Règle
**Si la Camera3D est dans une scène instanciée dynamiquement (sous-scène, spawn runtime), toujours forcer `current = true` dans `_ready()`.** Ne pas compter sur le comportement automatique de Godot.

---

## 33. `queue_free()` vs `.free()` en contexte éditeur (@tool / execute_script)

### Symptôme
Un node supprimé via `queue_free()` est encore présent dans l'arbre quand `save_scene` est appelé immédiatement après. La scène sauvée contient le node "supprimé".

### Cause
`queue_free()` marque le node pour suppression **à la fin du frame**. En contexte éditeur (scripts `@tool` ou `execute_script`), si `save_scene` est appelé dans le même frame, il sérialise l'arbre avant la suppression effective.

Ce piège est une extension du piège 7 (`queue_free()` n'est pas immédiat) appliqué spécifiquement au contexte éditeur où `save_scene` est impliqué.

### Fix
```gdscript
# MAUVAIS — queue_free + save dans le même frame
var old_cam: Node = instance.get_node_or_null("Camera3D")
if old_cam:
    old_cam.queue_free()
get_editor_interface().save_scene()  # Camera encore dans le tree !

# BON — free immédiat en contexte éditeur
var old_cam: Node = instance.get_node_or_null("Camera3D")
if old_cam:
    old_cam.free()  # Suppression immédiate
get_editor_interface().save_scene()  # Camera absente, OK
```

### Règle
**En contexte éditeur (@tool, execute_script), utiliser `.free()` pour la suppression immédiate quand `save_scene` suit.** Réserver `queue_free()` au runtime (jeu en cours) où il est plus sûr.

---

## 34. CharacterBody3D.step_height natif — insuffisant pour géométrie importée

### Symptôme
Le player bloque en haut des escaliers, ne peut pas descendre, ou se coince sur de petites pierres/débris au sol. `step_height` natif mis à 0.3 ou 0.5 ne change rien.

### Cause
`CharacterBody3D.step_height` (propriété native Godot 4.x) est trop simpliste pour de la géométrie de donjon importée (FBX, Synty packs). Les marches d'escalier, les petits props décoratifs et les irrégularités du mesh collider dépassent ce que le mécanisme natif peut gérer. La solution communauté unanime est un stair stepping custom.

### Fix
Implémenter `body_test_motion()` custom (algorithme Quake) :

```gdscript
# Ordre critique dans _physics_process :
# 1. _stair_step_up()    — trace up, forward, down AVANT move_and_slide
# 2. move_and_slide()     — mouvement normal
# 3. _stair_step_down()  — snap au sol APRÈS move_and_slide (seulement si _was_grounded)

const MAX_STEP_UP := 0.5    # Standard pour escaliers donjon Synty
const MAX_STEP_DOWN := 0.5

func _stair_step_up() -> void:
    var motion := velocity * get_physics_process_delta_time()
    var params := PhysicsTestMotionParameters3D.new()
    params.from = global_transform
    # Trace up → forward → down
    # (implémentation complète : voir repos Andicraft/stairs-character
    #  ou JheKWall/Godot-Stair-Step-Demo)
```

### Valeurs testées
- `max_step_up = 0.5` / `max_step_down = 0.5` — standard communauté pour escaliers Synty
- `_was_grounded` (frame précédente) essentiel pour step down — sans ça, step down permanent même en chute libre
- Fonctionne avec Jolt Physics (recommandé) — le moteur physique natif Godot a des bugs connus avec `body_test_motion` sur petits gaps

### Règle
**Ne pas compter sur `CharacterBody3D.step_height` natif pour de la géométrie importée (FBX/GLB).** Implémenter un stair stepping custom via `PhysicsServer3D.body_test_motion()`.

---

## 35. CapsuleShape3D.margin — accrochage sur arêtes de marches

### Symptôme
Le player accroche sur les arêtes des marches d'escalier même avec un stair stepping fonctionnel. Le mouvement saccade ou bloque sur des détails de géométrie.

### Cause
`CapsuleShape3D.margin` a une valeur par défaut de `0.04`. Sur de la géométrie importée (FBX) avec des arêtes vives, cette marge cause des accrochages entre le collider capsule et les bords des mesh colliders.

### Fix
```gdscript
# Réduire la marge de collision
$CollisionShape3D.shape.margin = 0.01  # Défaut = 0.04, trop élevé

# Ou via MCP
# godot_update_property(path="Player/CollisionShape3D", property="shape:margin", value=0.01)
```

### Complémentaire
Élargir légèrement la capsule aide aussi indépendamment du margin :
- Capsule trop étroite (radius `0.22`) → coincée sur petits obstacles
- Capsule élargie (radius `0.32`) → glisse mieux sur la géométrie irrégulière

### Règle
**Pour tout CharacterBody3D sur géométrie importée FBX/GLB, réduire `CapsuleShape3D.margin` à `<= 0.01`.** Le défaut `0.04` cause des accrochages sur les arêtes.

---

## 36. Nested CharacterBody3D — double collision depuis prefab instancié (3D)

### Symptôme
Collision erratique, entité poussée de manière instable, physique imprévisible au spawn. Les ennemis se repoussent entre eux ou glitchent au contact du sol.

### Cause
Instancier un CharacterBody3D (prefab visuel Synty) comme **enfant** d'un autre CharacterBody3D (notre logique de gameplay) crée une double collision body. Le moteur physique voit deux bodies imbriqués avec chacun ses CollisionShapes → interactions chaotiques.

Pattern typique :
```
EnemyBody (CharacterBody3D) ← notre logique
  └── SyntyPrefab (CharacterBody3D) ← le FBX importé
        └── CollisionShape3D ← double collision !
```

### Fix
```gdscript
# Désactiver la collision du child CharacterBody3D
var prefab: CharacterBody3D = synty_scene.instantiate()
prefab.collision_layer = 0
prefab.collision_mask = 0

# Désactiver aussi les CollisionShape3D enfants du prefab
for child in prefab.get_children():
    if child is CollisionShape3D:
        child.disabled = true
```

Seul le CharacterBody3D parent (notre logique) doit gérer la collision. Le prefab enfant est purement visuel (mesh + skeleton + animations).

### Règle
**Quand un CharacterBody3D importé (FBX/GLB) est instancié comme enfant d'un autre CharacterBody3D, désactiver toute sa collision (`layer=0`, `mask=0`) et disable ses CollisionShapes.** Le parent gère la physique, le child gère le visuel.

---

## 37. Async propagation oubliée — `await` manquant dans la chaîne d'appels

### Symptôme
Une fonction retourne immédiatement alors qu'elle devrait attendre la fin d'un `await` interne. Le code après le `await` interne s'exécute en arrière-plan, les appelants continuent sans attendre.

### Cause
Quand on ajoute un `await` dans une fonction existante qui était sync, elle devient **implicitement async** (retourne un `Signal`/coroutine). Mais l'appelant fait toujours un appel normal sans `await` — il ignore la coroutine retournée.

Pattern typique lors d'édits incrémentaux :
```gdscript
# Avant — tout sync
func _end_of_turn() -> void:
    _apply_effects()
    _check_win()

# Après — on ajoute un await dans _apply_effects
func _apply_effects() -> void:
    for effect in effects:
        await effect.animate()  # Nouveau !
    _cleanup()

# BUG : _end_of_turn() appelle _apply_effects() sans await
# → _check_win() s'exécute AVANT la fin des animations
```

### Fix
```gdscript
# Remonter await dans TOUTE la chaîne d'appels
func _end_of_turn() -> void:
    await _apply_effects()  # Ajouté !
    _check_win()
```

**Diagnostic** : `grep "func_name("` pour trouver tous les appelants et vérifier qu'ils `await` aussi.

### Règle
**Quand on ajoute un `await` dans une fonction, remonter `await` dans toute la chaîne d'appels.** Chaque appelant doit aussi `await`, et son appelant, etc. Grep le nom de la fonction pour trouver tous les sites d'appel.
