# Typewriter & Text FX — Dialogue vivant pour RPG narratif

> **Purpose**: Techniques de typewriter, effets texte par caractere, blips vocaux, et pacing emotionnel. Patterns issus d'Undertale, Celeste, Persona 5, Disco Elysium, Animal Crossing. Valeurs testees, combinaisons non-evidentes, pieges de timing.

---

## 1. Typewriter core — `visible_characters` + Timer

La base : un Timer incrementant `visible_characters` sur un RichTextLabel. Pas d'append de texte, pas de substring — juste le compteur.

```gdscript
# ui/dialogue/typewriter.gd
extends RichTextLabel

signal character_displayed(index: int, character: String)
signal line_finished

@export var base_speed: float = 0.03  # ~33 cps — standard Undertale
@export var punctuation_pause: float = 0.15  # pause comma, colon
@export var sentence_pause: float = 0.25  # pause period, !, ?
@export var ellipsis_pause: float = 0.35  # pause ...
@export var paragraph_pause: float = 0.5  # pause apres saut de ligne

var _timer: float = 0.0
var _delay: float = 0.0
var _speed_override: float = -1.0
var _is_typing: bool = false
var _stripped_text: String = ""

func display(bbcode_text: String) -> void:
    bbcode_enabled = true
    text = bbcode_text
    visible_characters = 0
    _stripped_text = get_parsed_text()  # Texte sans BBCode
    _is_typing = true
    _timer = 0.0
    _delay = 0.0

func _process(delta: float) -> void:
    if not _is_typing:
        return
    if _delay > 0.0:
        _delay -= delta
        return

    _timer += delta
    var current_speed := _speed_override if _speed_override > 0.0 else base_speed

    while _timer >= current_speed and _is_typing:
        _timer -= current_speed
        visible_characters += 1

        if visible_characters >= _stripped_text.length():
            _is_typing = false
            line_finished.emit()
            return

        var ch := _stripped_text[visible_characters - 1]
        character_displayed.emit(visible_characters - 1, ch)
        _delay = _get_punctuation_delay(ch)
        if _delay > 0.0:
            break

func _get_punctuation_delay(ch: String) -> float:
    match ch:
        ".":
            # Detecter ellipsis (3 points = pause longue, pas 3 pauses courtes)
            var idx := visible_characters - 1
            if idx >= 2 and _stripped_text.substr(idx - 2, 3) == "...":
                return ellipsis_pause
            return sentence_pause
        "!", "?":
            return sentence_pause
        ",", ":", ";":
            return punctuation_pause
        "\n":
            return paragraph_pause
        _:
            return 0.0

func skip() -> void:
    visible_characters = -1  # -1 = show all
    _is_typing = false
    line_finished.emit()

func is_typing() -> bool:
    return _is_typing
```

### Valeurs de reference (jeux publies)

| Parametre | Undertale | Celeste | Visual novel standard | Recommande RPG |
|-----------|-----------|---------|----------------------|----------------|
| Base speed | ~0.033s (30 cps) | ~0.03s | 0.02-0.05s | **0.03s** |
| Comma pause | ~0.1s | N/A | 0.1-0.15s | **0.15s** |
| Period pause | ~0.25s | N/A | 0.2-0.3s | **0.25s** |
| Ellipsis | ~0.4s | special tag | 0.3-0.5s | **0.35s** |

**Pourquoi 0.03s ?** 30 cps est le sweet spot "conversation naturelle". Plus rapide (0.02s / 50 cps) = urgent, nerveux. Plus lent (0.05s / 20 cps) = dramatique, solennel. En dessous de 0.06s, ca semble cassé.

---

## 2. Vitesse emotionnelle — la speed EST le feeling

La vitesse du texte n'est pas un setting global — c'est un outil narratif. Chaque personnage, chaque emotion a un rythme different.

### Speed presets par emotion

```gdscript
enum TextMood {
    CALM,       # 0.03s — conversation, narration
    URGENT,     # 0.015s — panique, combat, danger
    DRAMATIC,   # 0.05s — revelation, mort, trahison
    WHISPER,    # 0.06s + fade effect — secrets, peur
    SHOUT,      # 0.01s + shake — colere, explosion
    THINKING,   # 0.04s — reflexion interne, monologue
    GLITCH,     # variable — corruption, bug, instabilite
}

const MOOD_SPEEDS: Dictionary = {
    TextMood.CALM: 0.03,
    TextMood.URGENT: 0.015,
    TextMood.DRAMATIC: 0.05,
    TextMood.WHISPER: 0.06,
    TextMood.SHOUT: 0.01,
    TextMood.THINKING: 0.04,
    TextMood.GLITCH: -1.0,  # Gere dynamiquement
}
```

### Inline speed tags (style Celeste/Dialogic)

Le texte contient ses propres instructions de pacing. Parser avant affichage.

```
"Tu es sur de vouloir faire ca ?[pause=0.8] ...Vraiment ?"
"Il faut [speed=0.015]COURIR[speed=0.03] maintenant !"
"Le tresor est[pause=0.3]... [speed=0.06]sous le troisieme arbre."
```

**Pattern Celeste** — le jeu utilise `{>> 0.5}` pour la vitesse (0.5 = moitie), `{~}` pour wavy, `{*}` pour shaky, et `{0.5}` pour une pause brute. Les jeux de la meme ere utilisent tous un systeme de tags inline.

**Piege : la pause ellipsis doit etre UNE pause, pas trois.** Trois points = trois pauses de period = 0.75s au lieu de 0.35s. Toujours detecter le pattern `...` en entier.

---

## 3. BBCode effects Godot — valeurs qui marchent

Les defaults Godot sont TROP intenses pour du dialogue. Voici les valeurs testees :

### Wave (texte ondulant — reverie, magie, eau)

```bbcode
[wave amp=20.0 freq=3.0]texte doux[/wave]
```

| Contexte | amp | freq | Feeling |
|----------|-----|------|---------|
| Reverie, memoire | 15-20 | 2-3 | Flottant, doux |
| Magie active | 30-40 | 4-5 | Energique, vivant |
| Ivresse/confusion | 25-35 | 1.5-2 | Lent, nauseeux |
| **Default Godot** | **50** | **5** | **Trop violent pour du dialogue** |

**Piege** : `amp=50` (le default) fait sauter les lettres en dehors de la textbox. Toujours override. Pour du dialogue, ne jamais depasser `amp=35`.

### Shake (texte tremblant — peur, colere, douleur)

```bbcode
[shake rate=15.0 level=3 connected=1]ca fait mal[/shake]
```

| Contexte | rate | level | Feeling |
|----------|------|-------|---------|
| Nerveux, leger tremblement | 10-15 | 2-3 | Subtil, anxiete |
| Colere, impact | 18-25 | 5-7 | Agressif, instable |
| Terreur, seisme | 25-30 | 8-12 | Violent, panique |
| **Default Godot** | **20** | **5** | **Correct pour de la colere** |

**Astuce** : `connected=0` fait trembler chaque lettre independamment — plus chaotique, parfait pour la folie ou la corruption. `connected=1` (default) fait trembler le bloc entier — plus lisible, meilleur pour la colere.

### Rainbow (texte arc-en-ciel — magie, divinite, items legendaires)

```bbcode
[rainbow freq=0.5 sat=0.7 val=0.9 speed=0.3]artefact legendaire[/rainbow]
```

| Contexte | freq | sat | val | speed | Feeling |
|----------|------|-----|-----|-------|---------|
| Item rare/magique | 0.3-0.5 | 0.6-0.8 | 0.8-1.0 | 0.2-0.4 | Elegant, precieux |
| Divin, cosmique | 0.2 | 0.5 | 1.0 | 0.1 | Lent, majestueux |
| Glitch/corruption | 2.0 | 1.0 | 0.8 | 3.0 | Rapide, instable |
| **Default Godot** | **1.0** | **0.8** | **0.8** | **1.0** | **Trop rapide pour du texte lisible** |

**Piege** : `speed=1.0` (default) cycle trop vite — ca distrait de la lecture. Pour du dialogue, rester sous `speed=0.5`.

### Tornado (texte en spirale — rare, boss, fin du monde)

```bbcode
[tornado radius=5.0 freq=0.8 connected=1]le monde s'effondre[/tornado]
```

**Quasi jamais utilise en dialogue.** Reserve aux noms de boss, aux ecrans titre, ou aux moments de fin du monde. `radius=5.0` max pour du texte lisible (default `10.0` = illisible).

### Pulse (clignotement — nouveaute, alerte, interactible)

```bbcode
[pulse freq=0.5 color=#ffffff40 ease=-2.0]appuyez sur A[/pulse]
```

Utile pour les prompts ("Appuyez pour continuer") ou les mots-cles dans le dialogue. `freq=0.5` = un pulse toutes les 2 secondes. Au-dessus de `freq=2.0`, ca agresse.

---

## 4. Custom RichTextEffect — effects sur mesure

Etendre `RichTextEffect` pour les effets que les built-in ne couvrent pas.

### Fade-in par caractere (typewriter smooth)

```gdscript
# effects/fade_in_effect.gd
@tool
extends RichTextEffect
class_name FadeInEffect

var bbcode := "fadein"

func _process_custom_fx(char_fx: CharFXTransform) -> bool:
    var speed: float = char_fx.env.get("speed", 10.0)
    var delay: float = char_fx.env.get("delay", 0.05)

    var char_time := char_fx.elapsed_time - (char_fx.relative_index * delay)
    char_fx.color.a = clampf(char_time * speed, 0.0, 1.0)
    return true
```

Usage : `[fadein speed=8 delay=0.04]Le texte apparait en douceur[/fadein]`

**Feeling** : contrairement au typewriter brutal (caractere invisible → visible), le fadein donne une apparition progressive. Parfait pour la narration, les pensees internes, les reves.

### Tremble par caractere (peur individualisee)

```gdscript
# effects/tremble_effect.gd
@tool
extends RichTextEffect
class_name TrembleEffect

var bbcode := "tremble"

func _process_custom_fx(char_fx: CharFXTransform) -> bool:
    var intensity: float = char_fx.env.get("intensity", 2.0)
    var speed: float = char_fx.env.get("speed", 8.0)
    # Chaque caractere a son propre offset de phase
    var phase := float(char_fx.relative_index) * 0.7
    var offset_x := sin(char_fx.elapsed_time * speed + phase) * intensity
    var offset_y := cos(char_fx.elapsed_time * speed * 1.3 + phase) * intensity * 0.6
    char_fx.offset += Vector2(offset_x, offset_y)
    return true
```

Usage : `[tremble intensity=3 speed=10]j-je n'ai pas peur...[/tremble]`

**Difference avec [shake]** : shake est aleatoire et saccade. Tremble est sinusoidal et continu — plus "quelqu'un qui tremble" que "texte qui bug".

### Grow (texte qui grossit — cri, emphase)

```gdscript
# effects/grow_effect.gd
@tool
extends RichTextEffect
class_name GrowEffect

var bbcode := "grow"

func _process_custom_fx(char_fx: CharFXTransform) -> bool:
    var amount: float = char_fx.env.get("amount", 1.5)
    var speed: float = char_fx.env.get("speed", 3.0)
    var t := sin(char_fx.elapsed_time * speed) * 0.5 + 0.5  # 0→1 oscillation
    var scale_factor := lerpf(1.0, amount, t)
    # Pas de scale direct sur char_fx — simuler via offset vers le haut
    char_fx.offset.y -= (scale_factor - 1.0) * 8.0
    return true
```

**Limitation Godot** : `CharFXTransform` ne gere PAS le scale par caractere. L'offset simule un "grossissement" en decalant vers le haut. Pour un vrai scale, il faut un shader custom ou utiliser `font_size` dynamique sur un label separe.

---

## 5. Blip vocal — le son qui donne une voix

Le "blip" est un court son joue a chaque caractere affiche. C'est ce qui transforme du texte muet en dialogue vivant.

### Trois niveaux de complexite

**Niveau 1 — Un blip, pitch randomise (Undertale)**

Chaque personnage a UN son. Le pitch varie legerement.

```gdscript
@export var blip_sound: AudioStream
@export var base_pitch: float = 1.0
@export var pitch_variation: float = 0.1
@export var blip_interval: int = 2  # Jouer un blip tous les N caracteres

var _char_count: int = 0

func _on_character_displayed(index: int, ch: String) -> void:
    if ch in " \n\t":
        return  # Pas de blip sur les espaces

    _char_count += 1
    if _char_count % blip_interval != 0:
        return

    _audio_player.stream = blip_sound
    _audio_player.pitch_scale = base_pitch + randf_range(-pitch_variation, pitch_variation)
    _audio_player.play()
```

| Personnage type | base_pitch | pitch_variation | blip_interval | Feeling |
|-----------------|-----------|-----------------|---------------|---------|
| Homme adulte, grave | 0.7-0.9 | 0.05 | 2 | Pose, serieux |
| Femme / ado | 1.0-1.3 | 0.1 | 2 | Neutre, vif |
| Enfant | 1.4-1.8 | 0.15 | 1 | Rapide, aigu |
| Monstre/demon | 0.4-0.6 | 0.2 | 3 | Lent, menaçant |
| Robot/AI | 1.2 | 0.0 | 1 | Monotone, precis |
| Fantome/esprit | 1.5 | 0.3 | 2 | Ethéré, instable |

**Piege : blip sur chaque caractere = mitraillette.** `blip_interval=2` est le minimum confortable. A 30 cps avec interval=1, le son joue 30 fois par seconde — saturant le bus audio. Undertale utilise un interval effective d'environ 2-3 caracteres.

**Niveau 2 — Voyelles vs consonnes (Animal Crossing light)**

Deux sons : un pour les voyelles (plus ouvert), un pour les consonnes (plus percussif).

```gdscript
const VOWELS := "aeiouAEIOU"

func _on_character_displayed(index: int, ch: String) -> void:
    if ch in " \n\t":
        return

    _char_count += 1
    if _char_count % blip_interval != 0:
        return

    if ch in VOWELS:
        _audio_player.stream = vowel_blip
        _audio_player.pitch_scale = base_pitch + randf_range(0.0, pitch_variation)
    else:
        _audio_player.stream = consonant_blip
        _audio_player.pitch_scale = base_pitch + randf_range(-pitch_variation, 0.0)
    _audio_player.play()
```

**Niveau 3 — Phoneme mapping (Animal Crossing full)**

Chaque lettre mappe vers un phoneme pre-enregistre. Complexe, mais donne l'effet "presque de la parole".

```gdscript
# Mapping simplifie — 5 groupes phonetiques
const PHONEME_MAP := {
    "a": "open_vowel", "e": "mid_vowel", "i": "close_vowel",
    "o": "round_vowel", "u": "round_vowel",
    "b": "plosive", "d": "plosive", "g": "plosive", "p": "plosive",
    "t": "plosive", "k": "plosive",
    "s": "sibilant", "z": "sibilant", "f": "sibilant", "v": "sibilant",
    "m": "nasal", "n": "nasal",
    "l": "liquid", "r": "liquid",
}

@export var phoneme_streams: Dictionary = {}  # "open_vowel" → AudioStream, etc.
```

En pratique, le Niveau 1 suffit pour 90% des jeux indie. Le Niveau 2 ajoute une subtilite notable pour un effort minimal. Le Niveau 3 n'est justifie que si le dialogue est le coeur du jeu.

### AudioStreamRandomizer (Godot 4 natif)

Godot 4 a un type de Resource qui randomise automatiquement pitch et selection entre plusieurs streams :

```gdscript
# Dans l'inspecteur : creer un AudioStreamRandomizer
# Ajouter 2-3 variations du meme blip
# random_pitch = 1.1  (pitch varie de +-10%)
# random_volume_offset_db = 1.0  (volume varie de +-1dB)
# playback_mode = SEQUENTIAL ou RANDOM_NO_REPEATS
```

Plus simple que le pitch_scale manuel pour le Niveau 1. Mais pas assez flexible pour le mapping voyelles/consonnes.

---

## 6. Skip & auto-advance — le respect du joueur

### Pattern two-press (standard industrie)

1. Premier press pendant le typewriter → **reveal all** (affiche tout le texte d'un coup)
2. Deuxieme press quand le texte est complet → **advance** (passe a la ligne suivante)

```gdscript
func _input(event: InputEvent) -> void:
    if not event.is_action_pressed("ui_accept"):
        return

    if _typewriter.is_typing():
        _typewriter.skip()        # Press 1 : reveal all
    elif _has_next_line():
        _advance_to_next_line()   # Press 2 : next line
    else:
        _close_dialogue()         # Fin du dialogue
```

**JAMAIS de skip direct au next line.** Le joueur qui appuie vite veut lire plus vite, pas sauter du contenu. Le reveal-all est un filet de securite essentiel.

### Input buffering

Probleme : le joueur appuie pendant les 2 derniers caracteres du typewriter. Le skip se trigger, puis immediatement le advance (meme frame ou frame suivante). Resultat : une ligne entiere est sautee.

```gdscript
var _skip_cooldown: float = 0.0
const SKIP_BUFFER: float = 0.15  # 150ms minimum entre skip et advance

func _input(event: InputEvent) -> void:
    if not event.is_action_pressed("ui_accept"):
        return

    if _typewriter.is_typing():
        _typewriter.skip()
        _skip_cooldown = SKIP_BUFFER  # Empecher l'advance pendant 150ms
    elif _skip_cooldown <= 0.0:
        _advance_to_next_line()

func _process(delta: float) -> void:
    if _skip_cooldown > 0.0:
        _skip_cooldown -= delta
```

**150ms est le sweet spot.** En dessous, le double-press saute des lignes. Au-dessus, le joueur sent un lag entre les lignes.

### Auto-advance (cinematiques, narration)

Pour les sequences non-interactives ou le texte defile seul :

```gdscript
@export var auto_advance_delay: float = 1.5  # Temps apres fin du typewriter
@export var auto_advance_per_char: float = 0.04  # +40ms par caractere
# Total = delay + (text_length * per_char)
# "Bonjour" (7 chars) = 1.5 + 0.28 = 1.78s
# "Il etait une fois, dans un royaume lointain..." (47 chars) = 1.5 + 1.88 = 3.38s
```

**Pattern Dialogic** : `[aa=2]` dans le texte force l'auto-advance avec 2s de delai. `[ns]` desactive le skip (force le joueur a ecouter). Utile pour les cinematiques narratives, mais a utiliser avec parcimonie — le joueur deteste perdre le controle.

---

## 7. Combinaisons d'effets — le stack narratif

Les effets se combinent en BBCode. L'ordre compte.

### Colere qui monte

```bbcode
Tu n'as [shake rate=10 level=2]aucune[/shake] idee de ce que j'ai [shake rate=20 level=5][color=red]ENDURE[/color][/shake] !
```

Progression : texte normal → tremblement leger → tremblement fort + rouge. Le joueur SENT la colere monter dans la phrase.

### Revelation dramatique

```bbcode
Le meurtrier est...[pause=1.2]
[speed=0.08][fadein speed=5 delay=0.1]toi.[/fadein][/speed]
```

Pause longue → ralentissement → apparition douce. Silence avant la bombe.

### Corruption / glitch

```bbcode
Je suis [shake rate=30 level=8][rainbow freq=3.0 speed=5.0]t-tout a fait[/rainbow][/shake] normal.
```

Shake violent + rainbow rapide = visuellement "ca ne va pas". Le contraste avec le contenu "normal" cree le malaise.

### Murmure / secret

```bbcode
[color=#aaaaaa88][wave amp=8 freq=1.5]...il ne faut pas qu'ils sachent...[/wave][/color]
```

Couleur attenuee (semi-transparente) + vague lente = murmure. Combiner avec `TextMood.WHISPER` (speed 0.06s) et un blip a pitch 1.5+, volume bas.

---

## 8. Pieges

### `visible_characters` et BBCode = decalage

`visible_characters` compte les caracteres APRES parsing BBCode. Mais si tu compares avec le texte brut (contenant les tags), les index ne matchent pas. Toujours utiliser `get_parsed_text()` pour les pauses et les blips.

### Blip AudioStreamPlayer unique = sons coupes

Un seul AudioStreamPlayer jouant `play()` a chaque caractere coupe le son precedent. A 30 cps, chaque blip dure ~33ms — trop court pour etre audible.

Fix : utiliser `AudioStreamPolyphonic` ou un pool de 3-4 AudioStreamPlayer. Ou plus simplement, `blip_interval=2` minimum.

### RichTextEffect `@tool` oublie = invisible dans l'editeur

Les custom effects sans `@tool` ne s'affichent qu'au runtime. Pour le preview dans l'editeur, toujours ajouter `@tool` en premiere ligne du script.

### Custom effect name collision avec built-in

Un RichTextEffect avec `var bbcode := "fade"` entre en conflit avec le `[fade]` built-in. Godot utilise le built-in en priorite. Toujours prefixer : `"custom_fade"`, `"fadein"`, `"text_pulse"`, etc.

### `elapsed_time` dans RichTextEffect = temps global, pas par caractere

`char_fx.elapsed_time` est le temps depuis l'affichage du RichTextLabel, PAS depuis l'apparition du caractere. Pour des animations "depuis l'apparition", calculer : `char_fx.elapsed_time - (char_fx.relative_index * delay_par_caractere)`.

### Effets multiples sur le meme texte = accumulation d'offset

Si `[wave]` et `[shake]` s'appliquent au meme texte, les deux modifient `char_fx.offset`. Godot additionne les transformations dans l'ordre de declaration des effects dans `custom_effects`. L'ordre dans le BBCode NE compte PAS — c'est l'ordre dans l'Array qui decide.

### Pause sur ponctuation en plein mot = bug

"Dr. Smith" met une pause apres le point de "Dr." — non voulu. Pattern de detection : ne pauser que si le caractere SUIVANT est un espace, une majuscule, ou la fin du texte.

```gdscript
func _get_punctuation_delay(ch: String) -> float:
    if ch == ".":
        var next_idx := visible_characters
        if next_idx < _stripped_text.length():
            var next_ch := _stripped_text[next_idx]
            if next_ch != " " and next_ch != "\n":
                return 0.0  # "Dr." "etc." "Mr." — pas de pause
        # ... reste de la logique
```

### Speed player override vs speed narrative

Le joueur peut vouloir un texte plus rapide/lent (accessibilite). Mais les pauses narratives (ellipsis, revelations) doivent rester proportionnelles, pas absolues.

```gdscript
# Le multiplicateur player affecte TOUT proportionnellement
var player_speed_mult: float = 1.0  # 0.5 = double speed, 2.0 = half speed

func _process(delta: float) -> void:
    var current_speed := base_speed * player_speed_mult
    # Les pauses aussi sont affectees
    # pause de 0.25s a mult 0.5 = 0.125s — proportionnel
```

---

## Stack combine

```
1. visible_characters + Timer    -> base typewriter, pas de manipulation de string
2. Punctuation detection         -> pauses contextuelles (., !, ..., virgule)
3. Speed inline tags             -> [speed=], [pause=] dans le texte
4. Mood presets                  -> CALM/URGENT/DRAMATIC/WHISPER/SHOUT
5. BBCode effects reduits        -> wave amp<35, shake level<8, rainbow speed<0.5
6. Custom RichTextEffect         -> fadein, tremble, grow pour les effets manquants
7. Blip vocal interval=2+        -> pitch par personnage, AudioStreamRandomizer
8. Two-press skip + buffer 150ms -> respect du joueur, pas de skip accidentel
9. Auto-advance proportionnel    -> delay + per_char pour les cinematiques
```

Voir aussi : `.claude/skills/godot-audio.md` pour les bus audio et le spatial, `.claude/skills/godot-composition.md` pour la composition du dialogue box, `.claude/skills/godot-juice.md` pour le screenshake pendant les dialogues dramatiques.
