# Souffles et munitions

Schéma **73**. Deux chantiers liés : donner un vrai corps aux projectiles, et
introduire l'explosion comme comportement d'attaque.

## Ce qui n'allait pas

Trois constats, dont deux contredisaient ce qu'on croyait.

**Les patterns avancés supportaient déjà les projectiles.** Le moteur lit
`delivery` et `projectile` par bloc de frise, la validation PHP les accepte par
bloc, et l'éditeur expose le champ sur chaque coup. Le défaut était ailleurs :
`attackPatternEditor` écrivait la section « LIVRAISON DE L'IMPACT » dans la
branche **non avancée**. En mode avancé elle disparaissait, sans rien dire —
donc un auteur qui passait en avancé croyait la fonction perdue. La section
reste désormais, et résume ce que chaque coup de la frise livre.

**Une munition n'avait pas de corps réglable.** `_model_config()` et
`_visual_key()` n'avaient aucune branche pour elle : c'était la seule entité du
jeu qui ne pouvait être qu'une primitive teintée, dimensionnée sur son **rayon
de collision**. Or `egg_launcher_shot` — la seule munition du jeu, tirée aussi
bien par le Lance-œuf du héros que par le raton — a `radius: 1`, `speed: 1000`,
`lifetime: 0.5`. Soit un volume de **deux unités** à côté d'un héros qui en fait
soixante, traversant 500 px en une demi-seconde. Elle partait, elle touchait, et
personne ne la voyait. « Ça ne marche pas » était en fait « ça ne se voit pas ».

**Un piège dormant.** Un pattern en mode simple garde sa frise stockée dans les
données ; le moteur l'ignore, puisqu'il recompile depuis les champs à plat.
`thrown_debris` traîne ainsi un bloc qui dit `delivery: "direct"` alors que le
pattern dit `projectile`. Inerte aujourd'hui — mais le jour où ce pattern passe
en avancé, il cesse de tirer sans qu'aucun écran ne le signale.

## Le corps d'une munition

Le corps **vu** est désormais séparé de ce que la munition **touche**.

| Champ | Rôle |
| --- | --- |
| `shape` | `auto` ou une primitive. `auto` garde la correspondance historique : disque → cylindre, trait et aiguille → prisme, orbe → sphère |
| `visual_width`, `visual_length`, `visual_height` | Dimensions affichées. `0` retombe sur l'ancien calcul (deux fois le rayon) |
| `model_3d` | Un GLB, exactement comme un monstre — sans table d'animations |
| `visuals` | Une feuille 2D, état `fly` |

Le rayon de collision n'a pas bougé : il reste la seule autorité sur ce qui est
touché. **Aucune valeur d'auteur n'a été modifiée** par la migration ; les
munitions existantes se voient exactement comme avant.

### Deux primitives de plus

Le jeu n'en proposait que cinq — boîte, cylindre, sphère, capsule, prisme. Deux
s'y ajoutent, disponibles pour **toute** entité et pas seulement les munitions :

- **cône** — un cylindre dont le sommet est un point : un dard, un trait ;
- **dôme** — une demi-sphère posée à plat.

Ce sont les seules que Godot offre qui respectent la convention du projet :
dimensionnées sur l'emprise et la hauteur, ancrées sur leur face basse. Un tore,
par exemple, ne se laisse pas décrire par `tall` et flotterait au-dessus du sol.
`tools/shapes-3d-manager/tests/mirror_test.php` compare les deux listes et
refuse qu'elles divergent.

**Le piège que cet ajout a déclenché.** `Bridge3D._varied_shape()` répartit les
entités *sans forme déclarée* entre les primitives, **modulo la taille de la
liste**. Brancher la variation sur `Shape3DFactory.KINDS` signifiait donc qu'en
passer de 5 à 7 redistribuait tout : mesuré à **47 créatures sur 61**, dont un
raton devenu obus. C'est l'auteur qui l'a vu, en remarquant une silhouette de
repli qu'il n'avait jamais réglée.

La variation vit désormais dans `Bridge3D.VARIED_SHAPE_KINDS`, une liste
**figée** de cinq primitives, séparée du vocabulaire du moteur. Une primitive
neuve reste disponible à qui la choisit dans l'outil Formes 3D ; elle n'entre
pas dans le tirage. Deux assertions du test miroir le garantissent, et tombent
si quelqu'un rebranche l'une sur l'autre.

La règle générale : **l'apparence de repli d'une entité ne doit changer que sur
décision d'auteur.** Un tirage indexé sur une liste extensible viole cela sans
rien dire.

Pas d'états d'animation pour une munition : rien dans le moteur ne piloterait un
état, et un réglage que personne ne lit est précisément le piège que
`audio_wiring_audit.py` existe pour attraper.

## Le souffle

Une explosion n'est pas une famille d'effet — rien dans le code ne parle de feu,
de bruit ni de boum. C'est une **forme** : une zone qui naît d'un point et
s'ouvre. Ce qu'elle produit en chemin reste des dégâts et des effets de combat
ordinaires. Un souffle qui soigne s'écrit donc avec un coefficient de dégâts nul,
un effet de soin, et `affects` réglé sur le camp du lanceur.

| Champ | Rôle |
| --- | --- |
| `trigger` | `none`, `on_hit` (détone là où le coup a porté), `immediate` (l'attaque **est** le souffle, sans chercher de corps) |
| `radius` | Distance couverte — le rayon final |
| `angle` | Angle couvert. 360 ouvre tout autour ; moins dirige le souffle comme un cône |
| `expand_speed` | Vitesse de propagation, px/s. `0` couvre tout dans la même image |
| `falloff` | Coefficient de dégradation : la part qui **subsiste au bord**. 1 frappe aussi fort partout, 0 ne laisse rien à l'extrémité |
| `tick_interval` | Cadence de rebrûlage. `0` = un seul impact par corps |
| `damage_scale` | Part des dégâts du coup que le souffle porte |
| `knockback` | Poussée depuis le centre |
| `affects` | `targets`, `allies`, `all` |
| `combat_effects` | Les siens, et non ceux du coup |
| `color` | Sa teinte |

La dégradation se lit sur la **distance au centre**, jamais sur l'angle. C'est un
choix explicite de l'auteur du jeu : une dégradation latérale rendrait les bords
d'un cône illisibles, et personne ne l'a demandée.

### Bouffée ou flamme

Deux comportements, séparés par le seul `tick_interval`.

À **0**, chaque corps n'est frappé qu'une fois, quand le front l'atteint : une
onde de choc qui balaie. C'est le comportement d'origine, et il reste le défaut.

À une **valeur positive**, l'onde rebrûle à cette cadence ce qui reste dedans :
une flamme où l'on brûle en restant. Combiné à un `angle` étroit et une
`expand_speed` lente, c'est un souffle de dragon — un cône qui s'ouvre, qui
brûle plus longtemps près de la gueule qu'à la pointe, et qui faiblit avec la
distance si `falloff` est inférieur à 1.

Une limite à connaître : **l'onde meurt une fois arrivée au bout**, donc sa durée
vaut `radius ÷ expand_speed`. Une cadence sur une propagation instantanée ne
donne qu'un seul impact. L'éditeur annonce la durée calculée et avertit dans ce
cas précis, pour que ça se voie avant de tester en jeu.

Le répit final de 0,18 s est purement **visuel** : l'onde s'efface, elle ne
brûle plus. Sans cette garde, un corps atteint au tout dernier instant de la
course encaissait deux fois — c'est le contrôle automatique qui l'a trouvé.

Pour une flamme qu'on peut tenir indéfiniment, sans propagation ni dégradation,
le bon outil reste un bloc de coup en mode **« Zone qui dure »** (`continuous`)
avec son propre intervalle d'impacts : les deux mécanismes sont complémentaires,
et ni l'un ni l'autre ne remplace l'autre.

### Où le réglage vit

Sur la **ligne qui s'en sert**, jamais sur la ressource partagée : sur le pattern
en mode simple, sur le bloc de coup en mode avancé. C'est la leçon du système
sonore. Deux créatures qui partagent `egg_launcher_shot` doivent pouvoir, l'une
faire détoner sa bille, l'autre non — si le souffle vivait sur la munition, elles
exploseraient forcément pareil.

### « À l'impact » remplace le coup

`on_hit` ne s'ajoute pas au coup, il le **remplace** : la munition (ou la zone)
ne frappe plus le corps qu'elle rencontre, elle y détone. Rien n'est perdu — le
corps touché se retrouve au centre du souffle, donc il encaisse la pleine
puissance de toute façon, et tout ce qui l'entoure est pris en plus. C'était déjà
le comportement du plat lancé ; il est maintenant le seul.

### Une seule explosion dans le jeu

`player_projectile.gd` portait un `explosion_radius` et un `_explode()` privés,
absents du schéma et que seul le plat lancé alimentait. Ce second code
d'explosion a disparu : le plat lancé passe par `CombatExplosion.detonate()`
comme tout le reste, avec un cercle complet instantané sans dégradation —
exactement ce que l'ancien code produisait.

## Vérification

```powershell
python tools/dev/dev.py check --area gameplay   # smoke test + scene du souffle
```

`scenes/main/explosion_check.tscn` éprouve le souffle seul : portée, dégradation
au bord, angle couvert, délai de propagation, un seul impact par corps sans
cadence et plusieurs avec, choix du camp, et l'application des effets à zéro
dégât. Il vit hors du smoke test parce
que celui-ci porte **51 échecs connus** : une garantie neuve y serait invisible.

Vérifié rouge une fois à sa création, en neutralisant la dégradation et le filtre
d'angle ; les deux assertions correspondantes sont tombées. Même rouge/vert pour
la cadence, en forçant `tick_interval` à zéro.

## Correspondance simple / avancé des sections

Le mode avancé remplace les champs à plat de la fiche par la frise. Trois
sections avaient donc simplement **disparu** au lieu de dire où leur réglage
vivait désormais — et trois champs restaient lus par le moteur sans être
éditables nulle part.

| Section | Simple | Avancé | Homologue |
| --- | --- | --- | --- |
| Cible et ancrage | oui | oui, sans « Origine de la zone » | l'ancrage se règle par bloc |
| Frise | — | oui | c'est l'homologue de « Zone » et « Mouvement » |
| Identité et mode | oui | oui | — |
| Déclenchement | dans « Zone et déroulement » | section propre | — |
| Zone et déroulement | oui | — | pistes *coups* et *avertissements* |
| Livraison de l'impact | oui | résumé, réglage par bloc | champ « Livraison » du bloc |
| Souffle | oui | résumé, réglage par bloc | section « Souffle » du bloc |
| Mouvement pendant l'attaque | oui | résumé, réglage par bloc | piste *déplacements* |
| Effets de combat · Utilisé par · Aperçu | oui | oui | — |

### Les champs lus sans être éditables

Un réglage lu par le moteur et invisible dans l'écran est pire qu'un réglage
absent : l'auteur ne peut ni le voir ni le corriger, et rien ne le signale.

- **`show_hit_warning`** et **`show_movement_warning`** sont lus sur la fiche
  dans les **deux** modes (`enemy.gd`, au moment d'appliquer la ligne d'attaque)
  et commandent tout le dessin des avertissements — y compris ceux de la piste
  *avertissements* d'une frise. Ils n'étaient éditables qu'en mode simple. Un
  pattern passé en avancé avec l'un des deux éteint ne montrait plus rien, sans
  explication. Ils vivent désormais aussi dans « Déclenchement ».
- **`free_movement`** est stocké et lu dans les deux modes, mais n'était
  éditable qu'en avancé. En simple il paraissait dériver du type de mouvement —
  or la valeur stockée l'emporte, donc changer le type ne le corrigeait pas.
  Case ajoutée au mode simple.

### Un défaut du moteur trouvé au passage

`Player._begin_attack_pattern()` consultait **deux** sources d'ancrage : la
frise via `starts_anchored()`, puis à nouveau le champ `anchor` à plat de la
fiche. Redondant en mode simple, faux en avancé — où ce champ n'est ni lu ni
montré —, et surtout cassé pour le cas « Automatique » d'un pattern collant :
la compilation le résout en `world` dans la frise pendant que la fiche dit
encore `auto`, si bien que l'origine dans le monde n'était jamais prise. La
frise est maintenant la seule autorité, conformément à l'invariant du projet.

## La visée qui suit sa cible

Schéma **74**. Quelqu'un qui lance sur une cible mouvante pivote pour compenser
jusqu'au lâcher. Le moteur, lui, figeait le cap à l'instant où l'attaque
s'engageait : pendant tout l'avertissement, le lanceur visait l'endroit où sa
cible **était**.

`attack_patterns.aim_turn_speed`, en degrés par seconde, corrige cela.

- **0** — le cap est pris au départ et ne bouge plus. C'est l'ancien
  comportement, et le défaut : aucune attaque existante ne change.
- **valeur haute** — le lanceur compense parfaitement, on ne le sème pas.
- **valeur basse** — il pivote trop lentement pour rattraper un joueur rapide.
  C'est là l'intérêt : un curseur de difficulté lisible plutôt qu'un
  interrupteur, et une créature pataude qu'on prend à contre-pied.

C'est le pendant **angulaire** de `tracking_speed`, qui déplace le centre d'une
zone en pixels par seconde. Même idée, même vocabulaire, deux grandeurs.

### Trois décisions

**Sur le pattern, pas par bloc.** L'avertissement peut s'étendre sur plusieurs
blocs qui se chevauchent : un réglage par bloc serait ambigu, et lu seulement
parfois — exactement le réglage inerte que ce projet traque.

**Dans « Cible et ancrage ».** C'est la seule section visible dans les deux
modes, donc la correspondance simple/avancé est résolue par construction. Et
c'est cohérent : la correction suit la cible que cette section désigne. Un
pattern visant « droit devant » ne se met pas à poursuivre.

**La correction ne s'applique jamais pendant un balayage.** Un bloc qui tourne
décide déjà du cap ; superposer les deux les ferait se battre sur le même angle.

### Ce que « Cible » fait vraiment

Un point que la documentation laissait deviner : côté créature, le moteur passe
la position **vivante** du héros à chaque image. « Visée du lanceur » suit donc
déjà la cible, sans condition — c'est le meilleur choix pour une attaque qui
doit simplement rester braquée sur le joueur. Les modes « adversaire le plus
proche » et parents suivent aussi, mais **gèlent** la visée si la cible sort du
rayon de recherche : « il vous perd, et lance là où il vous a vu ».

Avant ce chantier, ce réglage n'était consulté que par les zones ancrées dans le
monde ; sur une zone tenue par l'acteur, il ne servait à rien.

### Vérification

`scenes/main/attack_targeting_check.tscn` éprouve le héros **et** la créature
avec le même pattern : à zéro le cap reste figé, à 90 °/s il tourne de 9° en un
dixième de seconde — jamais plus —, la correction va bien vers la cible, et une
vitesse élevée la rattrape. Le plafond est le cœur du test : sans lui, toute
valeur positive vaudrait une visée parfaite et le réglage n'aurait aucun sens.
Vérifié rouge en retirant le plafond, le cap saute alors à 90°.

Cette scène **existait sans jamais tourner automatiquement** : elle est
maintenant branchée dans le runner avec la zone `gameplay`.

## Voir et frapper sont deux mécanismes

Schéma **76**. Le déclenchement d'une attaque réutilisait le cône de vision :

```gdscript
elif attack_allowed and detected and ...
```

où `detected` compare le **regard du moment** à `detection_angle`. Une créature
qui fuit regarde devant elle ; sa cible est à 180°, donc jamais « détectée »,
donc **jamais attaquée**. Tout l'archétype de la bête qui riposte en s'enfuyant
était inatteignable — une moufette ne pouvait pas arroser derrière elle.

Le moteur gardait pourtant déjà `awareness.last_known_position` et s'en servait
pour le déplacement. Il refusait seulement de s'en servir pour frapper.

`enemies.attack_needs_sight`, coché par défaut, sépare les deux. Décoché, une
créature **déjà enragée** frappe depuis la dernière position connue. La vision
décide toujours si elle vous remarque ; la portée de déclenchement décide quand
elle tire.

### La recette complète d'une moufette

Trois réglages qui n'ont de sens qu'ensemble :

1. `aggro_on_damage` — elle s'enrage quand on la frappe (et `aggro_on_detection`
   décoché si on la veut placide jusque-là) ;
2. **« Doit voir sa cible pour attaquer »** décoché — sinon elle ne tire jamais
   en fuyant ;
3. **« Tourne le dos à sa cible »** — pour que le jet parte de l'arrière-train
   et non du museau.

## La résistance au recul monte à 1

Trois plafonds contradictoires coexistaient sans qu'aucun ne dise pourquoi :
**0,95** dans le schéma PHP, **0,90** dans un `clampf` du moteur, **0,30** dans
une assertion du smoke test. La formule est `force × (1 − résistance)`, donc 1
rend exactement zéro : il n'y a rien à arrondir.

Le plafond vaut désormais **1,0**, écrit une seule fois. Le test qui imposait
0,30 encodait une opinion d'équilibrage d'une autre époque ; il vérifie
maintenant l'invariant réel — une résistance vit entre 0 et 1 — au lieu de juger
l'équilibrage de l'auteur.

## Un contrôle ne doit pas emprunter les données d'auteur

Le smoke test prenait `scent_cloud` comme cobaye « collant ». L'auteur l'a passé
en *burst* pour sa moufette, et **quatre assertions du moteur sont tombées** sans
qu'une ligne de moteur ait bougé. Le contrôle porte désormais sa propre zone
collante, comme le fait `attack_targeting_check` avec son pattern.

La règle : **un contrôle du moteur ne dépend jamais d'un choix d'équilibrage.**
S'il lui faut un sujet d'un type donné, il le fabrique.

## Les plafonds qui bridaient la logique

Trois cas identiques se sont présentés le même jour, avec la même signature :
**le moteur serrait plus fort que le schéma**, si bien que l'éditeur acceptait
une valeur que le jeu ignorait en silence.

| Champ | Avant | Après | Pourquoi |
| --- | --- | --- | --- |
| `knockback_resistance` | 0,95 schéma / **0,90 moteur** / 0,30 test | **1,0** partout | `force × (1 − résistance)` : 1 rend exactement zéro |
| `movement_multiplier` | 1,0 schéma **et** moteur | **10,0** | au-dessus de 1 l'effet accélère ; c'est le seul levier de vitesse d'une créature |
| `throw_radius` | 1000 | **4000** | alimente `CombatExplosion`, dont le rayon va à 4000 |

`movement_multiplier` est le plus instructif : le plafond vivait **des deux
côtés**, donc lever le schéma seul n'aurait rien changé et aurait donné
l'illusion d'un correctif.

### L'audit, et ce qu'il reste

Deux classes ont été passées en revue systématiquement :

- **moteur plus serré que le schéma** : deux cas, les deux ci-dessus. Les six
  autres `clampf` appliqués à un champ d'auteur collent exactement au schéma.
  La classe est propre ;
- **éditeur plus serré que le schéma** : seize écarts apparents, **tous faux
  positifs**. Le même nom de champ porte des bornes légitimement différentes
  selon la collection — `radius` vaut 1..128 pour une munition et 1..4000 pour
  un souffle, `angle` 0..359,9 est le cap boussole d'un nœud d'amélioration.

Pour refaire cet audit, comparer les `clampf(float(definition.get(...)))` du
moteur aux `'champ' => [min, max]` de `config.php`, en se rappelant qu'un même
nom peut appartenir à plusieurs collections.

Restent des plafonds dont seul l'auteur peut dire s'ils brident : `min_size` /
`max_size` à 5, les hurtboxes à 256, `model_3d.scale` à 10,
`held_attack_speed_ratio` à 1 (au-dessus, maintenir serait plus rapide que
marteler), `count` et `piercing` à 64, `damage_scale` à 20.

## Ce qui reste

1. **Aucun souffle n'est configuré** dans les données. La migration n'en invente
   pas : les nouveaux champs arrivent à leur valeur neutre. C'est un travail
   d'auteur.
2. **Le corps des munitions n'a pas été retouché** non plus. `egg_launcher_shot`
   reste à `radius: 1` : pour la voir, lui donner une `visual_width` et une
   `visual_height`, ou un modèle.
3. **Le héros ne lit pas la livraison par bloc.** `Player._apply_attack_zone()`
   ignore `delivery`, contrairement à `Enemy` : une arme du héros en mode avancé
   suit la livraison du *pattern* pour tous ses coups. Antérieur à ce chantier,
   signalé ici pour ne pas le reperdre.
4. **`thrown_debris` garde sa frise périmée** (le piège dormant ci-dessus). La
   nettoyer est une décision d'auteur, pas une correction.
