La boucle de jeu et les tâches

Choisir la forme du programme, finir chaque image avec Sync(), attendre en temps de jeu, et mener plusieurs travaux de front avec des tâches coopératives.

En un mot#

C'est l'Engine qui dessine les images, pas votre programme. Un programme BASIC fait un peu de travail, puis rend la main pour que l'image soit dessinée. Sync() est ce passage de main. Les tâches permettent à plusieurs travaux de se relayer à ces moments-là.

Trois formes de programme#

FormeÀ quoi elle ressembleQuand elle s'exécutePour quoi faire
Linéairedes instructions, de haut en basune fois, au démarragel'initialisation, des outils, des tests
Boucle principaleun seul DO … LOOP avec Sync()le corps de la boucle tourne une fois par imageun jeu classique façon AGK ou DarkBASIC
Fonctions de cycle de vieFUNCTION start(), FUNCTION update(dt)…l'Engine les appellele même modèle que les scripts JavaScript

Linéaire#

lives = 3
Console("Vies : " + Str(lives))   ' Vies : 3

Le programme s'exécute une fois, de haut en bas, puis se termine.

La boucle principale#

[instructions]        ' une fois, avant la première image
DO
  [instructions]      ' à chaque image
  Sync()
LOOP
[instructions]        ' une fois, quand EXIT quitte la boucle
frames = 0
Console("début")                       ' début
DO
  frames = frames + 1
  IF frames = 3 THEN EXIT
  Sync()
LOOP
Console("sorti après " + Str(frames))  ' sorti après 3
  • Un seul DO … LOOP par programme, au niveau supérieur. Pas dans un IF, un WHILE ni une FUNCTION.
  • La boucle doit contenir Sync() (ou Swap(), ou YieldTask()). Sans lui, le compilateur s'arrête sur « DO/LOOP requires SYNC() so the loop can yield a frame ».
  • EXIT quitte la boucle. END arrête tout le programme sur-le-champ.
DO
  Sync()
LOOP
DO              ' refusé : une seconde boucle principale
  Sync()
LOOP

Dans AGK et DarkBASIC, DO … LOOP n'est qu'une boucle sans fin qu'on place où l'on veut. Dans aukimi, c'est la boucle d'image : une par programme. Pour toute autre boucle, utilisez WHILE … ENDWHILE ou REPEAT … UNTIL.

⚠️ N'écrivez pas de FUNCTION main() dans un programme qui a une boucle DO. La boucle est compilée dans une fonction de ce nom, et les deux se télescopent. Le compilateur ne dit rien, et la boucle ne tourne jamais.

Les fonctions de cycle de vie#

GLOBAL elapsed# = 0

FUNCTION start()
  Console("prêt")                     ' prêt
ENDFUNCTION

FUNCTION update(dt#)
  elapsed# = elapsed# + dt#
  Print("Temps : " + Str(elapsed#, 1))
ENDFUNCTION

L'Engine appelle start une fois, puis update à chaque image, avec la durée de l'image en secondes. Il n'y a pas de boucle à écrire ni de Sync() à appeler : l'Engine dessine l'image quand update rend la main. La liste complète des points d'entrée (fixedUpdate, onCollisionEnter, onMessage…) est dans Les fonctions.

⚠️ Un programme en cycle de vie ne peut pas attendre. Wait(), Sleep(), AWAITTASK, AWAITALL et AWAITANY sont refusés dans une FUNCTION. Pire : un seul d'entre eux n'importe où dans un programme sans DO, même dans l'étiquette d'une tâche, enferme tout le programme dans un unique main(), et l'Engine ne trouve plus start ni update. Rien ne le signale. Pour mesurer une durée dans un programme en cycle de vie, comptez les images dans une tâche avec YieldTask() (voir Les tâches, plus bas), ou additionnez les dt.

Finir une image : Sync(), Swap(), YieldTask()#

AppelCe qu'il fait
Sync()termine l'image : l'Engine dessine, et le programme reprend à l'image suivante. Il attend aussi les modèles 3D encore en chargement : l'image qui suit LoadObject(...) et Sync() contient le modèle.
Swap()la même chose. Conservé pour la séquence classique Render2DBack(), Render3D(), Render2DFront(), Swap() : les trois Render ne font rien, Swap() dessine toute l'image, et l'on obtient la bonne image.
YieldTask()encore la même chose, sous un nom qui se lit bien dans une tâche

Les trois exigent leurs parenthèses : SYNC seul est refusé.

Quand ils rendent vraiment la main#

Sync() ne cède une image que là où le programme peut attendre : le programme a un DO … LOOP de premier niveau, ou il contient une instruction END, ou l'appel se trouve dans une étiquette (un sous-programme GOSUB ou une tâche).

Partout ailleurs, Sync() dessine une fois et revient aussitôt. Le programme ne fait pas de pause.

⚠️ Le piège du WHILE linéaire. C'est la boucle classique « attendre un clic ». Dans un programme sans DO ni END, son Sync() ne cède jamais : la boucle tourne sans répit et fige l'onglet du navigateur. Aucune erreur.

' Ni DO ni END dans ce programme : ne le lancez pas.
ready = 0
WHILE ready = 0
  IF PointerPressed() THEN ready = 1
  Sync()                  ' dessine, mais n'attend pas l'image suivante
ENDWHILE

La correction tient en une ligne. Avec un END, la même boucle cède à chaque image :

ready = 0
WHILE ready = 0
  IF PointerPressed() THEN ready = 1
  Sync()                  ' cède : le programme contient un END
ENDWHILE
Console("clic")           ' clic
END
  • Un Wait() ou un Sleep() ailleurs dans le programme ne corrige pas le problème. Seuls DO … LOOP, END ou une étiquette le font.
  • Dans AGK, Sync() termine l'image où qu'il soit écrit. C'est la principale différence à retenir.

⚠️ N'appelez jamais Sync() dans une FUNCTION. Sans boucle DO, il ne cède pas. Avec une, le programme ne démarre pas. Les fonctions de cycle de vie n'en ont jamais besoin : l'Engine dessine quand elles rendent la main.

Avant DO et après LOOP#

OùS'exécuteAutorisé
avant DOune fois, avant la première imagetout : chargement, Wait(), un écran de chargement en WHILE … Sync()
entre DO et LOOPune fois par imagetout
après LOOPune fois, quand EXIT quitte la boucletout sauf Wait() et Sleep()
Console("chargement")                    ' chargement
Wait(0.5)                                ' une demi-seconde de temps de jeu
frames = 0
DO
  frames = frames + 1
  IF frames = 120 THEN EXIT
  Sync()
LOOP
Console("fini après " + Str(frames) + " images")   ' fini après 120 images
  • Un END dans la boucle saute le code qui suit LOOP.
  • Les blocs FUNCTION et les étiquettes peuvent se placer n'importe où, avant ou après la boucle. Ce n'est pas du « code après LOOP » : ils ne s'exécutent que quand on les appelle.
  • Wait() ou Sleep() après LOOP est refusé : must appear before or inside the top-level DO/LOOP.

Attendre : Wait() et Sleep()#

Wait(<secondes>)
Sleep(<millisecondes>)
Console("3")              ' 3
Wait(1)                   ' une seconde
Console("2")              ' 2
Sleep(500)                ' une demi-seconde
Console("partez")         ' partez
  • Les deux attendent en temps de jeu. Ils s'arrêtent quand le jeu est en pause, et suivent l'échelle de temps. L'Engine continue de dessiner pendant l'attente.
  • Wait compte en secondes, Sleep en millisecondes. Wait est un ajout d'aukimi ; Sleep est la commande d'AGK.
  • ⚠️ Dans DarkBASIC, WAIT compte en millisecondes. Ici, Wait(1000) attend seize minutes.
EndroitWait() / Sleep()Sync() / Swap() / YieldTask()
programme linéaireouine cède que si le programme contient un END
avant DO ou dans la boucleouioui
après LOOPrefuséoui
une étiquette (sous-programme GOSUB ou tâche)ouioui
dans une FUNCTION, cycle de vie comprisrefuséne cède pas : à éviter
FUNCTION Pause()
  Wait(1)            ' refusé : une FUNCTION ne peut pas attendre
ENDFUNCTION

Le message est Wait(...) is supported in the main BASIC program, not inside FUNCTION. Pour attendre dans un bout de code réutilisable, écrivez une étiquette et rejoignez-la par GOSUB, ou lancez-la comme tâche. Un GOSUB vers une étiquette qui attend exige une boucle DO ou un END dans le programme, sinon le compilateur le refuse.

AppelOù ça vaÀ quelle fréquence l'appeler
Print(...)dessiné à l'écran, en haut à gaucheà chaque image, dans la boucle
PrintC(...)pareil, sans retour à la ligneà chaque image
Console(...)la console de l'éditeurquand quelque chose se produit
Debug(...)comme Console, sous le nom de PureBasicquand quelque chose se produit
ConsoleWarning, ConsoleError, ClearConsolela consolequand quelque chose se produit

Print écrit dans l'image en cours de construction. Chaque nouvelle image qui dessine quoi que ce soit repart d'une couche de texte vide : un texte imprimé une seule fois disparaît. Imprimez-le à chaque image.

Console écrit une ligne, une fois. Ne l'appelez pas à chaque image : les vraies erreurs s'y noient. Les lignes identiques d'une même rafale sont regroupées et comptées.

score = 0
DO
  score = score + 1
  Print("Score : " + Str(score))                ' redessiné à chaque image
  IF score = 100 THEN Console("100 atteint")    ' journalisé une fois
  IF score = 200 THEN EXIT
  Sync()
LOOP

Les tâches#

Une tâche exécute une étiquette à côté du programme principal. Les tâches sont coopératives : un seul morceau de code tourne à la fois, et ils ne se passent le relais que lorsque celui qui tourne rend la main.

<poignée> AS TASKHANDLE             ' ou AS JOB ; facultatif
STARTTASK <poignée>, <Étiquette>
AWAITTASK <poignée>
AWAITALL <poignée>, <poignée> [, <poignée>…]
AWAITANY <poignée>, <poignée> [, <poignée>…]
CANCELTASK <poignée>
YieldTask()

<Étiquette>:
  [instructions]
RETURN <valeur>

Lancer une tâche et attendre son résultat#

job AS TASKHANDLE
STARTTASK job, CountDown
Console("lancée")                            ' lancée
AWAITTASK job
Console("résultat " + Str(TaskResult(job)))  ' résultat 42
END

CountDown:
  FOR i = 3 TO 1 STEP -1
    Console("tic " + Str(i))                 ' tic 3, tic 2, tic 1
    Wait(0.5)
  NEXT
RETURN 42
  • STARTTASK ne lance pas la tâche sur-le-champ. La tâche démarre au prochain moment où le programme principal rend la main : Sync(), Wait(), AWAITTASK… C'est pourquoi lancée s'affiche en premier.
  • Une tâche est une étiquette qui se termine par RETURN valeur. Cette valeur est ce que TaskResult rend. Un RETURN seul ne laisse aucun résultat utilisable.
  • Les tâches partagent les variables du programme. Une tâche peut mettre le score à jour ; la boucle principale voit la nouvelle valeur dès sa ligne suivante.
  • Une tâche peut lancer d'autres tâches et les attendre.
  • AWAITTASK, AWAITALL et AWAITANY sont refusés dans une FUNCTION, comme Wait(). STARTTASK et CANCELTASK y sont permis.

⚠️ Dans une étiquette, donnez à chaque attente sa propre ligne. Le compilateur décide si une étiquette attend en lisant ses lignes. Il ne voit pas IF … THEN Sync() ni IF … THEN Wait(1) écrits sur une ligne, ni Sync(), Swap() ou YieldTask() suivis d'un commentaire. Le programme ne démarre alors pas, sans aucune erreur à la compilation. Écrivez un IF en bloc, et mettez le commentaire sur la ligne du dessus, comme dans l'exemple plus bas.

La poignée#

STARTTASK crée la variable si elle n'existe pas, même sous OPTION EXPLICIT. La déclarer AS TASKHANDLE (ou AS JOB, le même type) lui donne un type :

  • elle vaut d'abord NULL ;
  • elle refuse tout ce qui n'est pas une tâche : job = 5 arrête le programme avec BASIC value is not a task handle.

⚠️ La poignée est une variable simple. STARTTASK jobs[1], Worker n'est pas pris en charge : lancez la tâche dans une variable, puis copiez-la dans le tableau.

Lire l'état d'une tâche#

AppelRend
TaskDone(h)vrai une fois que la tâche a rendu son résultat
TaskFailed(h)vrai si la tâche s'est arrêtée sur une erreur
TaskCancelled(h)vrai dès que CANCELTASK a été appelé sur elle
TaskResult(h)la valeur du RETURN. Une erreur si la tâche n'a pas fini, ou a échoué.

⚠️ Ces quatre-là répondent vrai ou faux, pas 1 ou 0. Testez-les seuls ou avec NOT : IF TaskDone(h) THEN …. Écrit IF TaskDone(h) = 1, le test est toujours faux, et Str(TaskDone(h)) affiche true.

Interroger la tâche depuis la boucle principale laisse le jeu dessiner pendant que le travail avance :

loader AS TASKHANDLE
STARTTASK loader, LoadLevel
DO
  IF TaskDone(loader)
    Console("salles : " + Str(TaskResult(loader)))  ' salles : 3
    EXIT
  ENDIF
  Print("Chargement...")
  Sync()
LOOP
Console("jouer")                                    ' jouer

LoadLevel:
  FOR room = 1 TO 3
    ' une salle par image
    Sync()
  NEXT
RETURN 3

Les erreurs dans une tâche#

Une erreur dans une tâche n'arrête pas le programme. La tâche est marquée en échec, et l'erreur ressort là où vous l'attendez :

t AS TASKHANDLE
STARTTASK t, Reload
TRY
  AWAITTASK t
CATCH e$
  Console("attrapé : " + e$)                ' attrapé : plus de munitions
ENDTRY
IF TaskFailed(t) THEN Console("échec")      ' échec
END

Reload:
  Wait(0.2)
  THROW "plus de munitions"
RETURN 0
AttenteQuand une tâche a échoué
AWAITTASK hlève l'erreur de la tâche
AWAITALL a, blève la première erreur rencontrée
AWAITANY a, bne lève rien : vérifiez TaskFailed ensuite
TaskResult(h)lève l'erreur de la tâche

Les erreurs sont traitées dans Les erreurs.

Annuler une tâche#

CANCELTASK h demande à la tâche de s'arrêter. Elle s'arrête à sa prochaine attente : Wait(), Sleep(), Sync(), Swap() ou YieldTask(). En général le code qui suit cette attente ne s'exécute jamais. L'exception est une attente placée dans un TRY : voir plus bas.

slow AS TASKHANDLE
fast AS TASKHANDLE
STARTTASK slow, Slow
STARTTASK fast, Fast
AWAITANY slow, fast
IF TaskDone(fast) THEN Console("fast a fini en premier")   ' fast a fini en premier
CANCELTASK slow
AWAITALL slow, fast
IF TaskCancelled(slow) THEN Console("slow annulée")        ' slow annulée
END

Slow:
  Wait(10)
  Console("jamais affiché")
RETURN 1

Fast:
  Sync()
RETURN 2
  • TaskCancelled est vrai aussitôt, et AWAITTASK sur une tâche annulée revient aussitôt, sans erreur.
  • TaskResult sur une tâche annulée est une erreur : elle n'a pas de résultat.
  • CANCELTASK sur une tâche déjà terminée ne fait rien.
  • ⚠️ Une tâche annulée juste après STARTTASK s'exécute quand même jusqu'à sa première attente, puisqu'elle n'avait pas encore démarré.
  • ⚠️ Une tâche qui n'attend jamais ne peut pas être arrêtée : elle va jusqu'à son RETURN d'une traite. AWAITTASK dans une tâche n'est pas non plus un point d'annulation.
  • ⚠️ Un TRY autour d'une attente attrape l'annulation. Son CATCH reçoit le message BASIC task cancelled, et le code après ENDTRY continue jusqu'à la prochaine attente. Pour laisser passer l'annulation, relancez-la : CATCH e$, puis IF e$ = "BASIC task cancelled", puis RETHROW.

Pourquoi il n'y a pas de fils d'exécution#

Les fils d'exécution à mémoire partagée sont volontairement exclus. Pas « pas encore » : exclus.

Avec les tâches, un seul morceau de code tourne à la fois, et le relais ne se fait qu'à une attente. Entre deux attentes, rien d'autre ne peut toucher à vos variables. Pas de verrou à poser, et pas de course de données à traquer.

PureBasic propose CreateThread avec des mutex ; AGK et DarkBASIC n'ont pas de tâches du tout. Dans aukimi, le vrai travail en parallèle passe ailleurs :

  • le gros calcul part sur le GPU, voir Le calcul GPU ;
  • CreateWorker démarre un Web Worker écrit en JavaScript. Il échange des messages JSON et ne partage aucune mémoire.

Limites et pannes courantes#

  • Sync() fige une boucle WHILE linéaire. Ajoutez un END, ou passez à un DO … LOOP.
  • Un seul DO … LOOP, au niveau supérieur. Les autres boucles sont des WHILE ou des REPEAT.
  • Pas d'attente dans une FUNCTION. Utilisez une étiquette avec GOSUB, ou une tâche.
  • Wait en secondes, Sleep en millisecondes. Les deux en temps de jeu.
  • Print à chaque image, Console une fois.
  • Testez l'état d'une tâche seul : IF TaskDone(h), jamais = 1.
  • Dans une étiquette, chaque attente sur sa ligne : pas de IF … THEN Sync(), pas de commentaire après Sync().