Chapitre 9.3 — Haptics & sons : le feedback du jeu
Où on en est : Drill est fonctionnel mais muet et inerte. Ce chapitre ajoute la couche sensorielle — vibrations et sons — celle qui sépare « ça marche » de « c’est bon en main ».
⏱️ TL;DR — expo-haptics :
impactAsync(Light/Medium/Heavy) pour les taps,notificationAsync(Success/Warning/Error) pour les verdicts. expo-audio :useAudioPlayer(require('…/correct.mp3'))+player.play()pour les jingles. Tout passe parlib/feedback.ts— UN module qui respecte les réglages (sons/vibrations désactivables) et centralise le vocabulaire sensoriel de l’app.
🎯 Objectifs
- Choisir le bon haptic pour chaque moment (le vocabulaire Light/Medium/Success/Error).
- Jouer des sons courts avec expo-audio sans latence ni fuite mémoire.
- Centraliser le feedback dans lib/feedback.ts, branché sur les réglages.
- Doser : la frontière entre feedback et sapin de Noël.
Le vocabulaire haptique
npx expo install expo-hapticsimport * as Haptics from 'expo-haptics'
// Les impacts — un contact physique : taps, sélections
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light) // tap ordinaire
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium) // action notable
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy) // rare, événement fort
// Les notifications — un VERDICT : résultat d'une action
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success) // bonne réponse !
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error) // mauvaise réponse
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning) // timer sous 3 s
// La sélection — le tick discret des molettes/sliders
Haptics.selectionAsync()La grammaire de Drill (et une bonne grammaire par défaut pour vos apps) :
| Moment | Haptic |
|---|---|
| Tap sur une réponse | impact Light (accusé de réception) |
| Verdict bonne réponse | notification Success |
| Verdict mauvaise réponse / timeout | notification Error |
| Timer passe sous 3 s | notification Warning (une fois !) |
| Nouveau record (écran résultat) | impact Heavy |
| Navigation, boutons ordinaires | rien — le silence est le fond qui fait ressortir le reste |
Les sons courts avec expo-audio
npx expo install expo-audio// Le hook du SDK : charge le son, le libère au démontage
import { useAudioPlayer } from 'expo-audio'
function ResultScreen() {
const fanfare = useAudioPlayer(require('../assets/sounds/record.mp3'))
// …
if (isNewRecord) fanfare.play()
}Pour les sons DE JEU (bonne/mauvaise réponse), le hook par écran ne suffit pas — on veut des players préchargés et globaux (latence zéro au moment du verdict). D’où le module :
// lib/feedback.ts — LE module sensoriel de Drill
import * as Haptics from 'expo-haptics'
import { createAudioPlayer, type AudioPlayer } from 'expo-audio'
import { useSettings } from '../stores/settings'
// Players créés une fois, préchargés (fichiers < 100 Ko, mp3)
const sounds: Record<'correct' | 'wrong' | 'tick' | 'record', AudioPlayer> = {
correct: createAudioPlayer(require('../assets/sounds/correct.mp3')),
wrong: createAudioPlayer(require('../assets/sounds/wrong.mp3')),
tick: createAudioPlayer(require('../assets/sounds/tick.mp3')),
record: createAudioPlayer(require('../assets/sounds/record.mp3')),
}
function play(name: keyof typeof sounds) {
if (!useSettings.getState().soundEnabled) return
const p = sounds[name]
p.seekTo(0) // un son de jeu peut se rejouer avant d'être fini
p.play()
}
function haptic(fn: () => Promise<void>) {
if (useSettings.getState().hapticsEnabled) fn()
}
// L'API que le reste de l'app consomme — le VOCABULAIRE, pas la technique
export const feedback = {
tap: () => haptic(() => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light)),
correct: () => {
haptic(() => Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success))
play('correct')
},
wrong: () => {
haptic(() => Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error))
play('wrong')
},
lastSeconds: () => haptic(() => Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning)),
newRecord: () => {
haptic(() => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy))
play('record')
},
}L’architecture à retenir : l’app appelle feedback.correct() — un verbe métier. Ni les
écrans ni le store ne savent ce que ça déclenche (vibration ? son ? les deux ? rien si
désactivé ?). Changer la grammaire sensorielle = un fichier. Et les réglages (ch. 6.4) sont
respectés par construction, via getState() hors React.
Le branchement : l’action answer du store retourne le verdict (ch. 6.2 — anticipé
précisément pour ça) :
onPress={() => {
feedback.tap()
const correct = answer(i, secondsLeft)
correct ? feedback.correct() : feedback.wrong()
}}⚠️ Piège — Deux écueils symétriques. (1) La latence : charger le mp3 au moment de jouer = 100-300 ms de retard, le feedback arrive « à côté » de l’action — d’où le préchargement. (2) La surenchère : un haptic sur CHAQUE interaction transforme l’app en brosse à dents électrique. La grammaire ci-dessus tient en 5 verbes — si vous dépassez 8, vous décorez, vous ne communiquez plus.
🍏 Côté iOS — Même API, rendu différent : le Taptic Engine des iPhone est plus fin que la plupart des vibreurs Android (où Heavy et Medium peuvent se ressembler). Raison de plus pour tester sur VOTRE téléphone : le feedback se règle à l’oreille et à la main, pas en théorie.
Où trouver les sons
Des jingles de 0,3-1 s, libres : freesound.org (filtres CC0), pixabay.com/sound-effects . Convention : mp3, < 100 Ko, volume normalisé (Audacity : Effect → Loudness Normalization, -14 LUFS) — un son deux fois plus fort que les autres ruine l’ensemble.
✏️ Exercices
1. Branchez la grammaire complète dans le quiz (tap, correct, wrong, lastSeconds au passage sous 3 s — le useRef de l’exercice 6.3, newRecord sur l’écran résultat). Jouez une partie son coupé, puis vibration coupée : les réglages sont-ils respectés partout ?
✅ Solution
Chaque appel passe par feedback.* → les deux Switch coupent tout d’un coup (getState dans
play/haptic). Le test des réglages est trivial PARCE QUE le module est centralisé — la
version « Haptics.impactAsync éparpillé dans 6 fichiers » aurait 6 endroits à vérifier.
2. Ajoutez un « tick » sonore par seconde sous 5 s — puis retirez-le après test. Pourquoi cette itération (ajouter → tester → retirer) est-elle la bonne méthode pour le feedback ?
✅ Solution
useEffect sur secondsLeft : if (secondsLeft <= 5 && phase.name === 'playing') play('tick').
Au test : le tick STRESSE plus qu’il n’informe (la ProgressBar + le Warning haptic suffisent)
— il sort. Le feedback ne se juge que téléphone en main : la boucle ajouter/ressentir/trancher
est la seule méthode, et « retirer » est un résultat de test réussi, pas un échec.
3. Votre future app Pomodoro (P19) : écrivez sa grammaire feedback (5 verbes max) en réutilisant la structure de lib/feedback.ts.
✅ Solution
start (impact Medium — engagement), pause (selection — discret), workDone (notification
Success + son doux — la récompense), breakDone (impact Light + tick — on repart),
abandoned (rien — ne pas punir). La structure (verbes métier + réglages respectés + players
préchargés) se copie telle quelle ; seul le vocabulaire change. C’est ça, un pattern.
🧠 Quiz
1. impactAsync vs notificationAsync — la distinction sémantique ?
Réponse
Impact = un contact (tap, sélection — Light/Medium/Heavy). Notification = un verdict (Success/Warning/Error — résultat d’une action). Drill : Light au tap, Success/Error au verdict.
2. Pourquoi précharger les sons de jeu ?
Réponse
Charger à la demande = 100-300 ms de latence : le feedback arrive décalé de l’action, l’effet est ruiné. Players créés au démarrage (createAudioPlayer) + seekTo(0) pour rejouer.
3. Quel est le rôle architectural de lib/feedback.ts ?
Réponse
Exposer des verbes métier (correct, wrong, newRecord) qui encapsulent haptics + sons + respect des réglages — un seul endroit pour toute la grammaire sensorielle.
4. Comment les réglages sont-ils consultés hors composants ?
Réponse
useSettings.getState().soundEnabled — l’accès hors-React de Zustand (ch. 6.2), au moment
exact de l’appel.
5. Quel est le signe d’un feedback sur-dosé ?
Réponse
Vibration/son sur les interactions ordinaires (navigation, chaque bouton) : le bruit de fond tue le signal. Une grammaire tient en ~5 verbes ; le silence est ce qui fait ressortir les moments qui comptent.
👉 Chapitre suivant : 9.4 — Partage & presse-papiers — le score qui sort de l’app.