Retour au cours

games / lua

Coroutines

Leçon 81 exercice

Explication

Un multitâche différent de ce à quoi on pense habituellement

Ce que vous allez apprendre

  • Créer une coroutine et comprendre le cycle create/resume/yield
  • Distinguer coroutine.create (contrôle fin des erreurs) de coroutine.wrap (syntaxe concise)
  • Construire un générateur paresseux qui produit des valeurs à la demande
  • Utiliser les coroutines pour représenter une séquence d'actions étalée dans le temps (dialogue, chargement)
  • Vérifier qu'on est bien dans une coroutine avant d'appeler yield (coroutine.isyieldable)

Dans quel contexte ?

Un scénario de quête dans un jeu doit afficher plusieurs répliques d'un PNJ, chacune séparée par une attente du joueur ou par un délai, sans bloquer le reste du jeu pendant cette attente et sans avoir à réécrire toute la logique en callbacks imbriqués. Les coroutines permettent d'écrire cette séquence comme une simple suite d'instructions linéaires, alors qu'en coulisses, elle est mise en pause et reprise plusieurs fois.

Quand on parle de "faire plusieurs choses en même temps" en programmation, on pense souvent aux threads, où le système d'exploitation décide arbitrairement quand interrompre chaque tâche. Les coroutines Lua fonctionnent différemment : c'est du multitâche COOPÉRATIF, où une seule coroutine s'exécute à la fois, et c'est ELLE-MÊME qui décide, via yield, du moment précis où elle cède volontairement la main. Aucune interruption surprise, aucun problème de concurrence classique — c'est prévisible et facile à raisonner.

L'image à retenir : mettre en pause et reprendre exactement où on était

Une coroutine, c'est comme un signet dans un livre : yield marque la page où l'on s'arrête, et resume reprend la lecture exactement à cet endroit, avec toutes les variables locales intactes, comme si le temps s'était juste arrêté puis redémarré. C'est ce qui rend les coroutines idéales pour représenter des séquences d'actions étalées dans le temps — un dialogue de jeu qui attend une réponse du joueur, un chargement de ressources progressif, une machine à états complexe.

Deux façons de créer une coroutine, deux philosophies

coroutine.create suivi de resume donne un contrôle fin sur les erreurs : resume retourne un booléen de succès plutôt que de faire planter le programme. coroutine.wrap est plus concis à écrire mais propage les erreurs de façon brutale, comme une fonction normale qui lève une exception — un compromis entre lisibilité et robustesse à choisir selon le contexte.

FonctionErreursType de retour
coroutine.createCapturées, resume renvoie false + messageobjet thread
coroutine.wrapPropagées comme une erreur classiquefonction appelable

Astuce

Utilise coroutine.create dès que tu veux gérer proprement un échec possible (par exemple une séquence de quête qui peut être interrompue), et réserve coroutine.wrap aux générateurs simples où une erreur signifie de toute façon un bug à corriger.

Un usage très naturel : les générateurs paresseux

Combiner coroutine.wrap avec une boucle interne qui yield une valeur à chaque itération permet de construire un itérateur qui ne calcule chaque élément qu'au moment où on le demande réellement — utile pour parcourir de grandes séquences sans tout calculer et stocker en mémoire d'un coup.

Commandes & code

Coroutines

Les coroutines permettent de suspendre et reprendre l'exécution d'une fonction, offrant une forme de multitâche coopératif (pas de parallélisme réel, un seul thread OS).

lua
-- Création et exécution basique d'une coroutine
local co = coroutine.create(function(a, b)
  print("début", a, b)
  local c = coroutine.yield(a + b)         -- suspend, renvoie a+b au "resume" appelant
  print("reprise avec c =", c)
  return "terminé"
end)

print(coroutine.resume(co, 1, 2))     -- true  3     (exécute jusqu'au yield)
print(coroutine.status(co))            -- "suspended"
print(coroutine.resume(co, 100))        -- true  "terminé"   (reprend après yield, c=100)
print(coroutine.status(co))              -- "dead"
lua
-- Générateur / itérateur paresseux via coroutine : produit des valeurs à la demande sans tout calculer d'avance
local function range(n)
  return coroutine.wrap(function()        -- coroutine.wrap retourne directement une fonction appelable
    for i = 1, n do
      coroutine.yield(i)
    end
  end)
end

for value in range(5) do
  print(value)   -- 1 2 3 4 5, chaque valeur générée à la demande
end
lua
-- Différence coroutine.create vs coroutine.wrap
-- create : retourne un objet "thread", nécessite coroutine.resume() explicite, gère les erreurs proprement
-- wrap   : retourne une fonction directement appelable, propage les erreurs par un "error()" classique (moins de contrôle)

local co = coroutine.create(function() error("boom") end)
local ok, err = coroutine.resume(co)
print(ok, err)   -- false   "chemin:ligne: boom"   (l'erreur est capturée, pas de crash du programme)
lua
-- Cas d'usage réel : simulation d'une séquence d'actions asynchrones dans une boucle de jeu
-- (pattern très courant dans les moteurs de script de jeux vidéo)
local function questSequence()
  print("Le PNJ dit: Bonjour aventurier !")
  coroutine.yield()               -- suspend, attend la frame/tick suivant du jeu
  print("Le PNJ dit: As-tu trouvé l'artefact ?")
  coroutine.yield()
  print("Le PNJ dit: Merci, voici ta récompense !")
end

local questCo = coroutine.create(questSequence)

-- Boucle de jeu simplifiée : une étape de la coroutine par "frame"
for frame = 1, 3 do
  print("--- frame " .. frame .. " ---")
  coroutine.resume(questCo)
end
lua
-- Producteur/consommateur avec coroutines : découpler la génération de données de leur traitement
local function producer()
  return coroutine.wrap(function()
    for i = 1, 5 do
      coroutine.yield("item_" .. i)
    end
  end)
end

local function consumer(gen)
  for item in gen do
    print("traitement de " .. item)
  end
end

consumer(producer())
lua
-- Pattern avancé : coroutines pour une machine à états (state machine) lisible sans callbacks imbriqués
local function loadingSequence()
  print("Chargement des assets...")
  coroutine.yield("loading_assets")
  print("Connexion au serveur...")
  coroutine.yield("connecting")
  print("Prêt à jouer !")
  coroutine.yield("ready")
end

local state = coroutine.wrap(loadingSequence)
print(state())   -- "loading_assets"  (avec les prints intercalés)
print(state())   -- "connecting"
print(state())   -- "ready"
lua
-- Vérifier qu'on est bien à l'intérieur d'une coroutine avant d'appeler yield (sinon erreur)
local function safeYield(value)
  if coroutine.isyieldable() then
    return coroutine.yield(value)
  end
  return value   -- fallback si appelé hors coroutine
end

Résumé

  • Les coroutines sont un multitâche COOPÉRATIF : une seule s'exécute à la fois, elle cède la main volontairement via yield.
  • coroutine.create+resume gère les erreurs proprement ; coroutine.wrap est plus concis mais propage les erreurs brutalement.
  • Un générateur/itérateur paresseux se construit naturellement avec coroutine.wrap et une boucle yield.
  • Les coroutines simplifient les séquences d'actions étalées dans le temps (dialogues, chargements, machines à états).

Exercices pratiques

1 disponible
1

Mission : la quête qui saute une réplique

Objectif : Analyser précisément le cycle resume/yield d'une séquence de dialogue de quête et choisir la bonne stratégie de gestion d'erreur.

Contexte

Une séquence de dialogue de quête utilise une coroutine :

lua
local function questSequence()
  print("PNJ: Bonjour aventurier !")
  local reponse = coroutine.yield("attente_reponse_1")
  print("PNJ: Tu as dit " .. reponse)
  coroutine.yield("attente_reponse_2")
  print("PNJ: Merci, voici ta récompense !")
  return "quete_terminee"
end

local co = coroutine.create(questSequence)
print(coroutine.resume(co))              -- ok, "attente_reponse_1"
print(coroutine.resume(co, "bonjour"))    -- ok, "attente_reponse_2"
print(coroutine.status(co))

Un testeur signale qu'un joueur qui répond très vite provoque parfois une erreur "cannot resume dead coroutine" en jeu.

Résoudre l’exercice →