Skip to Content
React Native6. État & formulaires6.3 Machine d'état d'une partie

Chapitre 6.3 — La machine d’état d’une partie

Où on en est : le store existe, mais une partie a des PHASES : on ne répond pas à une question déjà répondue, le timer ne tourne pas sur l’écran d’explication. On modélise — et Drill devient jouable.

⏱️ TL;DR — Une partie = machine d’état finie : playinganswered → (next) → playing … → finished, modélisée en union discriminée TS (chaque phase porte SES données ; les transitions sont des fonctions). Le timer suit le pattern « l’horloge fait foi » (endsAt), survit à l’arrière-plan, et déclenche timeout comme une réponse fausse. Le back matériel Android est intercepté proprement.

🎯 Objectifs

  • Modéliser la phase de jeu en union discriminée (le TS qui empêche les bugs d’exister).
  • Implémenter le timer honnête (endsAt + tick d’affichage + AppState).
  • Câbler l’écran de quiz complet : question, réponses, explication, next, fin.
  • Intercepter le back matériel pendant une partie.

L’union discriminée — la phase EST un type

Dix booléens (isAnswered, isTimeout, isFinished…) = combinaisons impossibles non interdites (answered ET finished ?). La modélisation correcte :

// lib/types.ts export type Question = { id: string prompt: string choices: [string, string, string, string] correctIndex: 0 | 1 | 2 | 3 explanation: string difficulty: 'facile' | 'moyen' | 'difficile' } export type Phase = | { name: 'playing'; endsAt: number } // timer en cours | { name: 'answered'; choiceIndex: number | null; correct: boolean } // null = timeout | { name: 'finished' }

On ajoute la phase au store (et l’action timeout) :

// stores/session.ts — ajouts type SessionState = { // … (chapitre 6.2) phase: Phase totalSeconds: number startQuestion: () => void answer: (choiceIndex: number, secondsLeft: number) => boolean timeout: () => void next: () => void } export const useSession = create<SessionState>((set, get) => ({ // … phase: { name: 'finished' }, totalSeconds: 20, startQuestion: () => set(s => ({ phase: { name: 'playing', endsAt: Date.now() + s.totalSeconds * 1000 } })), answer: (choiceIndex, secondsLeft) => { const s = get() if (s.phase.name !== 'playing') return false // ← la garde : pas de double réponse const q = s.questions[s.index] const correct = choiceIndex === q.correctIndex // … scoring/streak/answers comme au chapitre 6.2 … set({ phase: { name: 'answered', choiceIndex, correct } }) return correct }, timeout: () => { const s = get() if (s.phase.name !== 'playing') return set({ streak: 0, answers: [...s.answers, { questionId: s.questions[s.index].id, correct: false }], phase: { name: 'answered', choiceIndex: null, correct: false } }) }, next: () => { const s = get() const last = s.index >= s.questions.length - 1 if (last) set({ phase: { name: 'finished' } }) else { set({ index: s.index + 1 }) get().startQuestion() } }, }))

Les gardes (if (phase.name !== 'playing') return) rendent les états impossibles… impossibles : taper deux réponses, répondre après timeout — la machine refuse. Et TS vous force à traiter chaque phase dans l’UI (switch exhaustif sur phase.name).

Le timer honnête

Trois pièces : la vérité (endsAt dans la phase), un tick d’affichage, la resynchronisation au retour d’arrière-plan :

// hooks/useCountdown.ts import { useEffect, useState } from 'react' import { useSession } from '../stores/session' import { useAppStateChange } from './useAppStateChange' export function useCountdown() { const phase = useSession(s => s.phase) const timeout = useSession(s => s.timeout) const [secondsLeft, setSecondsLeft] = useState(0) useEffect(() => { if (phase.name !== 'playing') return const compute = () => Math.max(0, Math.ceil((phase.endsAt - Date.now()) / 1000)) setSecondsLeft(compute()) const id = setInterval(() => { const left = compute() setSecondsLeft(left) if (left <= 0) timeout() // la transition vient de l'horloge }, 250) return () => clearInterval(id) }, [phase, timeout]) // Retour d'arrière-plan : recalcul immédiat (le temps a continué de couler) useAppStateChange(status => { if (status === 'active' && phase.name === 'playing') { if (Date.now() >= phase.endsAt) timeout() } }) return secondsLeft }

Le setInterval de 250 ms n’est qu’un rafraîchissement d’affichage : même s’il dérive ou gèle, endsAt - Date.now() reste exact. Passer 30 s dans une autre app pendant une question = timeout à la réhydratation. Honnête.

L’écran de quiz assemblé

// app/quiz.tsx — la structure (styles omis) export default function QuizScreen() { const phase = useSession(s => s.phase) const question = useSession(s => s.questions[s.index]) const answer = useSession(s => s.answer) const next = useSession(s => s.next) const secondsLeft = useCountdown() useEffect(() => { if (phase.name === 'finished') router.replace('/quiz/results') // ch. 5.4 }, [phase.name]) if (!question) return null return ( <Screen> <SessionHeader /> <ProgressBar ratio={secondsLeft / 20} /> <View className="flex-1 justify-center"> <AppText variant="heading">{question.prompt}</AppText> </View> <View className="gap-2.5"> {question.choices.map((choice, i) => ( <AnswerButton key={i} label={choice} state={answerState(phase, i, question.correctIndex)} // lib pure ↓ onPress={() => answer(i, secondsLeft)} /> ))} </View> {phase.name === 'answered' && ( <Card className="mt-3"> <AppText variant="caption">{question.explanation}</AppText> <Button label="Question suivante" onPress={next} className="mt-3" /> </Card> )} </Screen> ) }
// lib/answer-state.ts — pur, testable : quelle tête a le bouton i selon la phase ? export function answerState(phase: Phase, i: number, correctIndex: number): AnswerState { if (phase.name !== 'answered') return 'idle' if (i === correctIndex) return 'correct' // la bonne réponse se révèle toujours if (i === phase.choiceIndex) return 'incorrect' // le mauvais choix du joueur return 'disabled' }

Le .map() sur 4 choix : légitime (borné — chapitre 3.4). L’explication apparaît en phase answered — le cœur pédagogique de Drill est en place.

Le back matériel, enfin géré

Promis au chapitre 5.4 — voici le paiement :

// Dans QuizScreen useEffect(() => { const sub = BackHandler.addEventListener('hardwareBackPress', () => { const { phase } = useSession.getState() if (phase.name === 'finished') return false // laisser faire router.push('/quiz/confirm-quit') // notre modal du ch. 5.4 return true // ← on a consommé l'événement }) return () => sub.remove() }, [])

return true = « je m’en occupe » : Android ne dépile pas. Le joueur passe par la confirmation. (Notez getState() : le listener lit l’état frais sans re-abonnement — le superpouvoir hors-React du chapitre 6.2.)

📱 Essaie sur ton téléphone — Branchez start(theme, hardcodedQuestions) sur le bouton du détail de thème, avec 5 questions écrites à la main. Jouez UNE vraie partie : timer, streak, explication, résultat. C’est le moment le plus satisfaisant du cours jusqu’ici — Drill est un jeu.

✏️ Exercices

1. Le switch d’affichage du timer doit passer en text-red-500 sous 5 s et déclencher un haptic à 3 s exactement. Implémentez sans casser le pattern endsAt.

✅ Solution

Couleur : dérivée du render — className={secondsLeft <= 5 ? 'text-red-500' : 'text-ink-100'}. Haptic « à 3 s » : un useRef mémorisant la dernière seconde vue ; quand elle passe de 4 à 3 → Haptics.notificationAsync(Warning). On réagit aux valeurs dérivées de l’horloge, on ne programme jamais « dans 17 s » (faux après un background).

2. Testez mentalement (puis sur téléphone) : le joueur répond à la question 10/10, l’écran résultat s’affiche ; il fait back. Où atterrit-il et pourquoi ? Puis : il met l’app en background PENDANT l’explication (phase answered) — que fait le timer au retour ?

✅ Solution

(1) Back depuis résultat → tabs : le quiz a été replace (ch. 5.4), il n’est plus dans la pile. (2) Rien : en phase answered il n’y a pas de timer (le tick ne tourne qu’en playing — la garde du useEffect). La machine d’état délimite naturellement ce qui vit dans chaque phase.

3. Ajoutez la phase { name: 'paused' } (future fonctionnalité premium) SANS l’implémenter : que force TS à modifier, et qu’est-ce que ça révèle du pattern ?

✅ Solution

Ajouter le membre à l’union → TS signale tous les switch/if non exhaustifs sur phase.name (answerState, l’écran, le countdown…). La machine d’état typée transforme une évolution de spec en checklist de compilation : rien ne peut être oublié silencieusement. (Retirer la phase ensuite — ou la garder en exercice pour P19.)

🧠 Quiz

1. Pourquoi une union discriminée plutôt que des booléens ?

Réponse

Chaque phase porte SES données (endsAt n’existe qu’en playing), les combinaisons absurdes sont irreprésentables, et TS force l’exhaustivité des traitements. Les gardes dans les actions complètent : transitions illégales refusées.

2. Que représente le setInterval du countdown — et que ne représente-t-il pas ?

Réponse

Un simple rafraîchissement d’affichage (250 ms). La vérité du temps est endsAt comparé à Date.now() — le timer reste juste même si les ticks gèlent (arrière-plan).

3. Comment le timeout est-il déclenché après un long passage en arrière-plan ?

Réponse

Au retour (AppState → active), on compare Date.now() >= endsAttimeout() immédiat. Le tick l’aurait raté puisqu’il était gelé.

4. Que signifie return true dans le listener hardwareBackPress ?

Réponse

« Événement consommé » : Android ne fait PAS son back par défaut — on a redirigé vers le modal de confirmation. return false = laisser le système dépiler.

5. Pourquoi answerState vit-il dans lib/ ?

Réponse

C’est de la logique pure (phase + index → état visuel) : testable avec Jest sans RN, réutilisable (P19), et l’écran reste déclaratif. La règle lib/ du cours.


👉 Chapitre suivant : 6.4 — Préférences & AsyncStorage — durée du timer, sons, thème : ce qui survit au redémarrage.