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 :
playing→answered→ (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() >= endsAt → timeout() 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.